Contents
29 Chapter

Capstone — finishing the TODO API

Tie the patterns from Chapters 1 ~ 28 into a single working service. Authenticated per-user TODO CRUD, tag filtering, pagination, background notifications, tests, and deploy.

The final chapter of Part 4, and the capstone where every tool from Chapters 1 ~ 28 comes together in a single service. Chapters 22 ~ 28 looked at FastAPI’s tools individually (routing / Pydantic / DB / auth / async / testing / deploy). This chapter shows how those tools mesh in a real project while building it step by step.

The artifact you build here becomes the target for Chapter 30 Type checker setup and CI integration, Chapter 31 Logging and observability, and Chapter 33 Building a CLI (Typer) in Part 5. So this chapter also serves as the bridge between Part 4 and Part 5.

What you’ll finish #

TODO API — a small backend where an authenticated user manages their own todos.

FeatureSource chapter
Sign-up / login (JWT)26
TODO CRUD23, 24, 25
Tag filtering + pagination23
Priority (1 ~ 5)24
Due-soon notifications (background)27
Health check + metrics22, 31
Integration tests28
Docker + cloud deploy28

Endpoint table:

MethodPathIntent
POST/auth/registerSign up
POST/auth/loginLog in, issue JWT
GET/meCurrent user info
POST/todosCreate TODO
GET/todosList TODOs (filter + pagination)
GET/todos/{id}Single TODO
PATCH/todos/{id}Partial update
DELETE/todos/{id}Delete TODO
GET/healthHealth check

This chapter builds step by step, with each step marked by a box indicating which chapter’s pattern is in play.

0. Final project structure #

Start by looking at the final shape.

todo-api/
todo-api/
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── .dockerignore
├── alembic.ini
├── alembic/
│   ├── env.py
│   └── versions/
├── app/
│   ├── __init__.py
│   ├── main.py
│   ├── core/
│   │   ├── config.py
│   │   ├── security.py
│   │   └── logging.py
│   ├── api/
│   │   ├── deps.py
│   │   ├── auth.py
│   │   ├── todos.py
│   │   └── health.py
│   ├── models/
│   │   ├── base.py
│   │   ├── user.py
│   │   └── todo.py
│   ├── schemas/
│   │   ├── common.py
│   │   ├── user.py
│   │   └── todo.py
│   ├── services/
│   │   ├── user.py
│   │   └── todo.py
│   ├── db/
│   │   └── session.py
│   └── tasks/
│       └── notifier.py
└── tests/
    ├── conftest.py
    ├── test_auth.py
    └── test_todos.py

The package-separation pattern from Chapter 7 Modules, packages, and pyproject.toml carries straight through. This structure shows why separating modules matters across the whole book.

1. Project setup [Chapter 22] #

Source chapters: Chapter 1 Getting started and uv setup, Chapter 7 Modules, packages, and pyproject.toml, Chapter 22 FastAPI setup and getting started

Setup
uv init todo-api --python 3.14
cd todo-api
uv add "fastapi[standard]" sqlalchemy "sqlalchemy[asyncio]" aiosqlite asyncpg
uv add argon2-cffi pyjwt pydantic-settings
uv add --dev pytest pytest-asyncio pytest-cov httpx pytest-httpx
uv add --dev pyright ruff alembic
app/core/config.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    app_name: str = "Todo API"
    database_url: str = "sqlite+aiosqlite:///./todo.db"
    jwt_secret: str = "dev-only-change-me"
    jwt_expire_minutes: int = 60
    notification_check_interval: int = 60   # background job interval (seconds)

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

settings = Settings()
app/main.py — starting point
from fastapi import FastAPI
from app.core.config import settings
from app.api import auth, todos, health

app = FastAPI(title=settings.app_name, version="0.1.0")
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)

2. DB model design [Chapter 25] #

Source chapters: Chapter 8 dataclass, Chapter 25 Connecting a DB

app/models/base.py
from sqlalchemy.orm import DeclarativeBase

