サーバーサイドをRest APIでやりとりする際にはOpenAPIを使ってインターフェースを定義してやりとりすることがあるかと思います。
今まではナイーブにOpen APIのyamlを見て型を書き起こして利用するようなことをすることが多かったのですが、
openapi-typescript
+openapi-fetch
を利用するとインターフェースに基づいた型及びクライアントを自動生成でき便利そうだったので色々触ってみたのでメモ
openapi-typescript
で型情報を自動生成する
基本は以下のドキュメントに従ってセットアップする。
noUncheckedIndexedAccess
をtrue
することが推奨されている。
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]
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"]
openapi-fetch
でクライアントを自動生成する
openapi-fetch
を使うとopenapi-typescript
で生成した型情報を元にクライアントを自動生成できます。
その他のライブラリでも実現できるようですが公式の推奨は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
以下のような感じでクライアントを生成して利用することができます。
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", }, });
クライアントの注意事項としては基本ブラウザネイティブのfetch
を意識して作られているようなので、
以下のissueのようにレスポンスエラーと思われるようなステータス(400系、500系)が返されてもthrowされない点は注意です。
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
相当の振る舞いがデフォルトになっているっぽい)
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; }
公式サンプルも特に型パズルと使わずに明示的に型指定してるのと、インターフェース部分の型パズルが上手く動いてなかったら本末転倒なので、この辺りはシンプルに利用する方がいいのかなと思いました🤔
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 を使って自動的に例外を発生させるようにも出来る📝
おわりに
openapi-typescript + openapi-fetchはサクッとOpenAPIのスキーマから型情報とクライアントを生成できて便利ですね✨ 型生成とクライアント両方が同一リポジトリにあり合わせてメンテナンスされてるのも個人的には安心感があるかなと思いました。
メンテナンスも活発そうですし、ちょっとまだ情報少なめな感もしましたが活発に利用されるようになっていきそう👀