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.pyis 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-Timeto 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 endpoint | app/routers/tasks.py |
| Change the model's validation | app/models/tasks.py |
| Modify pagination | app/dependencies/pagination.py |
| Change the initial data | app/dependencies/data.py |
| Add middleware | app/middleware/ + app/main.py |
| Add a new domain | Create 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.pyis under 30 lines -
app/routers/holdsroot.pyandtasks.py -
app/models/holdstasks.py -
app/dependencies/holdsdata.py,pagination.py,tasks.py -
app/middleware/holdslogging.py - Every directory has an
__init__.py - The
__init__.pyfiles inmodels/,dependencies/, androuters/export the symbols you need
Functionality:
-
GET /returns the service info with its version -
GET /healthreturns status and request_count -
GET /tasksreturns a list withtotal,skip,limit,tasks -
GET /tasks?completed=truefilters correctly -
GET /tasks?priority=highfilters correctly -
GET /tasks?search=textsearches in title and description -
GET /tasks?skip=2&limit=2paginates correctly -
GET /tasks/statsreturns the right statistics -
GET /tasks/1returns the task -
GET /tasks/999returns a 404 -
POST /taskscreates with Pydantic validation -
PATCH /tasks/1updates only the fields you send -
DELETE /tasks/1deletes and returns a confirmation
Middleware and Events:
-
X-Process-Timeshows 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"
-
/healthshowsrequest_countgoing up
Testing:
-
dependency_overridesworks 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
- FastAPI - Bigger Applications — The official modular structure tutorial
- FastAPI - Middleware — Custom middleware
- FastAPI - Lifespan Events — Startup/shutdown with asynccontextmanager
- FastAPI - Testing — TestClient and testing basics
- FastAPI - Testing Dependencies — dependency_overrides
- Python - logging — The standard logging module
- Python - Packages — init.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."