[[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`であることを推論可能だ。