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.
| Feature | Source chapter |
|---|---|
| Sign-up / login (JWT) | 26 |
| TODO CRUD | 23, 24, 25 |
| Tag filtering + pagination | 23 |
| Priority (1 ~ 5) | 24 |
| Due-soon notifications (background) | 27 |
| Health check + metrics | 22, 31 |
| Integration tests | 28 |
| Docker + cloud deploy | 28 |
Endpoint table:
| Method | Path | Intent |
|---|---|---|
| POST | /auth/register | Sign up |
| POST | /auth/login | Log in, issue JWT |
| GET | /me | Current user info |
| POST | /todos | Create TODO |
| GET | /todos | List TODOs (filter + pagination) |
| GET | /todos/{id} | Single TODO |
| PATCH | /todos/{id} | Partial update |
| DELETE | /todos/{id} | Delete TODO |
| GET | /health | Health 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/
├── 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.pyThe 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
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 alembicfrom 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()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
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
passfrom 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")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")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
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])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 user4. Dependency injection for auth + session [Chapter 23] #
Source chapter: Chapter 23 Routing, Pydantic models, dependency injection
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] #
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.
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 current6. TODO CRUD [Chapters 23, 24, 25] #
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: datetimefrom pydantic import BaseModel
class Page[T](BaseModel):
items: list[T]
total: int
skip: int
limit: intThis Page[T] is the Generic from Chapter 9 Typing in earnest + the Generic-model pattern from Chapter 24 Pydantic v2 in depth.
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.
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] #
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] #
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)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] #
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()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 invisibleThe ownership-separation test is one of the most important security cases. Always include it.
10. Docker + deploy [Chapter 28] #
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.
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: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.
| Chapter | Where |
|---|---|
| Chapter 1 uv | 1 — project setup |
| Chapter 2 Type hints | Every function signature |
| Chapter 3 Control flow | Branching in the service layer |
| Chapter 4 Collections / comprehensions | tag normalization (set comprehension) |
| Chapter 5 Function arguments | keyword-only args in service functions (*) |
| Chapter 6 Exception handling | HTTPException, JWT-decode except |
| Chapter 7 Modules / pyproject | Project structure |
| Chapter 8 dataclass | The Pagination dependency |
| Chapter 9 Generic / Protocol | Page[T] response |
| Chapter 10 Context managers | DB-session dependency, lifespan |
| Chapter 11 Iterables / generators | get_session async generator |
| Chapter 12 Decorators | @router.post(...), Depends |
| Chapter 13 Pattern matching | (optional — when adding event handling) |
| Chapter 14 Async intro | Every route async, asyncio.sleep |
| Chapter 15 Magic methods | On top of Pydantic’s __init__ / __call__ |
| Chapter 16 Descriptors | SQLAlchemy Mapped[T] / Pydantic Field |
| Chapter 17 Metaclasses | SQLAlchemy DeclarativeBase, Pydantic BaseModel |
| Chapter 18 Async in depth | lifespan’s create_task + cancel |
| Chapter 19 GIL / concurrency | The rationale for picking async def |
| Chapter 20 Advanced typing | Annotated[int, Field(...)], Self |
| Chapter 21 Performance | (during operations — cProfile / py-spy) |
| Chapter 22 FastAPI setup | app/main.py |
| Chapter 23 Routing / DI | APIRouter, Depends |
| Chapter 24 Pydantic v2 | Every schema, SecretStr, field_validator |
| Chapter 25 DB SQLAlchemy | Models + session + Alembic |
| Chapter 26 Authentication | argon2 + JWT + OAuth2 |
| Chapter 27 Async / background | notifier_loop |
| Chapter 28 Tests / deploy | conftest, Dockerfile, compose |
Part 5’s operations chapters stack on top of this capstone:
- Chapter 30 Type checker CI — proper mypy/pyright/ruff/pre-commit setup on this project
- Chapter 31 Logging — add
app/core/logging.py, structlog + Sentry - Chapter 32 Publishing a library — peel off a part of this project and publish to PyPI
- Chapter 33 CLI with Typer — turn this project’s management commands (create users / migrate, etc.) into a CLI
Exercises #
- Port the code above by hand and get
todo-apirunning. Confirmdocker compose up -d→curl /healthis OK, and that theregister→login→POST /todosflow works end to end. Also try authenticated calls via Swagger UI’s “Authorize” with a JWT. - Confirm that the background notifier logs print when there’s a TODO with
due_datetoday. Lowernotification_check_intervalto 5 seconds for a quick test. - 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.