スターフェスティバル Advent Calendar 2020 の10日目です。
これまでOpenAPIで仕様書を管理し、それに基づいてAPI・Frontendの開発を別途行っていたプロジェクトにて、この度OpenAPI Generatorで自動生成されたAPI Clientを導入しました。自分としてはかなり印象深い経験だったので、その記録を書きたいと思います。
導入したアプリケーションで採用している言語・フレームワークは下記のとおりです。
- API : Node.js (Express) + TypeScript
- Frontend : Next.js + TypeScript
なぜ導入するのか
これまでの開発では、API側ではFrontendから送られると思われるRequestの型を、Frontend側ではAPIから返却されるであろうResponseの型を、それぞれ手動で型定義していました。プロダクトリリース当初のエンドポイントの少ないうちはそれでも事足りましたが、エンドポイントが徐々に増えてきてアプリケーションも複雑になってくると、手動での型定義はかなりしんどくなってきました。
また、仕様書とソースコードを別々に管理していたため、仕様と実装のズレも出てくるようになってしまいました。もちろんAPIの変更時にはspecを同時に修正するよう注意してきましたが、hotfix対応などが発生した場合は実装が優先的に変更され、仕様書の変更が忘れ去られるなど、ほんの小さな出来事に仕様書は傷ついていきました。
OpenAPI Generatorの導入によって、下記のようなメリットがあると期待しました。
- 手動でRequest,Response型を定義しなくてもよくなる。
- プロパティ名が違うなどの細かなミスを見つけやすくなる。
- FrontendとBackendを同時に異なる人が開発しやすくなる。
- 仕様書ファーストの開発となるため、仕様書の管理が徹底される。
導入方法
API,Frontend双方からnpm packageとしてinstallして使用することを想定するため、API Client専用に新たにリポジトリを作り、そこに下記のようにspecファイルを置きました。
├── package.json
├── spec
│ └── sample.yml
├── specConfig.json
└── tsconfig.json
事前準備
必要なpackageをinstallします。
$ npm i -D @openapitools/openapi-generator-cli typescript
生成コマンドは下記のとおり。
// npm scripts
{
"scripts": {
"generate": "openapi-generator generate -i ./spec/sample.yml -g typescript-fetch -o ./src/sample -c ./specConfig.json --additional-properties=npmName=api_name,supportsES6=true,typescriptThreePlus=true",
"build": "tsc"
}
}
npm run generate && npm run build
で生成されます。
generatorオプションは依存ライブラリ不要のtypescript-fetchを採用しました。その他のオプションについてはこちら。
{
"modelPropertyNaming": "snake_case",
"enumPropertyNaming": "snake_case"
}
詳細なConfigの設定ファイルです。このプロジェクトではAPIでやりとりするプロパティ名は全てsnake_caseを採用しているため、生成される型のプロパティ名のnamingをsnake_caseに指定しました。その他のオプションはこちら。
Spec
メインのspecを書いていきます。実際のプロジェクトには存在しませんが、参考までにsampleとしてGET:/products
,POST:/products
という名前のエンドポイントのspecを書きます。
paths:
/products:
get:
tags:
- product
summary: 一覧取得
description: product一覧を取得
operationId: getProducts
parameters:
- name: limit
in: query
description: 取得件数
example: 0
schema:
type: number
default: 20
required: false
responses:
'200':
description: OK
content:
application/json:
schema:
$ref: '#/components/schemas/getProductsResponse'
'404':
$ref: '#/components/responses/NotFoundError'
'500':
$ref: '#/components/responses/InternalServerError'
post:
tags:
- product
summary: productの新規登録
description: 任意の個数のproductを新規登録する
operationId: registerProducts
requestBody:
description: 登録するproduct情報
content:
application/json:
schema:
$ref: '#/components/schemas/registerProductsRequest'
required: true
responses:
'200':
description: created
content:
application/json:
schema:
$ref: '#/components/schemas/registerProductsResponse'
'403':
$ref: '#/components/responses/Forbidden'
'500':
$ref: '#/components/responses/InternalServerError'
components:
schemas:
getProductsResponse:
type: object
required:
- products
properties:
products:
type: array
description: productリスト
items:
type: object
required:
- product_id
- product_name
properties:
product_id:
type: number
description: productID
product_name:
type: string
nullable: true
description: product名
other_property:
type: string
description: その他のプロパティ
registerProductsRequest:
type: object
required:
- products
properties:
products:
type: array
description: 登録するproductリスト
items:
type: object
required:
- product_name
- price
properties:
product_name:
type: string
description: product名
price:
type: number
description: productの価格
other_property:
type: string
description: その他のプロパティ
registerProductsResponse:
type: object
required:
- product_ids
properties:
product_ids:
type: array
description: 登録成功したproductのidリスト
items:
type: number
Request,Responseのschemaは#/components/schemas
に定義しておけば、その名前のモデルが生成されます。
このときpathsの中の各エンドポイントのschemaに直接オブジェクトを記載すると、生成される型がInlineObjectxxxx
といった無名のモデルになってしまい、エンドポイント名・メソッド名と関連した名前ではないため使用するときにわかりにくくなってしまいます。
このspecから生成される型は下記のような感じです。
// GET: /products
// Request
export interface GetProductsRequest {
limit?: number;
}
// Response
export interface GetProductsResponse {
products: Array<GetProductsResponseProducts>;
}
export interface GetProductsResponseProducts {
product_id: number;
product_name: string | null;
other_property?: string;
}
// POST: /products
// Request
export interface RegisterProductsRequest {
products: Array<RegisterProductsRequestProducts>;
}
export interface RegisterProductsRequestProducts {
product_name: string;
price: number;
other_property?: string;
}
// Response
export interface RegisterProductsResponse {
product_ids: Array<number>;
}
APIの実装
API Clientのリポジトリからsample-api-client
という名前でpublishされていると仮定します。
import { Handler, Request, Response } from 'express';
import { Sample } from 'sample-api-client';
export const getProducts: Handler = async (
req: Request<any, any, Sample.GetProductsRequest>,
res: Response<Sample.GetProductsResponse>,
) => {
const { limit } = req; // limit is a `number | undefined`
// ...impl
res.status(200);
res.json({ products }); // products is `Array<GetProductsResponseProducts>`
});
export const registerProducts: Handler = async (
req: Request<Sample.RegisterProductsRequest>,
res: Response<Sample.RegisterProductsResponse>,
) => {
const { products } = req; // products is `Array<RegisterProductsRequestProducts>`
// ...impl
res.status(202);
res.send({ product_ids }); // product_ids is `Array<number>`
});
Request型のジェネリクスにはパラメータの種類に応じた型を入れています。これでreq,resに型を与えることができます。
Frontendの実装
import { Sample } from 'sample-api-client';
const ProductsApiClient = new Sample.ProductsApi();
(async () => {
// GET: /products
const result = await ProductsApiClient.getProducts({
requestParameters: {
limit: 10, // limit is a `number`
},
});
const { products } = result; // products is `Array<GetProductsResponseProducts>`
// POST: /products
const result = await ProductsApiClient.registerProducts({
products, // products is `Array<RegisterProductsRequestProducts>`
});
const { product_ids } = result; // product_ids is `Array<number>`
})();
導入によって改善された点
上記の実装でAPI Clientからimportして使用している型は、もともと全て手動で型定義をしていたため、単純な記述量を大幅に削減することができました。また手動で型を定義していたことによる定義ミスの心配もなくなりました。
そして、これまではAPIの変更をするときは仕様書も変更する!というルールだったのに対し、仕様書を変更しなければAPIを開発できない状態となったため、仕様書の立場が急上昇しschemaファーストな開発体制となりました。
課題点
いくつかクセのある部分や気になった点があったので、課題として挙げておこうと思います。
schema名とoperationIdの命名について
schema名はModel名に、operationIdはメソッド名になるため、使用する箇所でエンドポイントを連想できるような命名にしておく必要があります。specのところでも書きましたが、paths>schemaにインラインでResponse内容を記載すると無名のModelになってしまうほか、Responseのトップレベルに直接別のschemaを入れた場合にも、期待する命名の型を生成することができませんでした。
enumを指定すると、Enum型になる。
当たり前のことを書いていますが、
status:
type: string
enum: [hoge, fuga]
のように書いた場合、生成される型は
export declare enum SomeResponseStatusEnum {
hoge = "hoge",
fuga = "fuga",
}
となります。API Clientを導入するまで、アプリケーション全体を通してEnum型は使用しておらず、列挙する必要がある場合はUnionTypesを使用して'hoge' | 'fuga'
と書いていました。openapi-generatorでUnionTypesの型を生成する方法が見つからなかったため、APIの型にのみEnum型を許容せざるを得ませんでした。
format: date
とするとDate型になる。
導入前までの仕様書には、stringのYYYY-MM-DDを返却している箇所でformat: date
と書いていました。当たり前かもしれませんが、openapi-generatorにかかればこれはDateオブジェクトを期待する書き方となるため、stringで返すために、
type: string
format: YYYY-MM-DD
example: 2020-12-10
として対応しました。
queryパラメータの型だけcamelCaseで生成されてしまう。
これはバグですが、generatorのconfigでnamingをsnake_caseと指定していても、queryパラメータの型だけはcamelCaseで生成されてしまいました。現時点で次回リリースバージョンにこのバグfixの変更が含まれているようなので、そのリリースを待ちたいと思います。
バージョン管理が面倒になる
これはAPI Clientに限らずnpm packageをpublishした場合に共通する話ですが、今回のAPI ClientはFrontend,API双方から同じバージョンをimportして使用する必要があり、一つ変更があるとバージョンをあげてpublish, 使う側で再installするという作業が発生します。
当初はバージョンを手動で管理していたのですが、開発が進むにつれて更新が面倒になってきたので、npm version scriptをつかった自動更新workflowの導入を検討しています。
まとめ
最初このAPI Client導入というタスクに直面した時は、なにをどうすれば実現できるのかさっぱりイメージできていませんでしたが、アドバイス・レビューをいただいて、調べながらなんとか導入することができました。
まだまだ使い勝手を改善できる可能性があるので、使いながら試していきたいと思います。