🗂️

OpenAPIのファイルを分割している話

2024/02/22に公開

株式会社IVRy (アイブリー)のエンジニアの kinashi です。

IVRy では設定画面の Web アプリケーションは Ruby on RailsNext.js で構成されています。
I/F 定義には OpenAPI を使っていて、各リポジトリから Git のサブモジュールで参照し、スキーマ駆動で開発しています。

2022年の夏にリプレイスをし、今の構成になってから早1年半、1ファイルで管理していた OpenAPI の定義も1.3万行ほどになりました。

リプレイスに関する記事はこちら
https://zenn.dev/ivry/articles/6c85815e52ad7a

定義の追加や編集、レビューなどが辛くなっているので、1ファイルでの定義から脱却したい!
ということで、徐々に分割を行っている最中です。

なぜ今までファイル分割していなかったのか

スキーマ定義通りの実装にするため、バックエンドでは committee-rails 、フロントエンドでは openapi-typescript-codegen を使っています。
これらのツールに若干の制約があるため、思うように参照やコード生成がうまくいかず、やむを得ず1ファイルで管理していた背景がありました。

問題点の前に、それぞれのツールが何をしているのか軽く紹介します。

committee-rails

バックエンドでは committee-rails という Gem を使い、テストを書くことで API の返却値がスキーマ定義に沿っていることを担保しています。

https://github.com/willnet/committee-rails

committee-rails を使うと下記ような感じでテストを書くことができるようになり、定義とずれた値が返却されているとテストが落ちます。

describe V1::PuffinsController, type: :request do
  describe 'GET /v1/puffins' do
    it 'conforms to schema with 200 response code' do
      get v1_puffins_path

      assert_schema_conform(200)
    end
  end
end

openapi-typescript-codegen

フロントエンドでは API クライアントを openapi-typescript-codegen で生成しています。

OAS からのコード生成は openapi-generator を使うのが一般的ですが、 CI サーバなどでコード生成を実行する場合、 Java が使える環境でないと実行できません。
フロントエンドの CI では Node.js が入っていれば実行できる openapi-typescript-codegen が使いやすかったため、こちらのツールを選定しました。

https://github.com/ferdikoomen/openapi-typescript-codegen

うまくいかなかったポイント

検討した書き方

まず、 paths と components の定義を別ファイルに切り出し、schema.yamlでは paths を参照するだけにしたいと考えました。

├── components
│   └── Puffin.yaml
├── paths
│   └── puffins.yaml
└── schema.yaml
components/Puffin.yaml
type: object
required:
  - id
  - name
additionalProperties: false
properties:
  id:
    type: integer
  name:
    type: string
paths/puffins.yaml
get:
  responses:
    200:
      description: OK
        content:
          application/json:
            schema:
              type: array
              items:
                $ref: '../components/Puffin.yaml'
schema.yaml
openapi: 3.0.3
info:
  title: sample
  version: 0.1.0
paths:
  /puffins:
    $ref: './paths/puffins.yaml'

うまくいかなかったポイント1(committee-rails)

コンポーネントセクションをファイル分割した状態でテストを実行すると、下記のようなエラーになります。

1) V1::PuffinsController GET /v1/puffins conforms to schema with 200 response code
   Failure/Error: assert_schema_conform(200)

   NoMethodError:
     undefined method `any_of' for #

うまくいかなかったポイント2(committee-rails)

パスセクションをファイル分割してテスト実行すると、同じく下記のようなエラーになります。

1) V1::PuffinsController GET /v1/puffins conforms to schema with 200 response code
   Failure/Error: assert_schema_conform(200)

   NoMethodError:
     undefined method `set_path_item_to_operation' for #

うまくいかなかったポイント3(openapi-typescript-codegen)

返却される値は paths/puffins.yaml から components/Puffin.yaml を参照しているので、 OpenAPI としては問題ないのですが、コンポーネント定義を型として出力してくれませんでした。

PuffinService.ts
export class PuffinService {
    constructor(public readonly httpRequest: BaseHttpRequest) {}
    /**
     * @returns any OK
     * @throws ApiError
     */
    public getPuffins(): CancelablePromise<Array<{
        id: number;
        name: string;
    }>> {
        return this.httpRequest.request({
            method: 'GET',
            url: '/puffins',
        });
    }
}

本当は

CancelablePromise<Array<Puffin>>

となってほしかった。

ちなみに openapi-generator だとこの問題は起きず、ちゃんと型として出力されます

PuffinApi.ts
export class PuffinApi extends runtime.BaseAPI implements PuffinApiInterface {
// 抜粋
    async getPuffins(initOverrides?: RequestInit | runtime.InitOverrideFunction): Promise<Array<Puffin>> {
        const response = await this.getPuffinsRaw(initOverrides);
        return await response.value();
    }
}

解決方法

committee-rails のエラーについては先人の方が解決法をまとめてくれている記事があったので、参考にさせていいただきました。

うまくいかなかったポイント1(committee-rails)の解決

コンポーネントセクションをファイル分割する際に components/schemas という構造にする。
committee のパーサがこの構造でないと読めない仕様みたいです。

components/Puffin.yaml
components:
  schemas:
    Puffin:
      type: object
      required:
        - id
        - name
      additionalProperties: false
      properties:
        id:
          type: integer
        name:
          type: string

こちらの記事を参考にさせていただきました。
https://zenn.dev/samuraikun/articles/5701862fa041c1

うまくいかなかったポイント2(committee-rails)の解決

こちらも同様に paths/[path] の構造にする。

paths/puffins.yaml
paths:
  /puffins:
    get:
      tags:
        - puffin
      operationId: getPuffins
      responses:
        200:
          description: OK
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '../components/Puffin.yaml#/components/schemas/Puffin'

こちらの記事を参考にさせていただきました。
https://hachi.hatenablog.com/entry/2021/07/23/180830

うまくいかなかったポイント3(openapi-typescript-codegen)の解決

冗長になるが、 schema.yaml 側にも参照を書く。

schema.yaml
openapi: 3.0.3
info:
  title: sample
  version: 0.1.0
paths:
  /puffins:
    $ref: './paths/puffins.yaml#/paths/~1puffins'
components:
  schemas:
    Puffin:
      $ref: './components/Puffin.yaml#/components/schemas/Puffin'

期待通りの出力になりました

PuffinService.ts
export class PuffinService {
    constructor(public readonly httpRequest: BaseHttpRequest) {}
    /**
     * @returns Puffin OK
     * @throws ApiError
     */
    public getPuffins(): CancelablePromise<Array<Puffin>> {
        return this.httpRequest.request({
            method: 'GET',
            url: '/puffins',
        });
    }
}

paths の参照にある ~1/ のエスケープです
https://swagger.io/docs/specification/using-ref/#escape

1ファイルに戻してから実行する

当初の想定よりも冗長な書き方になってしまいますが、無事ファイル分割することができました。

ここまで長々と説明してきたのですが、記事を書きながらふと思いました。
分割したファイルを1ファイルに戻せば解決するのでは?

はい、ありました。

https://github.com/Redocly/redocly-cli

npx redocly bundle schema.yaml -o bundle.yaml

とすることで1ファイルにできるので、テスト実行前などに、このコマンドを実行するようにすれば当初想定した書き方でファイル分割できそうです。

遠回りになりましたが、この方法でリファクタリングしていこうと思います!

最後に

IVRyでは一緒に働いてくれるエンジニアを募集しています!
https://ivry-jp.notion.site/IVRy-e1d47e4a79ba4f9d8a891fc938e02271

IVRyテックブログ

Discussion