Claude CodeでAPI設計ドキュメントから型安全なクライアント実装を自動生成する

Claude CodeでAPI設計ドキュメントから型安全なクライアント実装を自動生成する | mohablog
目次

なぜAPI設計からコード生成が必要なのか

フリーランスとして複数のプロジェクトに関わっていると、API仕様書とコード実装がズレるという厄介な問題に直面することが多いですね。サーバーサイドがJSON Schemaで定義した型と、クライアント側の実装が微妙に異なっていたり、APIエンドポイントの変更に気づかずクライアントコードが古い仕様で動いていたり。こうした不整合は本番環境でのバグにつながります。

Claude Codeを活用すると、OpenAPI/JSON Schema仕様をベースに、型安全なクライアントコードを自動生成できます。これにより、API仕様が真実の源(Single Source of Truth)となり、スキーマの変更と実装の同期が取れやすくなるんです。

本記事では、Claude CodeでTypeScript/Pythonの型安全なクライアントライブラリを生成する具体的な流れを、実際のプロジェクト例を交えて解説します。バージョンはClaude 3.5 Sonnet、TypeScript 5.3、Python 3.12を想定しています。

API仕様をメタデータとして定義する

OpenAPI仕様の最小限の構成

まず、API仕様をOpenAPI 3.1形式で定義します。公式ドキュメントによると、OpenAPI仕様はサーバーとクライアントの間で契約を結ぶためのメタデータとして機能します。

openapi: 3.1.0
info:
  title: E-commerce API
  version: 1.0.0
servers:
  - url: https://api.example.com/v1
paths:
  /products:
    get:
      operationId: listProducts
      parameters:
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
      responses:
        '200':
          description: Product list
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Product'
                  total:
                    type: integer
  /products/{id}:
    get:
      operationId: getProduct
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Product details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Product'
        '404':
          description: Product not found
components:
  schemas:
    Product:
      type: object
      required:
        - id
        - name
        - price
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        price:
          type: number
          exclusiveMinimum: 0
        description:
          type: string
          nullable: true
        createdAt:
          type: string
          format: date-time

このYAML形式の仕様には、エンドポイント、パラメータ、リクエスト/レスポンスボディの型情報が記述されています。operationIdを指定することで、生成されるコード内での関数名が一貫性を持ちます。

JSON Schemaで複雑な型も型安全に

OpenAPI内のcomponents.schemasで複雑なデータ構造を定義できます。Discriminator(判別子)を使ったポリモーフィズムも表現できるため、API仕様の段階で型の複雑さを表現することが重要ですね。

  Discount:
    type: object
    discriminator:
      propertyName: type
    required:
      - type
      - value
    properties:
      type:
        type: string
        enum:
          - percentage
          - fixed
      value:
        type: number
    oneOf:
      - $ref: '#/components/schemas/PercentageDiscount'
      - $ref: '#/components/schemas/FixedDiscount'
  PercentageDiscount:
    allOf:
      - $ref: '#/components/schemas/Discount'
      - type: object
        properties:
          value:
            type: number
            minimum: 0
            maximum: 100
  FixedDiscount:
    allOf:
      - $ref: '#/components/schemas/Discount'
      - type: object
        properties:
          value:
            type: number
            exclusiveMinimum: 0

Claude CodeでTypeScript型定義を自動生成する

プロンプト設計がコード生成の品質を左右する

Claude Codeに「OpenAPI仕様からTypeScript型定義を生成する」という指示を与える際、プロンプトの精度が重要になります。調べてみたら、具体的なジェネレーターロジックを明示すると、より実用的なコードが生成されることがわかりました。

以下はClaude CodeでのPrompt例です。

以下のOpenAPI 3.1仕様からTypeScript型定義ファイルを生成してください。

  • components.schemasをTypeScript インターフェース/型エイリアスに変換
  • required プロパティを必須フィールド、そうでないものはOptionalに
  • format: date-timeはDate型に、format: uuidはstring型に
  • oneOf/allOfの場合は union型またはintersection型に
  • すべての型にJSDocコメントを付与

このプロンプトでClaude Codeが生成する出力例:

/**
 * Product representation in the API
 */
export interface Product {
  /** Unique identifier (UUID) */
  id: string;
  /** Product name */
  name: string;
  /** Price in currency (must be positive) */
  price: number;
  /** Optional product description */
  description?: string | null;
  /** Creation timestamp */
  createdAt: Date;
}

