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.json describing every route, request body, response, and error. It is the single source of truth that both /redoc and contract-test tooling consume in this lesson.
  • json_schema_extra — a model_config key on a Pydantic model that injects arbitrary JSON Schema fields (most importantly an examples array) 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, /redoc shows only the 200 response.
  • ErrorResponse schema — a small Pydantic model (typically detail: str, error_code: str | None) referenced by every entry in responses=. Centralising it gives consumers a stable shape to switch on programmatically rather than parsing free-text detail.
  • Field description — the description= keyword on pydantic.Field. It carries domain-specific context (constraint reasoning, supported model identifiers, behaviour of stream=True) that the type annotation alone cannot communicate, and /redoc renders 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

Loading diagram...

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

  1. Do put a realistic examples payload on every request and response model"prompt": "Summarize this paper:\n\n{paper}" teaches; "prompt": "string" does not.
  2. Do declare a responses= entry for every non-2xx status your route can return — including 401, 422, 429, and any upstream-failure code; otherwise /redoc silently promises only the 200.
  3. Do use a single ErrorResponse model with a stable error_code enum — consumers can match on the code instead of parsing free-text detail strings that drift across releases.

Don'ts

  1. Don't rely on type annotations alone to document constraintsmax_tokens: int says nothing about why 8192 is the cap or which models accept it; put that in Field(description=...).
  2. 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.
  3. 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

All free lessons in GenAI Agent Engineering