🤖

OpenAPI定義からTypeScript型を生成し、フロントエンド・バックエンド間でスキーマ駆動開発

2022/09/18に公開約7,400字

最近、案件でGraphQLを使ったスキーマ駆動開発を行ったところ体験が非常に良かったため、OpenAPIでもスキーマ駆動開発を試してみました。

普及度でいうとOpenAPI Generatorの方が高そうですが、今回はAspidaエコシステムに乗ってみます。

Aspidaファミリーのopenapi2aspidaでOpenAPI YAMLファイルからTypeScriptの型定義を生成し、フロントエンド・バックエンドでimportして利用するようにします。また、バックエンドフレームワークにExpressを使用しているという前提で、express-openapi-validatorを設定しOpenAPIスキーマを元によしなにバリデーションするようにします。

Aspida選定の理由

OpenAPI GeneratorがJava製なのに対し、AspidaはTypeScript製のため、フロントエンドとバックエンドをTypeScriptで開発している場合はnpm経由で比較的ハマらず導入しやすい点、また、個人的にAspidaの方が生成される型が好みだった点があります。

フロントエンドとバックエンドの言語がTypeScriptでない場合は、クロスプログラミング言語対応のOpenAPI Generatorを選定する、もしくはツールを併用するなど検討する必要がありそうです。

環境

  • node 16.17.0
  • npm 8.19.2
  • typescript 4.5.4
  • aspida 1.11.0
  • openapi2aspida 0.19.0
  • express 4.18.1
  • express-openapi-validator 4.13.8
  • husky 7.0.4
  • lint-staged 12.1.7

サンプルOpenAPIファイル

まず、以下のようなOpenAPIファイルを用意しました。

openapi: 3.0.0
info:
  title: SampleOpenApi
  version: 0.0.0

servers:
  - url: http://localhost:3000
    description: Local
  - url: http://api.example.com
    description: Production

paths:
  /users:
    post:
      operationId: users.post
      description: Create user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateUserParams'
      responses:
        '200':
          description: OK.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateUserResult'
components:
  schemas:
    CreateUserParams:
      description: Create user params.
      type: object
      properties:
        name:
          type: string
          example: 田中太郎
      required:
        - name
    CreateUserResult:
      description: Create user result.
      type: object
      properties:
        id:
          type: string
          example: U1234
        name:
          type: string
          example: 田中太郎
      required:
        - id
        - name

openapi2aspidaでTS型定義ファイルを生成

使い方

以下の生成コマンドを叩きます。

openapi2aspida -i ./openapi.yml -o src/generated/api

上記コマンドの場合、 src/generated/api/@types/index.ts に以下のような型定義が生成されます。

src/generated/api/@types/index.ts
/* eslint-disable */
/** Create user params. */
export type CreateUserParams = {
  name: string
}

/** Create user result. */
export type CreateUserResult = {
  id: string
  name: string
}

生成された型定義をフロントエンドとバックエンドでimportして利用

生成された型定義をアプリケーションコードでimportして利用します。

import { CreateUserParams, CreateUserResult } from './generated/api/@types'

Aspidaが生成する型は基本的に「APIクライアント」としての用途を意図していると思いますが、バックエンドAPI側におけるリクエストとレスポンスの型付けとしても使用可能であり静的解析の恩恵をプラスできると考えています(この点、もしより良い方法をご存知の方がいらっしゃればコメント頂ければと思います)。

上書きの際は先に既存ファイルを削除する

上書きでコード生成しようとすると下記のエラーが発生することがあります。

Error: ENOENT: no such file or directory, scandir 'api'

こちらを回避するために、先に rm -rf src/generated/api を実行し既存ファイルを削除するようにします。npm scriptsにまとめるなら次のようになるでしょう。

{
  "scripts": {
    "codegen": "rm -rf src/generated/api && openapi2aspida -i ../../openapi.yml -o src/generated/api"
  }
}

