I built an automatic generation pipeline that "absolutely never drifts" the OpenAPI specification for FastAPI

I built an automatic generation pipeline that "absolutely never drifts" the OpenAPI specification for FastAPI

When I tried to use FastAPI's auto-generated OpenAPI schema as-is for documentation, I ran into many challenges including schema drift, lack of SSE support, and version compatibility issues. I'll introduce the process of building a system that uses Pydantic models as the single source of truth and automatically regenerates openapi.yml when Python files are saved, using Claude Code's PostToolUse hook.
2026.07.12

This page has been translated by machine translation. View original

Introduction

I needed to submit API specifications built with FastAPI to an external party. FastAPI automatically generates /docs and /openapi.json, but when it came to "can this actually be submitted as a specification document," it was completely unusable as-is.

In this article, I'll share my experience building a pipeline that uses FastAPI's auto-generated OpenAPI schema as a starting point to automatically generate and update specification documents with code as the Single Source of Truth. I'll walk through the many stumbling blocks along the way and the process of ultimately arriving at a system where "saving a Python file automatically regenerates openapi.yml" using Claude Code's PostToolUse hooks.

Prerequisites & Environment

  • Python 3.12 / FastAPI 0.136 / Pydantic v2
  • Node.js 24 LTS / pnpm v11+
  • Claude Code (AI assistant during development)
  • API integration target: API gateway requiring OpenAPI 3.0.x

Phase 1: Problems with the Raw FastAPI Schema

When you call app.openapi() in FastAPI, it automatically generates a schema from type annotations in your decorators. However, passing it directly to external parties had the following problems.

Problem 1: All Internal Endpoints Exposed

Data ingestion pipeline (/v1/ingest/trigger), external service proxy (/api/proxy/{path}), detailed health check (/readyz), and other internal management endpoints were all included in the public schema. /readyz even exposed internal service connection information.

Problem 2: Empty Metadata

summary, description, and request/response example fields were almost entirely unset. The document was essentially zero value as a specification.

Problem 3: SSE Streaming Not Documented

There were endpoints returning SSE streaming, but FastAPI cannot automatically document StreamingResponse. The return type -> StreamingResponse doesn't allow inference of the internal event format.

Phase 1 Solution

# Hide internal endpoints
@router.post("/trigger", include_in_schema=False)

# Manually declare SSE response with openapi_extra
@router.post("/stream", openapi_extra={
    "responses": {"200": {"content": {
        "text/event-stream": {"schema": {"type": "string"}}
    }}}
})

I added Field(description="...", examples=[...]) and ConfigDict(json_schema_extra={"example": {...}}) to Pydantic models, and created a mechanism to write the schema out to YAML via an export script.

cd backend && uv run python scripts/export_openapi.py > ../openapi.yml

At this point, manual execution was still required, leaving the risk of "forgetting to re-run the script after code changes."

Phase 2: Fighting Schema Drift

A few weeks after starting operations with the Phase 1 setup, feedback came in during a specification review.

"The spec references a response model, but there's no definition in components/schemas..."

Upon investigation, this turned out to be just the tip of the iceberg.

Problems Found

Problem Cause
Response model not defined in components/schemas FastAPI can't trace types from -> StreamingResponse
Nested models also undefined Same. Nested types weren't being traversed
Missing fields in request model Fields added after the last schema generation
application/json: schema: {} mixed into SSE 200 response FastAPI deep-merges openapi_extra with response_model
Output in OpenAPI 3.1.0 Integration gateway requires 3.0.x

Root Cause: FastAPI's Schema Graph Traversal

FastAPI analyzes route type annotations statically to generate schemas. When an endpoint returns -> StreamingResponse, FastAPI has no knowledge of the response contents. This meant the response model and all nested child models were outside the schema graph.

Solution: Using response_model for Documentation Only

