Module 2: APIRouter and Middleware

APIRouter and Modularization — Splitting Endpoints by Domain

Capsule overview

APIRouter is FastAPI's tool for splitting your app into modules. It's like a "mini FastAPI": it supports all the decorators (@router.get, @router.post, etc.), it has its own dependencies, and it connects to the main app with app.include_router(). Each router groups the endpoints of one domain: tasks, users, products, admin.

In this capsule you'll create routers, give them prefixes and tags, move endpoints out of main.py, and see how to share dependencies at the router level. By the end, your main.py will go from 200+ lines to under 50 — just creating the app and including routers.


Your first APIRouter

Creating a router

from fastapi import APIRouter

router = APIRouter()


@router.get("/tasks")
def list_tasks():
    return [{"id": 1, "title": "Task 1"}, {"id": 2, "title": "Task 2"}]


@router.get("/tasks/{task_id}")
def get_task(task_id: int):
    return {"id": task_id, "title": f"Task {task_id}"}

A router defines endpoints exactly like app does, but it isn't an app — it's a group of endpoints that needs to be connected to an app.

Connecting it to the app with include_router()

from fastapi import FastAPI
from app.routers import tasks  # assuming the router lives in app/routers/tasks.py

app = FastAPI()
app.include_router(tasks.router)

Now GET /tasks and GET /tasks/{task_id} work as if they'd been defined in main.py.

A complete example in a single file

To get the concept before splitting files:

from fastapi import FastAPI, APIRouter

app = FastAPI(title="Router Demo")

tasks_router = APIRouter()
users_router = APIRouter()


@tasks_router.get("/tasks")
def list_tasks():
    return [{"id": 1, "title": "Task 1"}]


@tasks_router.post("/tasks", status_code=201)
def create_task():
    return {"id": 2, "title": "New Task"}


@users_router.get("/users")
def list_users():
    return [{"id": 1, "name": "Alice"}]


@users_router.get("/users/{user_id}")
def get_user(user_id: int):
    return {"id": user_id, "name": f"User {user_id}"}


app.include_router(tasks_router)
app.include_router(users_router)
uvicorn app.main:app --reload

Open /docs — you'll see all the endpoints from both routers together.


Prefix: adding a prefix to the router

With prefix, you define a common prefix for every endpoint in the router. That saves you from repeating the base path:

from fastapi import APIRouter

router = APIRouter(prefix="/tasks")


@router.get("/")
def list_tasks():
    return [{"id": 1, "title": "Task 1"}]


@router.get("/{task_id}")
def get_task(task_id: int):
    return {"id": task_id}


@router.post("/", status_code=201)
def create_task():
    return {"id": 2, "title": "New"}
Decorator in the routerFinal URL
@router.get("/")GET /tasks
@router.get("/{task_id}")GET /tasks/{task_id}
@router.post("/")POST /tasks

The prefix is applied automatically to every endpoint in the router.

Prefix in include_router

You can also define the prefix when you include the router:

router = APIRouter()  # no prefix

@router.get("/")
def list_tasks():
    return []

app.include_router(router, prefix="/tasks")

Both forms work. The convention is to define the prefix on the router so the router is self-documenting.


Tags: organizing the documentation

Tags group endpoints in /docs. Without tags, every endpoint shows up together. With tags, they're grouped by section:

from fastapi import FastAPI, APIRouter

app = FastAPI(title="Task Manager")

tasks_router = APIRouter(prefix="/tasks", tags=["Tasks"])
users_router = APIRouter(prefix="/users", tags=["Users"])


@tasks_router.get("/")
def list_tasks():
    return []


@tasks_router.post("/", status_code=201)
def create_task():
    return {"id": 1}


@users_router.get("/")
def list_users():
    return []


@users_router.get("/{user_id}")
def get_user(user_id: int):
    return {"id": user_id}


app.include_router(tasks_router)
app.include_router(users_router)

In /docs:

  • Tasks section: GET /tasks, POST /tasks
  • Users section: GET /users, GET /users/{user_id}

Tags in include_router

You can also pass tags when you include:

