Madogiwa Blog

主に技術系の学習メモに使っていきます。

openapi-typescript + openapi-fetchを使ってOpenAPIのスキーマから型情報とクライアントを自動生成するメモ

サーバーサイドをRest APIでやりとりする際にはOpenAPIを使ってインターフェースを定義してやりとりすることがあるかと思います。

www.openapis.org

今まではナイーブにOpen APIyamlを見て型を書き起こして利用するようなことをすることが多かったのですが、 openapi-typescript+openapi-fetchを利用するとインターフェースに基づいた型及びクライアントを自動生成でき便利そうだったので色々触ってみたのでメモ

openapi-ts.dev

openapi-typescriptで型情報を自動生成する

基本は以下のドキュメントに従ってセットアップする。

openapi-ts.dev

noUncheckedIndexedAccesstrueすることが推奨されている。

noUncheckedIndexedAccessはインデックス型のプロパティや配列要素を参照したときundefinedのチェックを必須にするコンパイラオプションです。 noUncheckedIndexedAccess | TypeScript入門『サバイバルTypeScript』

以下のように取得元のOpen APIスキーマを定義(yaml)をinputにして任意のパスに型情報を自動生成できる。

# Local schema
npx openapi-typescript ./path/to/my/schema.yaml -o ./path/to/my/schema.d.ts
# 🚀 ./path/to/my/schema.yaml -> ./path/to/my/schema.d.ts [7ms]

# Remote schema
npx openapi-typescript https://myapi.dev/api/v1/openapi.yaml -o ./path/to/my/schema.d.ts
# 🚀 https://myapi.dev/api/v1/openapi.yaml -> ./path/to/my/schema.d.ts [250ms]

ref: https://openapi-ts.dev/introduction#basic-usage

openapi-typescriptで自動生成された型情報は以下のようにインデックス型のプロパティや配列要素で取得することができるので、推奨通りnoUncheckedIndexedAccessを有効にしておいた方が安全 📝

import type { paths, components } from "./my-openapi-3-schema"; // generated by openapi-typescript

// Schema Obj
type MyType = components["schemas"]["MyType"];

// Path params
type EndpointParams = paths["/my/endpoint"]["parameters"];

// Response obj
type SuccessResponse =
  paths["/my/endpoint"]["get"]["responses"][200]["content"]["application/json"]["schema"];
type ErrorResponse =
  paths["/my/endpoint"]["get"]["responses"][500]["content"]["application/json"]

ref: https://openapi-ts.dev/introduction#basic-usage

openapi-fetchでクライアントを自動生成する

openapi-fetchを使うとopenapi-typescriptで生成した型情報を元にクライアントを自動生成できます。

openapi-ts.dev

その他のライブラリでも実現できるようですが公式の推奨はopenapi-fetchのようです 👀

Data fetching Fetching data can be done simply and safely using an automatically-typed fetch wrapper:

  • openapi-fetch (recommended)
  • openapi-typescript-fetch by @ajaishankar

ref: https://openapi-ts.dev/examples

以下のような感じでクライアントを生成して利用することができます。

import createClient from "openapi-fetch";
import type { paths } from "./my-openapi-3-schema"; // generated by openapi-typescript

const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
const {
  data, // only present if 2XX response
  error, // only present if 4XX or 5XX response
} = await client.GET("/blogposts/{post_id}", {
  params: {
    path: { post_id: "123" },
  },
});

await client.PUT("/blogposts", {
  body: {
    title: "My New Post",
  },
});

ref: https://openapi-ts.dev/openapi-fetch/

クライアントの注意事項としては基本ブラウザネイティブのfetchを意識して作られているようなので、 以下のissueのようにレスポンスエラーと思われるようなステータス(400系、500系)が返されてもthrowされない点は注意です。

github.com

getリクエスト時のobject -> search queryとかの変換はやってくれますが、以下のような配列を渡す場合にはaxiosのように自動的にid[]=にしてくれるような機能ないようなので、

export const openApiFetchClient = createClient<paths>();
// NOTE: 以下のリクエスト先は`http://localhost:3000/api/records?id=1&id=2&id=3`になる
await openApiFetchClient.GET("/api/records", { params: { query: { id: [1, 2, 3] } } });

自前で対応する必要があります。(色々議論はあるようだが現状のデフォルトではOpenAPIのexplode=true相当の振る舞いがデフォルトになっているっぽい)

github.com

swagger.io

Tips

