導入
こんにちは、MNTSQ のソフトウェアエンジニアの森山です。今回は、REST API の OpenAPI 3.0 から API クライアントを自動生成するまでの過程を紹介します。
実はメインのプロダクトへ TypeScript を導入できたのはつい最近のことです。API クライアントを自動生成するまでの苦労や新たな発見が 1 つでも参考になれば嬉しく思います。
課題
API クライアントの自動生成に取り組む上で、現在の BE と FE には以下の課題がありました。
BE
API フレームワーク移行期のため、OpenAPI 2.0 と 3.0 の 2 つの API 定義ファイルが存在し、自動生成前に merge が必要。
FE
- TypeScript へ移行できていない JavaScript が大半。
- API コールを堅牢にするための独自の機構が複雑で認知負荷が高い。
- API レスポンスが class 化されているが TypeScript の型として利用できない。
自動生成の目的
型や API クライアントの自動生成の目的は以下です。
詳細な背景は以下の通りです。
よりシンプルな API コール
独自の機構を撤廃し、API コール処理の認知負荷を下げたい。TypeScript の型でよりシンプルに解決できる。
API の破壊的変更を検知
既存の API コールを堅牢化する機構はランタイム上で動作します。そのため API の破壊的な変更を開発中に見逃すことがありました。開発中に機械的に検知できる必要がある。
TypeScript の導入を加速
型が自動生成できると以下の要因で加速できる。
- TypeScript を導入したばかりで使える型が少ないが、一気に使える型が増える。
- 型のメンテナンス工数が削減できる。
- 過剰なプロパティを持った型が生まれない。(流用性を高める意図で生まれやすい)
ライブラリの比較
以下の 3 つのライブラリが検討の対象です。
- openapi-ts(採用)
- openapi-typescript
- swagger-typescript-api
結果としては 1. openapi-ts を採用しました。
次にその選定における観点と過程を説明します。
比較観点
- 型の流用性
- API コール時の認知負荷
型の流用性
特に API のパラメータやレスポンスの型が流用しやすい形式であるか。それらの型は API コールの前後の加工処理等で参照したいことがあります。出力される型が API コールの関数のみだと、その関数の型から引数や返り値の型を抜き出す必要があるため複雑になります。
API コール時の認知負荷
API コール時のインターフェースがシンプルかどうか。API コールのために関数や型をいくつも import したくないです。関数名を書いただけで補完が始まり実装が自然と進んでいく体験が理想です。
以下は上記の観点を具体化した比較表です。
ライブラリ | api クライアントの生成 | snake_case ↔ camelCase の変換 | 自動生成時の安定性 | 型の流用性 | API コールに必要な import 数 | endpoint の型制御 | path parameter の型制御 | query parameter の型制御 | request body の型制御 | response body の型注釈 |
---|---|---|---|---|---|---|---|---|---|---|
openapi-ts | ◯ | ◯ | ◯ | ◯ | △ | ◯ | ◯ | ◯ | ◯ | ◯ |
openapi-typescript | x | ◯ | ◯ | ◯ | x | △ | △ | ◯ | ◯ | ◯ |
swagger-typescript-api | ◯ | ◯ | x | △ | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ |
各ライブラリごとにプロトタイプを実装しました。手を動かして得た発見と評価も合わせて以下に記載します。
openapi-ts
メリット
デメリット
API コールの際に API クライアントのインスタンスとして毎回同じものを渡すのが冗長です。しかし、それ以外は観点を満たしていました。
import { typedAxios } from "./client" import { postV2Authentication } from "./generated/sdk.gen" import { getV2DocumentDocumentId } from "./generated/sdk.gen" import { getV2DocumentDiff } from "./generated/sdk.gen" // 認証 postV2Authentication({ client: typedAxios, // fetchやaxios等のAPIクライアントを毎回渡す必要がある body: { email, password }, }) // document取得 getV2DocumentDocumentId({ client: typedAxios, path: { documentId: 1 }, }) // user取得 getV2User({ client: typedAxios, query: { userId: 1 }, })
import { typedAxios } from "./client" import { postV2Authentication } from "~/api/openapi-ts/generated/sdk.gen" import type { PostV2AuthenticationData } from "./generated" // requestBodyの型をimport(pathパラメータ、queryパラメータも可) export const authentication = async ({ email, password }: PostV2AuthenticationData["body"]) => { // ...何か前処理をしたり const response = await postV2Authentication({ client: typedAxios, body: { email, password }, }) return response.data }
openapi-typescript
メリット
- 型のみの生成でカスタマイズ性が高い
デメリット
API クライアントが fetch であれば有力だった可能性がありますが、現状は axios を活用しています。また型のみを生成するのは流用性が高く良いと思っていました。しかし axios に型を渡して矯正すると API コールのために必要な import が増えます。そして型の構造的に必要な型を探り当てるのが面倒でした。
import { type paths, type operations } from "./generated/schema.d" import { typedAxios } from "./client" // 認証 type Request = operations["postV2Authentication"]["requestBody"]["content"]["application/x-www-form-urlencoded"] type Response = operations["postV2Authentication"]["responses"]["201"] typedAxios.post<Response>("/v2/authentication", { email, password, }) // ドキュメント取得 type Request = operations["getV2DocumentDocumentId"]["parameters"]["path"] type Response = operations["getV2DocumentDocumentId"]["responses"]["200"]["content"]["application/json"] typedAxios.get<Response>(`/v2/document/${documentId}`)
上記はプレーンな axios のため import が多く、型の深堀りが必要です。
それを解消したものも実装しました。OpenAPI 3.0 の構造では HTTP メソッドと endpoint の組み合わせで欲しい API が特定できます。そのため HTTP メソッドと endpoint を渡せばパラメータやリクエストの型を推論できる axios を実装しました。コードすべてではないですが実装の概要は把握できると思います。
// カスタマイズしたaxios const customAxios = async <M extends Methods, E extends Endpoint<M>>({ methods, endpoint, pathParams, queryParams, body, }: { methods: M endpoint: E pathParams?: Snake2Camel<PathParams<M>> queryParams?: Snake2Camel<QueryParams<M>> body?: Snake2Camel<RequestBody<M, E>> }): Promise<AxiosResponse<Snake2Camel<SuccessResponse<M, E>>>> => { const dynamicEndpoint = pathParams ? getDynamicEndpoint(endpoint, camel2SnakeDeep(pathParams)) : endpoint const snakeCaseBody = body ? camel2SnakeDeep(body) : body const response = await axios[methods]<SuccessResponse<M, E>>( `${dynamicEndpoint}${getQueryParams(queryParams)}`, snakeCaseBody ) return { ...response, data: snake2CamelDeep(response.data), } } // 呼び出しイメージ await customAxios({ methods: "get", endpoint: "/api/v2/document/{document_id}", pathParams: { documentId }, })
呼び出し時には補完が HTTP メソッド → endpoint → パラメータと順番に絞り込まれるように推論されます。しかし見ての通り実装が複雑です。他のライブラリのように endpoint 毎に関数が生えた方が圧倒的にリーダブルです。また上記を用いて AI にコード生成を指示するとコード生成 → 型エラー → コード生成 を繰り返して徐々に正しいコードに近づけていく様子で、AI の精度が落ちるのも難点でした。
swagger-typescript-api
メリット
- 呼び出しが最もシンプル
- API クライアント(fetch, axios, …etc)を選択できる
デメリット
- JSON ファイルに特定の文字が含まれると自動生成に失敗する
- パラメータやレスポンスの型が流用しづらい
API コールのインターフェースは最もシンプルでした。しかし参照元の JSON ファイルに「*(アスタリスク)」が含まれていると自動生成に失敗します。OpenAPI のコメント等には様々な文字列を使う可能性があるため運用が辛くなる印象です。またパラメータやレスポンスの型が独立して参照できません。型の取り出しが面倒でした。
import { typedAxios } from "./typedAxios" export const getDocument = async ({ documentId, }: // 特定のqueryパラメータが欲しい時にapiの関数から引数の型を抜き出す必要がある。 Parameters<typeof typedAxios.v2.getV2Document>["0"]) => { return await typedAxios.v2.getV2Document({ documentId, }) }
import { Api } from "./api" const typedAxios = new Api() // 認証 typedAxios.v2.postV2Authentication({ email, password, }) // ドキュメント取得 typedAxios.v2.getV2DocumentDocumentId(documentId)
ライブラリ比較まとめ
消去法的に openapi-ts を採用しました。
以下の懸念が他ライブラリのノックアウトファクターでした。
導入の前処理
冒頭にあった課題を払拭するために以下の前処理が必要です。
- OpenAPI 2.0(Swagger) → OpenAPI 3.0 の変換
- openapi.json の merge
- snake_case ↔ camelCase の変換
OpenAPI 2.0(Swagger) → OpenAPI 3.0 の変換
API クライアントの自動生成ライブラリは OpenAPI 3.0 形式であることを想定しているため、 前処理として swagger2openapi というライブラリで OpenAPI 2.0(Swagger) → OpenAPI 3.0 へ変換しました。
npx swagger2openapi swagger.json -o openapi.json
1 コマンドでキレイに 2 系 →3 系になってくれて嬉しかったです。
openapi.json の merge
openapi-ts が読み込める JSON ファイルは 1 つなので、2 つの API 定義 JSON を merge します。openapi.json の中には様々なプロパティがありますが、merge したいのは以下の 2 つです。
- path: 各 endpoint と HTTP method 等の情報が定義
- components: 具体的なスキーマを内包
import * as openapiJson1 from "openapi-1.json" import * as openapiJson2 from "openapi-2.json" import fs from "fs" /** * openapi.jsonをマージして新規ファイルとして出力 */ const mergedJson = { ...openapiJson1, paths: { ...openapiJson1.paths, ...openapiJson2.paths, }, components: { ...openapiJson1.components, ...openapiJson2.components, }, } fs.writeFileSync("merged.json", JSON.stringify(mergedJson, null, 2))
snake_case ↔ camelCase の変換
FE のコーディングスタイルが camelCase なのに対して BE は snake_case です。この乖離については API コールのパラメータ作成時やレスポンス受け取り時に変換をする必要があります。 API コール時に都度変換するのは認知負荷が高いため共通処理に含めることにしました。 共通処理に含めるデメリットとして以下があります。
- 変換ユーティリティの開発・メンテナンスの手間
- ランタイム上の変換処理によるオーバーヘッド
しかし上記よりも開発者体験の方が価値があると判断しました。
また重要なポイントとして API クライアントの自動生成前の API 定義 JSON にもケース変換を施しました。つまり API 定義 JSON の時点でパラメータやレスポンスを camelCase にしておきました。これをしないと生成後の API クライアントが型補完で snake_case を要求してしまうので type error になります。openapi.json の時点でケース変換ができると関数の引数と返り値の型としては camelCase で出力してくれます。あとは axios の interceptors に変換処理を入れるだけです。
import { client } from "client.gen" // 自動生成されたaxiosのクライアント client.setConfig({ baseURL: "/" }) /** リクエストパラメータをsnake_caseに変換 */ client.instance.interceptors.request.use((request: InternalAxiosRequestConfig<any>) => { // snake_caseへの変換処理 return request }) /** レスポンスデータをcamelCaseに変換 */ client.instance.interceptors.response.use((response: AxiosResponse<any, any>) => { // camelCaseへの変換処理 return response }) export { client }
BE が生成した参照元の JSON を FE の都合に合わせて変更してしまうと思わぬ不都合が起きるのではと懸念がありました。そのため型の上書き等も試してみました。
いざ試すと生成後の関数や型に対しての TypeScript 上でのケース変換はしんどいです。例えばパスパラメータの型を snake_case から camelCase に変換するだけでも後述の複雑な型が必要になります。またランタイムで実行されるコードと比較して型に対しての検証は難しいです。そのためこの複雑な実装よりかは JSON を書き換える方が妥当と考えました。
型変換の一部type Snake2CamelString<T extends string> = T extends `${infer R}_${infer U}` ? `${R}${Capitalize<Snake2CamelString<U>>}` : T // keyをsnake_case → camelCase type Snake2Camel<T> = T extends any[] ? Snake2Camel<T[number]>[] : T extends object ? { [K in keyof T as Snake2CamelString<string & K>]: Snake2Camel<T[K]> } : T // httpメソッド type Methods = "get" | "post" | "put" | "patch" | "delete" // endpointのURL type Endpoint<M extends Methods> = { [Key in keyof paths]: M extends keyof paths[Key] ? Key : never }[keyof paths] // path parameter type PathParams<M extends Methods> = { [Key in Endpoint<M>]: M extends keyof paths[Key] ? paths[Key][M] extends { parameters: { path: infer T } } ? T extends { [key: string]: string | number } ? T : never : never : never }[Endpoint<M>] // 最終的に欲しいpathParamsの型。 // これ以外にもqueryParamsやrequestBody,responseBodyにも似たようでちょっと違う変換をするgenericが必要 Snake2Camel<PathParams<M>>(脱線終わり。)
before / after
今までは API コールの前後にクラスを通していました。API クライアント自動生成後は関数を呼ぶだけでシンプルです。BE で破壊的変更も type error として検知できます。
今までの呼び出しイメージ
import { repositoryFactory } from "./repositoryFactory" // parameterのバリデーションやケース変換 import { DocumentEntityClass } from "./documentEntityClass" // responseのケース変換やオブジェクト化 const documentGetRequest = repositoryFactory.documentDiff.getParam() documentGetRequest.documentId = documentId const documentEntity = new DocumentEntityClass() const response = await repositoryFactory.documentDiff.get({ documentGetRequest }) documentEntity = response.data
新しい API コール
import { typedAxios } from "./client" import { getV2DocumentDocumentId } from "./generated/sdk.gen" getV2DocumentDocumentId({ client: typedAxios, path: { documentId }, })
まとめ
API 定義 JSON から型だけを出力しても、認知負荷の低い API コールの実現は難しいことが分かりました。型のみでは結局、認知負荷を下げるために共通処理に複雑さが必要になってしまいます。
共通化するのではなく、シンプルな成果物に変換できる機構が必要でした。頑張って共通化し、インターフェースがシンプルになれば実装が捗ると思っていましたが、複雑さのシワ寄せとして AI のコード生成精度に影響するという気づきも得ることができました。
また今回の選定においては早めにプロトタイプを実装したことが良かった点だと振り返って思います。やりたいことや実現したいことの中核はぼんやりありましたが、実際に手を動かしてみることで比較するべき観点や実装イメージが具体化されました。ドキュメントに記載のない思わぬ欠点を早めに検知したことも収穫でした。
ご精読ありがとうございました。こうした技術的な意思決定のプロセスや、MNTSQ の日々の開発の進め方にご興味を持っていただけた方は、ぜひお気軽にカジュアル面談でお話ししましょう。