👌

PrismaとZodが導く、型安全なAPI開発の省エネルート

2025/01/22に公開

効率化と品質向上を実現する実践的アプローチ

昨今の API 開発において、OpenAPI (Swagger) ドキュメントの重要性は増すばかりです。しかし、手作業によるドキュメント作成・更新は、開発者の大きな負担となり、コードとの不整合も発生しやすいという課題があります。

本記事では、Prisma と Zod を組み合わせることで、OpenAPI ドキュメントの生成を自動化し、型安全で効率的な API 開発を実現する方法を紹介します。

Prisma 生成の Zod スキーマを活用するメリット

この手法の鍵は、Prisma が zod-prisma-types によって自動生成する Zod スキーマ にあります。

Prisma はデータベーススキーマを定義するだけでなく、対応する型情報を自動生成します。さらに、zod-prisma-types を利用することで、この型情報を Zod スキーマとして出力できます。

つまり、データベーススキーマを定義するだけで、API のリクエスト・レスポンスのバリデーションと OpenAPI ドキュメント生成に利用可能な Zod スキーマが手に入るのです。

Prisma + Zod + OpenAPI の連携

Prisma: 型安全な ORM として、データベーススキーマ定義と型情報生成を担います。zod-prisma-types と組み合わせることで、Zod スキーマの自動生成を実現します。
Zod: スキーマ定義・バリデーションライブラリとして、API リクエスト・レスポンスの型を厳密に定義します。
zod-prisma-types: Prisma スキーマから Zod スキーマを自動生成するツールです。
@asteasolutions/zod-to-openapi: Zod スキーマから OpenAPI 定義を生成するツールです。
これらの連携により、Prisma → Zod → OpenAPI という流れで、型安全性を維持しながら OpenAPI ドキュメントを自動生成できます。

手順の概要

以下、具体的な手順を説明します。ここでは Next.js の App Router を用いた API を例にしますが、他のフレームワーク (Express, NestJS, FastAPI など) でも基本的な考え方は同じです。

1. 必要なライブラリのインストール

npm install zod @asteasolutions/zod-to-openapi js-yaml glob zod-prisma-types

2. Prisma スキーマの定義 (prisma/schema.prisma)

Plan モデルのみを定義した Prisma スキーマの例です。

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator zod {
  provider        = "zod-prisma-types"
  output          = "../src/schema/zod"
  createInputTypes = false
  useMultipleFiles = true
}

model Plan {
  id         Int    @id @default(autoincrement())
  name       String
  dataAmount String
  price      Int
}

3. Zod スキーマの生成

npx prisma generate

src/schema/zod ディレクトリに PlanSchema.ts などの Zod スキーマが自動生成されます。

4. OpenAPI ドキュメント生成スクリプト (scripts/generate-openapi.ts)

import { extendZodWithOpenApi } from "@asteasolutions/zod-to-openapi";
import { writeFileSync } from "fs";
import yaml from "js-yaml";
import { globSync } from "glob";
import { z, ZodSchema } from "zod";
import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi";
import { openApiRegistry } from "@/lib/openapi";

extendZodWithOpenApi(z);

// 認証スキーマの定義
openApiRegistry.registerComponent("securitySchemes", "CustomerIdAuth", {
  type: "apiKey",
  in: "header",
  name: "X-Customer-ID",
});
openApiRegistry.registerComponent("securitySchemes", "TokenAuth", {
  type: "apiKey",
  in: "header",
  name: "X-Token",
});

async function generateOpenApiDocument() {
  const files = globSync(["./src/app/api/**/schema.ts"], {
    cwd: process.cwd(),
  });

  await Promise.all(
    files.map(async (file) => {
      try {
        await import(`../${file}`);
      } catch (error) {
        console.error(`Error importing or processing ${file}:`, error);
      }
    })
  );

  const generator = new OpenApiGeneratorV3(
    openApiRegistry.definitions as unknown as ZodSchema[]
  );

  const openApi = generator.generateDocument({
    openapi: "3.0.0",
    info: {
      title: "API",
      version: "1.0.0",
    },
  });

  const yamlString = yaml.dump(openApi);
  writeFileSync("openapi.yaml", yamlString);

  console.log("OpenAPI specification generated to openapi.yaml");
}

generateOpenApiDocument();

src/app/api 配下の schema.ts ファイルを動的に読み込み、@asteasolutions/zod-to-openapi で OpenAPI ドキュメントを生成します。

5. 各 API エンドポイントのスキーマ定義 (src/app/api/plans/schema.ts)

Prisma から生成された Zod スキーマ (PlanSchema) を利用します。

import { PlanSchema } from "@/schema/zod";
import { z } from "zod";
import {
  errorResponseSchema,
  openApiRegistry,
} from "@/lib/openapi";

