Open API落穂拾い

この記事は [株式会社ビットキー Advent Calendar 2022](https://qiita.com/advent-calendar/2022/bitkey) 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](https://github.com/grantila/typeconv)

こちらを使って移行スクリプトを実装しています。

:::note info
今回は一時的な用途に使用していますがOpen API→TypeScriptのジェネレータとしても活用できるかもしれません
:::

### 非対応の型がある

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

1. enumは非対応
2. type operator (Omit等) を使った型は非対応
3. constから型を導出してenum相当のことをしているものもtype operatorなので非対応

2についてはどうしようもないので手動でschemaに変換しています。

1や3については以下のようにunion typeを定義し、それに合わせたconstを定義するtype firstな形への移行を進めています。

```tsx
type MappedConstCapitalize<T extends string> = {
  [key in T as Capitalize<T>]: key;
};

type Role = 'admin' | 'staff'
const Role: MappedConstCapitalize<Role> = {
  Admin: 'admin',
  Staff: 'staff',
}

```

:::note warn
最初は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` を与えることで別ファイルの定義を参照することができます。

```yaml
paths:
  /users:
    $ref: '../resources/users.yaml#/Users'

```

[https://swagger.io/docs/specification/using-ref/](https://swagger.io/docs/specification/using-ref/)

しかし、別ファイル参照を含むSpecファイルにジェネレータを対応させるのは面倒であるため、ジェネレータに入力する際にはバンドルして1ファイルにしています。

### バンドラ選定

最初は使用例を多く見かけた以下の swagger-cli を使用しました。

[https://github.com/APIDevTools/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/](https://redocly.com/docs/cli/)

ただ、それでも1点問題があり、Open APIの **Extension（`x-`）内で `$ref` を使っている場合にはサポート外** であり（独自拡張なのでそれはそう）参照を解決してくれないため、無理やり辻褄を合わせるようなことが必要になります。

### TypeScriptのOmitとかPick相当をやりたくなる問題

`$ref` によってschemaを再利用できるのは大変便利なんですが、 **一部フィールドだけ不要にしたいとか、`required` を外したいなどの場面** があります。コピペをした類似schemaを追加していくと管理コストが上がるデメリットがあり、TypeScriptの `Omit` とか `Pick`, `Partial` みたいなことがしたくなります。

`$ref` はschemaに対してじゃなくてフィールドに対しても可能なため、たとえば以下のようなことができます。

```yaml
# 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'

```

これをバンドルすると以下のようになります。

```yaml
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/](https://ajv.js.org/)

ジェネレータでAjvにそのままschemaを入力するようなコードを生成しています。

また、Open API Spec標準にはない独自の format についても拡張できるようになっており、以下のように実装できます。

```tsx
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](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](https://stoplight.io/open-source/prism)

PrismではOpen API Specにexampleを記述するとそれに応じたレスポンスを返してくれます。
複数のレスポンスを出し分けるのにも対応していて、 `Prefer` ヘッダーを使うことで可能になります。

また、起動パラメータか　`Prefer` ヘッダーで `dynamic` を指定するとランダムで返すことも可能です。

[https://meta.stoplight.io/docs/prism/83dbbd75532cf-http-mocking](https://meta.stoplight.io/docs/prism/83dbbd75532cf-http-mocking)

裏側にいるサブシステムのモックサーバーを制御する際には **`Prefer` ヘッダーをフォワーディングする** ようにして対応しています。

### リクエストパラメータの値に応じてレスポンスを切り替えられない悩み

できるなら「リクエストbodyのidが1ならA, 2ならBを返す」みたいなことがしたいなという気持ちがありますが現状Prismではそういった機能はなさそうです。

やるなら

```yaml
/users:
    get:
      x-response-body-matcher: requestBody.id=responseBody.id

```

とか

```yaml
/users:
    get:
      x-response-body-matcher: requestBody.id=exampleKey

```

みたいな雰囲気をイメージをしていますが、拡張記法な上に複雑な構文が入ってくるのであまり筋がよくない感があります。

テスト等であれば基本的には呼び出し側で何を返してほしいのかわかっているはずであり、 `Prefer` を指定するのも可能なのでそれが妥当かなというところではあります。
また、テストであれば複雑なケースはJestでモック化するといったことも可能です。

ユーザー操作でモックを使用するような状況のときはあまり良い解決策が浮かんでいませんが、手前にProxyを置いてそこでハンドルするか、もっと実サービスに近い自前のモックサーバーを実装することになるかなと考えています。
ただ、モックのために実装を増やすのは無駄なメンテコストを生むため慎重な検討が必要そうです。

# 最後に

ビットキーでは多様な用途でOpen APIを使用しており、これまで踏んだTipsについて共有させてもらいました。
今後もいろいろ踏み抜きそうなので悩みが解決した際などまた機会があれば共有しようと思います。

明日の15日目の株式会社ビットキー Advent Calendar 2022は、 workhubプロダクトチーム デザイナーの @Qtikimtna が担当します。お楽しみに。