OpenAPI Spec Generation — Deep Dive

OpenAPI 3.1 and JSON Schema alignment

OpenAPI 3.1 (released February 2021) fully aligns with JSON Schema draft 2020-12. This matters for Python because Pydantic v2 also targets JSON Schema compliance, meaning FastAPI’s generated specs are now standards-compliant without workarounds.

Key differences from 3.0:

  • nullable: true replaced by type: ["string", "null"]
  • exclusiveMinimum is now a number, not a boolean
  • $ref can sit alongside other keywords (no more allOf wrappers for nullable refs)
  • contentMediaType and contentEncoding for binary data

Customizing FastAPI’s generated spec

FastAPI’s spec generation is extensible at multiple levels:

Pydantic model configuration

from pydantic import BaseModel, Field
from enum import Enum

class OrderStatus(str, Enum):
    pending = "pending"
    shipped = "shipped"
    delivered = "delivered"
    cancelled = "cancelled"

class OrderResponse(BaseModel):
    id: int = Field(..., description="Unique order identifier", examples=[12345])
    status: OrderStatus
    total_cents: int = Field(
        ...,
        description="Order total in cents to avoid floating point issues",
        ge=0,
        examples=[4999],
    )
    items: list["OrderItem"] = Field(
        ...,
        description="Line items in this order",
        min_length=1,
    )

    model_config = {
        "json_schema_extra": {
            "examples": [
                {
                    "id": 12345,
                    "status": "shipped",
                    "total_cents": 4999,
                    "items": [{"product_id": 1, "quantity": 2, "price_cents": 2499}],
                }
            ]
        }
    }

The examples at both field and model level populate the OpenAPI spec’s example values, which Swagger UI displays in its “Try it out” forms.

Route-level customization

@app.post(
    "/orders",
    response_model=OrderResponse,
    status_code=201,
    responses={
        201: {"description": "Order created successfully"},
        400: {
            "description": "Invalid order data",
            "content": {
                "application/json": {
                    "example": {
                        "error": {"code": "INVALID_ITEM", "message": "Product 99 not found"}
                    }
                }
            },
        },
        409: {"description": "Duplicate order (idempotency key reused)"},
    },
    tags=["Orders"],
    summary="Create a new order",
    description="Creates an order and initiates payment processing. "
                "Include an Idempotency-Key header to safely retry.",
)
async def create_order(order: OrderCreate):
    ...

Multiple response codes with examples make the spec genuinely useful for client developers.

Discriminated unions (polymorphic schemas)

When an endpoint returns different shapes based on a type field, use discriminated unions:

from typing import Annotated, Literal, Union
from pydantic import BaseModel, Field

class CreditCardPayment(BaseModel):
    type: Literal["credit_card"]
    card_last_four: str
    exp_month: int
    exp_year: int

class BankTransferPayment(BaseModel):
    type: Literal["bank_transfer"]
    bank_name: str
    account_last_four: str

class CryptoPayment(BaseModel):
    type: Literal["crypto"]
    wallet_address: str
    network: str

PaymentMethod = Annotated[
    Union[CreditCardPayment, BankTransferPayment, CryptoPayment],
    Field(discriminator="type"),
]

@app.get("/payments/{payment_id}")
async def get_payment(payment_id: int) -> PaymentMethod:
    ...

FastAPI generates an OpenAPI oneOf schema with a discriminator mapping, which client generators handle correctly — creating separate types for each payment variant.

Security scheme generation

from fastapi import FastAPI, Depends, Security
from fastapi.security import (
    OAuth2AuthorizationCodeBearer,
    APIKeyHeader,
    HTTPBearer,
)

oauth2_scheme = OAuth2AuthorizationCodeBearer(
    authorizationUrl="https://auth.example.com/authorize",
    tokenUrl="https://auth.example.com/token",
    scopes={
        "read:users": "Read user profiles",
        "write:users": "Create and update users",
        "admin": "Full administrative access",
    },
)

api_key_header = APIKeyHeader(name="X-API-Key", auto_error=True)

@app.get("/users/me", dependencies=[Security(oauth2_scheme, scopes=["read:users"])])
async def get_current_user():
    ...

@app.get("/internal/health", dependencies=[Depends(api_key_header)])
async def health_check():
    ...

The generated spec includes full security scheme definitions, and Swagger UI renders “Authorize” buttons for each scheme.

Spec-first validation with Schemathesis

Schemathesis reads your OpenAPI spec and generates fuzz tests automatically:

pip install schemathesis

# Test against a running server
schemathesis run http://localhost:8000/openapi.json

# Or use programmatically in pytest
import schemathesis

schema = schemathesis.from_uri("http://localhost:8000/openapi.json")

@schema.parametrize()
def test_api(case):
    response = case.call()
    case.validate_response(response)

