LoginSignup
43
10

More than 1 year has passed since last update.

この記事は 株式会社ビットキー Advent Calendar 2022 14日目の記事です。
今回は workhubプロダクトチームの maruware が担当します。

背景

BitkeyではOpen APIを以下のような用途で活用しています。

  1. API仕様、ドキュメント
  2. 自社製のジェネレータによるサーバー実装やクライアント実装生成
  3. サブシステムのモックサーバー

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のジェネレータとしても活用できるかもしれません

非対応の型がある

ただし、以下のような制約があります。

  1. enumは非対応
  2. type operator (Omit等) を使った型は非対応
  3. 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' となる形)

https://redocly.com/docs/cli/

ただ、それでも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 が担当します。お楽しみに。

43
10
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
43
10