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 documentation for an API built with FastAPI to an external party. While FastAPI automatically generates /docs and /openapi.json, when it came to whether these could actually be "submitted as specification documents," they were completely unusable as-is.
In this article, I'll share my experience building a pipeline that uses FastAPI's auto-generated OpenAPI schema as the starting point to automatically generate and update specification documents, with code as the Single Source of Truth. I'll describe the many stumbling points along the way and the process of ultimately arriving at a setup using Claude Code's PostToolUse hooks where "saving a Python file automatically regenerates openapi.yml."
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 the type annotations in your decorators. However, passing it directly to external parties had the following problems.
Problem 1: All Internal Endpoints Fully Exposed
Internal management endpoints such as the data ingestion pipeline (/v1/ingest/trigger), external service proxy (/api/proxy/{path}), and detailed health check (/readyz) were all included in the public schema. The /readyz endpoint even exposed internal service connection information.
Problem 2: Empty Metadata
summary, description, and example for requests/responses were almost entirely unset. The document was essentially worthless as a specification.
Problem 3: SSE Streaming Not Documented
There were endpoints returning SSE streaming, but FastAPI cannot automatically document StreamingResponse. The return type annotation -> StreamingResponse provides no way to infer the internal event format.
Phase 1 Solution
# Hide internal endpoints from schema
@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 an export script to write the output to YAML.
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..."
On investigation, this turned out to be just the tip of the iceberg.
Problems Found
| Problem | Cause |
|---|---|
Response model not defined in components/schemas |
FastAPI cannot trace the type from -> StreamingResponse |
| Nested models also undefined | Same as above — nested types not being traced |
| 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 Schema Graph Traversal
FastAPI generates schemas by statically analyzing route type annotations. When an endpoint returns -> StreamingResponse, FastAPI treats the response content as "unknown." This means the response model and all child models nested within it 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 for serialization. This trick caused all child models nested within the response model to be added to components/schemas in one sweep via Pydantic's schema graph.
Post-Processing Pipeline
Adding response_model also created a new problem. Because FastAPI deep-merges schemas from openapi_extra and response_model, an unwanted application/json entry remains in the 200 response of SSE endpoints.
I implemented a post-processing pipeline in the export script.
def postprocess(schema: dict) -> dict:
# 1. Downgrade from 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's anyOf:[{...},{type:null}] → 3.0.x's 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
In particular, _convert_nullable() required recursively traversing the entire schema. Pydantic v2 outputs Optional[str] as anyOf: [{type: string}, {type: null}], but OpenAPI 3.0.x tooling cannot understand this format and requires nullable: true.
Phase 3: SSE_EVENTS Registry — Keeping Documentation and Implementation in Sync
SSE events came in multiple types, each with a different data format. Writing these manually into openapi_extra created 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 through processing steps"),
"search": (SearchData, "Search query and result metadata"),
"token": (TokenData, "Text chunk generated by AI (streaming)"),
"result": (ResultData, "Final result (emitted once on completion)"),
"error": (ErrorData, "Detailed error message on failure"),
}
The 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 completed a system where adding SSE emit logic to the code and adding an entry to SSE_EVENTS automatically reflects it in the specification document.
Phase 4: Full Automation with Claude Code PostToolUse Hooks
Even with everything so far, regenerating openapi.yml still required manually running a script. Human error — "edited a Python file but forgot to re-run the script" — remained a challenge.
Using Claude Code's PostToolUse hooks, you can automatically run commands 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 versions of the specification, I initially used Redocly.
// Initial implementation: Redocly for HTML → Chrome for PDF
spawnSync("pnpm", ["--package=@redocly/cli", "dlx", "redocly", "build-docs", ...]);
However, a problem emerged. Because Redocly generates HTML as an SPA, sections remained collapsed when converting to PDF with headless Chrome. 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: All tag names and field labels mapped appropriately
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. The output HTML requires no JavaScript, so all sections render correctly when converting to PDF with Chrome.
Final Pipeline Overview

Generation of openapi.yml is fully automated, but HTML/PDF generation is intentionally kept manual. Chrome startup is heavy, and there's no need to run it on every file save. Running pnpm docs:api before a release is all it takes to get an up-to-date specification.
Persisting Rules with CLAUDE.md
Automation alone carries the risk of "a new AI session that doesn't know the rules" breaking things. 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 that violates the rules is less likely to be generated, and drift between the specification and code is structurally prevented.
Lessons Learned
FastAPI Schema Generation is "Automatic" but Not "Complete"
FastAPI's automatic schema generation is an excellent feature, but the schema graph breaks down for non-standard responses like StreamingResponse or 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 by default and has 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 difference in nullable representation.
Claude Code Hooks Structurally Eliminate Human Carelessness
The process of "re-run 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 double layer of automation where "AI writes correct code" and "hooks update the specification."
Sometimes a Custom Tool Fits Better Than a General-Purpose One
Redocly is an excellent tool, but SPA-based HTML has poor compatibility with PDF conversion, and customizing Japanese labels was difficult in this context. Writing approximately 700 lines of a custom renderer allowed all project-specific requirements to be met: offline support, print optimization, localized UI, SSE event tables, and more.
Summary
Auto-generating OpenAPI specifications might seem like "FastAPI handles it automatically," but in practice there are many challenges: schema drift, version compatibility, SSE documentation, PDF generation, and more.
The final configuration I arrived at is as follows:
- Code as the sole source: All schemas derived from Pydantic model type annotations
- SSE_EVENTS registry: Event names, data types, and descriptions managed in one place
- PostToolUse hooks:
openapi.ymlauto-regenerated when 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, I've been able to maintain documentation that is always in sync with the code.
