Free lesson · GenAI Agent Engineering
Generate rich OpenAPI documentation with examples
You will enrich the auto-generated OpenAPI docs for production use. Add request body examples using Pydantic's model_config with json_schema_extra showing realistic prompt data. Define multiple response examples for each endpoint: success case, validation error, not found, and server error. Use FastAPI's responses parameter to document error schemas. Add endpoint descriptions with markdown formatting including usage notes and rate limit info. Configure the Swagger UI with a custom title, logo, and dark theme. Generate a static Redoc HTML page for offline documentation.
Course: Web APIs & Services for GenAI Engineers · Chapter 8 · Testing & Documentation
Free to read — no subscription required.
Introduction
When a teammate opens your /redoc page and sees a CompletionRequest with a prompt: string field and nothing else, they still don't know what a valid prompt looks like, what comes back when max_tokens is exceeded, or what JSON the 429 error returns. Auto-generated schemas list shapes; rich documentation answers questions. Get this wrong and integrators write defensive code against guesses, support tickets pile up around error semantics, and your /redoc page rots into a misleading snapshot the moment the next field lands.
By the end of this lesson you'll be able to enrich a FastAPI route so its OpenAPI document includes realistic request/response examples on every Pydantic model, an explicit ErrorResponse schema attached to each non-2xx status code via the responses= decorator argument, and field-level descriptions that survive into the rendered /redoc page.
Key Terminology
- OpenAPI document — the JSON specification FastAPI serves at
/openapi.jsondescribing every route, request body, response, and error. It is the single source of truth that both/redocand contract-test tooling consume in this lesson. json_schema_extra— amodel_configkey on a Pydantic model that injects arbitrary JSON Schema fields (most importantly anexamplesarray) into the generated OpenAPI schema for that model. It is the primary injection point for request/response example payloads.responses=decorator argument — a dictionary passed to@router.post(...)mapping HTTP status codes to OpenAPI response definitions (model,description,content.example). It is how non-2xx error schemas reach the spec; without it,/redocshows only the 200 response.ErrorResponseschema — a small Pydantic model (typicallydetail: str,error_code: str | None) referenced by every entry inresponses=. Centralising it gives consumers a stable shape to switch on programmatically rather than parsing free-textdetail.- Field
description— thedescription=keyword onpydantic.Field. It carries domain-specific context (constraint reasoning, supported model identifiers, behaviour ofstream=True) that the type annotation alone cannot communicate, and/redocrenders it inline next to each field.
Concepts
Three injection points feed the OpenAPI document FastAPI publishes at /openapi.json: Pydantic model annotations, the responses= decorator argument, and route-level metadata (summary, description, tags). Everything /redoc shows is downstream of these three; everything a contract test asserts against is downstream of the same JSON. That symmetry is the lesson — humans and machines read from one source.
The three injection points
json_schema_extra.examples on a model puts a copy-pasteable request body into the docs. Field(..., description=...) carries the why behind a constraint (e.g. why max_tokens caps at 8192 on one model and 4096 on another) into the rendered field row. Without these, /redoc shows a type and a constraint and nothing else.
Error schemas are documentation, not afterthoughts
Happy-path schemas come free with response_model=; error schemas do not. By default /redoc shows only the 200 response, so consumers have no idea what the 429 body looks like until they hit a rate limit in production. The fix is a responses= dictionary keyed by status code — each entry names a Pydantic model, a description, and a concrete content.example. A consumer who sees error_code: "RATE_LIMIT_EXCEEDED" in the docs writes a match statement against it; a consumer who sees only detail: string writes a brittle substring check (see Code Walkthrough).
Code Walkthrough
The snippet below combines the two injection points from Concepts: a CompletionRequest/CompletionResponse pair carrying Field(description=...) and json_schema_extra.examples, plus a /v1/completions route whose decorator passes responses=ERROR_RESPONSES to surface ErrorResponse schemas for 401, 422, 429, and 502.
Code snippetpython
1# app/routers/completions.py 2from typing import Literal 3from fastapi import APIRouter 4from pydantic import BaseModel, Field 5 6router = APIRouter(prefix="/v1", tags=["Completions"]) 7 8class CompletionRequest(BaseModel): 9 prompt: str = Field( 10 ..., 11 description="Input text. Supports {variable} template placeholders.", 12 min_length=1, max_length=32000, 13 ) 14 model: str = Field( 15 default="gpt-4o", 16 description="Supported: gpt-4o, gpt-4o-mini, claude-3-5-sonnet.", 17 ) 18 max_tokens: int = Field( 19 default=1024, ge=1, le=8192, 20 description="Effective max varies: 4096 for gpt-4o, 8192 for claude.", 21 ) 22 stream: bool = Field( 23 default=False, 24 description="When True, returns SSE stream instead of JSON object.", 25 ) 26 27 model_config = { 28 "json_schema_extra": { 29 "examples": [{ 30 "prompt": "Summarize this paper in three bullets:\n\n{paper}", 31 "model": "gpt-4o", "max_tokens": 256, "stream": False, 32 }] 33 } 34 } 35 36class CompletionResponse(BaseModel): 37 content: str = Field(description="Generated text from the LLM.") 38 model: str = Field(description="The model that produced this completion.") 39 finish_reason: Literal["stop", "length", "content_filter"] = Field( 40 description="'stop'=natural end, 'length'=max_tokens hit, " 41 "'content_filter'=blocked by safety layer.", 42 ) 43 44 model_config = { 45 "json_schema_extra": { 46 "examples": [{ 47 "content": "- 23% retrieval gain\n- 40ms latency drop\n- 15% cheaper", 48 "model": "gpt-4o", "finish_reason": "stop", 49 }] 50 } 51 } 52 53class ErrorResponse(BaseModel): 54 detail: str = Field(description="Human-readable error message.") 55 error_code: str | None = Field( 56 default=None, 57 description="Machine-readable code for programmatic handling.", 58 ) 59 60ERROR_RESPONSES = { 61 401: {"model": ErrorResponse, "description": "Missing or invalid API key.", 62 "content": {"application/json": {"example": { 63 "detail": "Invalid API key.", "error_code": "INVALID_API_KEY"}}}}, 64 422: {"model": ErrorResponse, "description": "Request validation failed.", 65 "content": {"application/json": {"example": { 66 "detail": "max_tokens must be 1-8192.", 67 "error_code": "VALIDATION_ERROR"}}}}, 68 429: {"model": ErrorResponse, "description": "Rate limit exceeded.", 69 "content": {"application/json": {"example": { 70 "detail": "Retry after 30 seconds.", 71 "error_code": "RATE_LIMIT_EXCEEDED"}}}}, 72 502: {"model": ErrorResponse, "description": "Upstream provider error.", 73 "content": {"application/json": {"example": { 74 "detail": "OpenAI returned 500.", 75 "error_code": "PROVIDER_ERROR"}}}}, 76} 77 78@router.post( 79 "/completions", 80 response_model=CompletionResponse, 81 responses=ERROR_RESPONSES, 82 summary="Generate a text completion", 83 description="Send a prompt to a hosted LLM and receive a completion.", 84) 85async def create_completion(req: CompletionRequest): 86 ...
Three things are doing the work here. First, Field(..., description=...) on every model field — the descriptions carry the why (which model identifiers are supported, what finish_reason="length" actually means for the consumer) that types alone cannot. Second, model_config["json_schema_extra"]["examples"] on each model embeds a realistic, copy-pasteable payload — not "prompt": "string" but a real summarisation prompt with a template variable. Third, responses=ERROR_RESPONSES on the decorator merges the four error schemas into the route's OpenAPI definition alongside the 200 from response_model=, so /redoc shows all five outcomes with concrete examples.
You'll know it works when curl -s localhost:8000/openapi.json | jq '.paths."/v1/completions".post.responses | keys' returns ["200", "401", "422", "429", "502"] and opening http://localhost:8000/redoc shows the realistic prompt and bullet-list completion in the request/response example tabs (not "string").
Do's and Don'ts
Do's
- ✓Do put a realistic
examplespayload on every request and response model —"prompt": "Summarize this paper:\n\n{paper}"teaches;"prompt": "string"does not. - ✓Do declare a
responses=entry for every non-2xx status your route canreturn— including 401, 422, 429, and any upstream-failure code; otherwise/redocsilently promises only the 200. - ✓Do use a single
ErrorResponsemodel with a stableerror_codeenum — consumers canmatchon the code instead of parsing free-textdetailstrings that drift across releases.
Don'ts
- ✗Don't rely on type annotations alone to document constraints —
max_tokens: intsays nothing about why 8192 is the cap or which models accept it; put that inField(description=...). - ✗Don't let documentation and implementation drift — if you add a response field without updating the model or the example, the next consumer integration breaks against a docs page that lies.
- ✗Don't paste fake placeholder examples like
"content": "string"or"detail": "error"— they pass schema validation and teach consumers nothing about the real shape they'll receive.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
More free lessons in Web APIs & Services for GenAI Engineers
- Ch 6Implement rate limiting with Redis sliding window
- Ch 8Generate rich OpenAPI documentation with examplesYou are here
- Ch 10Build production Docker images with multi-stage builds
- Ch 10Deploy to Kubernetes with health check probes
- Ch 10Instrument endpoints with Prometheus metrics
- Ch 10Implement distributed tracing with OpenTelemetry
- Ch 10Create Grafana dashboards for API monitoring