[[openapi-zod-client]]を使って、[[OpenAPI Specification]]から[[Zod]]の[[TypeScript]]のAPIクライアントコードを自動生成する方法。自動生成したコードをただ使うだけでなく、UIなどフロントエンドの実装に集中できるようなwrapper実装案も提供する。 ## 前提条件 - [[Bun]] v1.1.6 ## Bunのプロジェクト作成 ```console mkdir openapi-zod-client-sample cd openapi-zod-client-sample bun init . -y ``` ## インストール ```console bun add --dev openapi-zod-client ``` ## yamlファイル作成 READMEの[サンプル](https://github.com/astahmer/openapi-zod-client?tab=readme-ov-file#tldr)をベースに、400エラーと403エラーを追加した `spec.yaml` を作成する。 ```yaml openapi: "3.0.0" info: version: 1.0.0 title: Swagger Petstore license: name: MIT servers: - url: http://petstore.swagger.io/v1 paths: /pets: get: summary: List all pets operationId: listPets tags: - pets parameters: - name: limit in: query description: How many items to return at one time (max 100) required: false schema: type: integer format: int32 responses: "200": description: A paged array of pets headers: x-next: description: A link to the next page of responses schema: type: string content: application/json: schema: $ref: "#/components/schemas/Pets" "400": description: "400 error" content: application/json: schema: $ref: "#/components/schemas/Error" "403": description: "403 error" content: application/json: schema: $ref: "#/components/schemas/Error" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" post: summary: Create a pet operationId: createPets tags: - pets responses: "201": description: Null response default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" /pets/{petId}: get: summary: Info for a specific pet operationId: showPetById tags: - pets parameters: - name: petId in: path required: true description: The id of the pet to retrieve schema: type: string responses: "200": description: Expected response to a valid request content: application/json: schema: $ref: "#/components/schemas/Pet" default: description: unexpected error content: application/json: schema: $ref: "#/components/schemas/Error" components: schemas: Pet: type: object required: - id - name properties: id: type: integer format: int64 name: type: string tag: type: string Pets: type: array items: $ref: "#/components/schemas/Pet" Error: type: object required: - code - message properties: code: type: integer format: int32 message: type: string ``` ## 実行 ```console $ bun x openapi-zod-client spec.yaml -o client.ts Retrieving OpenAPI document from spec.yaml The following endpoints have no status code other than `default` and were ignored as the OpenAPI spec recommends. However they could be added by setting `defaultStatusBehavior` to `auto-correct`: Done generating <client.ts> ! ``` ## 結果確認 `client.ts`が作成されている。 ```ts import { makeApi, Zodios, type ZodiosOptions } from "@zodios/core"; import { z } from "zod"; const Pet = z .object({ id: z.number().int(), name: z.string(), tag: z.string().optional(), }) .passthrough(); const Pets = z.array(Pet); const Error = z .object({ code: z.number().int(), message: z.string() }) .passthrough(); export const schemas = { Pet, Pets, Error, }; const endpoints = makeApi([ { method: "get", path: "/pets", alias: "listPets", requestFormat: "json", parameters: [ { name: "limit", type: "Query", schema: z.number().int().optional(), }, ], response: z.array(Pet), errors: [ { status: 400, description: `400 error`, schema: Error, }, { status: 403, description: `403 error`, schema: Error, }, ], }, { method: "post", path: "/pets", alias: "createPets", requestFormat: "json", response: z.void(), }, { method: "get", path: "/pets/:petId", alias: "showPetById", requestFormat: "json", parameters: [ { name: "petId", type: "Path", schema: z.string(), }, ], response: Pet, }, ]); export const api = new Zodios(endpoints); export function createApiClient(baseUrl: string, options?: ZodiosOptions) { return new Zodios(baseUrl, endpoints, options); } ``` ## 使い方 生成されたコード(`client.ts`)は以下のように使う。 ```ts import { createApiClient } from "./client"; import { isErrorFromPath } from "@zodios/core"; import { ExhaustiveError } from "./errors"; async function main() { const client = createApiClient("http://localhost:8000"); try { const pets = await client.get("/pets"); console.log(pets.map((x) => x.name)); } catch (error) { // 例外処理はZodiosで紹介されているType guardsの手法を用いる if (isErrorFromPath(client.api, "get", "/pets", error)) { switch (error.response.status) { case 400: console.error(error.response.data); break; case 403: console.error(error.response.data); break; default: throw new ExhaustiveError(error.response); } } } } main(); ``` > [!hint] > ExhaustiveErrorについては以下を参照。 > > [[📕TypeScriptのswitch文でcaseの考慮漏れを検知]] ## Result型っぽく扱う [[即時関数 (JavaScript)|即時関数]]として実行すれば、[[let (TypeScript)|let]]を使わずに[[const (TypeScript)|const]]で表現できる。ただし、少し見た目が複雑になってしまう。 ```ts import { createApiClient } from "./client"; import { isErrorFromPath } from "@zodios/core"; import { ExhaustiveError } from "./errors"; async function main() { const client = createApiClient("http://localhost:8000"); const { pets, error } = await (async () => { try { return { pets: await client.get("/pets") }; } catch (error) { if (isErrorFromPath(client.api, "get", "/pets", error)) { return { error: error.response }; } throw Error("Unexpected error"); } })(); if (error) { switch (error.status) { case 400: console.error("User error", error.data.message); return; case 403: console.error("Forbidden error", error.data.message); return; default: throw new ExhaustiveError(error); } } console.log(pets.map((x) => x.name)); } main(); ``` `error`のswitch文は避けられないので仕方ないが、その前の[[即時関数 (JavaScript)|即時関数]]はうまいことwrapperにしたいところ。ただ、`isErrorFromPath`が[[型ガード (TypeScript)|型ガード]]になっているため、それを活かすのであれば厳しいかも...。 ## 分離する `vue`ファイルなどUIメインの層で上記のような実装は流石に意識したくない。ということでAPI Client層へ例外処理や[[型ガード (TypeScript)|型ガード]]の意識を追いやれるような実装になおしてみた。 ```ts import { createApiClient } from "./client"; import { isErrorFromPath } from "@zodios/core"; import { ExhaustiveError } from "./errors"; // API Client層に分離 const useBFF = (baseUrl: string) => { const client = createApiClient(baseUrl); // ターゲットにしているAPIの中でも、アプリケーションで利用する仕様のみに絞ったIFを定義 const getPets = async () => { try { return { res: await client.get("/pets") }; } catch (error) { if (isErrorFromPath(client.api, "get", "/pets", error)) { return { error: error.response }; } throw Error("Unexpected error"); } }; return { getPets }; }; async function main() { const bff = useBFF("http://localhost:8000"); const { res: pets, error } = await bff.getPets(); if (error) { switch (error.status) { case 400: console.error("User error", error.data.message); return; case 403: console.error("Forbidden error", error.data.message); return; default: throw new ExhaustiveError(error); } } console.log(pets.map((x) => x.name)); } main(); ``` APIの定義が冗長に思えるかもしれないが、[[OpenAPI Specification]]から作成されるAPIクライアントコードは **アプリケーションで不要なAPIやインターフェースもすべて作成されている** のに対し、wrapperした実装は **アプリケーションで必要なAPIとインターフェースのみを定義する** とすれば無駄にはならない。むしろ、API変更の影響範囲を瞬時に把握できる可能性する秘めている。 この方法であれば、不要な実装が表面に現れないので、本当にフロントエンドで実装したいことに集中できる。もちろん、`res`と`error`は片方に値があれば、片方が`undefined`であることを推論可能だ。