クライアントとサーバーで、APIの型・バリデーションルールを一元化する

こんにちは。株式会社 TechDoctor でソフトウェアエンジニアをしている大瀧です。

突然ですが、アプリケーション開発で、クライアントとサーバーの API の型やバリデーションルールが食い違い、予期せぬバグに繋がった経験はないでしょうか。

このような課題の解決策として、本記事では、OpenAPI スキーマをもとに、API の型とバリデーションルールを一元管理する方法を紹介します。

ここで紹介するアプローチは、 OpenAPI スキーマを作成できる限り特定の言語やフレームワークに依存しませんが、今回は具体例として FastAPI、Pydantic、Zod、openapi-zod-client を使った方法を解説します。

なお本記事のサンプルコードは、以下のリポジトリで確認できます。(Kiro と Claude Code が作ってくれました)
GitHub - shutootaki/sync-schema-demo

1. 背景と課題

課題 ①:API の型の二重管理

API のインターフェースの型が、バックエンドとフロントエンドでそれぞれ定義されていると、二重管理になってしまいます。

この状態では、仕様変更のたびに両方のコードを手動で同期させる必要が生まれます。

その際に片方で修正漏れが起きると、データの不整合や実行時エラーに直結します。結果として、ランタイムエラーが発生したり、「このフィールドは null 許容でしたっけ?」といった本来不要なコミュニケーションが頻発してしまいます。

課題 ②:バリデーションロジックの分散

優れた UX のためにはクライアント側での事前バリデーションが必要です。そしてデータの整合性とセキュリティのためにはサーバー側でのバリデーションも不可欠です。

しかし、これらのバリデーションルールをそれぞれ別のリポジトリで管理していると、いつの間にか仕様が乖離しがちです。

結果として、課題 ① と同様にエラーや不要なコミュニケーションコストが発生してしまいます。

2. 解決アプローチ

この課題を解決するために、弊社では以下のアプローチを採用しました。

バックエンドを信頼できる唯一の情報源(SSoT)にする

バックエンド(FastAPI + Pydantic)を、型とバリデーションルールにおける信頼できる唯一の情報源と位置づけます。

そのために、コーディング時はPydantic でリクエスト/レスポンスの型とバリデーションルールを定義します。
あとは自動的に同期が行われる仕組みにします。

コード生成による型の自動同期

同期のしくみはこうです。

概念図

Pydanticでのスキーマ定義をもとに、FastAPI が OpenAPI スキーマを自動生成します。
フロントエンド側では、openapi-zod-client を使って、OpenAPI ドキュメントをもとにZod スキーマを自動生成します。

この仕組みにより、バックエンドでスキーマを変更してもコマンド 1 つでフロントエンドの型を同期できるため、APIの型とフロントエンド側の実装との不整合を簡単に検知でき、バリデーションロジックの二重実装も不要になります。

3. 具体的な実装方法

基本的なユーザー作成 API を例に、具体的な実装方法を解説します。

① Pydantic モデルの定義と FastAPI によるドキュメント生成

バックエンドでは、Pydantic を使いリクエストとレスポンスのスキーマを定義します。

基本的なスキーマ定義
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from enum import Enum

class UserRole(str, Enum):
    USER = "user"
    ADMIN = "admin"

class UserCreateRequest(BaseModel):
    name: str = Field(..., min_length=1, max_length=100)
    email: EmailStr
    age: int = Field(..., ge=18, le=120)
    role: UserRole = UserRole.USER

class UserResponse(BaseModel):
    id: int
    name: str
    email: str
    age: int
    role: UserRole
FastAPI ルーターでのスキーマの使用

定義した Pydantic スキーマを、FastAPI のエンドポイントで利用します。

from fastapi import APIRouter, HTTPException
from app.models.user import UserCreateRequest, UserResponse
import random

router = APIRouter(tags=["Users"])

users_db: dict = {}

@router.post("/users", response_model=UserResponse)
async def create_user(user_data: UserCreateRequest) -> UserResponse:
    new_user = {
        "id": generate_user_id(),
        "name": user_data.name,
        "email": user_data.email,
        "age": user_data.age,
        "role": user_data.role,
    }

    users_db[new_user["id"]] = new_user
    return UserResponse.model_validate(new_user)
FastAPI アプリケーションの設定

main.py で、OpenAPI ドキュメント生成の設定を行います。

from fastapi import FastAPI
from app.routers import users

app = FastAPI(
    title="User API",
    version="1.0.0",
    description="シンプルなユーザー管理API"
)

app.include_router(users.router, prefix="/api/v1")
生成される OpenAPI スキーマ

上記の Pydantic モデルと FastAPI の設定から、次のような OpenAPI スキーマが自動生成されます。(一部抜粋)

