U03-01.TodoAPI.Solution
U03-01.TodoAPI.Solution
Overview
- Exercise 1 — Environment & Postgres on Docker CE
- Exercise 2 — FastAPI + Async SQLAlchemy + CRUD for TODOs
- Exercise 3 — Pagination, Filtering, Validation, and Error Handling
- Exercise 3 — Database Migrations (Alembic), Concurrency & Transactions
Target duration: Each lab 60–120 minutes. Capstone 2–4 hours. Prereqs: Python 3.10+, Linux with Docker CE installed (no Desktop), basic SQL, REST.
Lab 1 — Setup PostgreSQL on Docker CE (not Desktop)
Goal
Run a Postgres instance in Docker Engine - Community and verify connectivity.
Tasks
-
Install Docker CE (Engine) on Linux (Ubuntu/Debian recommended).
- Verify with:
docker --version docker info | grep -E "Server|Engine|Community" docker compose version # plugin v2 preferred - Acceptance proof:
docker infoshowsServer: Docker Engine - Community. Any evidence of “Docker Desktop” = fail.
- Verify with:
-
Start PostgreSQL via Docker Compose (or docker run)
- Compose file (
compose.yaml):services: db: image: postgres:16-alpine container_name: todo_db environment: POSTGRES_USER: todo_user POSTGRES_PASSWORD: todo_pass POSTGRES_DB: todo_db ports: - '5432:5432' volumes: - pg_data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB'] interval: 5s timeout: 3s retries: 10 volumes: pg_data: driver: local - If compose plugin isn’t available, use:
docker network create todo_net docker run -d --name todo_db \ --net todo_net \ -e POSTGRES_USER=todo_user \ -e POSTGRES_PASSWORD=todo_pass \ -e POSTGRES_DB=todo_db \ -p 5432:5432 \ -v pg_data:/var/lib/postgresql/data \ postgres:16-alpine
- Compose file (
-
Verify database connectivity
- From host or a psql client container:
docker exec -it todo_db psql -U todo_user -d todo_db -c "\dt"
- From host or a psql client container:
Acceptance Criteria
- Evidence
docker infoshows Community (CE). - Postgres container healthy (
docker ps, healthcheck passing). - Can run
SELECT 1against the DB.
Deliverables
- Screenshot/log of
docker infoindicating CE, container status, and a successful query.
Rubric (10 pts)
- Docker CE installed & verified (3)
- Postgres container running & healthy (4)
- Connectivity verified (3)
Lab 2 — FastAPI + Async SQLAlchemy + Basic TODO CRUD
Goal
Build an API-only TODO service using FastAPI + SQLAlchemy 2.x async (asyncpg) connected to the Dockerized Postgres.
Suggested Project Structure
app/ core/config.py db/session.py db/models.py db/repository.py api/routes/todos.py main.py alembic/ (created in Lab 4) compose.yaml (later adds app service) Dockerfile
Tasks
-
Dependencies
pip install fastapi uvicorn sqlalchemy asyncpg pydantic pydantic-settings -
Async SQLAlchemy setup
# app/db/session.py from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession from sqlalchemy.orm import DeclarativeBase class Base(DeclarativeBase): pass DATABASE_URL = "postgresql+asyncpg://todo_user:todo_pass@localhost:5432/todo_db" engine = create_async_engine(DATABASE_URL, echo=False, pool_size=5, max_overflow=10) async_session = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) -
Model
# app/db/models.py from sqlalchemy import String, Boolean, DateTime, func, Integer from sqlalchemy.orm import Mapped, mapped_column from .session import Base class Todo(Base): __tablename__ = "todos" id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) title: Mapped[str] = mapped_column(String(200)) description: Mapped[str | None] = mapped_column(String(1000), nullable=True) completed: Mapped[bool] = mapped_column(Boolean, default=False) created_at: Mapped[DateTime] = mapped_column(DateTime, server_default=func.now()) updated_at: Mapped[DateTime] = mapped_column(DateTime, onupdate=func.now(), nullable=True) -
Schema (Pydantic) & Routes
# app/api/routes/todos.py from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from app.db.session import async_session from app.db.models import Todo router = APIRouter(prefix="/todos", tags=["todos"]) class TodoCreate(BaseModel): title: str = Field(min_length=1, max_length=200) description: str | None = Field(default=None, max_length=1000) class TodoRead(BaseModel): id: int title: str description: str | None completed: bool class Config: from_attributes = True # SQLAlchemy 2.x async def get_session() -> AsyncSession: async with async_session() as session: yield session @router.post("", response_model=TodoRead, status_code=status.HTTP_201_CREATED) async def create_todo(payload: TodoCreate, session: AsyncSession = Depends(get_session)): todo = Todo(title=payload.title, description=payload.description) session.add(todo) await session.commit() await session.refresh(todo) return todo @router.get("/{todo_id}", response_model=TodoRead) async def get_one(todo_id: int, session: AsyncSession = Depends(get_session)): todo = await session.get(Todo, todo_id) if not todo: raise HTTPException(status_code=404, detail="Todo not found") return todo @router.patch("/{todo_id}", response_model=TodoRead) async def toggle_complete(todo_id: int, session: AsyncSession = Depends(get_session)): todo = await session.get(Todo, todo_id) if not todo: raise HTTPException(status_code=404, detail="Todo not found") todo.completed = not todo.completed await session.commit() await session.refresh(todo) return todo @router.delete("/{todo_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_one(todo_id: int, session: AsyncSession = Depends(get_session)): todo = await session.get(Todo, todo_id) if not todo: return # 204 is fine for idempotent deletes await session.delete(todo) await session.commit() return -
FastAPI App
# app/main.py from fastapi import FastAPI from app.api.routes.todos import router as todo_router from app.db.session import engine, Base app = FastAPI(title="TODO API") @app.on_event("startup") async def on_startup(): # For lab 2 only: create tables programmatically (later use Alembic) async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) app.include_router(todo_router) -
Run
uvicorn app.main:app --reload # Test in /docs (Swagger) and with curl curl -X POST http://localhost:8000/todos -H "Content-Type: application/json" -d '{"title":"Read paper"}'
Acceptance Criteria
- API responds with 201 on create, returns JSON aligned with schema.
- GET, PATCH toggle, DELETE behave as defined.
/docsloads correctly with generated OpenAPI.
Deliverables
- Source code.
- A short README with run instructions and sample curl calls.
Rubric (20 pts)
- Async DB setup (5)
- Model + schema correctness (5)
- CRUD endpoints working (8)
- README & API docs (2)
Lab 4 — Migrations (Alembic), Concurrency & Transactions
Goal
Use Alembic for schema management and add transaction-safe operations and indexes.
Note: Alembic typically runs with a sync driver (e.g.,
psycopg/psycopg2) even if the app usesasyncpg. Keep migrations separate from runtime DB access.
Tasks
-
Add fields:
due_date(nullable),priority(enum or int). -
Initialize Alembic
pip install alembic psycopg[binary] # psycopg3 alembic init alembic- Set
sqlalchemy.urlinalembic.inito sync URL: postgresql+psycopg://todo_user@localhost/todo_db
- Set
-
Create revision & upgrade
- Autogenerate revision or handwrite
upgrade()/downgrade(). - Run:
alembic upgrade head
- Autogenerate revision or handwrite
-
Indexes
- Index on
completed,created_at.
- Index on
-
Transaction-safe toggle
- Use
SELECT ... FOR UPDATEor session transactions to avoid race conditions when togglingcompletedin high concurrency.
- Use
Acceptance Criteria
- Alembic revision adds new columns/indexes.
- Toggle endpoint guarantees consistency under concurrent requests (explain your approach).
Deliverables
- Alembic migration files.
- Brief concurrency note in README.
Rubric (25 pts)
- Alembic set up & run (10)
- Correct schema changes & indexes (8)
- Concurrency-safe operation (7)