Module 2: APIRouter and Middleware

Project: Turning the To-Do API into a Modular App

Project overview

In this project you take your To-Do API — with Module 1's DI all in a single main.py — and turn it into a modular application with a professional structure. The endpoints move into separate routers. The dependencies go into dedicated files. The Pydantic models get their own directory. Logging and timing middleware intercept every request. Lifespan events initialize and clean up resources.

The result is an app where main.py is 25-30 lines, every file has one clear responsibility, and any developer can navigate the project without an explanation.

You'll bring together everything from capsules 02-04: APIRouter with prefixes and tags, a folder structure with __init__.py, custom middleware, and lifespan events with asynccontextmanager.


Project goals

By the end of this project:

  • main.py is under 30 lines — it only creates the app, registers middleware, and includes routers
  • ✅ The task endpoints live in app/routers/tasks.py
  • ✅ The general endpoints live in app/routers/root.py
  • ✅ The Pydantic models live in app/models/tasks.py
  • ✅ The dependencies live in app/dependencies/ (pagination, tasks, data)
  • ✅ A timing middleware adds X-Process-Time to every response
  • ✅ A logging middleware records method, path, status, and duration
  • ✅ Lifespan events print messages when the app starts and stops
  • ✅ The API behaves exactly like it did before the modularization
  • ✅ You can add a new domain (e.g. categories) without touching existing code

The final structure

fastapi-advanced/
├── app/
│   ├── __init__.py
│   ├── main.py                         ← 25-30 lines
│   ├── routers/
│   │   ├── __init__.py                 ← Exports the routers
│   │   ├── root.py                     ← GET /, GET /health
│   │   └── tasks.py                    ← Task CRUD
│   ├── models/
│   │   ├── __init__.py                 ← Exports the models
│   │   └── tasks.py                    ← TaskCreate, TaskUpdate, etc.
│   ├── dependencies/
│   │   ├── __init__.py                 ← Exports the dependencies
│   │   ├── data.py                     ← get_task_store, initial data
│   │   ├── pagination.py               ← pagination_params
│   │   └── tasks.py                    ← get_task_or_404, task_filters
│   └── middleware/
│       ├── __init__.py
│       └── logging.py                  ← Timing + request logging
├── tests/
│   ├── __init__.py
│   └── test_tasks.py                   ← Tests with dependency_overrides
├── requirements.txt
└── venv/

Step-by-step guide

Step 1: Create the folder structure

cd fastapi-advanced
mkdir -p app/routers app/models app/dependencies app/middleware tests
touch app/__init__.py
touch app/routers/__init__.py
touch app/models/__init__.py
touch app/dependencies/__init__.py
touch app/middleware/__init__.py
touch tests/__init__.py

Check it:

find app -name "*.py" | sort
# app/__init__.py
# app/dependencies/__init__.py
# app/main.py          (already exists)
# app/middleware/__init__.py
# app/models/__init__.py
# app/routers/__init__.py

Step 2: Create the models — app/models/tasks.py

from typing import Optional
from pydantic import BaseModel, Field


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200, description="The task's title")
    description: Optional[str] = Field(default=None, max_length=1000)
    priority: str = Field(default="medium", pattern="^(high|medium|low)$")

    model_config = {
        "json_schema_extra": {
            "examples": [
                {
                    "title": "Study advanced FastAPI",
                    "description": "Finish the APIRouter and Middleware module",
                    "priority": "high",
                }
            ]
        }
    }


class TaskUpdate(BaseModel):
    title: Optional[str] = Field(default=None, min_length=1, max_length=200)
    description: Optional[str] = Field(default=None, max_length=1000)
    completed: Optional[bool] = None
    priority: Optional[str] = Field(default=None, pattern="^(high|medium|low)$")


class PaginationResult(BaseModel):
    skip: int
    limit: int


class TaskFilters(BaseModel):
    completed: Optional[bool]
    priority: Optional[str]
    search: Optional[str]

Step 3: Export the models — app/models/__init__.py

from app.models.tasks import TaskCreate, TaskUpdate, PaginationResult, TaskFilters

Step 4: Create the data store — app/dependencies/data.py

