🛡️

AI時代のスキーマファースト開発 FastAPI × GitHub Packages で型安全なSDKを自動配布する

に公開

はじめに

この記事は 3-shake Advent Calendar 2025 の記事です。

フロントエンド開発者とバックエンド開発者の間で「APIの仕様が違う」「ドキュメントが古い」といった問題に悩まされたことはありませんか?

さらにAI時代になり、Claude CodeやCursorなどのAIコーディングツールを使う機会が増えてきました。しかし、AIにAPI呼び出しを実装させると、存在しないエンドポイントを「想像」で実装してしまったり、パラメータの型を間違えたりすることがあります。

本記事では、FastAPIのOpenAPI自動生成機能を活用し、GitHub ActionsでTypeScript SDKを自動生成・GitHub Packagesで配布するアーキテクチャを紹介します。この仕組みは、単なる開発効率化だけでなく、**AI時代における「ガードレール」**としても機能すると考えてこの記事を書きました。

このアーキテクチャで解決できること

  1. API仕様の Single Source of Truth: バックエンドのコードが唯一の仕様書
  2. 型安全なAPI呼び出し: TypeScriptの型補完でパラメータミスを防止
  3. 自動ドキュメント: Pythonのdocstringがそのままクライアントのドキュメントに
  4. AIのガードレール: node_modulesとして配布することで、AIが勝手に変更しない境界を作る

全体アーキテクチャ

実装ステップ

Step 1: FastAPIでOpenAPI仕様を生成する

FastAPIは標準でOpenAPI 3.0仕様を自動生成します。

このとき重要になるのが operation_id です。生成されるSDKのメソッド名はこの operation_id に基づいて決定されます。関数名に依存するとリファクタリング時に意図せずSDKのメソッド名が変わってしまう(破壊的変更になる)ため、FastAPIのデコレータで明示的に指定することを推奨します。

# operation_id="getUsers" と指定することで、
# Pythonの関数名を変えてもSDKのメソッド名は "getUsers" に固定される
@router.get("/users", operation_id="getUsers")
async def get_users_handler():
    pass

次に、CI上でOpenAPI仕様をJSONファイルとして出力するためのスクリプトを作成します。
※Github Actions内でopenapi.jsonを生成せずともHaskyなどでコミット前にrouterなどに変更があった場合、openapi.jsonを生成するというルールでも良いかもしれないです

tools/open-api-generate/main.py
#!/usr/bin/env python3
"""
FastAPIからOpenAPI仕様を生成してSDK用に出力するスクリプト
"""
import json
import os
import sys

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))


def generate_openapi():
    """OpenAPI仕様をJSONファイルとして出力"""
    from app.main import app

    openapi_data = app.openapi()
    openapi_data["info"]["title"] = "Your API Title"

    output_path = "client-sdk/openapi.json"
    os.makedirs(os.path.dirname(output_path), exist_ok=True)

    with open(output_path, "w", encoding="utf-8") as f:
        json.dump(openapi_data, f, indent=2, ensure_ascii=False)

    print(f"✅ OpenAPI spec generated: {output_path}")


if __name__ == "__main__":
    generate_openapi()

ポイント: operation_id を明示することで、バックエンドの実装詳細(関数名)とSDKのインターフェース(メソッド名)を分離できます。

# Python側
@router.get("/users", operation_id="getUsers")
async def implementation_detail_name(): ...

# 生成されるSDK側
getUsers()

Step 2: client-sdkディレクトリの構成

client-sdk/
├── package.json
├── tsconfig.json
├── openapi.json          # 自動生成
└── src/
    ├── index.ts          # エントリーポイント ※自分でカスタマイズしても良い
    └── generated/        # 自動生成されるコード
        ├── services.gen.ts
        ├── types.gen.ts
        └── core/

package.json:

client-sdk/package.json
{
  "name": "@your-scope/your-client-sdk",
  "version": "0.1.0",
  "description": "TypeScript Client SDK for Your API",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "files": ["dist"],
  "scripts": {
    "generate": "npx @hey-api/openapi-ts -i ./openapi.json -o ./src/generated -c fetch",
    "build": "tsc",
    "prepublishOnly": "npm run build"
  },
  "publishConfig": {
    "registry": "https://npm.pkg.github.com",
    "access": "restricted"
  },
  "devDependencies": {
    "@hey-api/openapi-ts": "^0.48.0",
    "typescript": "^5.3.0"
  }
}