/**
 * Product list response
 */
export interface ProductListResponse {
  /** Array of products */
  items: Product[];
  /** Total number of products */
  total: number;
}

/**
 * Discount type discriminator
 */
export type Discount = PercentageDiscount | FixedDiscount;

/**
 * Discount with percentage reduction
 */
export interface PercentageDiscount {
  type: 'percentage';
  /** Percentage value (0-100) */
  value: number;
}

/**
 * Fixed amount discount
 */
export interface FixedDiscount {
  type: 'fixed';
  /** Fixed discount amount (must be positive) */
  value: number;
}

この生成されたインターフェースは、TypeScriptコンパイラで型チェックされるため、ランタイムエラーを事前に防ぐことができます。

APIクライアントクラスの生成

型定義だけでなく、API呼び出しを行うクライアントクラスも生成しましょう。Claude CodeでのPrompt:

上記のOpenAPI仕様から、以下の機能を持つTypeScript APIクライアントクラスを生成してください。

  • 各operationIdをメソッド化(例:listProducts、getProduct)
  • パラメータは型安全な引数に
  • 戻り値は生成した型定義を使用
  • エラーハンドリングはHTTPステータスコードに基づいて
  • axiosを依存として想定し、リクエストメソッドはasync/await対応

生成コード例:

import axios, { AxiosInstance, AxiosError } from 'axios';
import {
  Product,
  ProductListResponse,
  Discount,
} from './types';

/**
 * API Client for E-commerce API
 */
export class ECommerceAPIClient {
  private client: AxiosInstance;

  constructor(baseURL: string = 'https://api.example.com/v1') {
    this.client = axios.create({
      baseURL,
      headers: {
        'Content-Type': 'application/json',
      },
    });
  }

  /**
   * List all products with pagination
   */
  async listProducts(options?: {
    limit?: number;
    offset?: number;
  }): Promise {
    try {
      const response = await this.client.get('/products', {
        params: {
          limit: options?.limit ?? 20,
          offset: options?.offset ?? 0,
        },
      });
      return response.data;
    } catch (error) {
      throw this.handleError(error);
    }
  }

  /**
   * Get a single product by ID
   */
  async getProduct(id: string): Promise {
    try {
      const response = await this.client.get(`/products/${id}`);
      return response.data;
    } catch (error) {
      if (
        axios.isAxiosError(error) &&
        error.response?.status === 404
      ) {
        throw new Error(`Product with ID ${id} not found`);
      }
      throw this.handleError(error);
    }
  }

  private handleError(error: unknown): Error {
    if (axios.isAxiosError(error)) {
      return new Error(
        `API Error: ${error.response?.status} - ${error.response?.statusText}`
      );
    }
    return error instanceof Error ? error : new Error('Unknown error');
  }
}

このクライアントクラスは完全に型安全です。メソッドの引数を間違えたり、存在しないプロパティにアクセスしようとしたりすると、TypeScriptコンパイラが即座にエラーを指摘します。

Pythonでの型安全なクライアント生成

Pydanticモデルの自動生成

Pythonではモダンなコード生成に向けて、Pydanticライブラリを使うのが標準的になってきました。Claude CodeでOpenAPI仕様からPydantic v2形式のモデルを生成するプロンプト:

OpenAPI 3.1仕様からPydantic v2のモデルクラスを生成してください。

  • components.schemasごとに BaseModel を継承したクラスを生成
  • 型ヒントはPython 3.12の標準形式(typing.Optional ではなく | None を使用)
  • Docstringはスキーマの description から自動抽出
  • format: date-time は datetime.datetime に、format: uuid は uuid.UUID に
  • validation でOpenAPI制約(minimum, maximum, pattern等)を Field()で表現

生成結果:

from datetime import datetime
from uuid import UUID
from typing import Optional
from pydantic import BaseModel, Field

