openapi-generator フレンドリーな OpenAPI ドキュメントを書く
この記事は Calendar for Legalscape アドベントカレンダー 2021 の 22日目の記事で、前後編のうちの後編です。前編は「 openapi-generator フレンドリーな OpenAPI ドキュメントを書く 」というタイトルで 12/17 に公開されました
前回の記事ではフロントエンド側でどうやって自動生成コードに置き換えていくかを紹介しました。今回は openapi.yaml
上でどのような定義を書いたらフロントエンドで扱いやすいのかを紹介します
調査時に READYFOR さんのブログ を参考にさせていただきました。ライブラリが違うので一部違う箇所もありますが、共通する部分は多いのでぜひ併せて読んでみてください
生成されるコードの解説
openapi-generator の typescript-axios
を利用してコード生成すると、大まかに以下の 3つのコードが出力されます
- ドメイン情報などのコンフィグや認証のためののコードなどが含まれたベースコード
- API エンドポイントごとの実装が含まれた API コード
- レスポンス型やスキーマで定義したモデルなどの型情報が含まれたモデル定義
このうちベース部分は特に複雑なことはないので割愛して残りのふたつを解説していきます
API コード
openapi.yaml
の paths
の内容を利用して API へリクエストを送るためのコードが生成されます。生成されるコードは クラス定義 と 関数定義 の 2種類が生成されるのでどちらを使っても大丈夫です。ちなみに Legalscape では クラス定義 のものを利用しています
引数と返り値の型が明確に定義されているため、間違ったパラメータでリクエストを送ってしまう事がないようになっています
モデル定義
openapi.yaml
の responses
や components
の定義を元に API とコミュニケーションする際の型情報を定義したコードが生成されます。 API コードと違って openapi.yaml
の記述の仕方で出力されるコードが大きく変わってくるため、フロントエンドに優しい記述をするためにはどのようなモデル定義が出力されるかをイメージして記述する必要があります
フロントエンドが使いやすい OpenAPI 定義とは
扱いやすいかどうかはチームによっても違ってくると思いますが、 MECE でモデリングが適切で実装と定義に差異がない定義は基本的に使いやすそうです。 openapi.yaml
はいくらでもフラットに書けますが、以下のようなポイントに気をつけて構造化することで定義としても出力されるコード的にも使いやすくできます
- エンドポイントには
tags
を指定する - オペレーション ID を適切に記述する
- 定義にウソをつかないように enum を活用する
- enum は
#/components/schemas
で指定する - レスポンス型は必ず
#/components/responses
で定義する -
#/components/schemas
を指定してobject
が出力されないようにする -
required
を正しく指定する -
oneOf
やallOf
を活用してユニオン型や合成型を表現する - ハッシュマップを表現するには
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
では表現力に差があるため完全に一致した型定義はできませんが、可能な限り実装とマッチした定義にする事で開発効率を向上させ、フロントでのランタイムエラーを防ぐ事ができます
#/components/schemas
で指定する
enum は openapi.yaml
では以下の箇所で enum を指定する事ができます(要確認)
- リクエスト時の
parameters
-
type: object
のproperties
#/components/schemas
parameters
や properties
に直接記述した場合でも、名前がついて 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;
oneOf
や allOf
を活用してユニオン型や合成型を表現する
型の拡張やユニオン型を表現したい場合は oneOf
や allOf
を使います。
特に oneOf
と enum
を組み合わせることで、フロント側で型ガードを活用して安全に固有のプロパティにアクセスすることができるようになります
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つの仕様からフロントエンドでは型ガードで判別をすることができません。以下のような出力になるようにスキーマを書くこともできますが、 oneOf
と enum
を使って型を判別する方が圧倒的に取り扱いやすいのでフラグによるフィールドの出し分けの実装は慎重になってくれるとフロントエンド的には嬉しいです
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
痒い所に手が届く記事ありがとうございます!
一つどうしてもわからない箇所がありまして、
#/components/schemas を指定して object が出力されないようにする
の部分でとありますが、どのような理由で例にある書き方から出力される型が object になってしまうのでしょう?
全く同じ問題に遭遇していまして、調べようにもググラビリティが良くなくて情報に辿り着けずにいまして...
内部的な処理はわからないんですが、
oneOf
やallOf
とインラインのプロパティを混ぜていると発生しやすそうです逆に定義している箇所にプロパティだけが書かれている場合はきちんと型定義ができているように思えます
なるほど、やはり内部的になぜそうなってしまうのかはわからないですよね...
ご返信ありがとうございます!