
I built an automatic generation pipeline that "absolutely never drifts" the OpenAPI specification for FastAPI
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 printrules 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

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:
- Code as the single source: All schemas derived from Pydantic model type annotations
- SSE_EVENTS registry: Event names, data types, and descriptions managed in one place
- PostToolUse hook: Automatically regenerates
openapi.ymlwhen Python files are saved - Post-processing: 3.0.3 conversion, nullable conversion, metadata addition
- Custom HTML renderer: Static HTML with zero external dependencies + PDF
- 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.