initial_tasks = [
    {
        "id": 1,
        "title": "Buy groceries",
        "description": "Milk, bread, eggs, fruit",
        "completed": False,
        "priority": "medium",
        "created_at": "2026-03-10T09:00:00",
    },
    {
        "id": 2,
        "title": "Study Dependency Injection",
        "description": "Finish capsules 02 through 04 of module 1",
        "completed": False,
        "priority": "high",
        "created_at": "2026-03-10T10:30:00",
    },
    {
        "id": 3,
        "title": "Work out",
        "description": None,
        "completed": True,
        "priority": "low",
        "created_at": "2026-03-10T07:00:00",
    },
    {
        "id": 4,
        "title": "Review pull requests",
        "description": "PR #42 and PR #43 waiting for review",
        "completed": False,
        "priority": "high",
        "created_at": "2026-03-11T14:00:00",
    },
    {
        "id": 5,
        "title": "Prepare presentation",
        "description": "Slides for Friday's demo",
        "completed": True,
        "priority": "medium",
        "created_at": "2026-03-09T16:00:00",
    },
]

task_store = initial_tasks[:]


def get_task_store():
    yield task_store

Step 5: Create the pagination dependency — app/dependencies/pagination.py

from fastapi import Query
from app.models import PaginationResult


def pagination_params(
    skip: int = Query(default=0, ge=0, description="Records to skip"),
    limit: int = Query(default=10, ge=1, le=100, description="Maximum records"),
) -> PaginationResult:
    return PaginationResult(skip=skip, limit=limit)

Step 6: Create the task dependencies — app/dependencies/tasks.py

from typing import Optional
from fastapi import Depends, HTTPException, Query
from app.models import TaskFilters
from app.dependencies.data import get_task_store


def task_filters(
    completed: Optional[bool] = Query(default=None, description="Filter by status"),
    priority: Optional[str] = Query(
        default=None, pattern="^(high|medium|low)$", description="Filter by priority"
    ),
    search: Optional[str] = Query(
        default=None, min_length=1, description="Search in title and description"
    ),
) -> TaskFilters:
    return TaskFilters(completed=completed, priority=priority, search=search)


def get_task_or_404(
    task_id: int,
    store: list = Depends(get_task_store),
) -> dict:
    for task in store:
        if task["id"] == task_id:
            return task
    raise HTTPException(status_code=404, detail=f"Task {task_id} not found")

Step 7: Export the dependencies — app/dependencies/__init__.py

from app.dependencies.data import get_task_store
from app.dependencies.pagination import pagination_params
from app.dependencies.tasks import task_filters, get_task_or_404

Step 8: Create the general router — app/routers/root.py

from fastapi import APIRouter, Request

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


@router.get("/")
def root(request: Request):
    config = getattr(request.app.state, "config", {})
    return {
        "service": "To-Do API",
        "version": config.get("version", "unknown"),
        "docs": "/docs",
    }


@router.get("/health")
def health(request: Request):
    config = getattr(request.app.state, "config", {})
    return {
        "status": "healthy",
        "started_at": config.get("started_at", "unknown"),
        "request_count": config.get("request_count", 0),
    }

Step 9: Create the tasks router — app/routers/tasks.py

from datetime import datetime
from fastapi import APIRouter, Depends
from app.models import TaskCreate, TaskUpdate, PaginationResult, TaskFilters
from app.dependencies import pagination_params, task_filters, get_task_or_404, get_task_store

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


def apply_filters(tasks_list: list, filters: TaskFilters) -> list:
    result = tasks_list[:]
    if filters.completed is not None:
        result = [t for t in result if t["completed"] == filters.completed]
    if filters.priority:
        result = [t for t in result if t["priority"] == filters.priority]
    if filters.search:
        term = filters.search.lower()
        result = [
            t
            for t in result
            if term in t["title"].lower()
            or (t["description"] and term in t["description"].lower())
        ]
    return result


@router.get("/")
def list_tasks(
    store: list = Depends(get_task_store),
    filters: TaskFilters = Depends(task_filters),
    pagination: PaginationResult = Depends(pagination_params),
):
    filtered = apply_filters(store, filters)
    start = pagination.skip
    end = start + pagination.limit
    return {
        "total": len(filtered),
        "skip": pagination.skip,
        "limit": pagination.limit,
        "tasks": filtered[start:end],
    }