Step 3: GitHub Actions ワークフロー

.github/workflows/generate-sdk.yml
name: Generate and Publish Client SDK

on:
  push:
    branches: [main, develop]
    paths:
      - "app/routers/**/*.py"  # APIの変更時のみトリガー
  workflow_dispatch:

jobs:
  generate-sdk:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      packages: write

    steps:
      - uses: actions/checkout@v4
        with:
          token: ${{ secrets.GITHUB_TOKEN }}

      # Python環境セットアップ
      - name: Setup Python
        uses: actions/setup-python@v4
        with:
          python-version: "3.12"

      - name: Install Python dependencies
        run: pip install fastapi uvicorn sqlmodel # 必要な依存関係

      # Node.js環境セットアップ
      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: "20"
          registry-url: "https://npm.pkg.github.com"
          scope: "@your-scope"

      # OpenAPI仕様生成
      - name: Generate OpenAPI spec
        run: python tools/open-api-generate/main.py

      # TypeScript SDK生成
      - name: Generate TypeScript SDK
        working-directory: ./client-sdk
        run: |
          npm ci
          npm run generate

      # 変更チェック
      - name: Check for SDK changes
        id: check_changes
        working-directory: ./client-sdk
        run: |
          git diff --exit-code src/generated || echo "has_changes=true" >> $GITHUB_OUTPUT

      # バージョンアップ & 公開
      - name: Version and publish SDK
        if: steps.check_changes.outputs.has_changes == 'true'
        working-directory: ./client-sdk
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"

          npm version patch --no-git-tag-version
          npm run build
          npm publish
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # 変更をコミット(無限ループ防止)
      - name: Commit SDK changes
        if: steps.check_changes.outputs.has_changes == 'true'
        run: |
          git add client-sdk/
          BRANCH_NAME=$(echo ${GITHUB_REF#refs/heads/})
          git commit -m "chore: update generated client SDK [skip ci]"
          git pull --rebase origin $BRANCH_NAME
          git push origin $BRANCH_NAME

重要なポイント:

  1. パストリガー: app/routers/**/*.py の変更時のみ実行
  2. [skip ci]: コミットメッセージに含めることで無限ループを防止
  3. リベース: 並行実行時のコンフリクトを防止

Step 4: フロントエンドでの利用

# .npmrcの設定(GitHub Packages認証)
echo "@your-scope:registry=https://npm.pkg.github.com" >> .npmrc
echo "//npm.pkg.github.com/:_authToken=YOUR_TOKEN" >> .npmrc

# インストール
npm install @your-scope/your-client-sdk
import { getUsers, createUser, OpenAPI } from '@your-scope/your-client-sdk';

// 認証トークンの設定
OpenAPI.TOKEN = 'your-jwt-token';
OpenAPI.BASE = 'https://api.example.com';

// 型安全なAPI呼び出し
const response = await getUsers({
  page: 1,
  limit: 10,
  sortBy: 'created_at',
  sortOrder: 'desc'
});

// response.items は User[] として型推論される
console.log(response.items);

AI時代の「ガードレール」としてのnode_modules

ここからが本記事の伝えたいことです。

なぜnode_modulesがガードレールになりえるのか

AIコーディングツール(Claude Code、Cursor、GitHub Copilotなど)は、大量のコードベースから学習しています。
node_modulesとしてインストールされたライブラリをAIが回収するという挙動は基本的にしないと考えて良いと思っています。
ここは自分の経験則なので、確証があるわけでは無いです・・。

これを利用すると:

  1. SDKとして提供されたAPI呼び出しロジックを、AIが勝手に書き換えない
  2. 存在しないエンドポイントを「想像」で実装しない
  3. 型定義を見て、正しいパラメータで呼び出す

具体例:AIとのペアプログラミング

SDKがない場合のAIの挙動:

// AIに「ユーザー一覧を取得して」と依頼すると...
const response = await fetch('/api/users');  // エンドポイントが正しいかわからない
const users = await response.json();          // 型がanyになる

AIは良かれと思って「それっぽい」エンドポイントを推測してしまいます。

SDKがある場合のAIの挙動:

// AIがnode_modulesのSDKを参照して実装
import { getUsers } from '@your-scope/your-client-sdk';

const response = await getUsers({ page: 1, limit: 10 });
// response は GetUsersResponse 型として推論される

AIはSDKの型定義を見て:

  • 存在するエンドポイントのみを使用
  • 必須パラメータを正しく指定
  • レスポンスの型を正確に把握

SDKは「契約(Contract)」として機能する

生成されるSDKの具体例

services.gen.ts(メソッド定義)

client-sdk/src/generated/services.gen.ts
/**
 * ユーザー一覧取得
 * ユーザー一覧を取得します(管理者認証必須)
 * @param data The data for the request.
 * @param data.page ページ番号
 * @param data.limit 1ページあたりの件数
 * @param data.loginId ログインIDでのフィルタリング(あいまい検索)
 * @returns UserListResponse Successful Response
 * @throws ApiError
 */
export const getUsers = (data: GetUsersData = {}): CancelablePromise<GetUsersResponse> => {
  return __request(OpenAPI, {
    method: 'GET',
    url: '/api/admin/users',
    query: {
      page: data.page,
      limit: data.limit,
      login_id: data.loginId,
      login_id: data.loginId,
    },
    errors: {
      401: '認証エラー',
      422: 'Validation Error',
    }
  });
};

注目ポイント:

  • FastAPIのdocstringがJSDocとしてそのまま反映
  • 日本語のエラーメッセージも保持
  • パラメータの説明も自動生成

types.gen.ts(型定義)

client-sdk/src/generated/types.gen.ts
export type GetUsersData = {
  page?: number;
  limit?: number;
  loginId?: string;
  companyId?: number;
  isAdmin?: boolean;
  sortBy?: string;
  sortOrder?: string;
  // ...
};

export type GetUsersResponse = UserListResponse;

export type UserListResponse = {
  items: User[];
  total: number;
  page: number;
  limit: number;
};

導入時のTips

1. operation_idの命名規則を統一する

# Good: 動詞 + 名詞で一貫性を保つ
@router.get("/users", operation_id="getUsers")
async def get_users(): pass

@router.post("/users", operation_id="createUser")
async def create_user(): pass

@router.get("/users/{user_id}", operation_id="getUserById")
async def get_user_by_id(): pass

2. エラーレスポンスも型定義する

raise HTTPException するだけではOpenAPI仕様には反映されません。デコレータの responses 引数を使って明示的に定義することで、SDKにもエラー情報が出力されます。

@router.get(
    "/users/{user_id}",
    operation_id="getUserById",
    responses={
        404: {"description": "ユーザーが見つかりません"}
    }
)
async def get_user_by_id(user_id: int):
    user = await get_user(user_id)
    if not user:
        raise HTTPException(status_code=404, detail="ユーザーが見つかりません")
    return user

3. Pydanticモデルにdescriptionを追加

from pydantic import BaseModel, Field

class UserCreate(BaseModel):
    login_id: str = Field(..., description="ログインID(メールアドレス形式)")
    password: str = Field(..., description="パスワード(8文字以上)")
    is_admin: bool = Field(False, description="管理者権限を付与するか")

まとめ

本記事で紹介したアーキテクチャの利点をまとめます:

観点 メリット
開発効率 API変更が自動でフロントに反映
型安全性 TypeScriptの型補完でミスを防止
ドキュメント Pythonコメントがそのままドキュメントに
AI協調 node_modulesとして「触らない境界」を作る
チーム開発 仕様の齟齬が発生しない

特にAI時代においては、「AIが変更して良い領域」と「変更すべきでない領域」を明確に分けることが重要です。SDKをnode_modulesとして配布することで、この境界を自然に作ることができます。

ぜひこのアーキテクチャを導入して、型安全で効率的なAPI開発を実現してください。

この記事がfastapiを使っていて、クライアントサイドの人とのコミュニケーションコストやスキーマをどうあわせるかで迷っている人の参考になれば幸いです。
ありがとうございました。

参考リンク

Discussion