FastAPI’s OpenAPI documentation is generated automatically, but the real power comes when you customize it to reflect your API’s structure and intent.
Let’s see it in action. Here’s a minimal FastAPI app:
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import Optional
app = FastAPI()
class Item(BaseModel):
name: str
price: float
is_offer: Optional[bool] = None
@app.get("/")
async def read_root():
return {"Hello": "World"}
@app.get("/items/{item_id}", response_model=Item)
async def read_item(item_id: int, q: Optional[str] = None):
if item_id == 1:
return {"name": "Foo", "price": 10.5, "is_offer": True}
elif item_id == 2:
return {"name": "Bar", "price": 20.0}
else:
raise HTTPException(status_code=404, detail="Item not found")
@app.post("/items/", response_model=Item)
async def create_item(item: Item):
print(f"Creating item: {item.name} with price {item.price}")
return item
When you run this (e.g., with uvicorn main:app --reload) and visit /docs, you’ll see the default documentation. It’s functional, but not very organized.
Organizing with Tags
Tags group related endpoints. This is crucial for large APIs. You apply them directly to your path operation decorators.
@app.get("/items/{item_id}", tags=["Items"])
async def read_item(item_id: int, q: Optional[str] = None):
# ... (same as before)
@app.post("/items/", tags=["Items"])
async def create_item(item: Item):
# ... (same as before)
@app.get("/users/", tags=["Users"])
async def read_users():
return [{"username": "johndoe"}]
Now, in /docs, you’ll see distinct sections for "Items" and "Users." This dramatically improves navigability.
Adding Rich Metadata
Beyond tags, you can add descriptive metadata to your endpoints, making the documentation more informative. This includes summaries and descriptions.
@app.get(
"/items/{item_id}",
tags=["Items"],
summary="Get a specific item by ID",
description="Retrieve detailed information about an item, including its name, price, and whether it's currently on offer. You can optionally provide a query parameter 'q' for additional filtering, though its effect is illustrative.",
response_description="The item details"
)
async def read_item(item_id: int, q: Optional[str] = None):
# ... (same as before)
@app.post(
"/items/",
tags=["Items"],
summary="Create a new item",
response_description="The newly created item"
)
async def create_item(item: Item):
# ... (same as before)
The summary appears as a concise title, while description provides more detail when the endpoint is expanded. response_description clarifies what the successful response body represents.
Customizing Schemas
Pydantic models are automatically converted into OpenAPI schemas, but you can enhance them with descriptions and examples.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI()
class Item(BaseModel):
name: str = Field(..., example="Foo")
price: float = Field(..., gt=0, example=10.5)
is_offer: Optional[bool] = Field(None, example=True)
class Config:
schema_extra = {
"example": {
"name": "Example Item",
"price": 99.99,
"is_offer": False,
}
}
# ... (rest of the app as before)
Using Field with the example argument injects specific data examples into the schema. The schema_extra in the Config class provides a more comprehensive example object for the entire schema. This is incredibly useful for users trying to understand how to structure requests.
Advanced Schema Control with response_model and exclude
You can precisely control what appears in the response using response_model. If your internal model has more fields than you want to expose, use a separate Pydantic model for the API response.
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from typing import Optional
app = FastAPI()
class ItemBase(BaseModel):
name: str = Field(..., example="Foo")
price: float = Field(..., gt=0, example=10.5)
class ItemCreate(ItemBase):
pass
class Item(ItemBase):
is_offer: Optional[bool] = Field(None, example=True)
class Config:
schema_extra = {
"example": {
"name": "Example Item",
"price": 99.99,
"is_offer": False,
}
}
@app.post("/items/", response_model=Item, tags=["Items"], summary="Create a new item")
async def create_item(item: ItemCreate):
# For demonstration, we'll create an Item object with a default is_offer
new_item_data = item.model_dump()
new_item_data["is_offer"] = False # Default to not on offer
created_item = Item(**new_item_data)
print(f"Creating item: {created_item.name} with price {created_item.price}")
return created_item
@app.get("/items/{item_id}", response_model=Item, tags=["Items"], summary="Get a specific item by ID")
async def read_item(item_id: int, q: Optional[str] = None):
if item_id == 1:
return Item(name="Foo", price=10.5, is_offer=True)
elif item_id == 2:
return Item(name="Bar", price=20.0)
else:
raise HTTPException(status_code=404, detail="Item not found")
Here, ItemCreate is used for the request body, ensuring only name and price are expected. The create_item function then returns an Item object, which includes is_offer. The response_model=Item on the post operation ensures that the response schema and documentation reflect the Item model, not ItemCreate.
The response_model argument on path operations is the primary lever for controlling what data is returned and thus what schema is documented for that specific endpoint. FastAPI automatically handles serialization and validation based on this model, and the OpenAPI spec reflects it precisely.
The next step is to explore customising the OpenAPI schema generation itself, which allows for more advanced modifications beyond what Pydantic models and path operation parameters offer.