ぽふ@あとべさんはTwitterを使っています: 「ほんとにaspida、pathpidaは良いライブラリ。 aspidaでopenapi2aspidaの実行をしたときに既に出力してあるものの上書きが出来ないから、実行コマンドにrm -rf 混ぜてディレクトリ消してから実行してるけどみんなそんな感じなのかな」 / Twitter

上記のTwitterスレッドで作者のSolufaさんが肯定しているため、こちらの対処で合っていると思われます。

git管理はしない方針

自動生成されたTSコードをgit管理に含めるべきかは悩ましいところです。

以下はOpenAPIでなくGraphQLツールのDiscussionですが参考にしてみます。

Do people check their generated files into Git? · Discussion #4253 · dotansimha/graphql-code-generator

上記の議論を参考に、git管理しない方針に寄せてみます。 .gitignoregenerated ディレクトリを加えます。

+generated

git管理しない場合、 openapi に変更があった際にコード再生成を忘れてしまう懸念があるので、 package.jsonscripts.preparenpm install の度に実行する、husky+lint-stagedを使ってファイルに変更がある度に実行するなどの設定をすると良さそうです。

{
  "scripts": {
    "prepare": "husky install && npm run codegen"
  }
}
{
  "lint-staged": {
    "openapi.yml": [
      "bash -c 'npm run codegen'"
    ]
  }
}

express-openapi-validatorでAPIバリデーション

バックエンドフレームワークにExpressを使っている場合、express-openapi-validatorを組み合わせることでバリデーションをある程度自動で設定することができます。

Expressのミドルウェアに加えることで簡単に導入できます。

import 'express-openapi-validator'

app.use(
  openApiValidator.middleware({
    apiSpec: './openapi.yml',
    validateRequests: true,
    validateResponses: true,
  }),
);

In my opinion

なぜスキーマファーストか

本記事は全体的に、スキーマファーストアプローチを採り、OpenAPIスキーマファイルを正とすることを強めに意識したものになります。

コードファーストとしてフレームワークやライブラリからOpenAPIファイルを生成する利点も大きいと思うのですが、一言語の一実装よりも複数言語間で標準化された仕様であるOpenAPI仕様の方が技術としての寿命が長いと考えるため、どちらかといえば開発者がスキーマファイルをメンテする形の方が好ましいと考えています。

また、コードファーストアプローチは基本的にバックエンドコードからスキーマファイルを生成するため、フロントエンドエンジニアがバックエンドの実装を待つケースが発生するように感じました。この点、スキーマファーストの場合は最初にスキーマファイルさえ策定できれば、フロントエンドエンジニアはMSWなどでモックを定義し即開発を始めるといった動きがしやすいと思っています。

最も、今の所自分はコードファースト開発の経験がないので、経験してみたら考えが変わるかもしれません。

型定義のみ利用し、ツールと一定の距離感を保つ選択肢

ここは特に個人的な考えが強いところになりますが、生成されたAPIクライアント実装は利用せず、型定義だけ利用するようにすると利便性・ツールとの距離感においてバランスが良くなるのではないかと思っています。

長期的に見ると、

  • ツールのトレンド状況が大きく変化した
  • 現在採用しているツールでは実現が難しい要求に突き当たった

などの理由によりツールを乗り換えたくなる可能性はあると考えるためです。

TypeScript の型生成における OpenAPI Generator のハマりどころ - READYFOR Tech Blog

READYFOR ではフロント側のジェネレーターとして typescript-fetch を選択しています。 また、生成されたコードのうち API クライアントは使用せずに型定義のみを使用しています。

こちらのブログによると、(AspidaではなくOpenAPI Generatorなのと、同じ理由かは不明ですが)READYFORさんも近い選択をしているようです。

一方、APIクライアントを使わないことでせっかくのAspidaのメリットをかなり失ってしまうことも事実であり、開発速度を重視しツールに強く依存する選択をすることももちろん有力です。その場合も、いざという時の乗り換えコストを見積もっておいたり、いわゆる「式年遷宮」的に大幅にコードを書き換える工数が発生する可能性があることをステークホルダーと握っておくなどの備えはしておくに越したことはないでしょう。

参考

Discussion

ログインするとコメントできます