app.include_router(router, prefix="/tasks", tags=["Tasks"])

If the router already has tags, the ones from include_router are added (they don't replace them).


Router-level dependencies

You can declare dependencies that apply to every endpoint in the router:

from fastapi import APIRouter, Depends, Header, HTTPException


def verify_api_key(x_api_key: str = Header(...)):
    if x_api_key != "valid-key":
        raise HTTPException(status_code=403, detail="Invalid API key")


admin_router = APIRouter(
    prefix="/admin",
    tags=["Admin"],
    dependencies=[Depends(verify_api_key)],
)


@admin_router.get("/stats")
def admin_stats():
    return {"users": 100, "tasks": 500}


@admin_router.delete("/cleanup")
def admin_cleanup():
    return {"message": "Cleanup done"}

Every endpoint on admin_router requires the X-API-Key header. You don't need to add dependencies=[Depends(verify_api_key)] to each endpoint.

Comparison: dependency per endpoint vs. per router

# ❌ Repetitive — a dependency on every endpoint
@router.get("/stats", dependencies=[Depends(verify_api_key)])
def stats(): ...

@router.delete("/cleanup", dependencies=[Depends(verify_api_key)])
def cleanup(): ...

@router.post("/reset", dependencies=[Depends(verify_api_key)])
def reset(): ...


# ✅ DRY — a router-level dependency
router = APIRouter(dependencies=[Depends(verify_api_key)])

@router.get("/stats")
def stats(): ...

@router.delete("/cleanup")
def cleanup(): ...

@router.post("/reset")
def reset(): ...

Splitting into files: the basic structure

Step 1: Create the routers directory

mkdir -p app/routers
touch app/routers/__init__.py

Step 2: Move the endpoints into a router

app/routers/tasks.py:

from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field

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

tasks = [
    {"id": 1, "title": "Buy groceries", "completed": False, "priority": "medium"},
    {"id": 2, "title": "Study FastAPI", "completed": False, "priority": "high"},
    {"id": 3, "title": "Work out", "completed": True, "priority": "low"},
]


class TaskCreate(BaseModel):
    title: str = Field(min_length=1, max_length=200)
    priority: str = Field(default="medium", pattern="^(high|medium|low)$")


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


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


@router.get("/")
def list_tasks(
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=10, ge=1, le=100),
):
    return tasks[skip : skip + limit]


@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):
    new_id = max((t["id"] for t in tasks), default=0) + 1
    new_task = task_data.model_dump()
    new_task["id"] = new_id
    new_task["completed"] = False
    tasks.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)):
    tasks.remove(task)
    return {"message": f"Task '{task['title']}' deleted"}

Step 3: Simplify main.py

app/main.py:

from fastapi import FastAPI
from app.routers import tasks

app = FastAPI(
    title="Task Manager API",
    description="Task API with APIRouter",
    version="2.0.0",
)

app.include_router(tasks.router)


@app.get("/", tags=["General"])
def root():
    return {"service": "Task Manager", "docs": "/docs"}

From 200+ lines to 15. main.py only creates the app, includes routers, and defines the root endpoint.

Step 4: Verify

uvicorn app.main:app --reload

Every endpoint should work just like before. /docs shows the endpoints organized by tags.


Multiple routers

# app/routers/tasks.py
from fastapi import APIRouter

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

@router.get("/")
def list_tasks():
    return [{"id": 1, "title": "Task 1"}]


# app/routers/users.py
from fastapi import APIRouter

router = APIRouter(prefix="/users", tags=["Users"])

@router.get("/")
def list_users():
    return [{"id": 1, "name": "Alice"}]


# app/main.py
from fastapi import FastAPI
from app.routers import tasks, users

app = FastAPI()
app.include_router(tasks.router)
app.include_router(users.router)

Each domain has its own file. main.py just wires the pieces together.


Sharing data between routers

When several routers need to reach the same data, extract the data store into a shared module:

# app/data.py
tasks = [
    {"id": 1, "title": "Task 1", "completed": False},
    {"id": 2, "title": "Task 2", "completed": True},
]


