Exercise: Refactoring into APIRouter
You'll:
- set up a central
dependencies.py(withSessionDepandProjectDep) - register a global
IntegrityErrorhandler - move the project endpoints into their own
APIRouter
What's provided in your branch:
- an updated
dependencies.py - the existing
crud.py(unchanged from the previous chapter) - a new
main.pythat wires up the exception handler and the (soon to be created) router - an updated
conftest.pyandtests/test_api.pythat account for the trailing-slash route paths.
Your focus is creating the new APIRouter.
Before You Start
Set up the starting state for this exercise:
git fetch
git checkout -b my-ch07 --no-track origin/ch07
uv sync
Files that changed in this chapter:
src/release_tracker/dependencies.py(new, holdsSessionDepandProjectDep)src/release_tracker/main.py(modified, registers the router and theIntegrityErrorexception handler)src/release_tracker/routers/__init__.py(new, empty package marker)src/release_tracker/routers/projects.py(new, you'll fill in TODOs)tests/conftest.py(modified, POSTs to/projects/with the trailing slash)tests/test_api.py(modified, paths use trailing slash plus a newtest_create_duplicate_project_fails)
Postgres running?
Confirm Postgres is up before you start:
docker compose ps
The db service should show as healthy. If it isn't, start it with docker compose up -d.
What's in Your Branch
Two new patterns learned in this chapter are already there:
src/release_tracker/dependencies.py introduces get_project_or_404 and the ProjectDep alias your router uses to fetch a project (with built-in 404 handling):
def get_project_or_404(project_id: int, session: SessionDep) -> Project:
project = crud.get_project(session, project_id)
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
)
return project
ProjectDep = Annotated[Project, Depends(get_project_or_404)]
src/release_tracker/main.py includes the router and registers a global handler that turns IntegrityError into a 409:
@app.exception_handler(IntegrityError)
def handle_integrity_error(request: Request, exc: IntegrityError):
return JSONResponse(
status_code=status.HTTP_409_CONFLICT,
content={"detail": "Data conflict occurred (e.g., duplicate entry)."},
)
tests/conftest.py carries forward the in-memory SQLite fixture pattern from the previous chapter, with the sample_project_id fixture now POSTing to /projects/ to match the router prefix. tests/test_api.py carries the existing tests (now using /projects/) and adds test_create_duplicate_project_fails to exercise the new 409 handler. Open both files to see the details.
Your Task: Implement routers/projects.py
Open src/release_tracker/routers/projects.py. Five route handlers are stubbed with TODO comments. Fill in each one:
from typing import Any
from fastapi import APIRouter, Response, status
from release_tracker import crud
from release_tracker.dependencies import ProjectDep, SessionDep
from release_tracker.models import ProjectCreate, ProjectRead, ProjectUpdate
router = APIRouter(prefix="/projects", tags=["projects"])
@router.get("/", response_model=list[ProjectRead])
def list_projects(session: SessionDep) -> Any:
# TODO: Call `crud.list_projects` with the session and return the result.
pass
@router.get("/{project_id}", response_model=ProjectRead)
def get_project(project: ProjectDep) -> Any:
# TODO: `ProjectDep` already fetched the project (and raised 404 if missing). Return it.
pass
@router.post(
"/", response_model=ProjectRead, status_code=status.HTTP_201_CREATED
)
def create_project(payload: ProjectCreate, session: SessionDep) -> Any:
# TODO: Call `crud.create_project` with the session and payload, and return the result.
pass
@router.patch("/{project_id}", response_model=ProjectRead)
def update_project(
project: ProjectDep, payload: ProjectUpdate, session: SessionDep
) -> Any:
# TODO: Call `crud.update_project` with the session, the existing project, and the payload. Return the updated project.
pass
@router.delete("/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(project: ProjectDep, session: SessionDep) -> Response:
# TODO: Call `crud.delete_project` with the session and the project, then return a `Response` with `status_code=status.HTTP_204_NO_CONTENT`.
pass
Run the Tests
Once you've wired up the router, run the test suite to ensure the API behaves exactly as it did before, plus the new 409 case.
uv run pytest
Success
If all six tests pass, your refactor delivers the same API behavior through an APIRouter, with IntegrityError mapped to a 409 by the global handler.
Troubleshooting Tips
-
404 Not Found on all routes:
Did you remember to use the correct
prefix="/projects"when creating yourAPIRouter? -
Missing or Extra Parameters:
Check the signatures of your route handlers. Does
update_projectacceptproject: ProjectDepinstead ofproject_id: int? -
Tests Failing due to 409:
Ensure
handle_integrity_erroris correctly decorated with@app.exception_handler(IntegrityError)inmain.py.
Compare with the Solution
git diff solutions/ch07 -- src/ tests/