Schemathesis generates random valid inputs based on your schemas and verifies that:

  • All responses match declared status codes
  • Response bodies conform to declared schemas
  • No 500 errors on valid inputs
  • No schema violations

This catches edge cases you’d never write manual tests for: unicode in string fields, boundary values for integers, empty arrays, deeply nested objects.

Generating client SDKs

The OpenAPI Generator creates typed clients in 50+ languages:

# Install
npm install @openapitools/openapi-generator-cli -g

# Generate TypeScript client
openapi-generator-cli generate \
    -i http://localhost:8000/openapi.json \
    -g typescript-fetch \
    -o ./generated/typescript-client

# Generate Python client (for consuming another service)
openapi-generator-cli generate \
    -i https://api.partner.com/openapi.json \
    -g python \
    -o ./generated/partner-client \
    --additional-properties=packageName=partner_client

The generated TypeScript client includes typed functions for every endpoint:

// Auto-generated
const api = new UsersApi(configuration);
const user: UserResponse = await api.getUser({ userId: 5 });
// TypeScript knows user.name is string, user.id is number

Quality of generated clients depends entirely on spec quality. Missing descriptions, Any types, and absent examples produce unusable clients.

Flask and Django spec generation

Flask with flask-smorest

from flask import Flask
from flask_smorest import Api, Blueprint
from marshmallow import Schema, fields

app = Flask(__name__)
app.config["API_TITLE"] = "User Service"
app.config["API_VERSION"] = "v1"
app.config["OPENAPI_VERSION"] = "3.1.0"

api = Api(app)

class UserSchema(Schema):
    id = fields.Int(dump_only=True)
    name = fields.Str(required=True, metadata={"description": "Full name"})
    email = fields.Email(required=True)

blp = Blueprint("users", __name__, url_prefix="/users")

@blp.route("/<int:user_id>")
@blp.response(200, UserSchema)
def get_user(user_id):
    """Get a user by ID."""
    ...

api.register_blueprint(blp)

Django with drf-spectacular

# settings.py
INSTALLED_APPS = [
    ...
    'drf_spectacular',
]

REST_FRAMEWORK = {
    'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
}

SPECTACULAR_SETTINGS = {
    'TITLE': 'User Service',
    'VERSION': '1.0.0',
    'SCHEMA_PATH_PREFIX': r'/api/v1',
}

# views.py
from drf_spectacular.utils import extend_schema, OpenApiExample

class UserViewSet(viewsets.ModelViewSet):
    @extend_schema(
        summary="Retrieve a user",
        examples=[
            OpenApiExample(
                "Active user",
                value={"id": 1, "name": "Alice", "is_active": True},
            ),
        ],
    )
    def retrieve(self, request, *args, **kwargs):
        ...

drf-spectacular generates more accurate specs than DRF’s built-in schema generator, especially for nested serializers, pagination, and authentication.

Spec diffing for breaking change detection

Use oasdiff to detect breaking changes between spec versions:

# Install
go install github.com/tufin/oasdiff@latest

# Compare specs
oasdiff breaking \
    --base http://localhost:8000/openapi.json \
    --revision ./new-openapi.json

Integrate this into CI to prevent accidental breaking changes:

# ci_check.py
import subprocess
import sys

result = subprocess.run(
    ["oasdiff", "breaking", "--base", "openapi-main.json", "--revision", "openapi-pr.json"],
    capture_output=True, text=True,
)

if result.stdout.strip():
    print("⚠️ Breaking changes detected:")
    print(result.stdout)
    sys.exit(1)
else:
    print("✅ No breaking changes")

Breaking changes include: removing endpoints, removing response fields, changing field types, adding required parameters, and narrowing enum values.

Runtime spec export and versioning

Export the spec during CI for archival:

# export_openapi.py
import json
from myapp import app

spec = app.openapi()
spec["info"]["version"] = os.environ.get("GIT_TAG", "dev")

with open("openapi.json", "w") as f:
    json.dump(spec, f, indent=2)

Store versioned specs alongside your code. They serve as a historical record of your API’s evolution and enable diff-based change detection.

One thing to remember: The real power of OpenAPI spec generation isn’t the documentation — it’s the ecosystem of testing, client generation, and change detection tools that only work when your spec is accurate, detailed, and auto-generated from code.

pythonwebapisopenapidocumentationfastapipydantic

See Also

  • Python Aiohttp Client Understand Aiohttp Client through a practical analogy so your Python decisions become faster and clearer.
  • Python Api Client Design Why building your own API client in Python is like creating a TV remote that only has the buttons you actually need.
  • Python Api Documentation Swagger Swagger turns your Python API into an interactive playground where anyone can click buttons to try it out — no coding required.
  • Python Api Mocking Responses Why testing with fake API responses is like rehearsing a play with stand-ins before the real actors show up.
  • Python Api Pagination Clients Why APIs send data in pages, and how Python handles it — like reading a book one chapter at a time instead of swallowing the whole thing.