def get_task_store():
    yield tasks
# app/routers/tasks.py
from fastapi import APIRouter, Depends
from app.data import get_task_store

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


@router.get("/")
def list_tasks(store: list = Depends(get_task_store)):
    return store
# app/routers/stats.py
from fastapi import APIRouter, Depends
from app.data import get_task_store

router = APIRouter(prefix="/stats", tags=["Stats"])


@router.get("/tasks")
def task_stats(store: list = Depends(get_task_store)):
    completed = sum(1 for t in store if t["completed"])
    return {"total": len(store), "completed": completed}

Both routers reach the same data store through the dependency.


Nested routers (routers inside routers)

You can include routers inside other routers:

from fastapi import APIRouter

# Sub-router for task statistics
task_stats_router = APIRouter(prefix="/stats", tags=["Task Stats"])

@task_stats_router.get("/summary")
def stats_summary():
    return {"total": 10, "completed": 5}

@task_stats_router.get("/by-priority")
def stats_by_priority():
    return {"high": 3, "medium": 4, "low": 3}

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

@tasks_router.get("/")
def list_tasks():
    return [{"id": 1}]

tasks_router.include_router(task_stats_router)
EndpointURL
list_tasksGET /tasks
stats_summaryGET /tasks/stats/summary
stats_by_priorityGET /tasks/stats/by-priority

The prefixes get concatenated: /tasks + /stats + /summary = /tasks/stats/summary.


Exercises

Exercise 1: A basic router (Easy)

Create an APIRouter with the /products prefix and the "Products" tag. Define 3 endpoints: GET / (list), GET /{id} (by ID), POST / (create). Connect it to an app with include_router.

See solution
from fastapi import FastAPI, APIRouter, HTTPException
from pydantic import BaseModel, Field

app = FastAPI()

products_router = APIRouter(prefix="/products", tags=["Products"])

products = [
    {"id": 1, "name": "Laptop", "price": 999.99},
    {"id": 2, "name": "Mouse", "price": 29.99},
]


class ProductCreate(BaseModel):
    name: str = Field(min_length=1, max_length=100)
    price: float = Field(gt=0)


@products_router.get("/")
def list_products():
    return products


@products_router.get("/{product_id}")
def get_product(product_id: int):
    for p in products:
        if p["id"] == product_id:
            return p
    raise HTTPException(status_code=404, detail="Product not found")


@products_router.post("/", status_code=201)
def create_product(data: ProductCreate):
    new_id = max((p["id"] for p in products), default=0) + 1
    product = data.model_dump()
    product["id"] = new_id
    products.append(product)
    return product


app.include_router(products_router)


@app.get("/", tags=["General"])
def root():
    return {"service": "Product API"}
curl http://127.0.0.1:8000/products
curl http://127.0.0.1:8000/products/1
curl -X POST http://127.0.0.1:8000/products \
  -H "Content-Type: application/json" \
  -d '{"name": "Keyboard", "price": 59.99}'

Exercise 2: Two routers with tags (Easy)

Create books_router (prefix /books, tag "Books") and authors_router (prefix /authors, tag "Authors"). Each with GET / and GET /{id}. Include them in the app. Check that /docs shows them in separate sections.

See solution
from fastapi import FastAPI, APIRouter, HTTPException

app = FastAPI(title="Library API")

books_router = APIRouter(prefix="/books", tags=["Books"])
authors_router = APIRouter(prefix="/authors", tags=["Authors"])

books = [
    {"id": 1, "title": "One Hundred Years of Solitude", "author_id": 1},
    {"id": 2, "title": "Don Quixote", "author_id": 2},
]

authors = [
    {"id": 1, "name": "Gabriel García Márquez"},
    {"id": 2, "name": "Miguel de Cervantes"},
]


@books_router.get("/")
def list_books():
    return books


@books_router.get("/{book_id}")
def get_book(book_id: int):
    for b in books:
        if b["id"] == book_id:
            return b
    raise HTTPException(status_code=404, detail="Book not found")


@authors_router.get("/")
def list_authors():
    return authors


