OpenAPIのファイルを分割している話
株式会社IVRy (アイブリー)のエンジニアの kinashi です。
IVRy では設定画面の Web アプリケーションは Ruby on Rails と Next.js で構成されています。
I/F 定義には OpenAPI を使っていて、各リポジトリから Git のサブモジュールで参照し、スキーマ駆動で開発しています。
2022年の夏にリプレイスをし、今の構成になってから早1年半、1ファイルで管理していた OpenAPI の定義も1.3万行ほどになりました。
リプレイスに関する記事はこちら
定義の追加や編集、レビューなどが辛くなっているので、1ファイルでの定義から脱却したい!
ということで、徐々に分割を行っている最中です。
なぜ今までファイル分割していなかったのか
スキーマ定義通りの実装にするため、バックエンドでは committee-rails 、フロントエンドでは openapi-typescript-codegen を使っています。
これらのツールに若干の制約があるため、思うように参照やコード生成がうまくいかず、やむを得ず1ファイルで管理していた背景がありました。
問題点の前に、それぞれのツールが何をしているのか軽く紹介します。
committee-rails
バックエンドでは committee-rails という Gem を使い、テストを書くことで API の返却値がスキーマ定義に沿っていることを担保しています。
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 が使いやすかったため、こちらのツールを選定しました。
うまくいかなかったポイント
検討した書き方
まず、 paths と components の定義を別ファイルに切り出し、schema.yaml
では paths を参照するだけにしたいと考えました。
├── components
│ └── Puffin.yaml
├── paths
│ └── puffins.yaml
└── schema.yaml
type: object
required:
- id
- name
additionalProperties: false
properties:
id:
type: integer
name:
type: string
get:
responses:
200:
description: OK
content:
application/json:
schema:
type: array
items:
$ref: '../components/Puffin.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 としては問題ないのですが、コンポーネント定義を型として出力してくれませんでした。
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 だとこの問題は起きず、ちゃんと型として出力されます
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:
schemas:
Puffin:
type: object
required:
- id
- name
additionalProperties: false
properties:
id:
type: integer
name:
type: string
こちらの記事を参考にさせていただきました。
うまくいかなかったポイント2(committee-rails)の解決
こちらも同様に paths/[path] の構造にする。
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'
こちらの記事を参考にさせていただきました。
うまくいかなかったポイント3(openapi-typescript-codegen)の解決
冗長になるが、 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'
期待通りの出力になりました
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
は /
のエスケープです
1ファイルに戻してから実行する
当初の想定よりも冗長な書き方になってしまいますが、無事ファイル分割することができました。
ここまで長々と説明してきたのですが、記事を書きながらふと思いました。
分割したファイルを1ファイルに戻せば解決するのでは?
はい、ありました。
npx redocly bundle schema.yaml -o bundle.yaml
とすることで1ファイルにできるので、テスト実行前などに、このコマンドを実行するようにすれば当初想定した書き方でファイル分割できそうです。
遠回りになりましたが、この方法でリファクタリングしていこうと思います!
最後に
IVRyでは一緒に働いてくれるエンジニアを募集しています!
Discussion