Skip to content

Exercise: Refactoring into APIRouter

You'll:

  • set up a central dependencies.py (with SessionDep and ProjectDep)
  • register a global IntegrityError handler
  • 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.py that wires up the exception handler and the (soon to be created) router
  • an updated conftest.py and tests/test_api.py that 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:

shell
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, holds SessionDep and ProjectDep)
  • src/release_tracker/main.py (modified, registers the router and the IntegrityError exception 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 new test_create_duplicate_project_fails)

Postgres running?

Confirm Postgres is up before you start:

shell
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):

src/release_tracker/dependencies.py
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:

src/release_tracker/main.py
@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:

src/release_tracker/routers/projects.py
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.

shell
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 your APIRouter?

  • Missing or Extra Parameters:

    Check the signatures of your route handlers. Does update_project accept project: ProjectDep instead of project_id: int?

  • Tests Failing due to 409:

    Ensure handle_integrity_error is correctly decorated with @app.exception_handler(IntegrityError) in main.py.

Compare with the Solution

shell
git diff solutions/ch07 -- src/ tests/