🛠️

openapi-generator フレンドリーな OpenAPI ドキュメントを書く

2021/12/22に公開約10,800字

この記事は Calendar for Legalscape アドベントカレンダー 2021 の 22日目の記事で、前後編のうちの後編です。前編は「 openapi-generator フレンドリーな OpenAPI ドキュメントを書く 」というタイトルで 12/17 に公開されました

前回の記事ではフロントエンド側でどうやって自動生成コードに置き換えていくかを紹介しました。今回は openapi.yaml 上でどのような定義を書いたらフロントエンドで扱いやすいのかを紹介します

調査時に READYFOR さんのブログ を参考にさせていただきました。ライブラリが違うので一部違う箇所もありますが、共通する部分は多いのでぜひ併せて読んでみてください

生成されるコードの解説

openapi-generator の typescript-axios を利用してコード生成すると、大まかに以下の 3つのコードが出力されます

  • ドメイン情報などのコンフィグや認証のためののコードなどが含まれたベースコード
  • API エンドポイントごとの実装が含まれた API コード
  • レスポンス型やスキーマで定義したモデルなどの型情報が含まれたモデル定義

このうちベース部分は特に複雑なことはないので割愛して残りのふたつを解説していきます

API コード

openapi.yamlpaths の内容を利用して API へリクエストを送るためのコードが生成されます。生成されるコードは クラス定義関数定義 の 2種類が生成されるのでどちらを使っても大丈夫です。ちなみに Legalscape では クラス定義 のものを利用しています

引数と返り値の型が明確に定義されているため、間違ったパラメータでリクエストを送ってしまう事がないようになっています

モデル定義

openapi.yamlresponsescomponents の定義を元に API とコミュニケーションする際の型情報を定義したコードが生成されます。 API コードと違って openapi.yaml の記述の仕方で出力されるコードが大きく変わってくるため、フロントエンドに優しい記述をするためにはどのようなモデル定義が出力されるかをイメージして記述する必要があります

フロントエンドが使いやすい OpenAPI 定義とは

扱いやすいかどうかはチームによっても違ってくると思いますが、 MECE でモデリングが適切で実装と定義に差異がない定義は基本的に使いやすそうです。 openapi.yaml はいくらでもフラットに書けますが、以下のようなポイントに気をつけて構造化することで定義としても出力されるコード的にも使いやすくできます

  • エンドポイントには tags を指定する
  • オペレーション ID を適切に記述する
  • 定義にウソをつかないように enum を活用する
  • enum は #/components/schemas で指定する
  • レスポンス型は必ず #/components/responses で定義する
  • #/components/schemas を指定して object が出力されないようにする
  • required を正しく指定する
  • oneOfallOf を活用してユニオン型や合成型を表現する
  • ハッシュマップを表現するには additionalProperties を指定する
  • boolean によるタイプ判別をしない

エンドポイントには tags を指定する

100のエンドポイントがある場合、これがひとつのクラス or 関数の返り値に含まれていると取り扱いが大変です。 openapi.yaml では tags を指定する事で API をカテゴリごとに分類する事ができます。 tags は複数指定する事ができます

'/api/sample':
    get:
      description: サンプル API
      summary: サンプルの API
      operationId: getSample
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/responses/sampleResponse'
          description: OK
      tags:
        - Sample

指定しないと api/default-api に含まれることになってしまうので、必ず指定しましょう。複数の tags を許容するかどうかはポリシーによると思いますが、まったく同じ処理が複数のコードに登場することになるのであまりお勧めはしません。「なんとなく関連がありそうだから」という理由で複数の tags を指定するのは避けた方が良さそうです

オペレーション ID を適切に記述する

リクエストボディに指定された operationId の値で API の関数名が決定されます。 tags と併せて必ず指定しましょう

定義にウソをつかないように enum を活用する

例えば 'member' 'admin' 'owner' しか値を持たないのに type: string が指定されていると、フロントエンド側でこれらの情報を持つことになります。そして修正時にはフロントエンドとバックエンドの両方を修正しなければならなくなり、ミスが発生する可能性が高くなります

数字も同様で固定で決まった値の範囲でしかレンポンスしないのであれば、それが定義に含まれるようにした方がより安全で使いやすい定義になります

こういった場合は enum を利用することで正しい定義にする事ができます

components:
  schemas:
    TypeEnum:
      description: 種別
      enum:
        - typeA
        - typeB
        - typeC
      type: string