@router.post(
    "/stream",
    response_model=MyResponse,  # Triggers schema traversal
    openapi_extra={...},
)
async def stream_handler(...) -> StreamingResponse:
    ...

When a Response subclass is returned, FastAPI uses response_model only for schema documentation, not serialization. This trick caused all child models nested from the response model to be added to components/schemas in bulk via Pydantic's schema graph.

Post-Processing Pipeline

Adding response_model introduced new problems. FastAPI deep-merges schemas from openapi_extra and response_model, leaving unwanted application/json entries in SSE endpoint 200 responses.

So I implemented a post-processing pipeline in the export script.

def postprocess(schema: dict) -> dict:
    # 1. Downgrade OpenAPI 3.1.0 → 3.0.3
    schema["openapi"] = "3.0.3"

    # 2. Remove unwanted application/json from SSE endpoints
    sse_path = "/v1/stream"  # Path of the SSE endpoint
    content_200 = schema["paths"][sse_path]["post"]["responses"]["200"]["content"]
    content_200.pop("application/json", None)

    # 3. Convert Pydantic v2 anyOf:[{...},{type:null}] → 3.0.x nullable:true
    schema = _convert_nullable(schema)

    # 4. Add metadata
    schema["info"].setdefault("contact", {"name": "API Team"})
    schema["info"].setdefault("license", {"name": "PRIVATE"})
    schema.setdefault("security", [])

    return schema

_convert_nullable() in particular needed to recursively traverse the entire schema. Pydantic v2 outputs Optional[str] as anyOf: [{type: string}, {type: null}], but OpenAPI 3.0.x tooling can't understand this format and requires nullable: true.

Phase 3: SSE_EVENTS Registry — Keeping Documentation and Implementation in Sync

SSE events come in multiple types, each with a different data format. If we wrote these manually in openapi_extra, there was a risk of forgetting to update the documentation when adding new events.

SSE_EVENTS Dictionary

SSE_EVENTS: dict[str, tuple[type, str]] = {
    "progress": (ProgressData,  "Progress of processing steps"),
    "search":   (SearchData,    "Search query and result metadata"),
    "token":    (TokenData,     "Text chunks generated by AI (streaming)"),
    "result":   (ResultData,    "Final result (emitted once on completion)"),
    "error":    (ErrorData,     "Detailed error message on failure"),
}

openapi_extra is automatically generated from this dictionary.

"x-sse-events": [
    {
        "event": name,
        "description": desc,
        "schema": {"$ref": f"#/components/schemas/{model.__name__}"},
    }
    for name, (model, desc) in SSE_EVENTS.items()
],

This created a system where adding an SSE emit in code and adding an entry to SSE_EVENTS automatically reflects it in the specification.

Phase 4: Full Automation with Claude Code PostToolUse Hooks

Even with everything up to this point, regenerating openapi.yml still required manually running the script. Human error from "editing a Python file but forgetting to re-run the script" remained a challenge.

Claude Code's PostToolUse hooks allow commands to be automatically executed after file edits.

Hook Script

#!/bin/bash
# Claude Code PostToolUse hook — auto-regenerate openapi.yml when backend/app/*.py changes

file_path=$(python3 -c "import sys, json; print(json.load(sys.stdin).get('file_path', ''))" \
  <<< "$CLAUDE_TOOL_INPUT" 2>/dev/null)

# Only target Python files under backend/app/
[[ "$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"))"

Hook Registration (.claude/settings.local.json)

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "bash .claude/hooks/regen-openapi.sh"
          }
        ]
      }
    ]
  }
}

This causes openapi.yml to be automatically regenerated every time Claude Code edits a Python file. Path pattern matching filters out changes to files outside backend/app/ (tests, scripts, etc.) so the hook doesn't fire for those.

Phase 5: Redocly's Limitations and a Custom HTML Renderer

Wanting to also auto-generate HTML/PDF for the specification, I initially used Redocly.