@authors_router.get("/{author_id}")
def get_author(author_id: int):
    for a in authors:
        if a["id"] == author_id:
            return a
    raise HTTPException(status_code=404, detail="Author not found")


app.include_router(books_router)
app.include_router(authors_router)

In /docs: a "Books" section with 2 endpoints, an "Authors" section with 2 endpoints.

Exercise 3: A router with shared dependencies (Medium)

Create an admin_router with the /admin prefix that requires an X-Admin-Token header. Use dependencies=[Depends(...)] at the router level. Add 2 endpoints: GET /stats and DELETE /reset. Check that both require the token.

See solution
from fastapi import FastAPI, APIRouter, Depends, Header, HTTPException

app = FastAPI()


def require_admin_token(x_admin_token: str = Header(...)):
    if x_admin_token != "admin-secret-123":
        raise HTTPException(status_code=403, detail="Invalid admin token")


admin_router = APIRouter(
    prefix="/admin",
    tags=["Admin"],
    dependencies=[Depends(require_admin_token)],
)


@admin_router.get("/stats")
def admin_stats():
    return {"users": 42, "tasks": 128, "uptime": "3 days"}


@admin_router.delete("/reset")
def admin_reset():
    return {"message": "System reset initiated"}


public_router = APIRouter(tags=["Public"])


@public_router.get("/health")
def health():
    return {"status": "healthy"}


app.include_router(admin_router)
app.include_router(public_router)
# No token → 422
curl http://127.0.0.1:8000/admin/stats

# Wrong token → 403
curl http://127.0.0.1:8000/admin/stats -H "X-Admin-Token: wrong"

# Correct token → 200
curl http://127.0.0.1:8000/admin/stats -H "X-Admin-Token: admin-secret-123"

# Health is public → 200 with no token
curl http://127.0.0.1:8000/health

Exercise 4: Splitting into files (Medium)

Take exercise 2 (books + authors) and split it into files: app/routers/books.py, app/routers/authors.py, and app/main.py. Check that it still works after the split.

See solution

app/routers/books.py:

from fastapi import APIRouter, HTTPException

router = APIRouter(prefix="/books", tags=["Books"])

books = [
    {"id": 1, "title": "One Hundred Years of Solitude", "author_id": 1},
    {"id": 2, "title": "Don Quixote", "author_id": 2},
]


@router.get("/")
def list_books():
    return books


@router.get("/{book_id}")
def get_book(book_id: int):
    for b in books:
        if b["id"] == book_id:
            return b
    raise HTTPException(status_code=404, detail="Book not found")

app/routers/authors.py:

from fastapi import APIRouter, HTTPException

router = APIRouter(prefix="/authors", tags=["Authors"])

authors = [
    {"id": 1, "name": "Gabriel García Márquez"},
    {"id": 2, "name": "Miguel de Cervantes"},
]


@router.get("/")
def list_authors():
    return authors


@router.get("/{author_id}")
def get_author(author_id: int):
    for a in authors:
        if a["id"] == author_id:
            return a
    raise HTTPException(status_code=404, detail="Author not found")

app/routers/__init__.py:

from app.routers.books import router as books_router
from app.routers.authors import router as authors_router

app/main.py:

from fastapi import FastAPI
from app.routers import books_router, authors_router

app = FastAPI(title="Library API")

app.include_router(books_router)
app.include_router(authors_router)


@app.get("/", tags=["General"])
def root():
    return {"service": "Library API", "docs": "/docs"}
uvicorn app.main:app --reload
curl http://127.0.0.1:8000/books
curl http://127.0.0.1:8000/authors

Exercise 5: A router with DI and filters (Medium-Hard)

Create a tasks router with: a get_task_or_404 dependency, a pagination_params dependency, a GET / endpoint with pagination, GET /{id} with lookup, and DELETE /{id} with lookup. Include some initial data and check it in /docs.

See solution
from typing import Optional
from fastapi import FastAPI, APIRouter, Depends, HTTPException, Query

app = FastAPI()