export enum TypeEnum {
    TypeA = 'typeA',
    TypeB = 'typeB',
    TypeB = 'typeC',
}

どうしても、 TypeScript と openapi.yaml では表現力に差があるため完全に一致した型定義はできませんが、可能な限り実装とマッチした定義にする事で開発効率を向上させ、フロントでのランタイムエラーを防ぐ事ができます

enum は #/components/schemas で指定する

openapi.yaml では以下の箇所で enum を指定する事ができます(要確認)

  • リクエスト時の parameters
  • type: objectproperties
  • #/components/schemas

parametersproperties に直接記述した場合でも、名前がついて Enum として定義されます

SampleModel:
  description: サンプル
  properties:
    key:
      enum:
        - -1
      type: integer
OthorModel:
  description: サンプル
  properties:
    key:
      enum:
        - -1
      type: integer
export enum SampleModelKeyEnum {
    NUMBER_MINUS_1 = -1
}
export enum OthorModelKeyEnum {
    NUMBER_MINUS_1 = -1
}

しかしこれでは同じ値であっても別々の定義が生成されてしまい、使い回す事ができません。また、定義している箇所に名前が依存するため何かを修正した時に不要な修正を強いられる可能性もあります

この場合、 enum の定義を #/components/schemas に移動させ、利用箇所で $ref を使って参照する事でひとつの Enum が使いまわされるようになり、命名も指定したものが使われるようになります

SampleModel:
  description: サンプル
  properties:
    key:
      $ref: '#/components/schemas/KeyEnum'
OthorModel:
  description: サンプル
  properties:
    key:
      $ref: '#/components/schemas/KeyEnum'
KeyEnum:
  description: キーに指定される Enum
  enum:
    - -1
  type: integer
export enum KeyEnum {
    NUMBER_MINUS_1 = -1
}

なお、インラインで記述した場合は勝手に HogehogeEnum のような名称になるのですが、 #/components/schemas に記述すると指定したままの名称が使われます。 Legalscape では分かりやすいように Enum を末尾に付けるようにしています

レスポンス型は必ず #/components/responses で定義する

例えば name: string のみを返すようなエンドポイントを作成する場合、インラインで記述したくなると思いますがそうしてしまうと、API のリクエスト関数の戻り値が AxiosPromise<InlineResponseXXX> のような定義になってしまいます

'/api/sample':
    get:
      description: サンプル API
      summary: サンプルの API
      operationId: getSample
      responses:
        '200':
          content:
            application/json:
              schema:
                type: object
                properties:
                  sampleText:
                    type: string
                  sampleFlag:
                    type: boolean
                required:
                  - sampleText
                  - sampleFlag                  
          description: OK
function getSample(): AxiosPromise<InlineResponse200X>>

interface InlineResponse200X {
    'sampleText': string;
    'sampleFlag': boolean;
}

この 200X の部分はステータスコード + 通し番号が入るのですが、当然のことながら同じステータスコードで新しい InlineResponse が追加されると通し番号が変わってしまい壊れてしまいます。これを見つけたら速攻で直すようにしましょう。具体的には以下のように #/components/responses に定義を移動させ $ref で参照するようにすれば OK です

paths:
  '/api/sample':
    get:
      description: サンプル API
      summary: サンプルの API
      operationId: getSample
      responses:
        '200':
          $ref: '#/components/responses/SampleResponse'
components:
  responses:
    SampleResponse:
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Sample'
      description: OK
  schemas:
    Sample:
      type: object
      properties:
        sampleText:
          type: string
        sampleFlag:
          type: boolean
      required:
        - sampleText
        - sampleFlag

シンプルでフラットなレスポンスを返すだけであれば #/components/responses にインラインで記述しても運用できるかもしれませんが、そんな素朴な API で済むことはないと思うので、ORM が生成されるクラスやバックエンドで定義しているモデルなど可能な限りスキーマで定義をする方針がいいと思います

#/components/schemas を指定して object が出力されないようにする

以下のような定義の場合、出力される型が object になってしまうことがあります

components:
  schemas:
    Sample:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - type: object
          properties:
            sampleText:
              type: string
            sampleFlag:
              type: boolean
          required:
            - sampleText
            - sampleFlag
type Sample = SampleBase & object;

