Skip to content

Write Endpoints

With our read endpoints complete, let's implement the routes that modify the database: POST, PATCH, and DELETE.

Writing to a relational database isn't always as simple as fetching a record. We need to handle data validation, unpack incoming JSON payloads, and manage the database session's transaction state.

Generating Slugs

Our Project model requires a unique slug that we'll use as part of the URL for each project. Let's generate them automatically from the project's name.

Add this helper function to main.py before the routes:

src/release_tracker/main.py
def slugify(value: str) -> str:
    cleaned = "".join(c for c in value.lower() if c.isalnum() or c == " ")
    return "-".join(cleaned.split()) or "project"

Note

In production, use a library like python-slugify to handle special cases like unicode and more. You'll also need to validate the slug doesn't already exist when updating and creating Projects.

Creating a Project

When a client sends a POST request, they provide a JSON payload matching the ProjectCreate schema. We need to validate that payload, generate a slug, create a Project database model, and commit it to the database.

The new route brings in two new imports. Update the existing from fastapi and from .models lines at the top of main.py:

src/release_tracker/main.py
from typing import Annotated

from fastapi import Depends, FastAPI, HTTPException, Response, status
from sqlmodel import Session, select

from .database import get_session
from .models import Project, ProjectCreate, ProjectRead, ProjectUpdate

Then add the route:

src/release_tracker/main.py
@app.post(
    "/projects", response_model=ProjectRead, status_code=status.HTTP_201_CREATED
)
def create_project(
    payload: ProjectCreate,
    session: SessionDep,
):
    project = Project.model_validate(
        payload, update={"slug": slugify(payload.name)}
    )
    session.add(project)
    session.commit()
    session.refresh(project)
    return project

Notice session.refresh(project). When we commit the new project, the database generates its primary key id and assigns a default created_at timestamp. Calling refresh queries the database to populate our Python object with those new, database-generated values so we can return them to the client.

Updating a Project

A PATCH request only updates the fields provided by the client. We need to fetch the existing project, update only the changed fields, regenerate the slug if the name changed, and commit.

src/release_tracker/main.py
@app.patch("/projects/{project_id}", response_model=ProjectRead)
def update_project(
    project_id: int,
    payload: ProjectUpdate,
    session: SessionDep,
):
    project = session.get(Project, project_id)
    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
        )

    updated_fields = payload.model_dump(exclude_unset=True)
    project.sqlmodel_update(updated_fields)

    if "name" in updated_fields and updated_fields["name"] is not None:
        project.slug = slugify(updated_fields["name"])

    session.add(project)
    session.commit()
    session.refresh(project)
    return project

exclude_unset=True ensures we only update fields the client explicitly sent in their request.

Deleting a Project

Finally, to delete a project, we fetch it and pass it to session.delete(). We return a 204 No Content status code to indicate the deletion was successful.

src/release_tracker/main.py
@app.delete("/projects/{project_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_project(project_id: int, session: SessionDep):
    project = session.get(Project, project_id)
    if not project:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND, detail="Project not found"
        )

    session.delete(project)
    session.commit()
    return Response(status_code=status.HTTP_204_NO_CONTENT)

Adding a Root Endpoint

Hitting the bare API root (/) currently returns a 404. That's correct, but unhelpful for anyone exploring the API for the first time. Add a read_root handler that points them at the docs and the health check:

src/release_tracker/main.py
@app.get("/")
def read_root() -> dict[str, str]:
    return {
        "app": "Release Tracker API",
        "docs": "/docs",
        "health": "/health",
    }

Now anyone hitting the root URL gets a JSON response with what routes are available.

The Bloated Handler Problem

Our API is fully functional, but look closely at main.py. Our route handlers are doing a lot of heavy lifting. They're handling HTTP concerns (status codes, exceptions) and deep database logic (slug generation, committing, refreshing).


Try It Out

With the dev server running, visit http://localhost:8000/

Expected: {"app": "Release Tracker API", "docs": "/docs", "health": "/health"}.

Then create a project:

shell
curl -X POST http://localhost:8000/projects \
  -H "Content-Type: application/json" \
  -d '{"name": "Mobile Launch", "description": "iOS and Android beta."}'

Expected: 201 with "slug": "mobile-launch" and a created_at timestamp.

Sending the same payload twice currently returns a 500 because of a unique-constraint violation.

We can't have two projects with the same name.

Later, we'll introduce a centralized exception handler that converts the server 500 error with a HTTP 409 Conflict response.

PATCH the project to rename it:

shell
curl -X PATCH http://localhost:8000/projects/{id} \
  -H "Content-Type: application/json" \
  -d '{"name": "Mobile App Beta"}'

Expected: 200 with "slug": "mobile-app-beta". The slug followed the name.

DELETE it:

shell
curl -X DELETE http://localhost:8000/projects/{id}

Expected: 204 No Content. GET /projects/{id} afterward returns 404.