class Base(DeclarativeBase):
    pass
app/models/user.py
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base

class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
    hashed_password: Mapped[str]

    todos: Mapped[list["Todo"]] = relationship(back_populates="owner", cascade="all, delete-orphan")
app/models/todo.py
from datetime import datetime, date
from sqlalchemy import String, Boolean, DateTime, Date, ForeignKey, Integer, JSON, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base

class Todo(Base):
    __tablename__ = "todos"

    id: Mapped[int] = mapped_column(primary_key=True)
    owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)

    title: Mapped[str] = mapped_column(String(200))
    description: Mapped[str] = mapped_column(default="")
    done: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
    priority: Mapped[int] = mapped_column(Integer, default=3)
    due_date: Mapped[date | None] = mapped_column(Date, nullable=True, index=True)
    tags: Mapped[list[str]] = mapped_column(JSON, default=list)

    created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
    updated_at: Mapped[datetime] = mapped_column(
        DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
    )

    owner: Mapped["User"] = relationship(back_populates="todos")
app/db/session.py
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
from app.core.config import settings

engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)

cascade="all, delete-orphan" — when a user is deleted, all of that user’s TODOs go with them. Domain policy stated in one line.

tags: list[str] as a JSON column — for a small project, a separate tag table + many-to-many is overkill. If the scale grows, revisit normalization.

3. Authentication [Chapter 26] #

Source chapter: Chapter 26 Authentication — OAuth2 + JWT

app/core/security.py
from datetime import datetime, timedelta, timezone
import jwt
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.core.config import settings

_hasher = PasswordHasher()
ALGORITHM = "HS256"

def hash_password(plain: str) -> str:
    return _hasher.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    try:
        _hasher.verify(hashed, plain)
        return True
    except VerifyMismatchError:
        return False

def create_access_token(subject: str) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": subject,
        "iat": now,
        "exp": now + timedelta(minutes=settings.jwt_expire_minutes),
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
app/services/user.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.core.security import hash_password, verify_password

async def create(db: AsyncSession, email: str, password: str) -> User:
    user = User(email=email, hashed_password=hash_password(password))
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

async def get_by_email(db: AsyncSession, email: str) -> User | None:
    result = await db.execute(select(User).where(User.email == email))
    return result.scalar_one_or_none()

async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
    user = await get_by_email(db, email)
    if user is None or not verify_password(password, user.hashed_password):
        return None
    return user

4. Dependency injection for auth + session [Chapter 23] #

Source chapter: Chapter 23 Routing, Pydantic models, dependency injection

app/api/deps.py
from typing import Annotated, AsyncIterator
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import AsyncSessionLocal
from app.core.security import decode_token
from app.models.user import User

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

async def get_session() -> AsyncIterator[AsyncSession]:
    async with AsyncSessionLocal() as session:
        yield session

DBSession = Annotated[AsyncSession, Depends(get_session)]

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DBSession,
) -> User:
    exc = HTTPException(
        status.HTTP_401_UNAUTHORIZED,
        "Invalid token",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_token(token)
        user_id = int(payload["sub"])
    except (InvalidTokenError, KeyError, ValueError):
        raise exc

    user = await db.get(User, user_id)
    if user is None:
        raise exc
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]

# Pagination dependency
from dataclasses import dataclass

@dataclass
class Pagination:
    skip: int = 0
    limit: int = 50

PaginationDep = Annotated[Pagination, Depends()]

The three dependencies DBSession, CurrentUser, and PaginationDep go into almost every route.

5. Auth routes [Chapter 26] #

app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field, ConfigDict, SecretStr

class UserCreate(BaseModel):
    email: EmailStr
    password: SecretStr = Field(min_length=8, max_length=128)

class UserOut(BaseModel):
    model_config = ConfigDict(from_attributes=True)
    id: int
    email: EmailStr

class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

The SecretStr use is the PII-masking pattern from Chapter 24 Pydantic v2 in depth. The input is plaintext but logs / repr are auto-masked.