tasks = [
    {"id": 1, "title": "Task 1", "completed": False},
    {"id": 2, "title": "Task 2", "completed": True},
    {"id": 3, "title": "Task 3", "completed": False},
    {"id": 4, "title": "Task 4", "completed": True},
    {"id": 5, "title": "Task 5", "completed": False},
]


def pagination_params(
    skip: int = Query(default=0, ge=0),
    limit: int = Query(default=10, ge=1, le=100),
) -> dict:
    return {"skip": skip, "limit": limit}


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


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


@router.get("/")
def list_tasks(pagination: dict = Depends(pagination_params)):
    start = pagination["skip"]
    end = start + pagination["limit"]
    return {"total": len(tasks), "tasks": tasks[start:end]}


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


@router.delete("/{task_id}")
def delete_task(task: dict = Depends(get_task_or_404)):
    tasks.remove(task)
    return {"message": f"Deleted task: {task['title']}"}


app.include_router(router)


@app.get("/", tags=["General"])
def root():
    return {"service": "Task API with Router + DI"}
curl "http://127.0.0.1:8000/tasks?skip=0&limit=2"
# {"total":5,"tasks":[...2 tasks...]}

curl http://127.0.0.1:8000/tasks/1
# {"id":1,...}

curl http://127.0.0.1:8000/tasks/999
# {"detail":"Task 999 not found"}

Troubleshooting

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

Cause: The routers/ directory is missing its __init__.py.

# Check
ls app/routers/__init__.py

# If it doesn't exist
touch app/routers/__init__.py

Problem 2: Duplicate endpoints in /docs

Cause: You defined the same endpoint in the router AND in main.py, or you included the same router twice.

# ❌ Duplicated
app.include_router(tasks.router)
app.include_router(tasks.router)  # included twice

# ✅ Just once
app.include_router(tasks.router)

Problem 3: The prefix isn't being applied

Cause: You define the prefix in include_router but the router already has the prefix baked into its paths.

# ❌ Double prefix: /tasks/tasks/
router = APIRouter(prefix="/tasks")

@router.get("/tasks")  # ← already has /tasks in the path
def list_tasks(): ...

# ✅ Only in one place
router = APIRouter(prefix="/tasks")

@router.get("/")  # ← no extra prefix
def list_tasks(): ...

Problem 4: A circular import between routers and dependencies

Cause: routers/tasks.py imports from dependencies/tasks.py, which imports from routers/tasks.py.

Fix: Dependencies must never import from routers. The import flow has to be one-directional:

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

Problem 5: "/tasks/stats" matches as "/tasks/{task_id}"

Cause: FastAPI evaluates endpoints in order. If /{task_id} comes before /stats, "stats" gets read as a task_id.

# ❌ Wrong order
@router.get("/{task_id}")
def get_task(task_id: int): ...

@router.get("/stats")
def stats(): ...

# ✅ Fixed paths first
@router.get("/stats")
def stats(): ...

@router.get("/{task_id}")
def get_task(task_id: int): ...

Summary

  • APIRouter groups endpoints by domain — it's a "mini FastAPI"
  • prefix adds a base path to every endpoint in the router
  • tags organize the documentation in /docs into sections
  • include_router() connects a router to the main app
  • Router-level dependencies apply to all of its endpoints
  • Split routers into files: app/routers/tasks.py, app/routers/users.py
  • main.py gets simple: create app + include routers
  • Prefixes get concatenated in nested routers
  • Fixed paths before paths with parameters, to avoid conflicts

Additional resources

  1. FastAPI - Bigger Applications — The complete APIRouter tutorial
  2. FastAPI - APIRouter reference — Every APIRouter parameter
  3. FastAPI - Tags — Organizing endpoints with tags
  4. FastAPI - include_router — Including routers with different prefixes
  5. FastAPI - Metadata and docs — Customizing the documentation
  6. Python - Packagesinit.py and package structure

What's next?

Next capsule: A Professional Folder Structure — You'll move models to app/models/, dependencies to app/dependencies/, and organize your imports with __init__.py. Your project will have the same structure that FastAPI projects use in production.