CSRF Tokenをデフォルトで付与する

サーバーサイドとのやりとりでCSRF Tokenによる保護を行うことが多いので、リクエスト時にヘッダーにCSRF Tokenを付与するミドルウェアを作成し適用するようにしました。

export const csrfToken = () => {
  const meta = document.querySelector("meta[name=csrf-token]");
  return meta && (meta as HTMLMetaElement).content;
};

const fetchRequestCsrfTokenMiddleware: Middleware = {
  onRequest({ request }) {
    const csrfTokenValue = csrfToken();
    if (csrfTokenValue) request.headers.set("X-CSRF-Token", csrfTokenValue);
    return request;
  },
};
export const openApiFetchClient = createClient<paths>({});
openApiFetchClient.use(fetchRequestCsrfTokenMiddleware);

ref: https://openapi-ts.dev/openapi-fetch/middleware-auth

id[]=の形式で配列のparamsを送信する

デフォルトでは前述の通りid: [1,2,3]の場合はid=1&id=2&=3になってしまうのでid[]=1&id[]=2&id[]=3にしたい婆には明示的に以下のように渡す必要がありそうでした。

export const openApiFetchClient = createClient<paths>();
// NOTE: 以下のリクエスト先は`http://localhost:3000/api/records?id=1&id=2&id=3`になる
await openApiFetchClient.GET("/api/records", { params: { query: { `id[]`: [1, 2, 3] } } });

もちろんOpenAPIのスキーマ側も以下のようにする必要があります。

  /api/records:
    get:
      summary: Get a list of record
      parameters:
        - name: id[]
          in: query
          required: true
          schema:
            type: array
            items:
              type: integer

クライアントを関数でwrapする

直接クライアントを利用するmockしたりするがめんどくさいな〜と思ったので、なんかいい感じのwrapperを用意したいと思ったんですが、 wrapして引数を引き継ぐには結構複雑な型パズルを解く必要がありメンテできる自信もないのでparameterの引数部分は明示的に渡すようにしてみました🥲

type getRecordApi = FromApiSchema<"/api/record/{id}", "get">;
type getRecordApiParams = getRecordApi["parameters"]["path"];
export async function getFeed(id: getRecordApiParams["id"]) {
  const { data } = await openApiFetchClient.GET("/api/records/{id}", { params: { path: { id } } });
  return data;
}

公式サンプルも特に型パズルと使わずに明示的に型指定してるのと、インターフェース部分の型パズルが上手く動いてなかったら本末転倒なので、この辺りはシンプルに利用する方がいいのかなと思いました🤔

github.com

400系、500系のステータスの際に例外をthrowする

前述の通り、openapi-fetchはデフォルトでレスポンスが400系・500系の時でも例外をthrowしないので以下のような感じで明示的に切り出したWrapper内でレスポンスのステータスコードを確認してthrowするようにしてみました。

確かに自動てthrowされるのは便利ですが、wrapperを経由してComponent等から利用する際には処理がfetchかどうかは意識しないで利用できた方がインターフェースとして好ましいような気がしたので、明示的に任意の相応しいエラーをthrowしてあげる方がいいのかなぁともちょっと思いました。

export class FetchError extends Error {
  response: Response;

  constructor(message: string, response: Response) {
    super(message);
    this.message = message;
    this.response = response;
    this.name = "FetchError";
  }
}

type getRecordApi = FromApiSchema<"/api/record/{id}", "get">;
type getRecordApiParams = getRecordApi["parameters"]["path"];
export async function getFeed(id: getRecordApiParams["id"]) {
  const { data, response } = await openApiFetchClient.GET("/api/records/{id}", { params: { path: { id } } });
  if (response.status >= 400) {
    throw new FetchError("fetch error: ${response.url}", response);
  }
  return data;
}

Middleware & Auth | OpenAPI TypeScript を使って自動的に例外を発生させるようにも出来る📝

madogiwa0124.hatenablog.com

おわりに

openapi-typescript + openapi-fetchはサクッとOpenAPIのスキーマから型情報とクライアントを生成できて便利ですね✨ 型生成とクライアント両方が同一リポジトリにあり合わせてメンテナンスされてるのも個人的には安心感があるかなと思いました。

メンテナンスも活発そうですし、ちょっとまだ情報少なめな感もしましたが活発に利用されるようになっていきそう👀

参考

zenn.dev

zenn.dev

zenn.dev

tech.revcomm.co.jp