class Product(BaseModel):
    """Product representation in the API."""
    id: UUID = Field(..., description="Unique identifier (UUID)")
    name: str = Field(..., description="Product name")
    price: float = Field(..., gt=0, description="Price in currency (must be positive)")
    description: Optional[str] = Field(
        None, description="Optional product description"
    )
    created_at: datetime = Field(..., description="Creation timestamp")

    class Config:
        # Allow population by field name or alias
        populate_by_name = True
        # JSON encoder config
        json_schema_extra = {
            "examples": [{
                "id": "550e8400-e29b-41d4-a716-446655440000",
                "name": "Example Product",
                "price": 29.99,
                "description": "A sample product",
                "created_at": "2024-01-15T10:30:00Z",
            }]
        }

class ProductListResponse(BaseModel):
    """Product list response."""
    items: list[Product] = Field(..., description="Array of products")
    total: int = Field(..., description="Total number of products")

class Discount(BaseModel):
    """Base discount model with type discrimination."""
    type: str = Field(..., description="Discount type")
    value: float = Field(..., description="Discount value")

HTTPクライアントの生成(httpx活用)

Pythonではasyncioを前提にhttpxライブラリを使うことが増えていますね。Claude CodeでOpenAPI仕様からPythonのAPIクライアントを生成する際、async/await対応の実装が現場で求められることが多いです。

import httpx
from typing import Optional
from models import Product, ProductListResponse