該当部分を #/components/schemas に定義する事で型定義が生成されるようになります。 gen/model に出力されるモデル定義を増やしてフロントエンドで扱いやすくするためにも、基本的に定義はできるかぎり #/components/schemas に記載するようにしましょう

components:
  schemas:
    Sample:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - $ref: '#/components/schemas/SampleAddtional'
type Sample = SampleBase & SampleAddtional;

oneOfallOf を活用してユニオン型や合成型を表現する

型の拡張やユニオン型を表現したい場合は oneOfallOf を使います。

特に oneOfenum を組み合わせることで、フロント側で型ガードを活用して安全に固有のプロパティにアクセスすることができるようになります

components:
  schemas:
    SampleOneOf:
      oneOf:
        - $ref: '#/components/schemas/SampleTypeA'
        - $ref: '#/components/schemas/SampleTypeB'
    SampleTypeA:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - $ref: '#/components/schemas/SampleAddtionalA'
    SampleTypeB:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - $ref: '#/components/schemas/SampleAddtionalB'
    SampleAddtionalA:
      type: object
      properties:
        type:
          type: string
          enum:
            - 'type_a'
        fieldA:
          type: string
        ...
    SampleAddtionalB:
      type: object
      properties:
        type:
          type: string
          enum:
            - 'type_b'
        fieldB:
          type: string
        ...
type SampleOneOf = SampleTypeA | SampleTypeB;

function getSample(): AxiosPromise<SampleOneOf>>;

const sample = await getSample();
if (sample.type === 'type_a') {
  // この中では typeof sample === SampleTypeA とみなせる
  console.log(sample.fieldA);
}
if (sample.type === 'type_b') {
  // この中では typeof sample === SampleTypeB とみなせる
  console.log(sample.fieldB);
}

boolean によるタイプ判別をしない

例えば isTypeA: boolean というフラグを作って true の場合のみプロパティが映えるという場合、 oneOf を使うと以下のようになります

components:
  schemas:
    SampleOneOf:
      oneOf:
        - $ref: '#/components/schemas/SampleTypeA'
        - $ref: '#/components/schemas/SampleTypeB'
    SampleTypeA:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - $ref: '#/components/schemas/SampleAddtionalA'
    SampleTypeB:
      allOf:
        - $ref: '#/components/schemas/SampleBase'
        - $ref: '#/components/schemas/SampleAddtionalB'
    SampleAddtionalA:
      type: object
      properties:
        isTypeA:
          type: boolean
        fieldA:
          type: string
        ...
    SampleAddtionalB:
      type: object
      properties:
        isTypeA:
          type: boolean
        fieldB:
          type: string
        ...

しかしこのような定義だと openapi.yaml では 1. プロパティに固定値を指定できない 2. enum に boolean を指定できない。という 2つの仕様からフロントエンドでは型ガードで判別をすることができません。以下のような出力になるようにスキーマを書くこともできますが、 oneOfenum を使って型を判別する方が圧倒的に取り扱いやすいのでフラグによるフィールドの出し分けの実装は慎重になってくれるとフロントエンド的には嬉しいです

interface Sample {
  isTypeA: boolean;
  fieldA?: string; // isTypeA が true の場合に値が入る
  fieldB?: string; // isTypeA が false の場合に値が入る
}

ハッシュマップを表現するには additionalProperties を指定する

{ [key: string]: ObjectType; } といったレスポンスを定義したい場合、 type: array を指定すると望ましい型定義になりません。 additionalProperties を利用するようにしましょう(参考

schema:
    additionalProperties:
      $ref: '#/components/schemas/Sample'

以上、おもにレスポンスで使われるコードをより扱いやすくするための openapi.yaml の記述方法について紹介してきました。リクエストに関しては API を呼び出す関数の引数のみが制約されます。 SampleRequestParams のような構造化されたモデルを利用したい場合はフロント側で定義してあげる必要があります。 REST API の仕様上の制約なので特に困ってはいませんが、 GraphQL のような構造化されたスキーマと比べるとこの点はデメリットと感じる場合もあるのかもしれません

今回の内容は typescript-axios を利用している Legalscape に特有の内容となりますが、これ以外の課題があった場合も openapi はシンプルながらもドキュメントがしっかりと書かれた仕様なので、 公式ドキュメント に目を通してみましょう

Legalscape では スキーマファーストでサーバサイドにもフロントエンドにも優しい開発ができるエンジニア を大募集しています。興味がある方はぜひコンタクトしてください

Discussion

ログインするとコメントできます