{
  "openapi": "3.1.0",
  "info": {
    "title": "User API",
    "version": "1.0.0"
  },
  "components": {
    "schemas": {
      "UserCreateRequest": {
        "properties": {
          "name": {
            "type": "string",
            "maxLength": 100,
            "minLength": 1,
            "title": "Name"
          },
          "email": {
            "type": "string",
            "format": "email",
            "title": "Email"
          },
          "age": {
            "type": "integer",
            "maximum": 120,
            "minimum": 18,
            "title": "Age"
          },
          "role": {
            "$ref": "#/components/schemas/UserRole",
            "default": "user"
          }
        },
        "title": "UserCreateRequest",
        "description": "ユーザー作成リクエストのスキーマ"
      },
      "UserRole": {
        "type": "string",
        "enum": ["user", "admin"],
        "title": "UserRole"
      }
    }
  }
}

画面キャプチャ

このスキーマには、minLength=1ge=18 といったバリデーションルール、UserRoleEnum 定義、必須フィールドの指定、整数・文字列・メール形式といった詳細な型情報が含まれています。

openapi-zod-clientによるスキーマファイルの生成

フロントエンド側では、FastAPI が生成した OpenAPI スキーマをもとに、openapi-zod-clientを使って Zod スキーマを自動生成します。

openapi-zod-clientのインストールと設定

まず、フロントエンドプロジェクトに必要なパッケージをインストールします。

pnpm install -D openapi-zod-client

次に、プロジェクトのルートに設定ファイルを作成します。

// schema-format.hbs

// This file is automatically generated. Do not change it manually.
import { z } from "zod";

{{#each schemas}}
export const {{@key}} = {{{this}}};
export type {{@key}} = z.infer<typeof {{@key}}>;
{{/each}}

export const schemas = {
{{#each schemas}}
	{{@key}},
{{/each}}
};

上記はシンプルな設定例です。詳細は公式ドキュメントを参照してください。
GitHub - astahmer/openapi-zod-client: Generate a zodios (typescript http client with zod validation) from an OpenAPI spec (json/yaml)


package.jsonへのスクリプト追加

開発ワークフローを効率化するため、package.jsonスクリプトを追加します。

{
  "scripts": {
    "codegen": "openapi-zod-client -o ./src/lib/api/schema/api_schemas.ts -t ./schema-format.hbs ${OPEN_API_JSON:-<http://localhost:8000/openapi.json>}"
  }
}

バックエンドサーバーが起動している状態で以下のコマンドを実行すると、Zod スキーマが自動で生成されます。

pnpm run codegen

実際には以下のような Zod スキーマが自動で生成されます。(一部抜粋)
OpenAPI スキーマをもとに UserCreateRequest の型とバリデーションルールが、Zod のスキーマに変換されています。

// src/lib/api/schema/api_schemas.ts
// This file is automatically generated. Do not change it manually.
import { z } from "zod";

export const UserRole = z.enum(["user", "admin"]);
export type UserRole = z.infer<typeof UserRole>;
export const UserCreateRequest = z
  .object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
    age: z.number().int().gte(18).lte(120),
    role: UserRole.optional(),
  })
  .passthrough();
export type UserCreateRequest = z.infer<typeof UserCreateRequest>;
React Hook Form との連携

生成された Zod スキーマは、React Hook Form のようなフォームの状態管理をするライブラリと簡単に連携できます。

以下のように、src/lib/api/schema/api_schemas.ts に出力された Zod スキーマを import して、useForm と zodResolver にセットするだけで、先程 Pydantic で定義した型とバリデーションルールを再利用することができます。

import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { UserCreateRequest } from "@/lib/api/schema/api_schemas";

export const UserForm = ({ onSubmit, isLoading }) => {
  const {
    register,
    handleSubmit,
    formState: { errors },
  } = useForm<UserCreateRequest>({
    resolver: zodResolver(UserCreateRequest),
    defaultValues: { role: "user" },
  });

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register("name")} placeholder="名前" />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register("email")} type="email" placeholder="メール" />
      {errors.email && <p>{errors.email.message}</p>}

      <input {...register("age", { valueAsNumber: true })} type="number" />
      {errors.age && <p>{errors.age.message}</p>}

      <button type="submit" disabled={isLoading}>
        {isLoading ? "作成中..." : "ユーザーを作成"}
      </button>
    </form>
  );
};

実際にサンプルコードのフロントエンドサーバーを起動して、フォームに不正な値を設定すると、以下のように期待通りバリデーションを実行してくれていることがわかります。

画面キャプチャ

4. まとめ

本記事では、FastAPI、Pydantic、Zod、openapi-zod-client を組み合わせ、API の型とバリデーションルールを一元管理する方法を紹介しました。弊社では、この取り組みによって以下のメリットを得ることができました。

  • 型の不一致によるバグを予防
    • バックエンドで定義した OpenAPI スキーマからフロントエンドの型を生成するため、型の不一致によるバグを大幅に減少させることができました。
  • バリデーションロジックを再利用
    • Zod へ変換されたスキーマを React Hook Form などに直接適用できるため、同一のルールを重複して実装する必要がなくなりました。
  • 変更への追従が容易
    • バックエンドのスキーマモデルを修正した後に、コード生成コマンドを実行するだけで API の型定義・バリデーションロジックが更新されるので、開発者の負担を減らすことができました。

API の型・バリデーションロジックの管理に課題感を持っている方がいましたら、ぜひ導入を検討してみてください!



書いた人:大瀧