app/api/auth.py
from typing import Annotated
from fastapi import APIRouter, HTTPException, status, Depends
from fastapi.security import OAuth2PasswordRequestForm
from app.api.deps import DBSession, CurrentUser
from app.schemas.user import UserCreate, UserOut, Token
from app.services import user as user_service
from app.core.security import create_access_token

router = APIRouter(prefix="/auth", tags=["auth"])

@router.post("/register", response_model=UserOut, status_code=201)
async def register(payload: UserCreate, db: DBSession) -> UserOut:
    existing = await user_service.get_by_email(db, payload.email)
    if existing:
        raise HTTPException(409, "Email already registered")
    return await user_service.create(db, payload.email, payload.password.get_secret_value())

@router.post("/login", response_model=Token)
async def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: DBSession,
) -> Token:
    user = await user_service.authenticate(db, form.username, form.password)
    if user is None:
        raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Incorrect credentials")
    token = create_access_token(subject=str(user.id))
    return Token(access_token=token)

# /me route alongside
me_router = APIRouter(tags=["me"])

@me_router.get("/me", response_model=UserOut)
async def me(current: CurrentUser) -> UserOut:
    return current

6. TODO CRUD [Chapters 23, 24, 25] #

app/schemas/todo.py
from datetime import datetime, date
from pydantic import BaseModel, Field, field_validator, ConfigDict
from typing import Annotated

Priority = Annotated[int, Field(ge=1, le=5)]

class TodoBase(BaseModel):
    model_config = ConfigDict(str_strip_whitespace=True)

    title: str = Field(min_length=1, max_length=200)
    description: str = ""
    priority: Priority = 3
    due_date: date | None = None
    tags: list[str] = Field(default_factory=list)

    @field_validator("tags")
    @classmethod
    def normalize_tags(cls, v: list[str]) -> list[str]:
        # dedupe + lowercase + drop blanks
        return sorted({t.strip().lower() for t in v if t.strip()})

class TodoCreate(TodoBase):
    pass

class TodoUpdate(BaseModel):
    title: str | None = Field(default=None, min_length=1, max_length=200)
    description: str | None = None
    done: bool | None = None
    priority: Priority | None = None
    due_date: date | None = None
    tags: list[str] | None = None

class TodoOut(TodoBase):
    model_config = ConfigDict(from_attributes=True)

    id: int
    done: bool
    created_at: datetime
    updated_at: datetime
app/schemas/common.py
from pydantic import BaseModel

class Page[T](BaseModel):
    items: list[T]
    total: int
    skip: int
    limit: int

This Page[T] is the Generic from Chapter 9 Typing in earnest + the Generic-model pattern from Chapter 24 Pydantic v2 in depth.

app/services/todo.py
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.todo import Todo
from app.schemas.todo import TodoCreate, TodoUpdate

async def create(db: AsyncSession, data: TodoCreate, owner_id: int) -> Todo:
    todo = Todo(**data.model_dump(), owner_id=owner_id)
    db.add(todo)
    await db.commit()
    await db.refresh(todo)
    return todo

async def get(db: AsyncSession, todo_id: int, owner_id: int) -> Todo | None:
    result = await db.execute(
        select(Todo).where(Todo.id == todo_id, Todo.owner_id == owner_id)
    )
    return result.scalar_one_or_none()

async def list_filtered(
    db: AsyncSession,
    owner_id: int,
    *,
    skip: int = 0,
    limit: int = 50,
    done: bool | None = None,
    tag: str | None = None,
) -> tuple[list[Todo], int]:
    stmt = select(Todo).where(Todo.owner_id == owner_id)
    if done is not None:
        stmt = stmt.where(Todo.done == done)
    if tag is not None:
        # rows whose JSON column contains the tag (DB-dependent — works on SQLite/Postgres)
        stmt = stmt.where(Todo.tags.contains([tag]))

    total = (await db.execute(select(func.count()).select_from(stmt.subquery()))).scalar_one()
    items = list((await db.execute(
        stmt.order_by(Todo.created_at.desc()).offset(skip).limit(limit)
    )).scalars())
    return items, total

