3
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

posted at

updated at

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

本記事は 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

ディレクトリ・ファイルの構成は以下のようにしました。
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を動かしてみましょう。

$ 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クライアントファイルを生成する

というコマンドです。

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

$ 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' // モデルが格納されるディレクトリ名

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

// 末尾の -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という名前を持たせることとしました。

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

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後のファイルに処理を施すことができます。
exportdirenvで定義してもいいのですが環境依存になりそうなので、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
.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などを使いましょう。

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

ちなみに実際に動作する環境はこちら

参考資料

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
3
Help us understand the problem. What are the problem?