Module 3: Advanced Response Models
Project: Professional Responses for the Task Manager API
Project overview
In this project you bring together everything you learned in Module 3: schemas differentiated by context, CSV export with streaming, custom headers, cookies, and complete response documentation in OpenAPI. Your Task Manager API goes from "returns everything as JSON" to "every endpoint returns exactly what the consumer needs, in the format they need, with the documentation they need."
The result is an API that serves three kinds of consumers: a web frontend that needs compact data with no internal fields, an admin dashboard that needs everything, and a reporting system that needs CSV streaming for large datasets. One codebase, three different experiences. That's professional response control.
Project objectives
By the time you finish this project:
- ✅ Three response schemas per resource:
TaskSummary,TaskPublic,TaskAdmin - ✅ A CSV export endpoint with
StreamingResponseand a generator - ✅ Custom headers:
X-Total-Count,X-Export-Count,X-API-Version - ✅
response_model_exclude_unseton the PATCH endpoint - ✅ Consistent error responses with an
ErrorResponseschema - ✅ Complete OpenAPI documentation with the
responsesparameter on every endpoint - ✅ A reusable response wrapper with standard headers
- ✅ A stats endpoint with an aggregated view
Project structure
fastapi-advanced/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── models.py ← Pydantic schemas (Task*, ErrorResponse)
│ ├── data.py ← In-memory data
│ ├── responses.py ← Response helpers and wrappers
│ └── routers/
│ ├── __init__.py
│ └── tasks.py ← The tasks router with professional responses
└── requirements.txt
Step 1: Pydantic schemas — app/models.py
Define every schema you'll use. The hierarchy keeps public fields separate from internal ones:
from pydantic import BaseModel, Field, ConfigDict
from datetime import datetime
from typing import Optional, Literal
class TaskBase(BaseModel):
"""Editable fields common to create and update."""
title: str = Field(min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=2000)
status: Literal["pending", "in_progress", "completed"] = "pending"
priority: int = Field(default=1, ge=1, le=5)
due_date: Optional[str] = None
class TaskCreate(TaskBase):
"""Schema for creating a task (POST)."""
pass
class TaskUpdate(BaseModel):
"""Schema for partial updates (PATCH). Everything is optional."""
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
description: Optional[str] = Field(default=None, max_length=2000)
status: Optional[Literal["pending", "in_progress", "completed"]] = None
priority: Optional[int] = Field(default=None, ge=1, le=5)
due_date: Optional[str] = None
class TaskSummary(BaseModel):
"""Compact view for listings."""
model_config = ConfigDict(from_attributes=True)
id: int
title: str
status: str
priority: int
class TaskPublic(TaskBase):
"""Public view — no internal fields."""
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
class TaskAdmin(TaskBase):
"""Admin view — every field visible."""
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
updated_at: Optional[datetime] = None
created_by: str
internal_notes: Optional[str] = None
is_archived: bool
class TaskInDB(TaskAdmin):
"""The complete model in storage."""
pass
class ErrorResponse(BaseModel):
"""The standard schema for error responses."""
status: Literal["error"] = "error"
message: str
detail: Optional[str] = None
error_code: str
class PaginationMeta(BaseModel):
"""Pagination metadata."""
total: int
skip: int
limit: int
count: int
Step 2: In-memory data — app/data.py
from datetime import datetime, timedelta
from app.models import TaskInDB
def create_sample_tasks() -> list[TaskInDB]:
"""Generates sample data for the API."""
base_date = datetime(2026, 3, 1, 9, 0, 0)
return [
TaskInDB(
id=1, title="Set up the CI/CD pipeline",
description="Implement GitHub Actions with testing and automatic deploys",
status="in_progress", priority=5, due_date="2026-03-20",
created_at=base_date, created_by="admin",
internal_notes="Coordinate with the DevOps team", is_archived=False,
),
TaskInDB(
id=2, title="Write unit tests",
description="Minimum 80% coverage for the core modules",
status="pending", priority=4, due_date="2026-03-25",
created_at=base_date + timedelta(hours=2), created_by="dev-lead",
internal_notes=None, is_archived=False,
),
TaskInDB(
id=3, title="Document the API endpoints",
description="Add docstrings and examples in OpenAPI",
status="completed", priority=3,
created_at=base_date + timedelta(days=1), updated_at=base_date + timedelta(days=3),
created_by="dev-lead", internal_notes="Finished ahead of the deadline",
is_archived=False,
),
TaskInDB(
id=4, title="Refactor the auth module",
description="Split authentication and authorization logic",
status="pending", priority=4, due_date="2026-04-01",
created_at=base_date + timedelta(days=2), created_by="tech-lead",
internal_notes="Needs a security review", is_archived=False,
),
TaskInDB(
id=5, title="Optimize the database queries",
description="Cut response time on the listing endpoints",
status="in_progress", priority=3,
created_at=base_date + timedelta(days=3), created_by="dba",
internal_notes=None, is_archived=False,
),
TaskInDB(
id=6, title="Migrate to Pydantic v2",
description="Update every model from v1 to v2",
status="completed", priority=2,
created_at=base_date - timedelta(days=10), updated_at=base_date - timedelta(days=2),
created_by="dev-lead", internal_notes="Migration finished with no issues",
is_archived=True,
),
TaskInDB(
id=7, title="Implement rate limiting",
description="Add per-IP rate limiting on the public endpoints",
status="pending", priority=5, due_date="2026-03-18",
created_at=base_date + timedelta(days=4), created_by="security-team",
internal_notes="High priority after last week's incident",
is_archived=False,
),
TaskInDB(
id=8, title="Add a health check endpoint",
description="A /health endpoint for monitoring",
status="completed", priority=1,
created_at=base_date + timedelta(days=5), updated_at=base_date + timedelta(days=5, hours=2),
created_by="system", internal_notes=None, is_archived=False,
),
]
tasks_db: list[TaskInDB] = create_sample_tasks()
Step 3: Response helpers — app/responses.py
from fastapi import Response
from fastapi.responses import JSONResponse
from app.models import ErrorResponse
API_VERSION = "3.0.0"
def add_standard_headers(response: Response, **extra_headers: str) -> None:
"""Adds the standard headers to any response."""
response.headers["X-API-Version"] = API_VERSION
for key, value in extra_headers.items():
header_name = key.replace("_", "-")
response.headers[header_name] = str(value)
def error_json(message: str, error_code: str, status_code: int = 400, detail: str | None = None) -> JSONResponse:
"""Builds an error JSONResponse with a consistent structure."""
error = ErrorResponse(
message=message,
error_code=error_code,
detail=detail,
)
response = JSONResponse(
status_code=status_code,
content=error.model_dump(),
)
response.headers["X-API-Version"] = API_VERSION
response.headers["X-Error-Code"] = error_code
return response
Step 4: The tasks router — app/routers/tasks.py
This is the main file. Every endpoint has professional responses:
from fastapi import APIRouter, HTTPException, Query, Path, Response
from fastapi.responses import StreamingResponse
from datetime import datetime
from typing import Optional, Literal
from app.models import (
TaskCreate, TaskUpdate, TaskPublic, TaskAdmin,
TaskSummary, TaskInDB, ErrorResponse, PaginationMeta,
)
from app.data import tasks_db
from app.responses import add_standard_headers, error_json
router = APIRouter(prefix="/tasks", tags=["Tasks"])
# --- Public listing ---
@router.get(
"",
response_model=list[TaskSummary],
summary="List tasks (compact view)",
responses={
200: {
"description": "A list of tasks in compact format",
"content": {
"application/json": {
"example": [
{"id": 1, "title": "Set up CI/CD", "status": "in_progress", "priority": 5},
],
},
},
},
},
)
def list_tasks(
response: Response,
status: Optional[Literal["pending", "in_progress", "completed"]] = Query(
default=None, description="Filter by status",
),
priority: Optional[int] = Query(default=None, ge=1, le=5, description="Filter by priority"),
search: Optional[str] = Query(default=None, min_length=1, max_length=100, description="Search in the title"),
skip: int = Query(default=0, ge=0, description="Records to skip"),
limit: int = Query(default=10, ge=1, le=100, description="Records per page"),
):
"""Lists tasks with filters and pagination. Returns the compact view (TaskSummary)."""
results = [t for t in tasks_db if not t.is_archived]
if status:
results = [t for t in results if t.status == status]
if priority is not None:
results = [t for t in results if t.priority == priority]
if search:
term = search.lower()
results = [t for t in results if term in t.title.lower()]
total = len(results)
paginated = results[skip : skip + limit]
add_standard_headers(
response,
X_Total_Count=total,
X_Skip=skip,
X_Limit=limit,
)
return paginated
# --- Admin listing ---
@router.get(
"/admin",
response_model=list[TaskAdmin],
summary="List tasks (admin view)",
responses={
200: {"description": "The complete list of tasks with every field"},
},
)
def list_tasks_admin(
response: Response,
include_archived: bool = Query(default=False, description="Include archived tasks"),
):
"""Lists tasks with every field. Includes internal fields."""
results = tasks_db if include_archived else [t for t in tasks_db if not t.is_archived]
add_standard_headers(response, X_Total_Count=len(results))
return results
# --- Public detail ---
@router.get(
"/{task_id}",
response_model=TaskPublic,
summary="Get a task by ID",
responses={
200: {
"description": "Task found",
"content": {
"application/json": {
"example": {
"id": 1, "title": "Set up CI/CD",
"description": "Implement GitHub Actions",
"status": "in_progress", "priority": 5,
"due_date": "2026-03-20",
"created_at": "2026-03-01T09:00:00",
},
},
},
},
404: {
"description": "Task not found",
"model": ErrorResponse,
},
},
)
def get_task(task_id: int = Path(ge=1, description="The task's ID")):
"""Returns a task by ID. The public view, with no internal fields."""
task = next((t for t in tasks_db if t.id == task_id), None)
if task is None:
return error_json(
message=f"Task with id {task_id} not found",
error_code="TASK_NOT_FOUND",
status_code=404,
)
return task
# --- Admin detail ---
@router.get(
"/{task_id}/admin",
response_model=TaskAdmin,
summary="Get a task by ID (admin)",
responses={
200: {"description": "The task with every field"},
404: {"model": ErrorResponse, "description": "Task not found"},
},
)
def get_task_admin(task_id: int = Path(ge=1)):
"""Returns a task by ID with every field (the admin view)."""
task = next((t for t in tasks_db if t.id == task_id), None)
if task is None:
return error_json(
message=f"Task with id {task_id} not found",
error_code="TASK_NOT_FOUND",
status_code=404,
)
return task
# --- Create ---
@router.post(
"",
response_model=TaskPublic,
status_code=201,
summary="Create a task",
responses={
201: {"description": "Task created successfully"},
422: {"description": "Invalid data"},
},
)
def create_task(task: TaskCreate, response: Response):
"""Creates a new task. Returns the public view."""
new_id = max((t.id for t in tasks_db), default=0) + 1
task_in_db = TaskInDB(
id=new_id,
**task.model_dump(),
created_at=datetime.now(),
created_by="api-user",
is_archived=False,
)
tasks_db.append(task_in_db)
add_standard_headers(response)
response.headers["Location"] = f"/tasks/{new_id}"
return task_in_db
# --- Partial update ---
@router.patch(
"/{task_id}",
response_model=TaskPublic,
response_model_exclude_unset=True,
summary="Update a task partially",
responses={
200: {"description": "Task updated"},
404: {"model": ErrorResponse, "description": "Task not found"},
422: {"description": "Invalid update data"},
},
)
def update_task(
task_id: int = Path(ge=1),
updates: TaskUpdate = ...,
):
"""Updates only the fields provided. Returns only the fields that changed."""
task = next((t for t in tasks_db if t.id == task_id), None)
if task is None:
return error_json(
message=f"Task with id {task_id} not found",
error_code="TASK_NOT_FOUND",
status_code=404,
)
update_data = updates.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(task, field, value)
task.updated_at = datetime.now()
return task
# --- Delete ---
@router.delete(
"/{task_id}",
status_code=200,
summary="Delete a task",
responses={
200: {
"description": "Task deleted",
"content": {
"application/json": {
"example": {"message": "Task deleted", "deleted_id": 1},
},
},
},
404: {"model": ErrorResponse, "description": "Task not found"},
},
)
def delete_task(task_id: int = Path(ge=1)):
"""Deletes a task by ID."""
task = next((t for t in tasks_db if t.id == task_id), None)
if task is None:
return error_json(
message=f"Task with id {task_id} not found",
error_code="TASK_NOT_FOUND",
status_code=404,
)
tasks_db.remove(task)
return {"message": "Task deleted", "deleted_id": task_id}
# --- CSV export ---
def tasks_csv_generator(tasks: list[TaskInDB]):
"""Generator for streaming tasks as CSV."""
yield "id,title,status,priority,due_date,created_at\n"
for task in tasks:
due = task.due_date or ""
created = task.created_at.isoformat()
title = task.title.replace(",", ";")
yield f"{task.id},{title},{task.status},{task.priority},{due},{created}\n"
@router.get(
"/export/csv",
summary="Export tasks as CSV",
responses={
200: {
"description": "A CSV file with the tasks",
"content": {
"text/csv": {
"example": "id,title,status,priority,due_date,created_at\n1,Setup CI/CD,in_progress,5,2026-03-20,2026-03-01T09:00:00\n",
},
},
},
},
)
def export_tasks_csv(
status: Optional[Literal["pending", "in_progress", "completed"]] = Query(
default=None, description="Filter by status before exporting",
),
):
"""Exports tasks as streaming CSV. Optionally filters by status."""
data = [t for t in tasks_db if not t.is_archived]
if status:
data = [t for t in data if t.status == status]
filename = f"tasks_{status or 'all'}_{datetime.now().strftime('%Y%m%d')}.csv"
return StreamingResponse(
content=tasks_csv_generator(data),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename={filename}",
"X-Export-Count": str(len(data)),
"X-API-Version": "3.0.0",
},
)
# --- Statistics ---
@router.get(
"/stats/summary",
summary="Task statistics",
responses={
200: {
"description": "Aggregated statistics",
"content": {
"application/json": {
"example": {
"total": 8,
"by_status": {"pending": 3, "in_progress": 2, "completed": 3},
"by_priority": {"1": 1, "5": 2},
"archived": 1,
},
},
},
},
},
)
def task_stats(response: Response):
"""Aggregated statistics across all tasks."""
total = len(tasks_db)
archived = sum(1 for t in tasks_db if t.is_archived)
active = total - archived
by_status: dict[str, int] = {}
by_priority: dict[str, int] = {}
for task in tasks_db:
by_status[task.status] = by_status.get(task.status, 0) + 1
key = str(task.priority)
by_priority[key] = by_priority.get(key, 0) + 1
add_standard_headers(response, X_Total_Count=total)
return {
"total": total,
"active": active,
"archived": archived,
"by_status": by_status,
"by_priority": by_priority,
}
Step 5: The main app — app/main.py
from fastapi import FastAPI
from app.routers import tasks
app = FastAPI(
title="Task Manager API",
description="A task management API with professional responses. Module 3 — FastAPI Advanced Features.",
version="3.0.0",
)
app.include_router(tasks.router)
@app.get("/", tags=["General"])
def root():
return {
"service": "Task Manager API",
"version": "3.0.0",
"modules": "DI + Routers + Response Models",
"docs": "/docs",
}
Step 6: Run it and verify
uvicorn app.main:app --reload
Check 1: The compact listing (TaskSummary)
curl -s http://127.0.0.1:8000/tasks | python -m json.tool
[
{"id": 1, "title": "Set up the CI/CD pipeline", "status": "in_progress", "priority": 5},
{"id": 2, "title": "Write unit tests", "status": "pending", "priority": 4}
]
Only 4 fields per task. No description, no created_at, no internal fields.
Check 2: The admin listing (TaskAdmin)
curl -s "http://127.0.0.1:8000/tasks/admin?include_archived=true" | python -m json.tool
Returns every task (including the archived ones) with every field: created_by, internal_notes, is_archived.
Check 3: Custom headers
curl -v http://127.0.0.1:8000/tasks 2>&1 | grep -i "x-"
< x-api-version: 3.0.0
< x-total-count: 7
< x-skip: 0
< x-limit: 10
Check 4: Filters with pagination
curl -s "http://127.0.0.1:8000/tasks?status=pending&skip=0&limit=2" | python -m json.tool
Returns only pending tasks, paginated.
Check 5: Public detail vs admin detail
curl -s http://127.0.0.1:8000/tasks/1 | python -m json.tool
# → Public view: no internal_notes, created_by, is_archived
curl -s http://127.0.0.1:8000/tasks/1/admin | python -m json.tool
# → Admin view: every field including internal_notes
Check 6: A consistent error response
curl -s http://127.0.0.1:8000/tasks/999 | python -m json.tool
{
"status": "error",
"message": "Task with id 999 not found",
"detail": null,
"error_code": "TASK_NOT_FOUND"
}
With an X-Error-Code: TASK_NOT_FOUND header and HTTP status 404.
Check 7: PATCH with exclude_unset
curl -s -X PATCH http://127.0.0.1:8000/tasks/1 \
-H "Content-Type: application/json" \
-d '{"status": "completed"}' | python -m json.tool
The response includes only the fields that hold a value — it doesn't return description: null or due_date: null if they weren't part of the model's original setup.
Check 8: CSV export
curl http://127.0.0.1:8000/tasks/export/csv
id,title,status,priority,due_date,created_at
1,Set up the CI/CD pipeline,in_progress,5,2026-03-20,2026-03-01T09:00:00
2,Write unit tests,pending,4,2026-03-25,2026-03-01T11:00:00
...
Check 9: Filtered CSV export
curl -v "http://127.0.0.1:8000/tasks/export/csv?status=completed" 2>&1 | grep -i "x-export"
# x-export-count: 2
It exports only completed tasks. The X-Export-Count header confirms how many went out.
Check 10: Statistics
curl -s http://127.0.0.1:8000/tasks/stats/summary | python -m json.tool
{
"total": 8,
"active": 7,
"archived": 1,
"by_status": {"in_progress": 2, "pending": 3, "completed": 3},
"by_priority": {"5": 2, "4": 2, "3": 2, "2": 1, "1": 1}
}
Check 11: Creating a task with a Location header
curl -v -X POST http://127.0.0.1:8000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "New task", "priority": 3}' 2>&1 | grep -i "location"
# location: /tasks/9
The Location header points to the URL of the resource you just created.
Check 12: The documentation in /docs
Open http://127.0.0.1:8000/docs. Expand each endpoint and verify:
- Every endpoint shows its possible responses (200, 404, 422)
- The response schemas are documented with examples
- The query parameters have descriptions
- The CSV export documents the
text/csvcontent-type
Completeness checklist
Schemas:
- [ ] TaskSummary with 4 fields (id, title, status, priority)
- [ ] TaskPublic with the base fields + id + created_at (no internal ones)
- [ ] TaskAdmin with every field including the internal ones
- [ ] TaskCreate for POST
- [ ] TaskUpdate with everything Optional for PATCH
- [ ] ErrorResponse with status, message, error_code
- [ ] TaskInDB as the complete storage model
Endpoints — Listing:
- [ ] GET /tasks returns list[TaskSummary]
- [ ] GET /tasks/admin returns list[TaskAdmin]
- [ ] Filters: status, priority, search
- [ ] Pagination: skip, limit
- [ ] Headers: X-Total-Count, X-Skip, X-Limit
Endpoints — Detail:
- [ ] GET /tasks/{id} returns TaskPublic
- [ ] GET /tasks/{id}/admin returns TaskAdmin
- [ ] 404 with ErrorResponse for a task that doesn't exist
Endpoints — Mutation:
- [ ] POST /tasks returns TaskPublic + a Location header
- [ ] PATCH /tasks/{id} uses response_model_exclude_unset
- [ ] DELETE /tasks/{id} returns a confirmation message
Endpoints — Export and Stats:
- [ ] GET /tasks/export/csv returns a StreamingResponse with CSV
- [ ] The CSV export accepts a status filter
- [ ] An X-Export-Count header on the export
- [ ] GET /tasks/stats/summary returns aggregated statistics
Responses:
- [ ] Every error response uses ErrorResponse
- [ ] An X-API-Version header on every response
- [ ] An X-Error-Code header on error responses
- [ ] The responses parameter documenting every possible response
OpenAPI documentation:
- [ ] /docs shows the TaskSummary, TaskPublic, TaskAdmin schemas
- [ ] Every endpoint documents its possible status codes
- [ ] Examples on the main responses
- [ ] The CSV export documents the text/csv content-type
Troubleshooting
Problem 1: GET /tasks/export/csv returns a 422 instead of the CSV
Cause: FastAPI reads /tasks/export as /tasks/{task_id} with task_id="export". The endpoint with the path parameter is capturing the route first.
Fix: Declare the fixed routes (/export/csv, /stats/summary, /admin) BEFORE the route with {task_id}:
# ✅ The right order in the router
@router.get("/export/csv") # fixed — first
@router.get("/stats/summary") # fixed — first
@router.get("/admin") # fixed — first
@router.get("/{task_id}") # parameterized — last
Problem 2: TaskPublic doesn't filter TaskInDB's fields
Cause: response_model=TaskPublic only filters if FastAPI can build a TaskPublic from the data. If you return a dict with extra fields, response_model excludes them. But if there are fields that don't match, it can fail.
Fix: Make sure TaskPublic has ConfigDict(from_attributes=True) and that you return the TaskInDB object directly (not a partial dict):
class TaskPublic(TaskBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_at: datetime
@router.get("/tasks/{id}", response_model=TaskPublic)
def get_task(id: int):
return task_in_db # FastAPI filters it down to TaskPublic automatically
Problem 3: The CSV repeats the header row between chunked responses
Cause: The generator is emitting the header in every chunk, not just at the start.
Fix: Check that the header's yield sits outside the loop:
def csv_gen(data):
yield "id,title,status\n" # Just once, outside the loop
for item in data:
yield f"{item.id},{item.title},{item.status}\n"
Problem 4: PATCH returns every field despite exclude_unset
Cause: response_model_exclude_unset works on the fields of the model that was used to create the instance. If you rebuild the object with every field, exclude_unset has no effect because everything counts as "set".
Fix: Don't rebuild the object — modify the existing one with setattr:
# ❌ Rebuilding loses the "unset" information
task = TaskInDB(**{**old_task.model_dump(), **updates})
# ✅ Modifying in place preserves it
for field, value in updates.items():
setattr(task, field, value)
Problem 5: The custom X- headers don't show up in the browser
Cause: CORS doesn't expose custom headers by default. The browser receives them but JavaScript can't read them.
Fix: Configure CORS to expose the headers (covered in FastAPI Fundamentals, but the quick fix is):
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
expose_headers=["X-Total-Count", "X-API-Version", "X-Export-Count", "X-Error-Code"],
)
Problem 6: error_json returns a 200 instead of the right status code
Cause: If you return a JSONResponse from an endpoint that has a response_model, FastAPI honors the JSONResponse's status code. But if you accidentally return a dict shaped like an error, FastAPI stamps it with status 200.
Fix: Always use an explicit JSONResponse or HTTPException for errors — never return an error dict as a normal return value:
# ❌ Returns an error dict with status 200
return {"status": "error", "message": "Not found"}
# ✅ Returns a JSONResponse with status 404
return JSONResponse(status_code=404, content={"status": "error", "message": "Not found"})
# ✅ Or use HTTPException
raise HTTPException(status_code=404, detail="Not found")
Problem 7: The stats endpoint counts archived tasks
Cause: The stats endpoint doesn't filter out archived tasks, so it shows the wrong totals for the active context.
Fix: Document clearly that stats include archived tasks (if that's intentional), or filter according to the use case:
# Stats include EVERYTHING (archived and active) — document it
return {
"total": len(tasks_db),
"active": sum(1 for t in tasks_db if not t.is_archived),
"archived": sum(1 for t in tasks_db if t.is_archived),
}
Patterns you applied
1. Schemas as a public contract
Every consumer gets its own schema. TaskSummary for quick listings, TaskPublic for normal views, TaskAdmin for the back office. The same TaskInDB object serializes differently depending on the context — response_model does the magic.
2. Consistent error responses
Every error uses ErrorResponse with the same structure: status, message, error_code. The client can build error handling around error_code without parsing strings.
3. Headers as metadata
The data goes in the body, the metadata about the response goes in headers. X-Total-Count for pagination, X-Export-Count for exports, X-API-Version for versioning. The body stays clean.
4. Streaming for exports
The CSV export uses StreamingResponse with a generator — constant memory no matter how many tasks there are. The X-Export-Count header tells the client how many records are in the file before it finishes downloading.
5. OpenAPI as living documentation
The responses parameter in every decorator documents all the possible responses. A developer who opens /docs knows exactly what they can get from each endpoint and each status code, with real examples.
Summary
In this project you brought together every tool from Module 3:
- Differentiated schemas:
TaskSummary,TaskPublic,TaskAdmin— the same data, three views depending on the consumer response_model_exclude_unseton PATCH — the response only includes the relevant fieldsStreamingResponsewith a generator for CSV export — constant memory- Custom headers:
X-Total-Count,X-API-Version,X-Export-Count,X-Error-Code,Location - Consistent error responses with the
ErrorResponseschema and theerror_jsonhelper - OpenAPI documentation with the
responsesparameter — every endpoint documents its possible responses - Response helpers (
add_standard_headers,error_json) encapsulate the repetitive logic - Filters on export — the CSV accepts filter parameters before exporting
Your API doesn't just "return data" anymore — it returns exactly what each consumer needs, in the right format, with useful metadata in the headers, and complete documentation in /docs.
Additional resources
- FastAPI - Response Model — response_model, exclude, include, exclude_unset
- FastAPI - Additional Responses — Documenting multiple responses with the responses parameter
- FastAPI - Custom Response — JSONResponse, StreamingResponse, FileResponse
- FastAPI - Response Headers — Custom headers on responses
- Pydantic v2 - Serialization — model_dump, include, exclude, exclude_unset
- OpenAPI Specification - Responses — How responses are documented in OpenAPI
What's next?
Your API returns data professionally. But there are operations that shouldn't block the response to the client. When a task gets created, maybe you want to send a notification email. When it gets completed, maybe you want to update a dashboard. Those operations take time — and the client shouldn't have to wait.
In Module 4 (Background Tasks) you'll learn to run operations after the response goes out:
Module 3 (now): Module 4 (next):
────────────────── ─────────────────────
POST /tasks → creates task → responds → POST /tasks → responds → sends email in the background
GET /tasks → returns everything at once → GET /tasks → returns → clears cache in the background
Synchronous operations → BackgroundTasks for fire-and-forget
No post-response processing → Celery/RQ for heavy tasks
Module 3 complete. You've mastered total control over responses. Next stop: Background Tasks, where your API does things after it replies to the client.