この記事は 株式会社ビットキー Advent Calendar 2022 14日目の記事です。
今回は workhubプロダクトチームの maruware が担当します。
背景
BitkeyではOpen APIを以下のような用途で活用しています。
- API仕様、ドキュメント
- 自社製のジェネレータによるサーバー実装やクライアント実装生成
- サブシステムのモックサーバー
Open APIに則ることで、1のメリットはもちろん、2によりサーバー実装で統一的なアーキテクチャ・validation・テストなどを得られたり、テストサーバー・クライアント間で整合した実装を得られたりといったメリットがあります。
しかし、残念ながら提供しているAPIはすべてがOpen APIで定義されている状態にはなっておらず、それらについてもOpen APIに載せていく移行を進めています。
その移行作業やジェネレータ整備などをする中で得た、Open API周辺の外堀を埋めるような知見についてとりとめもなく共有します。
Tips
TypeScriptの型からOpen API Spec(JSON Schema)に変換する
新規APIの流れとしては Open API Spec → TypeScript という生成をしますが、
既存の 非Open APIなAPIはTypeScriptで実装 されており、これをまずOpen API Specに移行する必要があります。
何も考えずにやるとyamlをちくちく手で書いていくことになりますが、既存のコードが大量にありすべてを手作業で行うのはしんどさがあります。
幸いにも typeconv というOSSツールを公開してくれている方がいて、こちらを活用させてもらいました。
TypeScript、JSON Schema、GraphQLの相互変換に対応しています。
https://github.com/grantila/typeconv
こちらを使って移行スクリプトを実装しています。
今回は一時的な用途に使用していますがOpen API→TypeScriptのジェネレータとしても活用できるかもしれません
非対応の型がある
ただし、以下のような制約があります。
- enumは非対応
- type operator (Omit等) を使った型は非対応
- constから型を導出してenum相当のことをしているものもtype operatorなので非対応
2についてはどうしようもないので手動でschemaに変換しています。
1や3については以下のようにunion typeを定義し、それに合わせたconstを定義するtype firstな形への移行を進めています。
type MappedConstCapitalize<T extends string> = {
[key in T as Capitalize<T>]: key;
};
type Role = 'admin' | 'staff'
const Role: MappedConstCapitalize<Role> = {
Admin: 'admin',
Staff: 'staff',
}
最初は1や3用の簡易な変換用スクリプトを追加で用意していましたが生産的ではないのでやめました
整形をする
TypeScript(というかJavaScript)では整数も小数も number
であるため、typeconvで変換した際には type: number
となります。
しかし、JSON Schema には integer
があり、アプリケーションにおいて number
のフィールドの多くは整数であるため、基本は integer
にするよう整形し、明示的に小数を扱いたいときは number
と手動で変更するようにしています。
また、フィールド名が timestamp
だったり *At
であれば format
を自動で日時とするようなヒューリスティックな整形もしています。
Specファイルを分割する
Open API Specを最初は1ファイルで定義していましたが、巨大になりすぎるのと、複数specファイルで同じschemaを再利用したい、という点からファイルを分割することにしました。
Open APIでは以下にあるように相対パスで $ref
を与えることで別ファイルの定義を参照することができます。
paths:
/users:
$ref: '../resources/users.yaml#/Users'
https://swagger.io/docs/specification/using-ref/
しかし、別ファイル参照を含むSpecファイルにジェネレータを対応させるのは面倒であるため、ジェネレータに入力する際にはバンドルして1ファイルにしています。
バンドラ選定
最初は使用例を多く見かけた以下の swagger-cli を使用しました。
https://github.com/APIDevTools/swagger-cli
しかし、バンドル結果に癖があり、別ファイルの定義を2回$refしているとインライン展開した上でその定義を参照するような動作をしていました。
(Bファイルに X: {a, b}
があり、Aファイルから Y: $ref: './B#/X', Z: $ref: './B#/X'
としていると Y: {a, b}, Z: $ref: '#/Y'
になるイメージ。説明がむずい。)
$ref
のある場所によっては $ref: '#/paths/~1blogs~1{blog_id}~1new~0posts'
のような複雑な参照になってしまいます。
この構文はOpen API Specとしては正しいのですが、現状社内ジェネレータでは対応しておらず実装がめんどくさいなと悩んでいました。
そんな中で、同様のツールで以下の Redocly CLI (@redocly/cli)
を見つけ、試してみたところ欲しかった出力を得ることができた のでこちらを採用しました。
(Aファイルに X: {a, b}
ができて、Y: $ref: '#/X', Z: $ref: '#/X'
となる形)
ただ、それでも1点問題があり、Open APIの Extension(x-
)内で $ref
を使っている場合にはサポート外 であり(独自拡張なのでそれはそう)参照を解決してくれないため、無理やり辻褄を合わせるようなことが必要になります。
TypeScriptのOmitとかPick相当をやりたくなる問題
$ref
によってschemaを再利用できるのは大変便利なんですが、 一部フィールドだけ不要にしたいとか、required
を外したいなどの場面 があります。コピペをした類似schemaを追加していくと管理コストが上がるデメリットがあり、TypeScriptの Omit
とか Pick
, Partial
みたいなことがしたくなります。
$ref
はschemaに対してじゃなくてフィールドに対しても可能なため、たとえば以下のようなことができます。
# user.yaml
User:
type: object
properties:
name:
type: string
required:
- name
# API.yaml
openapi: 3.1.0
info:
version: 1.0.0
components:
schemas:
# propertiesだけ $ref
UserParamA:
type: object
properties:
$ref: './user.yaml#/User/properties'
# 特定のpropertyだけ $ref
UserParamB:
type: object
properties:
name:
$ref: './user.yaml#/User/properties/name'
これをバンドルすると以下のようになります。
openapi: 3.1.0
info:
version: 1.0.0
components:
schemas:
UserParamA:
type: object
properties:
name:
type: string
UserParamB:
type: object
properties:
name:
$ref: '#/components/schemas/name'
name:
type: string
UserParamAのproperties参照によりrequiredを外す例はこれはこれでありです。
ただ一部フィールドはrequired残したいとか $ref
先でさらに $ref
していて、それもrequired外したい、とか複雑になってくると schemaの再利用が難しくなってきます。
次に、UserParamBについては name
がschema名として増えてしまい、同じような参照が増えると無限にschema名が増えてローカルな名前が グローバルに露出していく問題があります。
(衝突した場合はバンドラがプレフィックスを付けてユニークにはしてくれます)
バンドル後のファイルは中間ファイルとして捉えればそれでも問題のないケースもあると思いますが、自社製ジェネレータではschema名をTypeScript内の名前として使用している箇所などもあるため、このような状態は好ましくありません。
Redocly CLI
では --dereferenced
オプションにより $ref
をすべて展開した状態にもできるので、これを使うのもありですが、失われてしまう情報も出てくるので悩ましいです。
今のところ、良い解決策が思いついていないです(妙案がある方がいればぜひ)。
苦し紛れに"ネストも含めてすべてrequired外す"とか"ネストも含めてすべてnullableにする"という特殊で決めうちにできる操作についてのみ、Extension記法で拡張し、ジェネレータで解決するようにしています。
Ajvでvalidationする
Open API SpecのSchemaはJSON Schemaであるため、validationにAjvをそのまま使うことができます。
https://ajv.js.org/
ジェネレータでAjvにそのままschemaを入力するようなコードを生成しています。
また、Open API Spec標準にはない独自の format についても拡張できるようになっており、以下のように実装できます。
ajv.addFormat('epoch-milli', {
type: 'number',
validate: (v: number) => !isNaN(new Date(v).getTime()),
});
標準のemail, uuid等の format については ajv-formats
というパッケージにより対応可能です。
https://ajv.js.org/guide/formats.html
1点引っかかったところで、 enum
+ nullable
の際には null
が弾かれてしまう仕様であり、 enum
の候補に null
を含ませる必要があり注意が必要です。
https://ajv.js.org/json-schema.html#nullable
prismでレスポンスを出し分ける
Open APIのspecファイルからモックサーバーを立ち上げる際にPrismを使うと便利です。
https://stoplight.io/open-source/prism
PrismではOpen API Specにexampleを記述するとそれに応じたレスポンスを返してくれます。
複数のレスポンスを出し分けるのにも対応していて、 Prefer
ヘッダーを使うことで可能になります。
また、起動パラメータか Prefer
ヘッダーで dynamic
を指定するとランダムで返すことも可能です。
https://meta.stoplight.io/docs/prism/83dbbd75532cf-http-mocking
裏側にいるサブシステムのモックサーバーを制御する際には Prefer
ヘッダーをフォワーディングする ようにして対応しています。
リクエストパラメータの値に応じてレスポンスを切り替えられない悩み
できるなら「リクエストbodyのidが1ならA, 2ならBを返す」みたいなことがしたいなという気持ちがありますが現状Prismではそういった機能はなさそうです。
やるなら
/users:
get:
x-response-body-matcher: requestBody.id=responseBody.id
とか
/users:
get:
x-response-body-matcher: requestBody.id=exampleKey
みたいな雰囲気をイメージをしていますが、拡張記法な上に複雑な構文が入ってくるのであまり筋がよくない感があります。
テスト等であれば基本的には呼び出し側で何を返してほしいのかわかっているはずであり、 Prefer
を指定するのも可能なのでそれが妥当かなというところではあります。
また、テストであれば複雑なケースはJestでモック化するといったことも可能です。
ユーザー操作でモックを使用するような状況のときはあまり良い解決策が浮かんでいませんが、手前にProxyを置いてそこでハンドルするか、もっと実サービスに近い自前のモックサーバーを実装することになるかなと考えています。
ただ、モックのために実装を増やすのは無駄なメンテコストを生むため慎重な検討が必要そうです。
最後に
ビットキーでは多様な用途でOpen APIを使用しており、これまで踏んだTipsについて共有させてもらいました。
今後もいろいろ踏み抜きそうなので悩みが解決した際などまた機会があれば共有しようと思います。
明日の15日目の株式会社ビットキー Advent Calendar 2022は、 workhubプロダクトチーム デザイナーの @Qtikimtna が担当します。お楽しみに。