ZodスキーマからAPIクライアントを自動生成する仕組み
Zod + @hono/zod-openapiでOpenAPI仕様を生成し、Orvalで型付きTanStack Queryフックを自動生成する方法。APIクライアントのボイラープレートをゼロにする実装パターン。
手作業でAPIクライアントを書いていると、バックエンドの変更に追従するのが面倒だし、型のズレでバグることもあります。この部分を完全に自動化しているのですが、結構快適なので仕組みを共有します。
バックエンド側の型安全設計については Domain層のZodスキーマを起点にした型安全設計 で書いているので、この記事ではフロントエンドへの自動生成の仕組みに集中します。
生成の流れ
全体像はこんな感じ:
Domain (Zod) → Routes (Zod + @hono/zod-openapi)
→ openapi.json → Orval → TanStack Query hooks
- Domain層でZodスキーマを定義
- Routes層でAPIルート定義(Domainのスキーマを参照)
- OpenAPI仕様(JSON)を自動生成
- OrvalでTanStack Queryのフックを自動生成
Routes層のAPI定義
@hono/zod-openapi を使うと、ZodスキーマをそのままOpenAPI仕様に変換できます:
// packages/routes/src/item.route.ts
import { Item, ItemPatchInput, CategoryId } from "@myapp/domain";
export const CreateItemsRequestBody = z.object({
categoryId: CategoryId,
items: z.array(CreateItemInput),
});
export const CreateItemsResponse = z.object({
items: z.array(Item),
});
export const CreateItemsRoute = createPostRoute({
path: "/items",
operationId: "createItems",
body: CreateItemsRequestBody,
response: CreateItemsResponse,
description: "Create items",
});
createPostRoute は自作のヘルパーで、共通のエラーレスポンスなどを毎回書かなくて済むようにしています:
// packages/routes/src/helper.ts
export function createPostRoute<...>(config: {...}) {
return createRoute({
method: "post",
path: config.path,
request: buildRequest(config.params, undefined, config.body),
responses: {
201: {
content: { "application/json": { schema: config.response } },
description: "Created",
},
...errorResponses, // 400, 404, 500
},
});
}
OpenAPI仕様の生成
pnpm generate:openapi を実行すると、ルート定義からOpenAPI仕様が生成されます。
ただし、ここで一つ苦しみポイントがあります。@hono/zod-openapi はルート定義だけではOpenAPI仕様を生成できず、実際にハンドラーを登録した OpenAPIHono インスタンスが必要です。
クリーンアーキテクチャで層を分離していると、ハンドラーの実装はUsecase層やInfrastructure層に依存するため、DIは apps 側で行う必要があります。つまり、本来なら packages/routes 単体ではOpenAPI仕様を生成できない構造になってしまいます。
これを解消するために、*Route で終わるエクスポートを自動検出して、ダミーハンドラーを埋め込む仕組みを作りました:
// packages/routes/scripts/generate-openapi.ts
// *Routeをエクスポートしているファイルを検索
const routeExports = findRouteExports();
// ダミーハンドラーでOpenAPIHonoに登録
const app = new OpenAPIHono();
for (const route of routeExports) {
app.openapi(route, (c) => c.json({} as any));
}
// OpenAPI仕様を生成
const spec = app.getOpenAPIDocument({ ... });
これで apps に依存せず、packages/routes 単体でOpenAPI仕様を生成できるようになりました。ダミーハンドラーなので少しイケてない感はありますが、実用上は問題なく動いています。もっといい方法があれば改善したいところ。
Orvalでクライアント生成
生成された openapi.json から、OrvalでTanStack Queryのフックを生成します:
// packages/client/orval.config.ts
export default defineConfig({
api: {
input: {
target: "../routes/openapi.json",
},
output: {
target: "src/generated/api.ts",
client: "tanstack-query",
override: {
mutator: {
path: "./src/mutator/custom_instance.ts",
name: "customInstance",
},
query: {
useInfinite: true,
useInfiniteQueryParam: "cursor",
version: 5,
},
},
},
},
});
生成されるコードはこんな感じ:
// 自動生成: src/generated/api.ts
export const useListItems = <TData = ListItemsResponse>(
params?: ListItemsParams,
options?: {...}
) => {
return useQuery({
queryKey: getListItemsQueryKey(params),
queryFn: () => listItems(params),
...options,
});
};
export const useCreateItems = <TError = ErrorResponse>(
options?: {...}
) => {
return useMutation({
mutationFn: (data: CreateItemsBody) => createItems(data),
...options,
});
};
無限スクロール用の useListItemsInfinite も自動生成されます。ページネーションのカーソルを自動的に次のリクエストに渡してくれる。
フロントエンドでの使い方
React側では生成されたフックをそのまま使います:
// apps/web
import { useListItems, useCreateItems } from "@myapp/client";
function ItemList({ categoryId }: { categoryId: string }) {
const { data, isLoading } = useListItems({ categoryId });
const createMutation = useCreateItems();
if (isLoading) return <Loading />;
return (
<ul>
{data?.items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
item.name にはちゃんと型がついていて、補完も効きます。
Turboタスクへの組み込み
生成処理はTurborepoのタスクとして定義しています:
// turbo.json
{
"tasks": {
"generate": {
"dependsOn": ["^generate:openapi"],
"outputs": ["src/generated/**"]
},
"generate:openapi": {
"dependsOn": ["^build"],
"outputs": ["openapi.json"]
}
}
}
依存関係があるので:
packages/domainをビルドpackages/routesをビルドしてopenapi.json生成packages/clientで Orval 実行
この順序が自動で守られる。pnpm generate 一発で全部やってくれます。
ちなみに、ViteのHMRとの組み合わせで色々と問題が発生していて、それを頑張って解決しています。詳細は後日書きます -> 書きました: pnpm devとの戦いの歴史 - モノレポ開発環境の試行錯誤
開発時のワークフロー
APIを変更するときの流れ:
# 1. Domain層のスキーマを変更
# packages/domain/src/entities/event.ts
# 2. ルート定義を変更(必要なら)
# packages/routes/src/event.route.ts
# 3. 生成を実行
pnpm generate
# 4. フロントエンドで使う
# 型が変わっていれば TypeScript がエラーを出してくれる
Routes層やClient層を手で書き換える必要はありません。Domain層を変えて pnpm generate すれば、APIドキュメントもクライアントも更新される。
カスタムインスタンス
Orvalの mutator オプションで、axios の設定をカスタマイズできます:
// packages/client/src/mutator/custom_instance.ts
export const customInstance = async <T>(config: AxiosRequestConfig): Promise<T> => {
const source = axios.CancelToken.source();
const response = await AXIOS_INSTANCE({
...config,
cancelToken: source.token,
});
return response.data;
};
認証トークンの付与とか、エラーハンドリングとかをここで一元管理できます。
感じている効果
この仕組みで:
- APIクライアントのコードを1行も書かなくていい
- バックエンドとフロントエンドの型が必ず一致する
- APIドキュメントが常に最新
- 新しいエンドポイント追加時のボイラープレートがゼロ
最初のセットアップは少し手間だけど、一度動き始めると本当に楽です。