async def update(db: AsyncSession, todo: Todo, data: TodoUpdate) -> Todo:
    for field, value in data.model_dump(exclude_unset=True).items():
        setattr(todo, field, value)
    await db.commit()
    await db.refresh(todo)
    return todo

async def delete(db: AsyncSession, todo: Todo) -> None:
    await db.delete(todo)
    await db.commit()

Ownership check lives in the service layer — if owner_id doesn’t match, None is returned. The route translates None → 404.

app/api/todos.py
from fastapi import APIRouter, HTTPException, Query
from typing import Annotated
from app.api.deps import DBSession, CurrentUser, PaginationDep
from app.schemas.todo import TodoCreate, TodoUpdate, TodoOut
from app.schemas.common import Page
from app.services import todo as todo_service

router = APIRouter(prefix="/todos", tags=["todos"])

@router.post("", response_model=TodoOut, status_code=201)
async def create_todo(payload: TodoCreate, user: CurrentUser, db: DBSession) -> TodoOut:
    return await todo_service.create(db, payload, owner_id=user.id)

@router.get("", response_model=Page[TodoOut])
async def list_todos(
    user: CurrentUser,
    db: DBSession,
    pagination: PaginationDep,
    done: Annotated[bool | None, Query()] = None,
    tag: Annotated[str | None, Query()] = None,
) -> Page[TodoOut]:
    items, total = await todo_service.list_filtered(
        db, owner_id=user.id,
        skip=pagination.skip, limit=pagination.limit,
        done=done, tag=tag,
    )
    return Page(items=items, total=total, skip=pagination.skip, limit=pagination.limit)

@router.get("/{todo_id}", response_model=TodoOut)
async def read_todo(todo_id: int, user: CurrentUser, db: DBSession) -> TodoOut:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    return todo

@router.patch("/{todo_id}", response_model=TodoOut)
async def update_todo(
    todo_id: int,
    payload: TodoUpdate,
    user: CurrentUser,
    db: DBSession,
) -> TodoOut:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    return await todo_service.update(db, todo, payload)

@router.delete("/{todo_id}", status_code=204)
async def delete_todo(todo_id: int, user: CurrentUser, db: DBSession) -> None:
    todo = await todo_service.get(db, todo_id, owner_id=user.id)
    if todo is None:
        raise HTTPException(404, "Todo not found")
    await todo_service.delete(db, todo)

A single user: CurrentUser line untangles auth + authorization in every route. The route code stays focused on business logic.

7. Health check + metrics [Chapters 22, 31] #

app/api/health.py
from fastapi import APIRouter
from app.api.deps import DBSession
from sqlalchemy import text

router = APIRouter(tags=["ops"])

@router.get("/health")
async def health() -> dict[str, str]:
    return {"status": "healthy"}

@router.get("/health/db")
async def health_db(db: DBSession) -> dict[str, str]:
    try:
        await db.execute(text("SELECT 1"))
        return {"status": "ok", "db": "connected"}
    except Exception as e:
        return {"status": "error", "db": str(e)}

Split basic health from DB health. Cloud-provider health probes (Railway / Fly, etc.) use /health only, while /health/db is for operators debugging.

8. Background notifications [Chapter 27] #

app/tasks/notifier.py
import asyncio
from datetime import date, timedelta
from sqlalchemy import select
from app.db.session import AsyncSessionLocal
from app.models.todo import Todo
from app.core.config import settings

async def check_due_todos() -> list[int]:
    """IDs of unfinished TODOs due today or tomorrow."""
    tomorrow = date.today() + timedelta(days=1)
    async with AsyncSessionLocal() as db:
        result = await db.execute(
            select(Todo.id).where(
                Todo.done == False,
                Todo.due_date.between(date.today(), tomorrow),
            )
        )
        return list(result.scalars())

