OpenAPI Generatorを使ったスキーマ駆動開発でAPI仕様と型安全な実装を同時に得るための方法

本記事は [CBcloud Advent Calendar 2020](https://qiita.com/advent-calendar/2020/cbcloud) の8日目の記事です。

---


## TL;DR
* 書くこと：OpenAPI Generatorの紹介と実例
* 書かないこと：
    * TypeScript + Axios以外の環境での実践方法
    * 記事内で登場するライブラリの細かな紹介

## モチベーション
* ドキュメントが無いことが只々ツラいからどうにかしたい
* WebAPIの戻り値をフロント側で愚直に型定義するのをやめたい
* バックエンド・フロントそれぞれの都合だけでAPIの仕様を決めない未来を作りたい
    * ドメインモデルを検討する上では双方歩み寄れる筈

## OpenAPI Gereratorとは？
そもそもの[**OpenAPI**](https://swagger.io/specification/)は何かというと、**OpenAPI Specification**というREST APIのドキュメントなどを記述する形式のことを指します。
宣言的なDSLを使ってAPIのスキーマを定義できます。

で、[**OpenAPI Gererator**](https://github.com/OpenAPITools/openapi-generator)は何かというと、OpenAPI形式で記述されたYAMLやJSONファイルを元にコードを生成してくれるツールのことです。
JavaやRuby、PHP、Typescriptといった言語のAPIクライアントから、C++やPython、Rustといった言語のスタブまで様々なツールを生成できるようです。

今回はこのOpenAPI GereratorからTypeScript + Axiosのテンプレートを使い、型安全なAPIクライアントを生成してみたいと思います。

## 試してみる
### インストール
とりあえずTypeScriptとOpenAPI Generatorが動く環境を作ります。

```sh
$ mkdir openapi && cd openapi

// NPMモジュールのインストール
$ yarn add typescript
$ yarn add @openapitools/openapi-generator-cli
```

ディレクトリ・ファイルの構成は以下のようにしました。
```sh
$ tree -I node_modules
.
├── docs
├── src
│   └── js
└── tsconfig.json
```

### APIファイルを生成する
まずは`./docs`配下でスキーマを定義します。
何でもいいのですが、とりあえず定番っぽいBook APIとしました。
（業務で利用しているスキーマはうまく取り込めなかった）

```docs/schema.yml
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を動かしてみましょう。

```sh
$ npx openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios
```

1. `typescript-axios`というテンプレートを使い
2. `./docs/schema.yml`というスキーマ定義から
3. `./src/js/api/client-base`というディレクトリにAPIクライアントファイルを生成する

というコマンドです。

生成後はこんな感じになりました。

```sh
$ 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
```

```api.ts
...

/**
 * 
 * @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してみましたが、ちゃんと型が定義されていました。
素晴らしい。

```index.ts
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`には設定ファイルを読み込むオプションがあるので、クライアントと型定義を別のファイルにするよう指示してあげましょう。

```config.yml
withSeparateModelsAndApi: true // APIクライアントとモデルを分ける
apiPackage: 'api' // APIクライアントファイルが格納されるディレクトリ名
modelPackage: 'domains' // モデルが格納されるディレクトリ名
```

この状態でコマンドを実行します。

```sh
// 末尾の -c オプションを追加
$ npx openapi-generator-cli generate -i ./docs/schema.yml -o ./src/js/api/client-base -g typescript-axios -c ./docs/config.yml
```

できました。
以下のような構成になり、APIクライアントとモデルが分離されたことが分かります。

```sh
.
├── 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`という名前を持たせることとしました。

例によって設定ファイルに記述します。

```config.yml
...
modelNameSuffix: 'Response'
```

以下の結果となりました。

```book-response.ts
...

/**
 *
 * @export
 * @interface BookResponse
 */
export interface BookResponse {
  /**
   *
   * @type {string}
   * @memberof BookResponse
   */
  title: string;
}

```

以下のようなケースで使えると思います。

- 実際のAPIのレスポンスの責務が肥大化して、そのままフロントのドメインとして取り扱うことが困難
- フロントが既にドメインモデルの型定義を利用してしまっている

### 自前のAxiosを差し込む
自身でAxiosのインスタンスを作り、APIクライアントのコアとして差し込むこともできます。

```index.ts
...

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`に引き渡して整形してもらいました。

```package.json
{
  "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",
    ...
  }
}
```

```config.yml
...
enablePostProcessFile: true
```

### 固定したいファイルや不要なファイルを再生成しないようにする
`.openapi-generator-ignore`というファイルにファイル名やディレクトリ名を記載することで、対象のファイルを固定したり再生成しないようにすることができます。
書き方は`.gitignore`と同じです。

生成されたファイルに使わなそうなものがあったので、それらを弾いた上でGitの追跡から外しました。

```.openapi-generator-ignore
.openapi-generator
.gitignore
.npmignore
git_push.sh
```

### スキーマの変更をWatchして常にBuildする
スキーマファイルを変更したら勝手にBuildしてほしいので、ファイルの変更を監視して自動で再Buildが走るようにしました。

これは`watch`というNPMモジュールをインストールして利用すればいいだけなので、簡単です。

```package.json
  "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`などを使いましょう。

```json
{
  "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の場合は実装が先行できてしまうので、運用ルールをしっかり決めておく必要があると思います。

ちなみに実際に動作する環境は[こちら](https://github.com/dadayama/openapi-generator-trial)。

## 参考資料
- [SmartHR Tech Blog - OpenAPI Generator と TypeScript で型安全にフロントエンド開発をしている話](https://tech.smarthr.jp/entry/2020/08/25/135631)
- [OPTIM TECH BLOG - 俺的【OAS】との向き合い方 (爆速でOpenAPIと友達になろう)](https://tech-blog.optim.co.jp/entry/2020/04/13/100000)
- [OpenAPI Generator - File post-processing](https://openapi-generator.tech/docs/file-post-processing/)
- [WEB CAKE - npm scriptsでPugとSassを同時にwatchする](https://webcake.stars.ne.jp/npm-scripts-pug-sass-watch.html)