Skip to main content

U03-01.TodoAPI.Solution

Overview

  1. Exercise 1 — Environment & Postgres on Docker CE
  2. Exercise 2 — FastAPI + Async SQLAlchemy + CRUD for TODOs
  3. Exercise 3 — Pagination, Filtering, Validation, and Error Handling
  4. 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

  1. 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 info shows Server: Docker Engine - Community. Any evidence of “Docker Desktop” = fail.
  2. 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
  3. Verify database connectivity

    • From host or a psql client container:
      docker exec -it todo_db psql -U todo_user -d todo_db -c "\dt"

Acceptance Criteria

  • Evidence docker info shows Community (CE).
  • Postgres container healthy (docker ps, healthcheck passing).
  • Can run SELECT 1 against the DB.

Deliverables

  • Screenshot/log of docker info indicating 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

  1. Dependencies

    pip install fastapi uvicorn sqlalchemy asyncpg pydantic pydantic-settings
  2. 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)
  3. 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)
  4. 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
  5. 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)
  6. 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.
  • /docs loads 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 uses asyncpg. Keep migrations separate from runtime DB access.

Tasks

  1. Add fields: due_date (nullable), priority (enum or int).

  2. Initialize Alembic

    pip install alembic psycopg[binary]  # psycopg3
    alembic init alembic
    • Set sqlalchemy.url in alembic.ini to sync URL: postgresql+psycopg://todo_user:todo_pass@localhost:5432/todo_db
  3. Create revision & upgrade

    • Autogenerate revision or handwrite upgrade()/downgrade().
    • Run:
      alembic upgrade head
  4. Indexes

    • Index on completed, created_at.
  5. Transaction-safe toggle

    • Use SELECT ... FOR UPDATE or session transactions to avoid race conditions when toggling completed in high concurrency.

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)