async def notifier_loop():
    """Periodically check due-soon TODOs and simulate notifications."""
    while True:
        ids = await check_due_todos()
        for todo_id in ids:
            print(f"[notifier] due-soon TODO #{todo_id}")    # email / Slack in reality
        await asyncio.sleep(settings.notification_check_interval)
Extend app/main.py — lifespan
from contextlib import asynccontextmanager
import asyncio
from fastapi import FastAPI
from app.tasks.notifier import notifier_loop
from app.api import auth, todos, health

@asynccontextmanager
async def lifespan(app: FastAPI):
    # Start background work on startup
    task = asyncio.create_task(notifier_loop())
    app.state.notifier_task = task
    yield
    # Clean up on shutdown
    task.cancel()
    try:
        await task
    except asyncio.CancelledError:
        pass

app = FastAPI(title="Todo API", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(todos.router)
app.include_router(health.router)

The lifespan + Task pattern from Chapter 27 Async and background jobs. Once this simple notifier grows, that’s when you move to an external queue like ARQ / Celery.

9. Tests [Chapters 28, 30] #

tests/conftest.py
import pytest_asyncio
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.main import app
from app.api.deps import get_session, get_current_user
from app.models.base import Base
from app.models.user import User
from app.core.security import hash_password

TEST_URL = "sqlite+aiosqlite:///:memory:"

@pytest_asyncio.fixture
async def db_engine():
    engine = create_async_engine(TEST_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()

@pytest_asyncio.fixture
async def db_session(db_engine):
    Session = async_sessionmaker(db_engine, expire_on_commit=False)
    async with Session() as session:
        yield session

@pytest_asyncio.fixture
async def test_user(db_session) -> User:
    user = User(email="test@example.com", hashed_password=hash_password("password123"))
    db_session.add(user)
    await db_session.commit()
    await db_session.refresh(user)
    return user

@pytest_asyncio.fixture
async def authed_client(db_session, test_user):
    async def override_session():
        yield db_session

    async def override_user():
        return test_user

    app.dependency_overrides[get_session] = override_session
    app.dependency_overrides[get_current_user] = override_user

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as c:
        yield c

    app.dependency_overrides.clear()
tests/test_todos.py
import pytest

pytestmark = pytest.mark.asyncio

async def test_create_and_get_todo(authed_client):
    create = await authed_client.post("/todos", json={"title": "test", "priority": 4})
    assert create.status_code == 201
    todo_id = create.json()["id"]

    get = await authed_client.get(f"/todos/{todo_id}")
    assert get.json()["title"] == "test"
    assert get.json()["priority"] == 4

async def test_filter_by_done(authed_client):
    await authed_client.post("/todos", json={"title": "open"})
    closed = await authed_client.post("/todos", json={"title": "closed"})
    await authed_client.patch(f"/todos/{closed.json()['id']}", json={"done": True})

    open_only = await authed_client.get("/todos?done=false")
    assert open_only.json()["total"] == 1
    assert open_only.json()["items"][0]["title"] == "open"

async def test_tag_normalization(authed_client):
    resp = await authed_client.post("/todos", json={
        "title": "with tags",
        "tags": ["Python", "  fastapi  ", "python", ""],
    })
    # dedupe + lowercase + drop blanks
    assert sorted(resp.json()["tags"]) == ["fastapi", "python"]

async def test_404_on_other_user_todo(authed_client, db_session):
    # other user + their TODO inserted directly
    from app.models.user import User
    from app.models.todo import Todo
    other = User(email="other@x.com", hashed_password="x")
    db_session.add(other)
    await db_session.commit()
    foreign = Todo(title="not yours", owner_id=other.id)
    db_session.add(foreign)
    await db_session.commit()

    resp = await authed_client.get(f"/todos/{foreign.id}")
    assert resp.status_code == 404    # another user's TODO is invisible

The ownership-separation test is one of the most important security cases. Always include it.

10. Docker + deploy [Chapter 28] #

Dockerfile
FROM python:3.14-slim AS builder
COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev
COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini ./

FROM python:3.14-slim AS runtime
RUN useradd --create-home --uid 1000 appuser
WORKDIR /app
COPY --from=builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
USER appuser
EXPOSE 8000

# Apply migrations, then start the server
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000"]

Small projects can run migrations at container start; for larger projects, prefer option 2 (split as a CI/CD step) from Chapter 28.

docker-compose.yml — local full stack
services:
  api:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgresql+asyncpg://todo:todo@db:5432/todo
      JWT_SECRET: dev-secret-only
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: todo
      POSTGRES_PASSWORD: todo
      POSTGRES_DB: todo
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U todo"]
      interval: 5s
      retries: 5
    volumes:
      - pg-data:/var/lib/postgresql/data

volumes:
  pg-data:
Run
docker compose up -d
curl http://localhost:8000/health    # {"status":"healthy"}

Where every book pattern landed — checklist #

A check on how every tool from the whole book showed up in this capstone.

ChapterWhere
Chapter 1 uv1 — project setup
Chapter 2 Type hintsEvery function signature
Chapter 3 Control flowBranching in the service layer
Chapter 4 Collections / comprehensionstag normalization (set comprehension)
Chapter 5 Function argumentskeyword-only args in service functions (*)
Chapter 6 Exception handlingHTTPException, JWT-decode except
Chapter 7 Modules / pyprojectProject structure
Chapter 8 dataclassThe Pagination dependency
Chapter 9 Generic / ProtocolPage[T] response
Chapter 10 Context managersDB-session dependency, lifespan
Chapter 11 Iterables / generatorsget_session async generator
Chapter 12 Decorators@router.post(...), Depends
Chapter 13 Pattern matching(optional — when adding event handling)
Chapter 14 Async introEvery route async, asyncio.sleep
Chapter 15 Magic methodsOn top of Pydantic’s __init__ / __call__
Chapter 16 DescriptorsSQLAlchemy Mapped[T] / Pydantic Field
Chapter 17 MetaclassesSQLAlchemy DeclarativeBase, Pydantic BaseModel
Chapter 18 Async in depthlifespan’s create_task + cancel
Chapter 19 GIL / concurrencyThe rationale for picking async def
Chapter 20 Advanced typingAnnotated[int, Field(...)], Self
Chapter 21 Performance(during operations — cProfile / py-spy)
Chapter 22 FastAPI setupapp/main.py
Chapter 23 Routing / DIAPIRouter, Depends
Chapter 24 Pydantic v2Every schema, SecretStr, field_validator
Chapter 25 DB SQLAlchemyModels + session + Alembic
Chapter 26 Authenticationargon2 + JWT + OAuth2
Chapter 27 Async / backgroundnotifier_loop
Chapter 28 Tests / deployconftest, Dockerfile, compose

Part 5’s operations chapters stack on top of this capstone:

Exercises #

  1. Port the code above by hand and get todo-api running. Confirm docker compose up -dcurl /health is OK, and that the registerloginPOST /todos flow works end to end. Also try authenticated calls via Swagger UI’s “Authorize” with a JWT.
  2. Confirm that the background notifier logs print when there’s a TODO with due_date today. Lower notification_check_interval to 5 seconds for a quick test.
  3. Add one of the following features to this capstone: (1) TODO search (?q= query for title LIKE), (2) TODO sorting options (?sort=priority / ?sort=-due_date), (3) a stats endpoint (GET /stats — total / done / undone counts). Add test cases alongside the change.

In one line: The capstone shows how the tools from Chapters 1 ~ 28 connect inside a real project. The steps = project setup → DB models → auth → dependencies / routes → CRUD → health check → background → tests → Docker. The key to using this chapter is porting it by hand while being aware of which chapter’s pattern goes into each step. Part 5’s operations chapters build on top of this capstone.

Next chapter #

Starting with Chapter 30 Type checker setup and CI integration, the four new chapters of Part 5 Operations · Packaging · Testing begin. This capstone project becomes the target for Part 5.

X