Open7

OpenAPI から TypeScript の型を生成して API をいい感じで呼びたかった

ピン留めされたアイテム
Kazuki TakahashiKazuki Takahashi

結論

結局 openapi-typescript + openapi-fetchを使い、トークン切り替えはカスタム fetchを書いてあげることにした

Kazuki TakahashiKazuki Takahashi

openapi-generatorへの課題感

Nuxt2からNuxt3に書き換えるにあたって、axios から、 OAS を利用したコード生成に切り替え、4つ中2つのサイトについては既に対応を完了した。

対応済みの2つのサイトについては、openapi-generatorを用いてAPI呼び出しコードを生成している。

https://github.com/OpenAPITools/openapi-generator

しかし、以下のような気になる点があった。

  • 生成コードが冗長でバンドルサイズ・パフォーマンスに懸念がある。classで書かれているためメソッド単位ではtreeshakingできない。
  • axiosなどで書かれたコードから書き換える場合、operationId を元にした関数名に書き換える必要がある。単純にパターン置換などでは既存のaxios利用コードからの書き換えができない。

上記は、開発上は楽な面もあったので、一概に悪いといえないが、残りの2サイトの書き換えをするにあたってより適切な手法を調査・検討したいと思った。

Kazuki TakahashiKazuki Takahashi

技術選定における要点

  • バンドルサイズへの影響が少ない
  • 処理が軽い
  • axiosからの書き換えが容易
  • TypeScriptによる補完が、入力(URL, query, path etc) や 出力 (Response body) に効くこと
  • onRequest, onResponseなどのinterceptor用のコールバックが設定できる
    • トークンの切り替えなどを利用コード側から隠蔽して行えるようにしたいため
Kazuki TakahashiKazuki Takahashi

openapi-typescript と openapi-fetch

https://openapi-ts.pages.dev/

思想が非常によいなと思っていて、特に snake_case の利用に関する記述については、今まで複数のプロジェクトで snake_case から camelCase に変換しており、作業としてもブラウザの動作としても無駄だったなと思わされた。

他方、非常に薄く作られているがために他のfetchライブラリでは必ずあるような機能がない。
例えば、middlewareとして、onResponseを設定できるが、このタイミングでRequestを取得できないため、何を送信したかを知ることができない。

https://github.com/drwpow/openapi-typescript/issues/1122#issuecomment-1952298096

対応

もしやるとすると カスタムfetchを実装してあげることになる

Kazuki TakahashiKazuki Takahashi

nuxt-open-fetch

どうせカスタムするのであれば、Nuxt3でデフォルトで$fetchとして利用できるようになっているofetchをOpenAPIの型がつかえるようにすれば良いのではと思い調べてみると、ほぼ同じことをしているライブラリが存在した。

https://nuxt-open-fetch.vercel.app/

しかし試してみると、parameters のないoperationにおいてエラーが発生する

生成された型

paths:{
'/certification/company/login': {
    /**
     * ReDS: 企業アカウント認証
     * @description 企業アカウントのログイン処理
     */
    post: operations['authenticate-company-account']
  }
}, 
operations:{
  'authenticate-company-account': {
    requestBody?: {
      content: {
        'application/json': {
          email?: string
          password?: string
        }
      }
    }
    responses: {
      /** @description OK */
      200: {
        headers: {}
        content: {
          'application/json': {
            /** @description 企業認証トークン */

  //省略
 }
}

利用コード

    const data = await $apiFetch('/certification/company/login', {
        method: 'POST',
        body: { email, password },
      })

エラー

Argument of type '{ method: "POST"; body: { email: string; password: string; }; }' is not assignable to parameter of type 'OpenFetchOptions<"POST", "post", { post: { requestBody?: { content: { 'application/json': { email?: string | undefined; password?: string | undefined; }; }; } | undefined; responses: { 200: { headers: {}; content: { ...; }; }; 400: { ...; }; }; }; }, { ...; }>'.
  Type '{ method: "POST"; body: { email: string; password: string; }; }' is not assignable to type 'Record<string, never>'.
    Property 'method' is incompatible with index signature.
      Type 'string' is not assignable to type 'never'.ts(2345)

エラーの原因

ParamsOption がparameters が operation にないと、Record<string, never> を返すようになっており、これを options 全体と union していて、プロパティのkey が全て never になっているように見える。
parameters ないというのは普通にありえることで、まちがっているのではないかなと思っている。

export type ParamsOption<T> = T extends {
    parameters?: any;
    query?: any;
} ? T['parameters'] : Record<string, never>;

対応

そもそもoperation直下のparamsをofetchの引数としてぶちこんでもheaderなどに値がわたらないなど、普通に使うにあたって困りそうなので一旦候補からはずす。

Kazuki TakahashiKazuki Takahashi

検討

  • ofetch使うために自前で型書くのもコストがかかりそう
  • カスタムfetchを書くのは比較的想像しやすい

カスタムfetchとりあえず書いてみる

Kazuki TakahashiKazuki Takahashi

カスタムfetchを使った openapi-fetchのクライアント生成

headerまわりもうちょっと綺麗にできそうだけど一旦書いた

    const addHeader = (init: RequestInit, key: string, value: string) => {
      if (init.headers) {
        ;(init.headers as Headers).set(key, value)
      } else {
        init.headers = new Headers({
          [key]: value,
        })
      }
    }
  
    // ベースになるカスタムfetch
    // 共通で必要なheaderを追加する
    const apiFetchBase = (
      request: string | URL | globalThis.Request,
      init?: RequestInit
    ): Promise<Response> => {
      if (!init) {
        init = {}
      }
      addHeader(init, 'Content-Type', 'application/json')
      addHeader(init, 'X-API-Token', apiToken)
      return fetch(request, init)
    }

    // トークンの追加・トークンのローテーションを行うカスタムfetch
    const apiFetch = async (
      request: string | URL | globalThis.Request,
      init?: RequestInit
    ): Promise<Response> => {
      if (!init) {
        init = {}
      }
      if (authToken.value) {
        addHeader(init, 'X-ADMIN-Token', authToken.value)
      }
      let response = await apiFetchBase(request, init)
      if (!response.ok) {
        const responseData = await response.clone().json()
        if (responseData.status === 407) {
          const tokenResponse = await apiFetchBase(
            baseUrl + '/certification/company/reissue'
          )
          const tokenResult = await tokenResponse.json()
          setAuthToken(tokenResult.admin_token)
          addHeader(init, 'X-API-Token', tokenResult.admin_token)
          response = await apiFetchBase(request, init)
        }
      }
      return response
    }

    // openapi-fetchのclient生成
    const api = createClient<paths>({
      baseUrl,
      fetch: apiFetch,
    })