@router.get("/stats")
def task_stats(
    store: list = Depends(get_task_store),
    filters: TaskFilters = Depends(task_filters),
):
    filtered = apply_filters(store, filters)
    completed_count = sum(1 for t in filtered if t["completed"])
    by_priority = {}
    for task in filtered:
        p = task["priority"]
        by_priority[p] = by_priority.get(p, 0) + 1
    return {
        "total": len(filtered),
        "completed": completed_count,
        "pending": len(filtered) - completed_count,
        "by_priority": by_priority,
    }


@router.get("/{task_id}")
def get_task(task: dict = Depends(get_task_or_404)):
    return task


@router.post("/", status_code=201)
def create_task(
    task_data: TaskCreate,
    store: list = Depends(get_task_store),
):
    new_id = max((t["id"] for t in store), default=0) + 1
    new_task = task_data.model_dump()
    new_task["id"] = new_id
    new_task["completed"] = False
    new_task["created_at"] = datetime.now().isoformat()
    store.append(new_task)
    return new_task


@router.patch("/{task_id}")
def update_task(
    task: dict = Depends(get_task_or_404),
    task_data: TaskUpdate = ...,
):
    update = task_data.model_dump(exclude_unset=True)
    task.update(update)
    return task


@router.delete("/{task_id}")
def delete_task(
    task: dict = Depends(get_task_or_404),
    store: list = Depends(get_task_store),
):
    store.remove(task)
    return {"message": f"Task '{task['title']}' deleted", "task": task}

Step 10: Export the routers — app/routers/__init__.py

from app.routers.root import router as root_router
from app.routers.tasks import router as tasks_router

Step 11: Create the middleware — app/middleware/logging.py

import time
import logging
from fastapi import Request

logger = logging.getLogger("api")


async def request_logging_middleware(request: Request, call_next):
    start = time.time()

    response = await call_next(request)

    duration = time.time() - start
    response.headers["X-Process-Time"] = f"{duration:.4f}"

    logger.info(
        "%s %s → %d (%.4fs)",
        request.method,
        request.url.path,
        response.status_code,
        duration,
    )
    return response

Step 12: Create main.py — the entry point

import logging
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
from app.routers import root_router, tasks_router
from app.middleware.logging import request_logging_middleware

logging.basicConfig(level=logging.INFO)


@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.config = {
        "version": "2.0.0",
        "started_at": datetime.now().isoformat(),
        "request_count": 0,
    }
    logging.getLogger("api").info("App started — version %s", app.state.config["version"])
    yield
    logging.getLogger("api").info(
        "App stopped — served %d requests", app.state.config["request_count"]
    )


app = FastAPI(
    title="To-Do API",
    description="A modular task API. Module 2 — FastAPI Advanced Features.",
    version="2.0.0",
    lifespan=lifespan,
)

app.middleware("http")(request_logging_middleware)

app.include_router(root_router)
app.include_router(tasks_router)

27 lines. That's your entire main.py.

Step 13: Update the middleware to count requests

Modify app/middleware/logging.py to increment the counter:

import time
import logging
from fastapi import Request

logger = logging.getLogger("api")


async def request_logging_middleware(request: Request, call_next):
    start = time.time()

    config = getattr(request.app.state, "config", None)
    if config:
        config["request_count"] = config.get("request_count", 0) + 1

    response = await call_next(request)

    duration = time.time() - start
    response.headers["X-Process-Time"] = f"{duration:.4f}"

    logger.info(
        "%s %s → %d (%.4fs)",
        request.method,
        request.url.path,
        response.status_code,
        duration,
    )
    return response

Step-by-step verification

1. Check that the server boots

cd fastapi-advanced
source venv/bin/activate
uvicorn app.main:app --reload

You should see:

INFO:api:App started — version 2.0.0
INFO:     Application startup complete.

2. Check /docs

Open http://localhost:8000/docs. You should see:

  • Title: "To-Do API"
  • General section: GET /, GET /health
  • Tasks section: GET /tasks, GET /tasks/stats, GET /tasks/{task_id}, POST /tasks, PATCH /tasks/{task_id}, DELETE /tasks/{task_id}

3. Check that the endpoints work

# Root
curl http://127.0.0.1:8000/
# {"service":"To-Do API","version":"2.0.0","docs":"/docs"}

# Health
curl http://127.0.0.1:8000/health
# {"status":"healthy","started_at":"2026-03-13T...","request_count":2}

# List tasks
curl http://127.0.0.1:8000/tasks