// リクエストスキーマ
const getPlansRequestSchema = z.object({
  query: z.object({
    page: z.string().optional().openapi({
      description: '取得するページ番号',
      example: '1',
    }),
    limit: z.string().optional().openapi({
      description: '1ページあたりのデータ数',
      example: '10',
    })
  })
});

// レスポンススキーマ
const getPlansResponseSchema = z.object({
  success: z.boolean().openapi({ description: "成功かどうか" }),
  plans: z.array(PlanSchema).openapi({ description: "プラン" }),
});

/** ------------------------------------------------------------
 * OpenAPIのスキーマを登録
 * ------------------------------------------------------------ */
openApiRegistry.registerPath({
  method: "get",
  path: "/api/plans",
  summary: "プラン一覧を取得",
  security: [
    { CustomerIdAuth: [] },
    { TokenAuth: [] },
  ],
  request: {
    query: getPlansRequestSchema.shape.query
  },
  responses: {
    200: {
      description: "Success",
      content: {
        "application/json": {
          schema: getPlansResponseSchema,
        },
      },
    },
    400: {
      description: "Bad request",
      content: {
        "application/json": {
          schema: errorResponseSchema,
        },
      },
    },
    500: {
      description: "Internal Server Error",
      content: {
        "application/json": {
          schema: errorResponseSchema,
        },
      },
    },
  },
});

6. API ハンドラ (src/app/api/plans/route.ts)

リクエストとレスポンスのバリデーションに Zod スキーマを利用します。

import { PlanService } from "@/services/app/plans/PlanService";
import { NextRequest, NextResponse } from "next/server";
import { getPlansRequestSchema, getPlansResponseSchema } from "./schema";

/** ------------------------------------------------------------
 * GET: プラン一覧を取得
 * ------------------------------------------------------------ */
export async function GET(req: NextRequest) {
  try {
    // リクエストのバリデーション
    const request = getPlansRequestSchema.safeParse({
      query: {
        page: req.nextUrl.searchParams.get("page"),
        limit: req.nextUrl.searchParams.get("limit"),
      }
    });

    if (!request.success) {
      return NextResponse.json(
        { error: "Bad request", details: request.error.format() },
        { status: 400 }
      );
    }

    // プラン一覧を取得 (page, limit を使用する部分は適宜実装)
    const planService = new PlanService();
    const plans = await planService.getPlans({
      page: request.data.query.page ? Number(request.data.query.page) : undefined,
      limit: request.data.query.limit ? Number(request.data.query.limit) : undefined,
    });

    // レスポンスのバリデーション
    const response = getPlansResponseSchema.safeParse({
      success: true,
      plans,
    });

    if (!response.success) {
      console.error("Response validation error:", response.error);
      return NextResponse.json(
        { error: "Internal server error" },
        { status: 500 }
      );
    }

    // レスポンスを返す
    return NextResponse.json(response.data, { status: 200 });
  } catch (error) {
    const errorMessage = error instanceof Error ? error.message : String(error);
    console.error(errorMessage);
    return NextResponse.json({ error: errorMessage }, { status: 500 });
  }
}

7. 実行

npx tsx scripts/generate-openapi.ts

openapi.yaml が生成されます。

本手法のメリット

  • 型安全性の確保: Prisma と Zod の組み合わせにより、型安全な開発を実現します。
  • 開発効率の向上: OpenAPI ドキュメントを手作業で記述する必要がなく、開発に集中できます。
  • ドキュメントの最新化: コード (Prisma スキーマ) を変更すれば、自動的に OpenAPI ドキュメントも更新されます。
  • リクエスト/レスポンスのバリデーション強化: 不正なリクエストを早期に検知し、堅牢な API を構築できます。
  • スキーマ定義の一元管理: Prisma スキーマを元に Zod スキーマが生成されるため、スキーマ定義を一元管理できます。

他フレームワークへの応用

上記では Next.js の App Router を例に説明しましたが、他のフレームワークでも応用可能です。

  • Express: req.body, req.query, req.params などを Zod スキーマでバリデーションし、res.json() で返すレスポンスも同様にバリデーションします。
  • NestJS: デコレーターを使って、より宣言的にスキーマを定義できます。
  • FastAPI: Pydantic を使ったスキーマ定義が標準でサポートされており、型ヒントから OpenAPI ドキュメントを自動生成できます。

まとめ

本記事では、Prisma と Zod を組み合わせた OpenAPI ドキュメントの自動生成手法を紹介しました。この手法により、型安全性を確保しながら、開発効率とドキュメントの品質を大幅に向上させることができます。Prisma と Zod を活用し、スマートな API 開発を実践しましょう。

参考情報

補足

この記事はGemini 2.0 Experimental Adbancedを用いて作成しました。

Discussion