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:
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
TypeVaror PEP 695[T]syntax.
The Task Router
Five endpoints:
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_idproject_slugtask_statustask_priorityoverdue_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:
from .routers import projects, tasks
# ... app initialization ...
app.include_router(projects.router)
app.include_router(tasks.router)