# Filter
curl "http://127.0.0.1:8000/tasks?priority=high"
curl "http://127.0.0.1:8000/tasks?completed=true"
curl "http://127.0.0.1:8000/tasks?search=study"

# Pagination
curl "http://127.0.0.1:8000/tasks?skip=2&limit=2"

# Statistics
curl http://127.0.0.1:8000/tasks/stats

# By ID
curl http://127.0.0.1:8000/tasks/1
curl http://127.0.0.1:8000/tasks/999

# Create
curl -X POST http://127.0.0.1:8000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "New modular task", "priority": "high"}'

# Update
curl -X PATCH http://127.0.0.1:8000/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"completed": true}'

# Delete
curl -X DELETE http://127.0.0.1:8000/tasks/3

4. Check the middleware

# The timing header
curl -v http://127.0.0.1:8000/tasks 2>&1 | grep X-Process
# X-Process-Time: 0.0012

# Logging in the console
# INFO:api:GET /tasks → 200 (0.0012s)

5. Check the lifespan

# On boot
# INFO:api:App started — version 2.0.0

# Ctrl+C to stop
# INFO:api:App stopped — served 12 requests

6. Check that dependency_overrides works

Create tests/test_tasks.py:

from fastapi.testclient import TestClient
from app.main import app
from app.dependencies.data import get_task_store

test_tasks = [
    {
        "id": 100,
        "title": "Test Task",
        "description": "Only for testing",
        "completed": False,
        "priority": "high",
        "created_at": "2026-01-01T00:00:00",
    },
]


def fake_store():
    yield test_tasks


def test_list_tasks_with_override():
    app.dependency_overrides[get_task_store] = fake_store
    client = TestClient(app)

    response = client.get("/tasks")
    assert response.status_code == 200
    data = response.json()
    assert data["total"] == 1
    assert data["tasks"][0]["id"] == 100

    app.dependency_overrides.clear()


def test_task_not_found():
    app.dependency_overrides[get_task_store] = fake_store
    client = TestClient(app)

    response = client.get("/tasks/1")
    assert response.status_code == 404

    app.dependency_overrides.clear()


def test_health():
    client = TestClient(app)
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"


if __name__ == "__main__":
    test_list_tasks_with_override()
    print("✅ test_list_tasks_with_override passed")

    test_task_not_found()
    print("✅ test_task_not_found passed")

    test_health()
    print("✅ test_health passed")

    print("\n✅ All tests passed!")
python tests/test_tasks.py
# ✅ test_list_tasks_with_override passed
# ✅ test_task_not_found passed
# ✅ test_health passed
# ✅ All tests passed!

Comparison: before vs. after

main.py

Before (Module 1) — 250+ lines:

# Models, data, dependencies, helpers, app, endpoints — EVERYTHING here
from typing import Optional
from datetime import datetime
from fastapi import FastAPI, Depends, HTTPException, Query
from pydantic import BaseModel, Field

class TaskCreate(BaseModel): ...  # 10 lines
class TaskUpdate(BaseModel): ...  # 8 lines
# ... 230+ more lines

After (this project) — 27 lines:

import logging
from contextlib import asynccontextmanager
from datetime import datetime
from fastapi import FastAPI
from app.routers import root_router, tasks_router
from app.middleware.logging import request_logging_middleware

# ... lifespan + app + include_router

Navigating the project

I need to...File
Add a task endpointapp/routers/tasks.py
Change the model's validationapp/models/tasks.py
Modify paginationapp/dependencies/pagination.py
Change the initial dataapp/dependencies/data.py
Add middlewareapp/middleware/ + app/main.py
Add a new domainCreate app/routers/new_domain.py + app/models/new_domain.py

Troubleshooting

Problem 1: "ModuleNotFoundError: No module named 'app'"

Cause: You're running uvicorn from the wrong directory.

# ❌ From inside app/
cd app && uvicorn main:app

# ✅ From the project root
cd fastapi-advanced
uvicorn app.main:app --reload

Problem 2: Circular imports after moving files

Cause: app/dependencies/tasks.py imports from app/routers/tasks.py, or the other way around.

The rule: The import flow is one-directional:

app/models/ ← app/dependencies/ ← app/routers/ ← app/main.py

If you need shared data, it goes in app/dependencies/data.py, never in routers.

Problem 3: "init.py doesn't export the symbol"

Cause: You added a model or a dependency but never exported it in the corresponding __init__.py.