class ECommerceAPIClient:
    """Async API client for E-commerce API."""

    def __init__(
        self,
        base_url: str = "https://api.example.com/v1",
        timeout: float = 10.0,
    ):
        self.base_url = base_url
        self.timeout = timeout
        self._client: Optional[httpx.AsyncClient] = None

    async def __aenter__(self):
        """Context manager entry."""
        self._client = httpx.AsyncClient(
            base_url=self.base_url,
            timeout=self.timeout,
        )
        return self

    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """Context manager exit."""
        if self._client:
            await self._client.aclose()

    async def list_products(
        self,
        limit: int = 20,
        offset: int = 0,
    ) -> ProductListResponse:
        """List all products with pagination.
        
        Args:
            limit: Maximum number of products to return (default 20)
            offset: Number of products to skip (default 0)
            
        Returns:
            ProductListResponse with items and total count
            
        Raises:
            httpx.HTTPStatusError: If API returns an error status
        """
        if not self._client:
            raise RuntimeError("Client not initialized. Use async with statement.")
        
        response = await self._client.get(
            "/products",
            params={"limit": limit, "offset": offset},
        )
        response.raise_for_status()
        return ProductListResponse(**response.json())

    async def get_product(self, product_id: str) -> Product:
        """Get a single product by ID.
        
        Args:
            product_id: UUID of the product
            
        Returns:
            Product details
            
        Raises:
            httpx.HTTPStatusError: If product not found or API error
        """
        if not self._client:
            raise RuntimeError("Client not initialized. Use async with statement.")
        
        try:
            response = await self._client.get(f"/products/{product_id}")
            response.raise_for_status()
            return Product(**response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                raise ValueError(f"Product {product_id} not found") from e
            raise

生成されたコードをテストする

テストの自動生成(型安全なモック)

Claude Codeで型定義とクライアント実装を生成したら、次はテストコードも自動生成しましょう。Pytestのfixture併用で、本番環境との連携をテストできます。

import pytest
import httpx
from unittest.mock import AsyncMock, patch
from uuid import uuid4
from datetime import datetime
from client import ECommerceAPIClient
from models import Product, ProductListResponse

@pytest.fixture
async def api_client():
    """Fixture for API client."""
    async with ECommerceAPIClient(
        base_url="https://api.example.com/v1"
    ) as client:
        yield client

@pytest.fixture
def sample_product():
    """Fixture for sample product."""
    return Product(
        id=uuid4(),
        name="Test Product",
        price=99.99,
        description="A test product",
        created_at=datetime.now(),
    )

@pytest.mark.asyncio
async def test_list_products(api_client, sample_product):
    """Test listing products."""
    mock_response = ProductListResponse(
        items=[sample_product],
        total=1,
    )
    
    with patch.object(
        api_client._client,
        "get",
        new_callable=AsyncMock,
    ) as mock_get:
        mock_get.return_value.json.return_value = mock_response.model_dump(by_alias=True)
        mock_get.return_value.raise_for_status.return_value = None
        
        result = await api_client.list_products(limit=10)
        
        assert result.total == 1
        assert len(result.items) == 1
        assert result.items[0].name == "Test Product"
        mock_get.assert_called_once_with(
            "/products",
            params={"limit": 10, "offset": 0},
        )

@pytest.mark.asyncio
async def test_get_product_not_found(api_client):
    """Test getting non-existent product."""
    with patch.object(
        api_client._client,
        "get",
        new_callable=AsyncMock,
    ) as mock_get:
        mock_get.return_value.raise_for_status.side_effect = httpx.HTTPStatusError(
            "404 Not Found",
            request=httpx.Request("GET", "http://test"),
            response=httpx.Response(404),
        )
        
        with pytest.raises(ValueError, match="not found"):
            await api_client.get_product("invalid-id")

このテストコードは、型安全なPydanticモデルを活用してモックレスポンスを作成しているため、スキーマ変更時に自動的に破綻が検出されます。

スキーマ変更時のワークフロー

変更の影響範囲を自動検出

API仕様が変わった時の流れをシンプルにできるのが、このアプローチの大きなメリットです。

  • サーバーサイドチームがOpenAPI仕様ファイルを更新
  • Claude Codeでクライアント型定義とクライアント実装を再生成
  • TypeScriptやPythonのコンパイラ/型チェッカーが、破綻した呼び出しをすぐに指摘
  • 開発者は指摘された箇所だけを修正すればOK

これまでのように「APIの仕様書を読み落とす」「パラメータの型を間違える」といったミスが根本的に減ります。

バージョニング戦略

複数のAPI バージョンをサポートする場合、OpenAPI仕様ファイルを api-v1.yamlapi-v2.yaml のように分けて、それぞれClaude Codeで別々のクライアントを生成するのが実用的です。

src/
├── api-clients/
│   ├── v1/
│   │   ├── types.ts
│   │   ├── client.ts
│   │   └── __init__.py (Pythonの場合)
│   └── v2/
│       ├── types.ts
│       ├── client.ts
│       └── __init__.py
└── specs/
    ├── api-v1.yaml
    └── api-v2.yaml

よくあるハマりどころ

null許容フィールドと必須フィールドの混同

OpenAPI仕様でrequired配列に含まれていないフィールドは、自動生成時にOptional/nullable型になります。これを見落とすと、ランタイムでundefinedNoneが期待せず現れます。Claude Codeが生成する際、JSDocやDocstringに「Optional」と明記されていることを確認してください。

日時フォーマットのタイムゾーン問題

OpenAPI仕様でformat: date-timeと書くと、ISO 8601形式(例:2024-01-15T10:30:00Z)が標準です。Pydanticはデフォルトでこれをdatetime.datetimeに変換しますが、デシリアライズ時にタイムゾーン情報が失われることがあります。型定義にField(..., json_schema_extra={"format": "date-time"})を明記すると、より一貫性が保たれます。

まとめ

  • OpenAPI/JSON Schema仕様をメタデータの真実の源にすることで、スキーマ漂流を防ぎます
  • Claude Codeで型定義とクライアント実装を自動生成すれば、手書きバグが減り開発速度が上がります
  • TypeScriptのaxios/Pythonのhttpxで型安全なAPIクライアントを実装し、ランタイムエラーを事前に検出します
  • スキーマ変更時の影響範囲が自動的に型チェッカーで指摘されるため、リグレッション防止に有効です
  • テストコードも自動生成されるため、保守性が飛躍的に向上します

よくある質問(FAQ)

既存のAPIクライアントライブラリ(swagger-clientなど)との使い分けは?

swagger-clientはランタイムにスキーマを読み込む動的な手法ですが、Claude Codeの生成アプローチはビルド時の静的コード生成です。型安全性が強い静的生成の方が、TypeScriptやPythonのモダンな開発では好まれる傾向にあります。ただし、非常に動的なAPI(GraphQL等)の場合は、ランタイム生成の方が柔軟かもしれません。

Claude Codeで生成したコードの著作権や再利用は?

Claude Codeが生成したコードはあなたが所有できます。本番プロジェクトでの使用に問題はありません。ただし生成コードにはバグがある可能性があるので、テストと code review は必須です。

大規模なOpenAPI仕様(数百エンドポイント)でも一括生成できる?

Claude Codeの出力トークン制限に引っかかる可能性があります。その場合、エンドポイントを機能ごとに複数のOpenAPI仕様ファイルに分割し、個別に生成することをお勧めします。generated code を monorepo で一元管理するのが実用的なアプローチです。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次