Week 3: Building REST APIs

Everything so far has lived as loose routes bolted directly onto one FastAPI() instance. This week splits that into APIRouter modules organized by resource, and replaces the single ad hoc Pydantic model from Week 2 with separate create, update and response schemas — so your API's public contract isn't just whatever shape your code happens to store internally. It closes with the other half of a real API: raising HTTPException with the right status codes, and registering one centralized exception handler so every failure in your app returns errors in the same shape, instead of each route inventing its own.

Module 3 of 22 Week 3 of 26 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Organize routes into APIRouter modules with prefix/tags, using resource-oriented URLs instead of verb-based ones
  • Define separate Create, Update and Out Pydantic models per resource, and apply partial-update semantics with exclude_unset
  • Raise HTTPException with correct status codes, and register a custom exception handler for consistent error responses

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(...).

src/app/routers/tasks.py
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]
src/app/main.py
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.

Routers can nest dependencies too

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.

src/app/routers/tasks.py
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.

src/app/routers/tasks.py
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.

response_model filters even ORM objects later on

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.

src/app/routers/tasks.py
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:

src/app/routers/tasks.py
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:

src/app/main.py
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.

Pick the status code deliberately

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

Hands-on

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:

  1. Create src/app/routers/tasks.py with an APIRouter(prefix="/tasks", tags=["tasks"]), and wire it into main.py with include_router.
  2. Define three Pydantic models: TaskCreate (just title), TaskUpdate (all fields Optional), and TaskOut (id, title, done).
  3. Add a title_must_not_be_blank @field_validator on TaskCreate that strips whitespace and rejects an empty result.
  4. Implement POST /tasks, GET /tasks, GET /tasks/{id}, PATCH /tasks/{id} and DELETE /tasks/{id}, all using response_model=TaskOut where a task is returned.
  5. 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.
  6. Implement PATCH with model_dump(exclude_unset=True) so sending only {"done": true} leaves title untouched.
Hint

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?

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?

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?

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?

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.