// Initial implementation: Redocly for HTML → Chrome for PDF
spawnSync("pnpm", ["--package=@redocly/cli", "dlx", "redocly", "build-docs", ...]);

However, problems arose. Redocly generates HTML as an SPA, so when converting to PDF with headless Chrome, sections remained collapsed. Content dynamically expanded by JavaScript may not be expanded by Chrome's --print-to-pdf.

Custom Static HTML Renderer

Ultimately, I wrote a custom renderer (approximately 700 lines of Node.js) that parses the OpenAPI spec and generates fully expanded static HTML.

Design principles:

  • Zero external dependencies: No CDN required, viewable offline
  • Inline CSS: All styles embedded in <style> tags
  • Print-optimized: @media print rules control margins and page breaks
  • English UI: Tag names and field labels all mapped to English
const TAG_DISPLAY = {
  health: "Health Check",
  chat: "Chat",
  systems: "Systems",
};

const RESPONSE_DESC = {
  "Validation Error": "Validation Error",
  "Successful Response": "Successful Response",
};

The renderer handles SSE event tables, automatic $ref resolution, inline schema expansion for array[T], and table of contents generation, all at build time. Since the output HTML requires no JavaScript, all sections are correctly expanded when converting to PDF with Chrome.

Final Pipeline Overview

fastapi-openapi-auto-generation-claude-code-hooks-pipeline

openapi.yml generation is fully automated, but HTML/PDF generation is intentionally kept manual. Chrome startup is heavy and doesn't need to run on every file save. Running pnpm docs:api before a release is all that's needed to get the latest specification document.

Persisting Rules with CLAUDE.md

Automation alone carries the risk of "a new AI session that doesn't know the rules" breaking things. So I wrote the rules in the project's CLAUDE.md to enforce them across all Claude Code sessions.

## 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.

This ensures that when Claude Code adds new endpoints or SSE events, it automatically follows these rules. Code violating the rules becomes less likely to be generated, structurally preventing drift between the specification and code.

Lessons Learned

FastAPI's Schema Generation Is "Automatic" but Not "Complete"

FastAPI's automatic schema generation is an excellent feature, but the schema graph breaks for non-standard responses like StreamingResponse and SSE. The trick of using response_model "for documentation only" is worth remembering.

Post-Processing Is Unavoidable

FastAPI 0.136 outputs OpenAPI 3.1.0 with no openapi_version parameter. When compatibility with 3.0.x tooling is required, post-processing in the export script is mandatory. The same applies to Pydantic v2's differences in nullable representation.

Claude Code Hooks Structurally Eliminate "Human Carelessness"

The process of "re-running the script after code changes" will inevitably be forgotten. PostToolUse hooks made this manual step completely unnecessary. Combined with CLAUDE.md rules, this achieves a dual automation where "AI writes correct code" and "hooks update the specification."

Sometimes Custom-Built Fits Better Than General-Purpose Tools

Redocly is an excellent tool, but SPA-based HTML is poorly suited for PDF conversion, and customizing Japanese labels was also difficult. Writing approximately 700 lines of a custom renderer allowed us to meet all project-specific requirements: offline support, print optimization, Japanese UI, and SSE event tables.

Summary

Auto-generating OpenAPI specification documents may seem like "FastAPI handles it automatically," but in practice there were many challenges including schema drift, version compatibility, SSE documentation, and PDF generation.

The final configuration reached was:

  1. Code as the single source: All schemas derived from Pydantic model type annotations
  2. SSE_EVENTS registry: Event names, data types, and descriptions managed in one place
  3. PostToolUse hook: Automatically regenerates openapi.yml when Python files are saved
  4. Post-processing: 3.0.3 conversion, nullable conversion, metadata addition
  5. Custom HTML renderer: Static HTML with zero external dependencies + PDF
  6. CLAUDE.md rules: Rules persisted across AI development sessions

Without ever writing a specification document by hand, we've been able to maintain a specification that is always in sync with the code.


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

Share this article