# You forgot to add this to app/models/__init__.py:
from app.models.tasks import TaskCreate, TaskUpdate, PaginationResult, TaskFilters

Problem 4: The middleware doesn't count requests correctly

Cause: app.state.config doesn't exist yet when the middleware runs before the lifespan.

Fix: Check that config exists before reaching into it:

config = getattr(request.app.state, "config", None)
if config:
    config["request_count"] = config.get("request_count", 0) + 1

Problem 5: /tasks/stats returns a 404

Cause: The /{task_id} endpoint is defined before /stats in the router, and FastAPI reads "stats" as a task_id.

Fix: Order the endpoints: fixed paths (/stats) before paths with parameters (/{task_id}).

Problem 6: The tests fail with import errors

Cause: The tests directory has no __init__.py, or you're running the tests from the wrong directory.

# Check the __init__.py
ls tests/__init__.py

# Run from the root
cd fastapi-advanced
python tests/test_tasks.py

Problem 7: File changes don't show up with --reload

Cause: uvicorn's --reload sometimes doesn't pick up new files in subdirectories.

# Restart it by hand
# Ctrl+C
uvicorn app.main:app --reload

Problem 8: The CORS from the previous project stopped working

Cause: While modularizing, you didn't include CORSMiddleware in the new main.py.

from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Add it to main.py after creating the app.


Completion checklist

Before you call the project done, check:

Structure:

  • app/main.py is under 30 lines
  • app/routers/ holds root.py and tasks.py
  • app/models/ holds tasks.py
  • app/dependencies/ holds data.py, pagination.py, tasks.py
  • app/middleware/ holds logging.py
  • Every directory has an __init__.py
  • The __init__.py files in models/, dependencies/, and routers/ export the symbols you need

Functionality:

  • GET / returns the service info with its version
  • GET /health returns status and request_count
  • GET /tasks returns a list with total, skip, limit, tasks
  • GET /tasks?completed=true filters correctly
  • GET /tasks?priority=high filters correctly
  • GET /tasks?search=text searches in title and description
  • GET /tasks?skip=2&limit=2 paginates correctly
  • GET /tasks/stats returns the right statistics
  • GET /tasks/1 returns the task
  • GET /tasks/999 returns a 404
  • POST /tasks creates with Pydantic validation
  • PATCH /tasks/1 updates only the fields you send
  • DELETE /tasks/1 deletes and returns a confirmation

Middleware and Events:

  • X-Process-Time shows up on every response
  • The console logging shows method, path, status, duration
  • On boot, it prints "App started"
  • On stop (Ctrl+C), it prints "App stopped"
  • /health shows request_count going up

Testing:

  • dependency_overrides works with test data
  • The tests pass when you run python tests/test_tasks.py

Connecting to Module 3 (Advanced Response Models)

Your app is modular now, it has middleware, it has lifecycle management. In Module 3 (Advanced Response Models) you'll learn to control exactly what your endpoints return:

Module 2 (now):                            Module 3 (next):
──────────────────                          ─────────────────────
return dict                             →   response_model + multiple schemas
One model for everything                →   TaskSummary, TaskDetail, TaskList
No streaming                            →   StreamingResponse for large data
No files                                →   FileResponse for downloads

The modular structure you built here makes adding response models easy: the models go in app/models/tasks.py, and the endpoints use them in app/routers/tasks.py. The separation of concerns you built in this module means every new feature is a localized change, not a global one.


Additional resources

  1. FastAPI - Bigger Applications — The official modular structure tutorial
  2. FastAPI - Middleware — Custom middleware
  3. FastAPI - Lifespan Events — Startup/shutdown with asynccontextmanager
  4. FastAPI - Testing — TestClient and testing basics
  5. FastAPI - Testing Dependencies — dependency_overrides
  6. Python - logging — The standard logging module
  7. Python - Packagesinit.py and organization

What's next?

Module 2 complete. Your To-Do API went from a single-file monolith to a modular app with routers, separate dependencies, logging/timing middleware, and lifespan events. Every file has one clear responsibility. Any developer can navigate your project in seconds.

In Module 3 (Advanced Response Models) you're going to take control of the responses: separate schemas for listing vs. fetching detail, StreamingResponse for large data, FileResponse for downloads, and advanced response_model to filter out sensitive data. The transition: "Your app is modular → now control exactly what each endpoint returns."