BASEプロダクトチームブログ

ネットショップ作成サービス「BASE ( https://thebase.in )」、ショッピングアプリ「BASE ( https://thebase.in/sp )」のプロダクトチームによるブログです。

OpenAPI Generator で API Client と型を自動生成した話

タイトル画像

フロントエンドエンジニアの @rry です。

自分は BASE の Sales Promotion というチームで主に新規機能開発を行っています。このチームでは主にオーナーさんの使う管理画面に新しく機能追加をしています。

そこで、管理画面で使っている API Client と型を、OpenAPI Generator を使って自動生成するようにしてみたのでそのお話を書きたいと思います。

そもそも OpenAPI とは?

https://www.openapis.org/

OpenAPI とは、RESTful Web サービスを記述、生成、使用、および視覚化するための仕様です。

※ 以前は OpenAPI ではなく仕様自体も Swagger と呼ばれていましたが、現在は仕様自体については OpneAPI と呼ばれており、Swagger というのは OpenAPI を使ったツール群のことをさすようになりました。まぎらわしいので Swagger ではなく主に OpenAPI と呼びます(ツール群のほうも「OpenAPI のツール」と呼んでいきます)

BASE では YAML ファイルで記述しています。

OpenAPI とそのツール群を使うことでなにができる?

  • API の仕様(スキーマ)を定義
    • 定義の一元管理ができる
  • API ドキュメントを生成
    • ドキュメントのメンテナンスが楽
  • API モックサーバーを立てられる
    • API が出来上がっていなくても先にフロントエンドの開発ができる
  • API Client を自動生成
    • API Client のコードをフロントエンドで書かずにすむ!
  • API リクエスト・レスポンスの型を自動生成
    • スキーマから生成した型を使うことでより型安全になる
  • 周辺ツールでいろいろできる
    • バックエンドの実装と定義したスキーマが乖離した場合に自動テストが落ちるようにしたりもできる
      • バックエンドの実装が乖離しないようにできる

OpenAPI のようなスキーマを中心にした開発のことを、「スキーマ駆動開発」といいます。

OpenAPI を使ったスキーマ駆動開発をすることでなにがうれしいの?どういう問題を解決するの?というところは、以下のスライドが参考になるのでそちらをどうぞ。

BASE 既存システムへの OpenAPI 導入の背景について

BASE では最近カートの大規模リプレイスを行いました。 BASE Tech Talk #1 〜Next.jsを使ったカート大規模リプレイスPJの裏側〜 - connpass

新しいカートのアーキテクチャでは、既に OpenAPI が導入されておりスキーマ駆動開発を行っていました。自分もフロントエンドの開発で OpenAPI から生成した API Client を利用したりしていました。

しかし BASE のオーナーさんの使う管理画面など、カート以外のシステムでは既存の API 定義は OpenAPI ではなく API Blueprint を利用していました。

API Blueprint を使った開発では

  • API の仕様(スキーマ)を定義
  • API ドキュメントを生成
  • API モックサーバーを立てられる

これらのことはできますが、API Client や型を自動生成することはできず、毎回手動で API Client と型を定義していました。

手動で定義したり API の仕様が変わったときにそれらの追従をすることが大変だと思い、カート開発のときと同様の開発体験を得たかった自分は「今回の PJ から OpenAPI を使ったスキーマ駆動開発をしよう!API Client と型を自動生成していこう!」と呼びかけ、そのための仕組みを導入することにしました。

API Client って何?自動生成ってどういうこと?

API にリクエストを送るためのコードを API Client と呼んでいます。

// このような感じのコード
export class FooApiClient extends APIClient {
    async getBar() {
        return this.request<APIResponseWith<Bar>>({
            url: `${BASE_PATH}/foo/bar`,
        })
    }
}

BASE では今まで上記のような API Client を手動で書いていたのですが、これからは OpenAPI から自動生成する API Client を利用していくことにしました。

以下は実際にどのようにして API Client と型を自動生成しているのかについて詳しく説明していきます。

① API Client を自動生成する仕組みの概要

OpenAPI Generator で API Client と型を自動生成している図

OpenAPI からどのようにして API Client を生成しているかをまとめました。

  1. OpenAPI の個別のファイル群を編集
  2. 一つの大きな merged.yaml という OpenAPI ファイルを swagger-merger を使って生成
  3. merged.yaml を元に openapi-generator-cli を使って API Client やスキーマの型を生成
    • その他便利関数と一緒に GitHub Packages を使って npm パッケージとして配信

openapi-generator-cli の typescript-fetch を使って fetch API の API Client を生成しています。

API Client を生成する流れはこのような感じですが、他にも merged.yaml を元に Docker を利用して色々しています。ReDoc / SwaggerUI を立てて API ドキュメントを読んだり、API Sprout を使って API モックサーバーを立てたりもしています。

どのような開発体験になるか

まずバックエンドエンジニアが API を作る前に、OpenAPI の YAML ファイルだけを追加した PR を出して API のスキーマについてフロントエンドエンジニアと共にレビューします。PR がマージされると GitHub Actions が自動で API Client の npm パッケージを配信してくれます。

フロントエンドは配信されたパッケージを利用して、スキーマに沿った API へのリクエスト・レスポンスを実現することができます。

また、便利関数として API モックサーバーへのリクエストもできるようにしています。 これにより API の開発を待たずしてフロントエンド側の実装を進めていくことができます。

このようにして自動生成した API Client は以下のような形で使うことができます。

import { apiConfig, FooApi, FooBarResponse } from 'api-client'

const client = new FooApi(apiConfig)
const result: FooBarResponse = await client.getBar()

② OpenAPI の個別のファイル群について

OpenAPI の個別のファイル群は、API Client を生成しやすいようにいくつかの命名規則に沿って作られています。

ディレクトリ構成は以下のとおりです。

├── README.md
├── docker-compose.yaml
├── src
    ├── _components.yaml - components 定義
    ├── _paths.yaml - paths 定義
    ├── components - 共通 components 定義
    │   └── error_response.yaml
    ├── merged.yaml - Docker から参照するためのファイル。特にいじらない
    ├── openapi.yaml - ベースファイル
    └── services - サービスディレクトリ
        └── <service_name>
            ├── components - service の components 定義
            │   ├── xxx_request.yaml
            │   └── xxx_response.yaml
            ├── definitions - service の definitions 定義
            │   └── user.yaml
            ├── examples - examples 定義
            │   ├── <paths_name>
            │   │   ├── default.yaml
            │   │   └── xxx.yaml
            │   └── 400_example.yaml
            └── <paths>.yaml
  • src/openapi.yaml の tags に name を定義
  • src/_paths.yaml に path を定義
  • src/services/ 配下に yaml ファイルを作成
  • yaml ファイルの内容が src/merged.yaml に反映される

というのがザックリとした編集方法です。

OpenAPI は $ref というキーワードを使って外部ファイルを参照可能なため、

  • path
  • API エンドポイント
  • リクエスト・レスポンスの schema
  • example

これらを services 配下にまとめて、細かくファイル分割をして管理しやすい形にしています。

③ API Client やスキーマの型を生成するにあたっての命名規則

ファイル名やその他命名など細かい規則をいくつか設けていますが、その中でも API Client の生成に影響するものをまとめました。

フォルダの命名規則

  • src/services/* 配下
    • 各 API のスキーマを置く場所
    • API の URL と同じ構成にする
      • この際 path に api が入っている場合は api を抜く
        • 例)/apps/api/foo/bar なら、 src/services/apps/foo/bar.yaml となる

生成される API Client はフラットな階層に一律出力されるため、そもそもの命名としてユニーク性が必要です。そのため URL の構成に従って命名しています。

tags と operationId

  • tags
    • フォルダの path の services から親となるリソースまでの path をつなげる
      • 例) src/services/apps/foo/bar.yaml なら appsFoo になる
    • tags は services の各まとまりごとに同じものを用いる
      • 例) /services/apps/foo/bar.yaml/services/apps/foo/baz.yaml は同じ tags appsFoo を使う
  • operationId
    • リクエストメソッド + リソース名
      • 例) /services/apps/foo/bar.yaml の GET リクエストだったら getBar となる

API Client が生成されるとき、tags が API クラス名で operationId がメソッド名となります。そのため上記の命名規則で生成されるクラスは以下のような形になります。

export class AppsFooApi extends runtime.BaseAPI {
    async getBar(requestParameters: GetBarRequest = {}, initOverrides?: RequestInit): Promise<AppsFooBarResponse> {
        // ...
    }
}

requestBody と responses のスキーマと examples

これらは別ファイルに models として切り出すようにしています。

get:
  tags:
    - appsFoo
  operationId: getBar
  responses:
    '200':
      description: OK
      content:
        application/json:
          schema:
            $ref: ./components/bar_response.yaml
          examples:
            default:
              $ref: ./examples/default.yaml
    '500':
      description: Internal Server Error
      content:
        application/json:
          schema:
            $ref: ../../../components/error_response.yaml
          examples:
            barInternalError:
              $ref: ./examples/bar_internal_error.yaml
# ...
# ./components/bar_response.yaml
title: appsFooBarResponse
type: object
properties:
  status:
    type: number
  bar:
    type: string
    nullable: true
# ./examples/default.yaml
value:
  status: 200
  bar: null

models として切り出して個別に title を定義することで、レスポンスの型の命名が自動的に InlineResponseXXX のようになってしまうのを防ぎます。

また、examples も default のように名前を定義することで、API モックサーバーへリクエストするときに意図したレスポンスを返してもらうことができるようにしています。

import { mockApiConfig, AppsFooApi } from 'api-client'

const client = new AppsFooApi(mockApiConfig({ status: 500 }))
const result = await client.getBar({}) // 500 エラーが返ってくる

// example の value は OpenAPI 定義の examples の key を指定
const client = new AppsFooApi(mockApiConfig({ example: 'default' }))
const result = await client.getBar({}) // default で設定した example の値が返ってくる

④ その他 API Client を利用する上で用意した便利関数

非同期関数の catch や try / catch でエラーが起きたときのハンドリングを行う便利関数も用意しています。

  • apiErrorType を使って起こったエラーを3つのパターンに整形する
    • APIError: API から返ってきたエラーレスポンス
    • Error: それ以外の何かしらのエラー
    • null: 401エラーの場合は一律でエラーハンドリングしており何もしないため null を返す
  • isAPIError を使ってエラーが APIError なのか Error なのかを判別する
const client = new AppsFooApi(apiConfig)
const result = await client.getBar({}).catch(async (e) => {
  const error = await apiErrorType(e)
  // 401 のときは自動的に FlashMessage が出るようにしているため早期リターン
  if (!error) return
  if (isAPIError(error)) {
    // APIError で 404 など返ってきたときのエラーハンドリング
  } else {
    // そうでないただのエラーが返ってきたときのエラーハンドリング
    console.log(error.message)
  }
})

また、エラーハンドリングについての考え方は今まで実装者の判断に委ねていた部分がありましたが、エラーハンドリングのやり方についても別記事でまとめて認識合わせをしたりしました。

Web サービスを開発するときのエラーハンドリングについて

ユーザーに表示するエラーメッセージを管理するのはフロントエンド?バックエンド?

注意したいのがここで返ってくる title や detail などは、ユーザーに表示するための文言ではなくあくまで開発者に何のエラーか教えてあげるための文言だということです。

エラーを返しているのは API であって、API を操作するのはフロントエンドのコード(つまりフロントエンド開発者)なので、開発者がわかるエラーメッセージで十分です。

ユーザーに表示するエラー文言については、デザイナーと相談して決めることがほとんどかと思います。ここは細かい調整が行いやすいフロントエンドで管理するのが良いでしょう。ユーザーに表示する領域はフロントエンドの領域です。

⑤ GitHub Actions を使った GitHub Packages の配信

src/merged.yaml または api-client/package.json に変更があった PR が main ブランチにマージされた場合は GitHub Actions で GitHub Packages を配信するようにしています。

      - uses: docker://openapitools/openapi-generator-cli
        with:
          args: generate -g typescript-fetch -i src/merged.yaml -o api-client/src/generated --additional-properties=modelPropertyNaming=camelCase,supportsES6=true,withInterfaces=true,typescriptThreePlus=true
      - run: |
          cd api-client
          yarn install --frozen-lockfile
          yarn build
      - env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          cd api-client
          npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN
          npm publish

しかし、main にマージした際に何らかの理由で npm publish が失敗したらどうしましょう?🤔

そんなときのために、CI で publish できるかもチェックしています。

can-npm-publish を利用して npm publish ができない場合は CI が落ちて気づけるように GitHub Actions を設定しています。便利ですね。

      - env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          cd api-client
          npm config set //npm.pkg.github.com/:_authToken=$GITHUB_TOKEN
          yarn run can-npm-publish --verbose

以上、①〜⑤まで API Client と型の自動生成をする上でのポイントをあげました。

このような形で現在は自分のやっている PJ 以外でも OpenAPI から生成した API Client と型が利用されるようになってきています。

使っていく上でのメリットとこれからの課題

OpenAPI を利用したスキーマ駆動開発をやってみて感じたメリットは以下の通りです。

  • API の実装を待たずしてフロントエンド開発ができる
  • API モックサーバーを使うことで、外部連携などが必要な複雑な API であったとしてもフロントエンドの動作確認や開発を楽に行うことができる
  • API Client やスキーマの型をいちいち手動で書く必要がなくなる
  • 仕様と実装の乖離がなくなる

このように、開発を楽に&速くすることができました! 🙌

とはいえ、全体を通した課題はまだまだ感じます。

フロントエンドの開発にとってメリットを感じられることは大きいのですが、バックエンドの開発にとってはどうでしょうか。

  • バックエンドの実装と定義したスキーマが乖離した場合に自動テストが落ちるようにしたりなどの設定がまだできていない
    • バックエンドは仕様と実装の乖離が起こっても検知できないので、API に変更がある場合はコミュニケーションでなんとかする必要がある

といったように、現状はフロントエンドの開発ではスキーマ駆動開発の良さを享受できるけどバックエンドの開発ではフロントエンドほどの恩恵は受けられていない状況です。

とはいえバックエンドの開発でも、API の実装がフロントエンドの実装のブロッカーにならずにすむというというのはバックエンド開発者の精神衛生上とても良いことだと、うれしいフィードバックを受けたりもしました。

今後バックエンドの状態が変わり次第 OpenAPI のツールを入れるなりして、さらにより良いスキーマ駆動開発を推進していければいいなと思っています。

おわりに

OpenAPI を利用したスキーマ駆動開発で得られるメリットはとても大きいものです。

ぜひ API Client と型を自動生成して各 PJ で活用してみてください!