本記事は CBcloud Advent Calendar 2020 の8日目の記事です。
TL;DR
- 書くこと:OpenAPI Generatorの紹介と実例
- 書かないこと:
- TypeScript + Axios以外の環境での実践方法
- 記事内で登場するライブラリの細かな紹介
モチベーション
- ドキュメントが無いことが只々ツラいからどうにかしたい
- WebAPIの戻り値をフロント側で愚直に型定義するのをやめたい
- バックエンド・フロントそれぞれの都合だけでAPIの仕様を決めない未来を作りたい
- ドメインモデルを検討する上では双方歩み寄れる筈
OpenAPI Gereratorとは?
そもそものOpenAPIは何かというと、OpenAPI SpecificationというREST APIのドキュメントなどを記述する形式のことを指します。
宣言的なDSLを使ってAPIのスキーマを定義できます。
で、OpenAPI Gereratorは何かというと、OpenAPI形式で記述されたYAMLやJSONファイルを元にコードを生成してくれるツールのことです。
JavaやRuby、PHP、Typescriptといった言語のAPIクライアントから、C++やPython、Rustといった言語のスタブまで様々なツールを生成できるようです。
今回はこのOpenAPI GereratorからTypeScript + Axiosのテンプレートを使い、型安全なAPIクライアントを生成してみたいと思います。
試してみる
インストール
とりあえずTypeScriptとOpenAPI Generatorが動く環境を作ります。
$ mkdir openapi && cd openapi
// NPMモジュールのインストール
$ yarn add typescript
$ yarn add @openapitools/openapi-generator-cli
ディレクトリ・ファイルの構成は以下のようにしました。
$ tree -I node_modules
.
├── docs
├── src
│ └── js
└── tsconfig.json
APIファイルを生成する
まずは./docs
配下でスキーマを定義します。
何でもいいのですが、とりあえず定番っぽいBook APIとしました。
(業務で利用しているスキーマはうまく取り込めなかった)
openapi: 3.0.0
info:
title: sample
version: '1.0'
servers:
- url: http://localhost:4010
paths:
'/api/books/{book_id}':
get:
operationId: fetchBook
parameters:
- name: book_id
in: path
schema:
type: string
required: true
style: simple
explode: false
responses:
'200':
description: 'OK'
content:
application/json:
schema:
$ref: '#/components/schemas/book'
components:
schemas:
book:
type: object
properties:
title:
type: string
required:
- title
ではGeneratorを動かしてみましょう。
$ npx openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios
-
typescript-axios
というテンプレートを使い -
./docs/schema.yml
というスキーマ定義から -
./src/js/api/client-base
というディレクトリにAPIクライアントファイルを生成する
というコマンドです。
生成後はこんな感じになりました。
$ tree -I node_modules
.
├── docs
│ └── schema.yml
├── openapitools.json
├── src
│ └── js
│ └── api
│ ├── client-base
│ │ ├── api.ts
│ │ ├── base.ts
│ │ ├── configuration.ts
│ │ ├── git_push.sh
│ │ └── index.ts
│ └── index.ts
└── tsconfig.json
...
/**
*
* @export
* @interface Book
*/
export interface Book {
/**
*
* @type {string}
* @memberof Book
*/
title: string;
}
...
/**
* DefaultApi - object-oriented interface
* @export
* @class DefaultApi
* @extends {BaseAPI}
*/
export class DefaultApi extends BaseAPI {
/**
*
* @param {string} bookId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof DefaultApi
*/
public fetchBook(bookId: string, options?: any) {
return DefaultApiFp(this.configuration).fetchBook(bookId, options).then((request) => request(this.axios, this.basePath));
}
}
...
スキーマで定義したAPIレスポンスの型と、APIを叩くためのメソッドが生成されていますね。
試しに別のTSファイルからimportしてみましたが、ちゃんと型が定義されていました。
素晴らしい。
import { DefaultApi } from './client-base';
const api = new DefaultApi();
const response = await api.fetchBook('1');
const book = response.data; // Book型になっている
これだけでもいいのですが、実運用する上ではもう少し考える必要があるかなと思います。
その辺りのTipsを以下に紹介します。
Tips
APIクライアントファイルとモデルのファイルを分ける
APIクライアントとモデルが同じファイルに入ってしまっているのが嫌だったので、分けました。(この辺は好みかもですが…)
openapi-generator-cli
には設定ファイルを読み込むオプションがあるので、クライアントと型定義を別のファイルにするよう指示してあげましょう。
withSeparateModelsAndApi: true // APIクライアントとモデルを分ける
apiPackage: 'api' // APIクライアントファイルが格納されるディレクトリ名
modelPackage: 'domains' // モデルが格納されるディレクトリ名
この状態でコマンドを実行します。
// 末尾の -c オプションを追加
$ npx openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios -c ./docs/config.yml
できました。
以下のような構成になり、APIクライアントとモデルが分離されたことが分かります。
.
├── docs
│ ├── config.yml
│ └── schema.yml
├── openapitools.json
├── src
│ └── js
│ └── api
│ ├── client-base
│ │ ├── api
│ │ ├── api.ts // APIクライアント
│ │ ├── base.ts
│ │ ├── configuration.ts
│ │ ├── domains
│ │ │ ├── book.ts // モデル
│ │ │ └── index.ts
│ │ └── index.ts
│ └── index.ts
型定義に共通の命名規則を持たせる
生成されるモデル(APIのレスポンスの型)には独自の命名規則を持たせることが可能です。
今回はSuffixとしてResponse
という名前を持たせることとしました。
例によって設定ファイルに記述します。
...
modelNameSuffix: 'Response'
以下の結果となりました。
...
/**
*
* @export
* @interface BookResponse
*/
export interface BookResponse {
/**
*
* @type {string}
* @memberof BookResponse
*/
title: string;
}
以下のようなケースで使えると思います。
- 実際のAPIのレスポンスの責務が肥大化して、そのままフロントのドメインとして取り扱うことが困難
- フロントが既にドメインモデルの型定義を利用してしまっている
自前のAxiosを差し込む
自身でAxiosのインスタンスを作り、APIクライアントのコアとして差し込むこともできます。
...
const instance = axios.create(); // Axiosインスタンス
export const api = new DefaultApi({}, '', instance);
Axiosのinterceptors
を使いたい時などに便利です。
BuildしたAPIファイルを整形する
TS_POST_PROCESS_FILE
という環境変数の値にコマンドを引き渡すことで、Build後のファイルに処理を施すことができます。
export
やdirenv
で定義してもいいのですが環境依存になりそうなので、NPM Scriptsにベタ書きする方がいいかなと思います。
またこの時、先程の設定ファイルにも追記が必要です。
今回はprettier
に引き渡して整形してもらいました。
{
"scripts": {
"build:api": "TS_POST_PROCESS_FILE='./node_modules/.bin/prettier --write' openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios -c ./docs/config.yml",
...
}
}
...
enablePostProcessFile: true
固定したいファイルや不要なファイルを再生成しないようにする
.openapi-generator-ignore
というファイルにファイル名やディレクトリ名を記載することで、対象のファイルを固定したり再生成しないようにすることができます。
書き方は.gitignore
と同じです。
生成されたファイルに使わなそうなものがあったので、それらを弾いた上でGitの追跡から外しました。
.openapi-generator
.gitignore
.npmignore
git_push.sh
スキーマの変更をWatchして常にBuildする
スキーマファイルを変更したら勝手にBuildしてほしいので、ファイルの変更を監視して自動で再Buildが走るようにしました。
これはwatch
というNPMモジュールをインストールして利用すればいいだけなので、簡単です。
"scripts": {
"build:api": "TS_POST_PROCESS_FILE='./node_modules/.bin/prettier --write' openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios -c ./docs/config.yml",
"watch:api": "watch 'yarn run build:api' ./docs", // ./docs以下のファイルの変更を検知してbuild:apiを実行
...
},
補足すると、watch
コマンドのプロセスはKILLするまで延々と動くので、仮にdev: yarn run watch:api && yarn run ${次のコマンド}
と書いてもwatch
が終了せず、次のコマンドが走らないので注意が必要です。
watch
の後で別のコマンドを動かしたい場合は、並列でコマンドを実行してくれるconcurrently
などを使いましょう。
{
"scripts": {
"build:api": "TS_POST_PROCESS_FILE='./node_modules/.bin/prettier --write' openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios -c ./docs/config.yml",
"watch:api": "watch 'yarn run build:api' ./docs",
"serve:api": "prism mock -h 0.0.0.0 ./docs/schema.yml",
...
"dev": "concurrently 'yarn run watch:api' 'yarn run serve:api'" // concurrentlyを使って並列にコマンドを実行する
},
}
感想
以下の点で非常に良いと感じました。
- 型の強い恩恵を受けられる
- 仕様が自動で型になるため、非属人的になる
-
妄想で実装を読み解いて型定義をしなくて良い
-
- 仕様書を書くモチベーションに繋がる
- ※ というか導入したら書かないと終わる
- API(の仕様)がフロントエンド・バックエンドの双方の関心になる
まとめ
導入しない手はないですね。
ただRESTの場合は実装が先行できてしまうので、運用ルールをしっかり決めておく必要があると思います。
ちなみに実際に動作する環境はこちら。