フロントエンドエンジニアの @rry です。
自分は BASE の Sales Promotion というチームで主に新規機能開発を行っています。このチームでは主にオーナーさんの使う管理画面に新しく機能追加をしています。
そこで、管理画面で使っている API Client と型を、OpenAPI Generator を使って自動生成するようにしてみたのでそのお話を書きたいと思います。
そもそも OpenAPI とは?
OpenAPI とは、RESTful Web サービスを記述、生成、使用、および視覚化するための仕様です。
※ 以前は OpenAPI ではなく仕様自体も Swagger と呼ばれていましたが、現在は仕様自体については OpneAPI と呼ばれており、Swagger というのは OpenAPI を使ったツール群のことをさすようになりました。まぎらわしいので Swagger ではなく主に OpenAPI と呼びます(ツール群のほうも「OpenAPI のツール」と呼んでいきます)
BASE では YAML ファイルで記述しています。
OpenAPI とそのツール群を使うことでなにができる?
- API の仕様(スキーマ)を定義
- 定義の一元管理ができる
- API ドキュメントを生成
- ドキュメントのメンテナンスが楽
- API モックサーバーを立てられる
- API が出来上がっていなくても先にフロントエンドの開発ができる
- API Client を自動生成
- API Client のコードをフロントエンドで書かずにすむ!
- API リクエスト・レスポンスの型を自動生成
- スキーマから生成した型を使うことでより型安全になる
- 周辺ツールでいろいろできる
- バックエンドの実装と定義したスキーマが乖離した場合に自動テストが落ちるようにしたりもできる
- バックエンドの実装が乖離しないようにできる
- バックエンドの実装と定義したスキーマが乖離した場合に自動テストが落ちるようにしたりもできる
OpenAPI のようなスキーマを中心にした開発のことを、「スキーマ駆動開発」といいます。
OpenAPI を使ったスキーマ駆動開発をすることでなにがうれしいの?どういう問題を解決するの?というところは、以下のスライドが参考になるのでそちらをどうぞ。
BASE 既存システムへの OpenAPI 導入の背景について
BASE では最近カートの大規模リプレイスを行いました。 BASE Tech Talk #1 〜Next.jsを使ったカート大規模リプレイスPJの裏側〜 - connpass
新しいカートのアーキテクチャでは、既に OpenAPI が導入されておりスキーマ駆動開発を行っていました。自分もフロントエンドの開発で OpenAPI から生成した API Client を利用したりしていました。
しかし BASE のオーナーさんの使う管理画面など、カート以外のシステムでは既存の API 定義は OpenAPI ではなく API Blueprint を利用していました。
API Blueprint を使った開発では
- API の仕様(スキーマ)を定義
- API ドキュメントを生成
- API モックサーバーを立てられる
これらのことはできますが、API Client や型を自動生成することはできず、毎回手動で API Client と型を定義していました。
手動で定義したり API の仕様が変わったときにそれらの追従をすることが大変だと思い、カート開発のときと同様の開発体験を得たかった自分は「今回の PJ から OpenAPI を使ったスキーマ駆動開発をしよう!API Client と型を自動生成していこう!」と呼びかけ、そのための仕組みを導入することにしました。
API Client って何?自動生成ってどういうこと?
API にリクエストを送るためのコードを API Client と呼んでいます。
// このような感じのコード export class FooApiClient extends APIClient { async getBar() { return this.request<APIResponseWith<Bar>>({ url: `${BASE_PATH}/foo/bar`, }) } }
BASE では今まで上記のような API Client を手動で書いていたのですが、これからは OpenAPI から自動生成する API Client を利用していくことにしました。
以下は実際にどのようにして API Client と型を自動生成しているのかについて詳しく説明していきます。
① API Client を自動生成する仕組みの概要
OpenAPI からどのようにして API Client を生成しているかをまとめました。
- OpenAPI の個別のファイル群を編集
- 一つの大きな merged.yaml という OpenAPI ファイルを swagger-merger を使って生成
- merged.yaml を元に openapi-generator-cli を使って API Client やスキーマの型を生成
- その他便利関数と一緒に GitHub Packages を使って npm パッケージとして配信
openapi-generator-cli の typescript-fetch を使って fetch API の API Client を生成しています。
API Client を生成する流れはこのような感じですが、他にも merged.yaml を元に Docker を利用して色々しています。ReDoc / SwaggerUI を立てて API ドキュメントを読んだり、API Sprout を使って API モックサーバーを立てたりもしています。
どのような開発体験になるか
まずバックエンドエンジニアが API を作る前に、OpenAPI の YAML ファイルだけを追加した PR を出して API のスキーマについてフロントエンドエンジニアと共にレビューします。PR がマージされると GitHub Actions が自動で API Client の npm パッケージを配信してくれます。
フロントエンドは配信されたパッケージを利用して、スキーマに沿った API へのリクエスト・レスポンスを実現することができます。
また、便利関数として API モックサーバーへのリクエストもできるようにしています。 これにより API の開発を待たずしてフロントエンド側の実装を進めていくことができます。
このようにして自動生成した API Client は以下のような形で使うことができます。
import { apiConfig, FooApi, FooBarResponse } from 'api-client' const client = new FooApi(apiConfig) const result: FooBarResponse = await client.getBar()
② OpenAPI の個別のファイル群について
OpenAPI の個別のファイル群は、API Client を生成しやすいようにいくつかの命名規則に沿って作られています。
ディレクトリ構成は以下のとおりです。
├── README.md ├── docker-compose.yaml ├── src ├── _components.yaml - components 定義 ├── _paths.yaml - paths 定義 ├── components - 共通 components 定義 │ └── error_response.yaml ├── merged.yaml - Docker から参照するためのファイル。特にいじらない ├── openapi.yaml - ベースファイル └── services - サービスディレクトリ └── <service_name> ├── components - service の components 定義 │ ├── xxx_request.yaml │ └── xxx_response.yaml ├── definitions - service の definitions 定義 │ └── user.yaml ├── examples - examples 定義 │ ├── <paths_name> │ │ ├── default.yaml │ │ └── xxx.yaml │ └── 400_example.yaml └── <paths>.yaml
src/openapi.yaml
の tags に name を定義src/_paths.yaml
に path を定義src/services/
配下に yaml ファイルを作成- yaml ファイルの内容が
src/merged.yaml
に反映される
というのがザックリとした編集方法です。
OpenAPI は $ref
というキーワードを使って外部ファイルを参照可能なため、
- path
- API エンドポイント
- リクエスト・レスポンスの schema
- example
これらを services 配下にまとめて、細かくファイル分割をして管理しやすい形にしています。
③ API Client やスキーマの型を生成するにあたっての命名規則
ファイル名やその他命名など細かい規則をいくつか設けていますが、その中でも API Client の生成に影響するものをまとめました。
フォルダの命名規則
src/services/*
配下- 各 API のスキーマを置く場所
- API の URL と同じ構成にする
- この際 path に
api
が入っている場合はapi
を抜く- 例)
/apps/api/foo/bar
なら、src/services/apps/foo/bar.yaml
となる
- 例)
- この際 path に
生成される API Client はフラットな階層に一律出力されるため、そもそもの命名としてユニーク性が必要です。そのため URL の構成に従って命名しています。
tags と operationId
- tags
- フォルダの path の services から親となるリソースまでの path をつなげる
- 例)
src/services/apps/foo/bar.yaml
ならappsFoo
になる
- 例)
- tags は services の各まとまりごとに同じものを用いる
- 例)
/services/apps/foo/bar.yaml
と/services/apps/foo/baz.yaml
は同じ tagsappsFoo
を使う
- 例)
- フォルダの path の services から親となるリソースまでの path をつなげる
- operationId
- リクエストメソッド + リソース名
- 例)
/services/apps/foo/bar.yaml
の GET リクエストだったらgetBar
となる
- 例)
- リクエストメソッド + リソース名
API Client が生成されるとき、tags が API クラス名で operationId がメソッド名となります。そのため上記の命名規則で生成されるクラスは以下のような形になります。
export class AppsFooApi extends runtime.BaseAPI { async getBar(requestParameters: GetBarRequest = {}, initOverrides?: RequestInit): Promise<AppsFooBarResponse> { // ... } }
requestBody と responses のスキーマと examples
これらは別ファイルに models として切り出すようにしています。
get: tags: - appsFoo operationId: getBar responses: '200': description: OK content: application/json: schema: $ref: ./components/bar_response.yaml examples: default: $ref: ./examples/default.yaml '500': description: Internal Server Error content: application/json: schema: $ref: ../../../components/error_response.yaml examples: barInternalError: $ref: ./examples/bar_internal_error.yaml # ...
# ./components/bar_response.yaml title: appsFooBarResponse type: object properties: status: type: number bar: type: string nullable: true
# ./examples/default.yaml value: status: 200 bar: null
models として切り出して個別に title を定義することで、レスポンスの型の命名が自動的に InlineResponseXXX
のようになってしまうのを防ぎます。
また、examples も default
のように名前を定義することで、API モックサーバーへリクエストするときに意図したレスポンスを返してもらうことができるようにしています。
import { mockApiConfig, AppsFooApi } from 'api-client' const client = new AppsFooApi(mockApiConfig({ status: 500 })) const result = await client.getBar({}) // 500 エラーが返ってくる // example の value は OpenAPI 定義の examples の key を指定 const client = new AppsFooApi(mockApiConfig({ example: 'default' })) const result = await client.getBar({}) // default で設定した example の値が返ってくる
④ その他 API Client を利用する上で用意した便利関数
非同期関数の catch や try / catch でエラーが起きたときのハンドリングを行う便利関数も用意しています。
apiErrorType
を使って起こったエラーを3つのパターンに整形する- APIError: API から返ってきたエラーレスポンス
- Error: それ以外の何かしらのエラー
- null: 401エラーの場合は一律でエラーハンドリングしており何もしないため null を返す
isAPIError
を使ってエラーが APIError なのか Error なのかを判別する
const client = new AppsFooApi(apiConfig) const result = await client.getBar({}).catch(async (e) => { const error = await apiErrorType(e) // 401 のときは自動的に FlashMessage が出るようにしているため早期リターン if (!error) return if (isAPIError(error)) { // APIError で 404 など返ってきたときのエラーハンドリング } else { // そうでないただのエラーが返ってきたときのエラーハンドリング console.log(error.message) } })
また、エラーハンドリングについての考え方は今まで実装者の判断に委ねていた部分がありましたが、エラーハンドリングのやり方についても別記事でまとめて認識合わせをしたりしました。
ユーザーに表示するエラーメッセージを管理するのはフロントエンド?バックエンド?
注意したいのがここで返ってくる title や detail などは、ユーザーに表示するための文言ではなくあくまで開発者に何のエラーか教えてあげるための文言だということです。
エラーを返しているのは API であって、API を操作するのはフロントエンドのコード(つまりフロントエンド開発者)なので、開発者がわかるエラーメッセージで十分です。
ユーザーに表示するエラー文言については、デザイナーと相談して決めることがほとんどかと思います。ここは細かい調整が行いやすいフロントエンドで管理するのが良いでしょう。ユーザーに表示する領域はフロントエンドの領域です。
⑤ GitHub Actions を使った GitHub Packages の配信
src/merged.yaml
または api-client/package.json
に変更があった PR が main ブランチにマージされた場合は GitHub Actions で GitHub Packages を配信するようにしています。
- uses: docker://openapitools/openapi-generator-cli with: args: generate -g typescript-fetch -i src/merged.yaml -o api-client/src/generated --additional-properties=modelPropertyNaming=camelCase,supportsES6=true,withInterfaces=true,typescriptThreePlus=true - run: | cd api-client yarn install --frozen-lockfile yarn build - env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd api-client npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN npm publish
しかし、main にマージした際に何らかの理由で npm publish が失敗したらどうしましょう?🤔
そんなときのために、CI で publish できるかもチェックしています。
can-npm-publish を利用して npm publish ができない場合は CI が落ちて気づけるように GitHub Actions を設定しています。便利ですね。
- env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | cd api-client npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN yarn run can-npm-publish --verbose
以上、①〜⑤まで API Client と型の自動生成をする上でのポイントをあげました。
このような形で現在は自分のやっている PJ 以外でも OpenAPI から生成した API Client と型が利用されるようになってきています。
使っていく上でのメリットとこれからの課題
OpenAPI を利用したスキーマ駆動開発をやってみて感じたメリットは以下の通りです。
- API の実装を待たずしてフロントエンド開発ができる
- API モックサーバーを使うことで、外部連携などが必要な複雑な API であったとしてもフロントエンドの動作確認や開発を楽に行うことができる
- API Client やスキーマの型をいちいち手動で書く必要がなくなる
- 仕様と実装の乖離がなくなる
このように、開発を楽に&速くすることができました! 🙌
とはいえ、全体を通した課題はまだまだ感じます。
フロントエンドの開発にとってメリットを感じられることは大きいのですが、バックエンドの開発にとってはどうでしょうか。
- バックエンドの実装と定義したスキーマが乖離した場合に自動テストが落ちるようにしたりなどの設定がまだできていない
- バックエンドは仕様と実装の乖離が起こっても検知できないので、API に変更がある場合はコミュニケーションでなんとかする必要がある
といったように、現状はフロントエンドの開発ではスキーマ駆動開発の良さを享受できるけどバックエンドの開発ではフロントエンドほどの恩恵は受けられていない状況です。
とはいえバックエンドの開発でも、API の実装がフロントエンドの実装のブロッカーにならずにすむというというのはバックエンド開発者の精神衛生上とても良いことだと、うれしいフィードバックを受けたりもしました。
今後バックエンドの状態が変わり次第 OpenAPI のツールを入れるなりして、さらにより良いスキーマ駆動開発を推進していければいいなと思っています。
おわりに
OpenAPI を利用したスキーマ駆動開発で得られるメリットはとても大きいものです。
ぜひ API Client と型を自動生成して各 PJ で活用してみてください!