FastAPIのOpenAPI仕様書を「絶対にドリフトさせない」自動生成パイプラインを構築した
はじめに
FastAPIで構築したAPIの仕様書を外部に提出する必要が出てきました。FastAPIは /docs と /openapi.json を自動生成してくれますが、実際に「仕様書として提出できるか」というと、そのままでは全く使えませんでした。
この記事では、FastAPIの自動生成OpenAPIスキーマを起点に、コードを唯一の真実の源(Single Source of Truth)として仕様書を自動生成・自動更新するパイプラインを構築した経験を紹介します。途中で何度もハマったポイントと、最終的にClaude CodeのPostToolUseフックで「Pythonファイルを保存するだけで openapi.yml が自動再生成される」仕組みにたどり着くまでの過程を書きます。
前提・環境
- Python 3.12 / FastAPI 0.136 / Pydantic v2
- Node.js 24 LTS / pnpm v11+
- Claude Code(開発時のAIアシスタント)
- API連携先: OpenAPI 3.0.x必須のAPIゲートウェイ
Phase 1: 素のFastAPIスキーマの問題点
FastAPIで app.openapi() を呼ぶと、デコレータの型アノテーションからスキーマを自動生成してくれます。しかし、そのまま外部に渡すには以下の問題がありました。
問題1: 内部エンドポイントが全公開
データ取り込みパイプライン (/v1/ingest/trigger)、外部サービスプロキシ (/api/proxy/{path})、詳細ヘルスチェック (/readyz) など、内部管理用のエンドポイントがすべて公開スキーマに含まれていました。/readyz に至っては、内部サービスの接続情報が見えてしまう状態でした。
問題2: メタデータが空
summary、description、リクエスト/レスポンスの example がほぼ未設定。仕様書としての情報量がゼロに等しい状態でした。
問題3: SSEストリーミングがドキュメント化されない
SSEストリーミングを返すエンドポイントがありましたが、FastAPIは StreamingResponse を自動ドキュメント化できません。-> StreamingResponse という戻り値型からは、中身のイベント形式が推論できないためです。
Phase 1の解決策
# 内部エンドポイントを非公開化
@router.post("/trigger", include_in_schema=False)
# SSEレスポンスを openapi_extra で手動宣言
@router.post("/stream", openapi_extra={
"responses": {"200": {"content": {
"text/event-stream": {"schema": {"type": "string"}}
}}}
})
Pydanticモデルに Field(description="...", examples=[...]) と ConfigDict(json_schema_extra={"example": {...}}) を追加し、エクスポートスクリプトで YAML に書き出す仕組みを作りました。
cd backend && uv run python scripts/export_openapi.py > ../openapi.yml
この時点ではまだ手動実行が必要で、「コード変更後にスクリプトを再実行し忘れる」というリスクが残っていました。
Phase 2: スキーマドリフトとの戦い
Phase 1の仕組みで運用を始めて数週間後、仕様書のレビューでフィードバックが来ました。
「仕様書でレスポンスモデルを参照しているのに、
components/schemasに定義がないんだけど...」
調査すると、これは氷山の一角でした。
見つかった問題
| 問題 | 原因 |
|---|---|
レスポンスモデルが components/schemas に未定義 |
-> StreamingResponse からFastAPIが型を辿れない |
| ネストされたモデルも未定義 | 同上。ネストされた型まで辿れていない |
| リクエストモデルにフィールドが欠落 | 最後にスキーマを生成した以降に追加されたフィールド |
SSEの200レスポンスに application/json: schema: {} が混在 |
FastAPIが openapi_extra と response_model をディープマージする |
| OpenAPI 3.1.0で出力される | 連携先ゲートウェイは3.0.x系が必要 |
根本原因: FastAPIのスキーマグラフ走査
FastAPIはルートの型アノテーションを静的に解析してスキーマを生成します。エンドポイントが -> StreamingResponse を返す場合、FastAPIにとってレスポンスの中身は「不明」。つまりレスポンスモデルおよびそこからネストされるすべての子モデルが、スキーマグラフの外になっていました。
解決策: response_model のドキュメント専用利用
@router.post(
"/stream",
response_model=MyResponse, # スキーマ走査のトリガー
openapi_extra={...},
)
async def stream_handler(...) -> StreamingResponse:
...
FastAPIは Response サブクラスを返す場合、response_model をシリアライズには使わずスキーマ文書化のみに使用します。このトリックにより、レスポンスモデルからネストされるすべての子モデルまで、Pydanticのスキーマグラフが一括で components/schemas に追加されました。
ポストプロセッシングパイプライン
response_model を追加したことで新しい問題も発生しました。FastAPIが openapi_extra と response_model 由来のスキーマをディープマージするため、SSEエンドポイントの200レスポンスに不要な application/json エントリが残ります。
そこでエクスポートスクリプトにポストプロセッシングパイプラインを実装しました。
def postprocess(schema: dict) -> dict:
# 1. OpenAPI 3.1.0 → 3.0.3 にダウングレード
schema["openapi"] = "3.0.3"
# 2. SSEエンドポイントから不要な application/json を除去
sse_path = "/v1/stream" # SSEを返すエンドポイントのパス
content_200 = schema["paths"][sse_path]["post"]["responses"]["200"]["content"]
content_200.pop("application/json", None)
# 3. Pydantic v2 の anyOf:[{...},{type:null}] → 3.0.x の nullable:true に変換
schema = _convert_nullable(schema)
# 4. メタ情報追加
schema["info"].setdefault("contact", {"name": "API Team"})
schema["info"].setdefault("license", {"name": "PRIVATE"})
schema.setdefault("security", [])
return schema
特に _convert_nullable() は再帰的に全スキーマを走査する必要がありました。Pydantic v2は Optional[str] を anyOf: [{type: string}, {type: null}] として出力しますが、OpenAPI 3.0.x のツール群はこの形式を理解できず、nullable: true が必要です。
Phase 3: SSE_EVENTSレジストリ — ドキュメントと実装の同期
SSEイベントは複数種類あり、それぞれ異なるデータ形式を持ちます。これを手動で openapi_extra に書いていると、新しいイベントを追加したときにドキュメントを更新し忘れるリスクがありました。
SSE_EVENTSディクショナリ
SSE_EVENTS: dict[str, tuple[type, str]] = {
"progress": (ProgressData, "処理ステップの進捗"),
"search": (SearchData, "検索クエリと結果のメタ情報"),
"token": (TokenData, "AIが生成するテキストチャンク(ストリーミング)"),
"result": (ResultData, "最終結果(処理完了時に1回送出)"),
"error": (ErrorData, "エラー発生時の詳細メッセージ"),
}
openapi_extra はこのディクショナリから自動生成されます。
"x-sse-events": [
{
"event": name,
"description": desc,
"schema": {"$ref": f"#/components/schemas/{model.__name__}"},
}
for name, (model, desc) in SSE_EVENTS.items()
],
これにより、コードにSSE送出処理を追加したら、SSE_EVENTS にエントリを追加するだけで仕様書にも自動反映される仕組みが完成しました。
Phase 4: Claude Code PostToolUseフックで完全自動化
ここまでの仕組みでも、openapi.yml の再生成は手動でスクリプトを実行する必要がありました。「Pythonファイルを編集したのにスクリプトの再実行を忘れる」というヒューマンエラーが依然として課題でした。
Claude CodeのPostToolUseフックを使うと、ファイル編集後に自動でコマンドを実行できます。
フックスクリプト
#!/bin/bash
# Claude Code PostToolUse hook — backend/app/*.py 変更時に openapi.yml を自動再生成
file_path=$(python3 -c "import sys, json; print(json.load(sys.stdin).get('file_path', ''))" \
<<< "$CLAUDE_TOOL_INPUT" 2>/dev/null)
# backend/app/ 配下のPythonファイルのみ対象
[[ "$file_path" =~ /backend/app/.*\.py$ ]] || exit 0
project_root=$(git -C "$(dirname "$file_path")" rev-parse --show-toplevel 2>/dev/null)
cd "$project_root/backend" || exit 1
"$project_root/backend/.venv/bin/python" scripts/export_openapi.py > "$project_root/openapi.yml"
echo "[openapi] Regenerated openapi.yml (triggered by $(basename "$file_path"))"
フック登録 (.claude/settings.local.json)
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/regen-openapi.sh"
}
]
}
]
}
}
これにより、Claude Code がPythonファイルを編集するたびに openapi.yml が自動再生成されます。backend/app/ 配下以外のファイル(テスト、スクリプトなど)への変更ではフックが発火しないよう、パスのパターンマッチでフィルタリングしています。
Phase 5: Redoclyの限界とカスタムHTMLレンダラー
仕様書のHTML/PDFも自動生成したいと考え、最初はRedoclyを使いました。
// 初期実装: Redocly で HTML → Chrome で PDF
spawnSync("pnpm", ["--package=@redocly/cli", "dlx", "redocly", "build-docs", ...]);
しかし問題が発生しました。RedoclyはSPAとしてHTMLを生成するため、ヘッドレスChromeでPDFに変換するとセクションが折りたたまれたままになりました。JavaScriptで動的に展開されるコンテンツは、Chrome の --print-to-pdf では展開されない場合があります。
カスタム静的HTMLレンダラー
最終的に、OpenAPIスペックをパースして完全に展開された静的HTMLを生成するカスタムレンダラー(約700行のNode.jsスクリプト)を書きました。
設計方針:
- 外部依存ゼロ: CDN不要、オフラインで閲覧可能
- CSSインライン: 全スタイルを
<style>タグに埋め込み - 印刷最適化:
@media printルールで余白・改ページを制御 - 日本語UI: タグ名やフィールドラベルをすべて日本語にマッピング
const TAG_DISPLAY = {
health: "ヘルスチェック",
chat: "チャット",
systems: "システム",
};
const RESPONSE_DESC = {
"Validation Error": "バリデーションエラー",
"Successful Response": "正常レスポンス",
};
レンダラーはSSEイベントテーブル、$ref の自動解決、array[T] のインラインスキーマ展開、目次生成などをすべてビルド時に処理します。出力されるHTMLはJavaScript不要なので、ChromeでPDFに変換しても全セクションが正しく展開されます。
最終的なパイプライン全体像

