記事一覧に戻る
ZodスキーマからAPIクライアントを自動生成する仕組み

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
  1. Domain層でZodスキーマを定義
  2. Routes層でAPIルート定義(Domainのスキーマを参照)
  3. OpenAPI仕様(JSON)を自動生成
  4. 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"]
    }
  }
}

依存関係があるので:

  1. packages/domain をビルド
  2. packages/routes をビルドして openapi.json 生成
  3. 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ドキュメントが常に最新
  • 新しいエンドポイント追加時のボイラープレートがゼロ

最初のセットアップは少し手間だけど、一度動き始めると本当に楽です。