Skip to content

Building Task Endpoints

The task router looks similar to the project router from the previous chapter, with one addition: a TaskDep dependency that loads a task by ID and raises a 404 if it doesn't exist.

TaskDep, Mirroring ProjectDep

Earlier in dependencies.py, get_project_or_404 and the ProjectDep alias gave every project route handler a fully loaded Project for free. The same pattern works for Task:

src/release_tracker/dependencies.py
def get_task_or_404(task_id: int, session: SessionDep) -> Task:
    task = crud.get_task(session, task_id)
    if task is None:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Task not found"
        )
    return task


TaskDep = Annotated[Task, Depends(get_task_or_404)]

The crud.get_task function it depends on will pick up an extra detail (eager loading the related project) when we add the filter logic. For now, treat it the same way crud.get_project worked: it returns a Task or None.

One dependency per model gets tedious

ProjectDep, TaskDep, and a future UserDep each take one dependency function and one type alias. Two models is fine; ten models means a lot of near-identical code.

Larger APIs reach for one of three patterns to compress this:

  • FastAPI class-based dependencies: a Depends(SomeClass) instance encapsulates the lookup logic.
  • Repository or service layers: lookup logic moves out of dependencies into a class that route handlers receive.
  • Generic helpers: a single function parameterized by model type using TypeVar or PEP 695 [T] syntax.

The Task Router

Five endpoints:

src/release_tracker/routers/tasks.py
from typing import Annotated, Any

from fastapi import APIRouter, Query, Response, status

from release_tracker import crud
from release_tracker.dependencies import ProjectDep, SessionDep, TaskDep
from release_tracker.models import (
    TaskCreate,
    TaskPriority,
    TaskRead,
    TaskStatus,
    TaskUpdate,
)

router = APIRouter(tags=["tasks"])


@router.get("/tasks", response_model=list[TaskRead])
def list_tasks(
    session: SessionDep,
    project_id: int | None = None,
    project_slug: str | None = None,
    task_status: Annotated[TaskStatus | None, Query(alias="status")] = None,
    task_priority: Annotated[
        TaskPriority | None, Query(alias="priority")
    ] = None,
    overdue_only: bool = False,
) -> Any:
    return crud.list_tasks(
        session,
        project_id=project_id,
        project_slug=project_slug,
        task_status=task_status,
        task_priority=task_priority,
        overdue_only=overdue_only,
    )


@router.post(
    "/projects/{project_id}/tasks",
    response_model=TaskRead,
    status_code=status.HTTP_201_CREATED,
)
def create_task(
    project: ProjectDep, payload: TaskCreate, session: SessionDep
) -> Any:
    return crud.create_task(session, project.id, payload)


@router.get("/tasks/{task_id}", response_model=TaskRead)
def get_task(task: TaskDep) -> Any:
    return task


@router.patch("/tasks/{task_id}", response_model=TaskRead)
def update_task(task: TaskDep, payload: TaskUpdate, session: SessionDep) -> Any:
    return crud.update_task(session, task, payload)


@router.delete("/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_task(task: TaskDep, session: SessionDep) -> Response:
    crud.delete_task(session, task)
    return Response(status_code=status.HTTP_204_NO_CONTENT)

Differences from the project router:

tags=["tasks"] without a prefix.:

list_tasks is GET /tasks, but create_task is POST /projects/{project_id}/tasks. Two of the routes start with /projects, three start with /tasks. A single router prefix would force the path on every route, which doesn't fit. We drop the prefix and write the full paths in each decorator.

Creating a task is rooted under a project.:

POST /projects/{project_id}/tasks makes the parent-child relationship visible in the URL, and ProjectDep does the 404 work if project_id doesn't exist. crud.create_task then receives the project's ID and the task payload.

Query-parameter filters with aliases.:

list_tasks accepts five filter parameters:

  • project_id
  • project_slug
  • task_status
  • task_priority
  • overdue_only

The Annotated[TaskStatus | None, Query(alias="status")] form lets the API expose ?status=done to clients while the Python parameter inside the function stays as task_status (avoiding a shadow over the imported status module from FastAPI).

The router is used in main.py the same way the project router is:

src/release_tracker/main.py
from .routers import projects, tasks

# ... app initialization ...

app.include_router(projects.router)
app.include_router(tasks.router)