openapi.yml の生成は完全自動化されていますが、HTML/PDFの生成は意図的に手動にしています。Chromeの起動は重く、毎回のファイル保存で実行する必要はないためです。リリース前に pnpm docs:api を実行するだけで最新の仕様書が得られます。
CLAUDE.mdによるルールの永続化
自動化だけでは「ルールを知らない新しいAIセッション」が壊すリスクがあります。そこでプロジェクトの CLAUDE.md にルールを記述し、Claude Codeの全セッションで強制されるようにしました。
## OpenAPI / FastAPI Schema Rules
### New API endpoints
- Every new @router.get/post/... MUST have an explicit return type
annotation using a Pydantic model (not JSONResponse, not dict, not bare Any).
### New SSE events
- Every new SSE emit call MUST have a matching entry in SSE_EVENTS.
- The data shape MUST be a Pydantic model defined in the models module.
- x-sse-events in openapi_extra is built from SSE_EVENTS automatically
— never edit it manually.
これにより、Claude Codeが新しいエンドポイントやSSEイベントを追加する際に、自動的にこれらのルールに従います。ルールに違反するコードは生成されにくくなり、仕様書とコードのドリフトが構造的に防止されます。
学んだこと
FastAPIのスキーマ生成は「自動」だが「完全」ではない
FastAPIの自動スキーマ生成は優れた機能ですが、StreamingResponse やSSEのような非標準レスポンスではスキーマグラフが途切れます。response_model を「ドキュメント専用」に使うトリックは覚えておく価値があります。
ポストプロセッシングは避けられない
FastAPI 0.136はOpenAPI 3.1.0を固定で出力し、openapi_version パラメータを持ちません。3.0.x系ツールとの互換性が必要な場合、エクスポートスクリプトでの後処理は必須です。Pydantic v2の nullable 表現の差異も同様です。
Claude Codeフックは「人間のうっかり」を構造的に排除する
「コード変更後にスクリプトを再実行する」というプロセスは、必ず忘れられます。PostToolUseフックにより、この手動ステップが完全に不要になりました。CLAUDE.mdのルールと組み合わせることで、「AIが正しいコードを書き」「フックが仕様書を更新する」という二重の自動化が実現しています。
汎用ツールより自作の方がフィットする場合がある
Redoclyは優れたツールですが、SPAベースのHTMLはPDF変換との相性が悪く、日本語ラベルのカスタマイズも難しい状況でした。約700行のカスタムレンダラーを書くことで、オフライン対応・印刷最適化・日本語UI・SSEイベントテーブルなど、プロジェクト固有の要件をすべて満たせました。
まとめ
OpenAPI仕様書の自動生成は、一見「FastAPIが勝手にやってくれる」と思いがちですが、実際にはスキーマドリフト・バージョン互換性・SSEドキュメント化・PDF生成など、多くの課題がありました。
最終的に到達したのは以下の構成です:
- コードが唯一のソース: Pydanticモデルの型アノテーションから全スキーマを導出
- SSE_EVENTSレジストリ: イベント名・データ型・説明を一箇所で管理
- PostToolUseフック: Pythonファイル保存時に
openapi.ymlを自動再生成 - ポストプロセッシング: 3.0.3変換・nullable変換・メタ情報付加
- カスタムHTMLレンダラー: 外部依存ゼロの静的HTML + PDF
- CLAUDE.mdルール: AI開発セッション間でルールを永続化
手書きの仕様書を一度も書かずに、常にコードと同期した仕様書を維持できています。





