1. APIRouter & Resource-Oriented URLs
Every route so far has been declared directly on the single global app
object. That works for a demo, but it doesn't scale past a handful of endpoints —
you end up with one enormous main.py, and no clean way to reuse a set
of routes or test them in isolation. APIRouter is FastAPI's answer: it
behaves like a mini FastAPI instance you define in its own module, and
wire into the real app with app.include_router(...).
from fastapi import APIRouter
router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("")
def list_tasks():
return list(tasks_by_id.values())
@router.get("/{task_id}")
def get_task(task_id: int):
return tasks_by_id[task_id]
from fastapi import FastAPI
from app.routers import tasks
app = FastAPI()
app.include_router(tasks.router)
prefix="/tasks" means every path declared on this router — even the
bare "" in @router.get("") — is actually mounted under
/tasks, so you write @router.get("/{task_id}") instead of
repeating /tasks/{task_id} in every route. tags=["tasks"]
groups every route on this router under one heading in /docs, which is
far less error-prone than tagging each route individually. A real app typically has
one router module per resource — tasks.py, users.py, and
so on — each include_router'd into main.py.
This module split also reinforces a design choice worth being deliberate about:
resource-oriented URLs. GET /tasks/{id},
PATCH /tasks/{id} and DELETE /tasks/{id} name a
thing — a task — and let the HTTP method carry the verb. Compare that to a
verb-based style like GET /getTask?id=5 or
POST /deleteTask: every action needs its own uniquely-named endpoint,
HTTP methods stop carrying any meaning, and tooling that understands REST
conventions (caching proxies, generated API clients, even /docs itself)
has nothing consistent to key off. One URL pattern per resource, with the method
doing the work, is what makes an API predictable to a client that's never seen it
before.
APIRouter(prefix="/tasks", dependencies=[Depends(some_check)]) applies a dependency to every route on that router at once — useful once you have a check (like an auth guard, introduced later in the course) that every route under a resource needs, without repeating Depends() on each one individually.
2. Request, Response & Patch Models
Week 2's BookCreate was the only model in the picture — fine for a
single POST, but it breaks down the moment a resource needs a response
shape that differs from its input shape. A created task has an id the
client never sent; a response you send back may deliberately omit fields you store
internally. The fix is to stop treating "the model" as one thing and define one
Pydantic model per direction: a Create model for what a client
sends to make one, and an Out model for what you send back.
from fastapi import APIRouter, status
from pydantic import BaseModel, Field
router = APIRouter(prefix="/tasks", tags=["tasks"])
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
class TaskOut(BaseModel):
id: int
title: str
done: bool
@router.post("", response_model=TaskOut, status_code=status.HTTP_201_CREATED)
def create_task(body: TaskCreate):
task = {"id": next_id(), "title": body.title, "done": False}
tasks_by_id[task["id"]] = task
return task
create_task only accepts what TaskCreate declares — a
title — so a client can't smuggle in done: true or an
id of their choosing on creation; the server decides both. On the way
out, response_model=TaskOut both documents the response shape and
filters the returned dict down to exactly those three fields, regardless of what
else task happens to contain by the time it's returned.
A PATCH needs a third shape entirely, because a partial update has to
represent "this field wasn't sent" as something distinct from
"this field was sent as its default value." A plain required field can't
express that — so a patch model makes every field Optional with a
default of None, and the route uses
model_dump(exclude_unset=True) to get back only the fields the client
actually included in the request body.
from typing import Optional
from fastapi import HTTPException
class TaskUpdate(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=120)
done: Optional[bool] = None
@router.patch("/{task_id}", response_model=TaskOut)
def update_task(task_id: int, body: TaskUpdate):
task = tasks_by_id.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
updates = body.model_dump(exclude_unset=True)
task.update(updates)
return task
exclude_unset=True is the whole trick here: if a client sends
{"done": true}, model_dump(exclude_unset=True) returns
just {"done": True} — not {"title": None, "done": True} —
so task.update(updates) only touches the field that was actually sent.
Without exclude_unset, every omitted field would resolve to its
None default and silently overwrite existing data. This is the concrete
difference between PUT semantics (replace the whole resource) and
PATCH semantics (merge in only what's provided) at the code level.
Once you're returning database rows instead of dicts (a later week), response_model still only serializes the declared fields — it reads attributes off whatever you return by name and discards the rest. That's what makes it safe to return a whole row without manually hand-picking fields on every route.
3. Validation & Centralized Error Handling
Field() constraints cover length and numeric ranges, but some rules
need real logic — "no leading/trailing whitespace," "must not equal an existing
value," anything that isn't expressible as a single keyword argument. Pydantic's
@field_validator decorator runs your own function during validation,
and can reject a value by raising ValueError, which Pydantic folds into
the same structured error response as a failed Field() constraint.
from pydantic import BaseModel, Field, field_validator
class TaskCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
@field_validator("title")
@classmethod
def title_must_not_be_blank(cls, value: str) -> str:
stripped = value.strip()
if not stripped:
raise ValueError("title must not be blank or only whitespace")
return stripped
min_length=1 alone would let " " through, since three
spaces satisfy "at least one character" — the validator catches that and, by
returning stripped, also normalizes the value before it's stored.
Anything not expressible as a resource lookup or field shape belongs in a validator
like this rather than scattered as if checks inside the route function.
Missing-resource errors, on the other hand, are exactly what
HTTPException is for: raise HTTPException(status_code=404, detail="Task not found")
short-circuits the route and returns that status and body immediately. Repeating the
same "look it up, raise 404 if missing" logic in every route is exactly the kind of
thing worth pulling into one helper:
def get_task_or_404(task_id: int) -> dict:
task = tasks_by_id.get(task_id)
if task is None:
raise HTTPException(status_code=404, detail="Task not found")
return task
@router.get("/{task_id}", response_model=TaskOut)
def get_task(task_id: int):
return get_task_or_404(task_id)
That covers one domain error consistently within tasks.py, but a
real app accumulates domain errors that aren't naturally an
HTTPException — say, a TaskLimitExceeded exception raised
deep inside some business logic, far from any route. Rather than catching it in
every caller, register one exception handler on the app that
converts it to a consistent response wherever it's raised:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class TaskLimitExceeded(Exception):
def __init__(self, limit: int):
self.limit = limit
app = FastAPI()
@app.exception_handler(TaskLimitExceeded)
def handle_task_limit(request: Request, exc: TaskLimitExceeded) -> JSONResponse:
return JSONResponse(
status_code=409,
content={"detail": f"Cannot exceed {exc.limit} open tasks"},
)
Every route, service function, or dependency in the app can now
raise TaskLimitExceeded(50) without importing
JSONResponse or knowing the exact status code — the handler owns that
decision in exactly one place. This is what "centralized" means in practice: the
shape of an error response is decided once, not re-derived by every route that might
trigger it.
404 means the resource doesn't exist; 409 Conflict means the request is valid but clashes with current state (like the task limit above); 422 is reserved for FastAPI's own request-shape validation. Reusing 400 for everything throws that information away — a client (or your future self debugging logs) loses the ability to tell "you sent garbage" apart from "the thing you asked for doesn't exist."
4. Hands-on Exercise
Build full CRUD for a tasks resource
Move the books API's ideas from Week 2 into a proper router, with the create/update/response model split and centralized error handling from this week.
Requirements:
- Create
src/app/routers/tasks.pywith anAPIRouter(prefix="/tasks", tags=["tasks"]), and wire it intomain.pywithinclude_router. - Define three Pydantic models:
TaskCreate(justtitle),TaskUpdate(all fieldsOptional), andTaskOut(id,title,done). - Add a
title_must_not_be_blank@field_validatoronTaskCreatethat strips whitespace and rejects an empty result. - Implement
POST /tasks,GET /tasks,GET /tasks/{id},PATCH /tasks/{id}andDELETE /tasks/{id}, all usingresponse_model=TaskOutwhere a task is returned. - Write one shared
get_task_or_404(task_id)helper and call it from every route that needs an existing task, instead of repeating the lookup. - Implement
PATCHwithmodel_dump(exclude_unset=True)so sending only{"done": true}leavestitleuntouched.
DELETE /tasks/{id} conventionally returns 204 No Content with no response body — set status_code=204 on the decorator and just return None. Don't add the custom TaskLimitExceeded-style exception handler unless you want the extra practice; a shared 404 helper and one validator are enough to satisfy this exercise.
5. Knowledge Check
Four quick questions. Expand each to check your answer.
Q1
Why use a separate response model instead of returning the stored task dict (or database row) directly?
Why use a separate response model instead of returning the stored task dict (or database row) directly?
A dedicated Out model creates a stable public contract that's decoupled from however you happen to store the data internally — you can add an internal field (an audit timestamp, a soft-delete flag) without ever exposing it, because response_model only serializes fields the model declares. Returning the raw object directly means every internal field is accidentally part of your API's contract, and any future internal refactor risks silently changing what clients receive.
Q2
What's the practical difference between PUT and PATCH semantics, and how does exclude_unset=True support that?
What's the practical difference between PUT and PATCH semantics, and how does exclude_unset=True support that?
PUT conventionally means "replace the whole resource with exactly this" — any field the client omits should be treated as gone or reset to a default. PATCH means "merge these specific fields in," leaving everything else untouched. model_dump(exclude_unset=True) is what makes PATCH possible with an Optional-fields model: it returns only the keys the client's JSON body actually included, so task.update(...) never overwrites a field the client simply didn't mention with a stray None.
Q3
Why raise HTTPException(status_code=404, ...) instead of just returning a dict like {"error": "not found"} with a normal 200 response?
Why raise HTTPException(status_code=404, ...) instead of just returning a dict like {"error": "not found"} with a normal 200 response?
HTTP status codes are how every client, proxy, monitoring tool, and generated API client understands whether a request succeeded without parsing the body — a 200 response reads as "success" to all of that tooling regardless of what the JSON inside says. HTTPException also immediately halts execution of the rest of the route function, the same way an uncaught exception would, so you don't need an explicit if/else and return to avoid running the success-path code after detecting the error.
Q4
What does registering an @app.exception_handler for a custom exception buy you that raising HTTPException everywhere doesn't?
What does registering an @app.exception_handler for a custom exception buy you that raising HTTPException everywhere doesn't?
It separates where an error is detected from how it's turned into an HTTP response. Business logic buried deep in a service function — far from any route — can raise a plain domain exception like TaskLimitExceeded without importing HTTPException or deciding on a status code itself; the handler registered once on the app converts every instance of that exception, wherever it's raised, into the same status code and response shape. Without it, you'd need to catch and translate that exception in every single route that might trigger it, and any inconsistency between those translations becomes an inconsistent API.