APIRouter and project structure

FastAPI · Practical · 12 min

A single main.py stops scaling after a few dozen endpoints; routers give you modules a team can work in.

An APIRouter is a mini-app: it has the same .get, .post and other decorators, and is attached to the real app with app.include_router(router). Give each feature its own module and router:

app/
  main.py          # creates FastAPI(), includes routers
  dependencies.py  # get_session, get_current_user...
  models.py        # Pydantic / SQLModel models
  routers/
    items.py
    users.py

A router can carry configuration for all its routes: APIRouter(prefix="/items", tags=["items"], dependencies=[Depends(get_current_user)], responses={404: {"description": "Not found"}}). Inside it, paths are relative: @router.get("/{item_id}") serves /items/{item_id}.

The same options can be passed when including instead: app.include_router(admin.router, prefix="/admin", dependencies=[Depends(require_admin)]). That's handy when you include a router you don't own, or mount one router under two prefixes. Settings combine: prefixes concatenate, and tags and dependencies accumulate from app to router to route.

Routers can include other routers, so API versioning is just a v1 = APIRouter(prefix="/v1") that includes the feature routers, and the app includes v1.

Two habits keep larger codebases healthy. Keep imports flowing one way: routers import from dependencies.py and models.py, and main.py imports the routers; circular imports usually mean shared code lives in the wrong module. And keep handlers thin: parse input, call a plain service function, return a model. Business logic in plain functions can be tested without HTTP at all.

Example

from typing import Annotated
from fastapi import APIRouter, Depends, FastAPI, Header, HTTPException
from fastapi.testclient import TestClient

# app/dependencies.py
def require_admin(x_role: Annotated[str, Header()] = "user"):
    if x_role != "admin":
        raise HTTPException(status_code=403, detail="Admins only")

# app/routers/items.py
items = APIRouter(prefix="/items", tags=["items"])

@items.get("/{item_id}")
def read_item(item_id: int):
    return {"item_id": item_id}

# app/routers/admin.py
admin = APIRouter()

@admin.post("/reindex")
def reindex():
    return {"started": True}

# app/main.py
app = FastAPI()
app.include_router(items)
app.include_router(admin, prefix="/admin", dependencies=[Depends(require_admin)])

c = TestClient(app)
print(c.get("/items/3").json())               # {'item_id': 3}
print(c.post("/admin/reindex").status_code)   # 403
print(c.post("/admin/reindex", headers={"X-Role": "admin"}).json())  # {'started': True}

Remember

  • APIRouter has the same decorators as the app; include it with app.include_router
  • prefix, tags and dependencies can be set on the router or at include time
  • Prefixes concatenate and dependencies accumulate down the tree
  • One router per feature module; keep imports flowing one way

Watch out for

  • Ending a prefix with a slash (prefix='/items/') and getting paths like /items//{id}.

Check yourself

  1. router = APIRouter(prefix='/users') with @router.get('/{id}') is included with app.include_router(router, prefix='/v1'). What's the final path?
  2. Where should a dependency that protects every admin route be declared?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: fastapi.tiangolo.com/tutorial/bigger-applications/