プロジェクトにOpenAPI Generatorで自動生成された型付きAPI Clientを導入した話

[スターフェスティバル Advent Calendar 2020](https://qiita.com/advent-calendar/2020/stafes) の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
```

生成コマンドは下記のとおり。

```json
// 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を採用しました。その他のオプションについては[こちら](https://openapi-generator.tech/docs/generators)。

```specConfig.json
{
  "modelPropertyNaming": "snake_case",
  "enumPropertyNaming": "snake_case"
}
```
詳細なConfigの設定ファイルです。このプロジェクトではAPIでやりとりするプロパティ名は全てsnake_caseを採用しているため、生成される型のプロパティ名のnamingをsnake_caseに指定しました。その他のオプションは[こちら](https://openapi-generator.tech/docs/generators/typescript-fetch)。

## Spec

メインのspecを書いていきます。実際のプロジェクトには存在しませんが、参考までにsampleとして`GET:/products`,`POST:/products`という名前のエンドポイントのspecを書きます。

```sample.yml
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から生成される型は下記のような感じです。

```typescript
// 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されていると仮定します。

```typescript
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の実装

```typescript
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型になる。
当たり前のことを書いていますが、

```yaml
status:
  type: string
  enum: [hoge, fuga]
```

のように書いた場合、生成される型は

```typescript
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で返すために、

```yml
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導入というタスクに直面した時は、なにをどうすれば実現できるのかさっぱりイメージできていませんでしたが、アドバイス・レビューをいただいて、調べながらなんとか導入することができました。

まだまだ使い勝手を改善できる可能性があるので、使いながら試していきたいと思います。