The previous lessons cover the mechanics. This one is about the decisions — what the URLs should be, which verb does what, what a response should and should not contain, and how to return a page of results without the pager lying. These are the choices a client lives with, and they are the expensive ones to change later.
Resources, not procedures
The first instinct is usually to name endpoints after the code behind them. That produces a remote procedure call with a URL:
POST /getPropertyById
POST /createNewBooking
POST /updatePropertyPrice
POST /doCancelBookingThe alternative is to name the thing and let the verb say what is being done to it:
GET /properties/{id}
POST /bookings
PATCH /properties/{id}
POST /bookings/{id}/cancelThe practical gains are concrete rather than aesthetic. Caches and proxies understand that
GET is safe to repeat and cache; they cannot know that about
POST /getPropertyById. Anyone who has used another REST API can guess your URLs.
And the set stays small — four verbs against a noun rather than a new endpoint per operation.
Plural nouns for collections, consistently. /properties/{id} reads as "the
properties collection, this one", and mixing /property/{id} with
/bookings guarantees someone gets it wrong at three in the morning.
PATCH, not PUT
PUT replaces a resource; PATCH modifies part of it. The difference
matters more than it sounds:
@router.patch("/{public_id}", response_model=PropertyResponse)
def update_property(
public_id: UUID, payload: PropertyUpdateRequest, user: CurrentUser, db: DbSession
) -> PropertyResponse:
"""PATCH, not PUT: only the fields present in the body are changed."""
return PropertyResponse.model_validate(PropertyService(db).update(public_id, user, payload))With PUT, a client that wants to change a price must send the whole listing back
— title, description, amenities, images, everything. If it fetched that object thirty seconds
ago and somebody edited the description in between, the write silently reverts it. That is a lost
update, and it happens without anybody doing anything wrong.
PATCH avoids it by sending only what changed, which is why the update model has
every field optional:
class PropertyUpdateRequest(ApiModel):
"""Every field optional — this is a PATCH, and `None` means "leave it alone".
That is also why status is not here: publishing is a transition with rules, not a field
assignment. It gets its own endpoint.
"""There is a wrinkle worth knowing: with everything optional, None means both "leave
it alone" and "set it to null". StayHub reads it as the first, which makes explicitly nulling a
field impossible through PATCH. When you need both, model_fields_set tells you which
keys were actually present in the request as opposed to defaulted.
Some changes are not field assignments
This is the design point people miss, and the one that keeps an API honest.
A listing has a status. It is tempting to let clients PATCH it. StayHub does not,
and instead gives the transition its own endpoint:
@router.post("/{public_id}/publish", response_model=PropertyResponse)
def publish_property(public_id: UUID, user: CurrentUser, db: DbSession) -> PropertyResponse:
"""Go live. Checks the listing is complete, then indexes it into Elasticsearch."""
return PropertyResponse.model_validate(PropertyService(db).publish(public_id, user))
@router.post("/{public_id}/unpublish", response_model=PropertyResponse)
def unpublish_property(public_id: UUID, user: CurrentUser, db: DbSession) -> PropertyResponse:
"""Back to draft and out of search. Existing bookings are untouched."""
return PropertyResponse.model_validate(PropertyService(db).unpublish(public_id, user))Publishing is not "set a column to PUBLISHED". It checks the listing is complete, then writes it into the search index. Unpublishing removes it from search and deliberately leaves existing bookings alone. Neither is expressible as a field assignment, and exposing the field would let a client skip all of it.
The signal to watch for: if changing a field has preconditions or side effects, it is an
action, not a field. Cancelling a booking is the same shape — there is a deadline,
and the dates have to be released — so it is POST /bookings/{id}/cancel and the
status is read-only in the response.
Purists dislike the verb in the URL. The alternative is a client that has to know the business rules, which is worse. A sub-resource action on a noun is a widely used and perfectly defensible compromise.
response_model is a security boundary
It looks like a formatting convenience. It is the thing standing between your database columns and the internet.
@router.get("/{public_id}", response_model=PropertyResponse)FastAPI filters the return value through that model. A field not on the model does not appear, even if the object you returned has it. So the default changes direction: a new column is private until somebody deliberately exposes it, rather than public the moment it is added.
StayHub's listing response omits the street address, and its nested host omits the email:
class PropertyHostResponse(ApiModel):
"""The slice of a host a guest is allowed to see. Note the absence of `email`."""
public_id: UUID
first_name: str
avatar_url: str | None = None
host_bio: str | None = NoneReturning the ORM object directly would publish the host's email, their password hash, their role and their soft-delete flag. Not because anyone decided to — because nobody decided not to.
Pagination that does not lie
A list endpoint that returns a bare list has nowhere to put the total, so the client cannot render "page 3 of 12" or even know whether to show a Next button. By the time you notice, the endpoint is public and changing its shape is a breaking change.
An envelope solves it, and a generic one solves it once:
class Page(ApiModel, Generic[T]):
items: list[T]
total: int
page: int
page_size: int
@property
def pages(self) -> int:
return (self.total + self.page_size - 1) // self.page_size if self.page_size else 0
@classmethod
def of(cls, items: list[T], total: int, params: PageParams) -> "Page[T]":
return cls(items=items, total=total, page=params.page, page_size=params.page_size)Page[AdminUserRow] produces a distinct schema in the OpenAPI document, so a
generated client gets a real type rather than any. That is the payoff for making it
generic rather than writing a bespoke envelope per endpoint.
{"items":[{"publicId":"c6f21f71-...","email":"guest@stayhub.test","firstName":"Sam",
"lastName":"Okafor","role":"CUSTOMER","isHost":false,
"createdAt":"2026-08-21T06:17:32.571844Z"}],
"total":4,"page":1,"pageSize":2}Two ways a pager goes wrong
The count and the query must carry the same filters. It is easy to build the filtered page and then count the whole table — the rows look right, the pager is silently wrong, and nothing errors:
"""Browse accounts.
⚠️ The COUNT and the SELECT must carry the SAME filters. It is easy to build the filtered page
and then count the whole table — the rows look right, the pager is silently wrong, and nothing
errors. Both are built from `conditions` below for exactly that reason.
"""Building one list of conditions and using it twice makes the mistake hard to make:
total = db.execute(select(func.count(User.id)).where(*conditions)).scalar_one()LIMIT/OFFSET without ORDER BY is undefined. Postgres makes no promise about row order without one, so the same page-2 request can return rows already seen on page 1:
rows = db.execute(
select(User)
.where(*conditions)
.order_by(User.id.desc())
.limit(page.page_size)
.offset(page.offset)
).scalars().all()Order by something unique. Sorting by created_at alone is not
enough when two rows share a timestamp — their relative order is still undefined, and they
can swap between requests. Add the primary key as a tiebreaker.
One more thing OFFSET does badly: it gets slower as the offset grows, because the database still produces and discards every skipped row. It is fine for an admin console and wrong for an infinite scroll over millions of rows, where keyset pagination — "give me the next 20 after this id" — is the right tool.
The whole endpoint, assembled
Put the pieces together and a paginated, filtered list endpoint is about twenty lines — most of it the conditions:
@router.get("/users", response_model=Page[AdminUserRow])
def list_users(
_: AdminUser,
db: DbSession,
page: PageQuery,
q: str | None = Query(default=None, max_length=200, description="Matches email or name"),
is_host: bool | None = Query(default=None, alias="isHost"),
) -> Page[AdminUserRow]: return Page.of([AdminUserRow.model_validate(u) for u in rows], total, page)The signature carries the contract — staff only, a bounded page, two optional filters — and the body carries the query. Adding a third filter is one parameter and one condition, and the pagination, the bounds and the envelope all come along unchanged.
Note the response row is its own model rather than the full user:
class AdminUserRow(ApiModel):
public_id: UUID
email: str
first_name: str
last_name: str
role: str
is_host: bool
created_at: datetimeA list row and a detail view want different amounts of data. Reusing the detail model for lists means every row carries fields nobody renders, which on a page of a hundred is real bytes for nothing — and any nested relationship it declares is a query per row unless you eager-load it.
Filtering
Filters are query parameters, optional, and each one appends a condition:
conditions = [User.deleted.is_(False)]
if q:
pattern = f"%{q}%"
conditions.append(
User.email.ilike(pattern)
| User.first_name.ilike(pattern)
| User.last_name.ilike(pattern)
)
if is_host is not None:
conditions.append(User.is_host.is_(is_host))Two details. if is_host is not None, not if is_host — otherwise
?isHost=false is indistinguishable from not sending it, and filtering for non-hosts
becomes impossible.
And ilike here is SQLAlchemy building a parameterised LIKE, not string
interpolation. An f-string in a where clause is how SQL injection gets in. The pattern's
% characters are part of the bound value, not part of the statement:
def test_a_quote_in_the_query_is_a_value_not_syntax(self, client):
"""Parameterised, not interpolated. If this 500s, a string is being built into SQL."""
r = client.get("/api/v1/admin/users?q=%27%20OR%201%3D1%20--")
assert r.status_code == 200
assert r.json()["total"] == 0Sorting
Accept a small closed set of sorts, never a raw column name. This is StayHub's:
sort: str = Query(default="relevance", pattern="^(relevance|price_asc|price_desc|rating)$"),The temptation is ?sortBy=price_per_night&order=desc, mapped straight onto a
column. Two problems. It exposes your schema as your API, so a column rename is a breaking change.
And unless every value is validated against an allow-list, it is an injection point —
order_by takes a string.
A closed set also lets a sort mean something the database does not: relevance is a
search score, not a column, and rating can mean "rating, then number of reviews as a
tiebreaker" without the client knowing.
Caching, which is mostly free
Two headers do most of the work, and REST's verb discipline is what makes them usable at all
— a proxy can cache GET /properties/{id} precisely because GET is defined as
safe.
@router.get("/amenities", response_model=list[AmenityResponse])
def list_amenities(db: DbSession) -> list[AmenityResponse]:
"""The amenity vocabulary, for the listing form and the search filter panel.
A read on the write service, deliberately: it is a tiny static lookup, and routing it through
Hasura would mean the listing form needs a GraphQL client just for this.
"""That endpoint returns a vocabulary that changes perhaps twice a year and is fetched on every
page load of the listing form. A Cache-Control: public, max-age=3600 on it removes
those requests entirely:
@router.get("/amenities", response_model=list[AmenityResponse])
def list_amenities(db: DbSession, response: Response):
response.headers["Cache-Control"] = "public, max-age=3600"
...ETag is the other half, for data that changes unpredictably: return a hash of the
representation, and a client that sends it back as If-None-Match gets a
304 Not Modified with no body. You still do the work of producing the answer, but
you do not send it — worth it when responses are large.
The rule that matters: never cache anything user-specific as
public. A shared proxy caching one guest's /bookings/mine and
serving it to another is a data breach delivered by an optimisation. Use
Cache-Control: private for anything behind a token, or no caching at all.
How deep to nest
Nesting expresses ownership, and one level of it is almost always enough:
GET /properties/{id}/bookings one level — fine
GET /hosts/{id}/properties/{pid}/bookings/{bid}/payments four — do notBy the time a URL has four ids, three of them are redundant — a payment id already identifies its booking, its property and its host. Nest one level to scope a collection, then use the top-level resource for anything with an id of its own:
GET /bookings/availability/{property_id} scoped by property
GET /bookings/{public_id} identified on its ownStayHub goes further and uses a filter rather than nesting for a host's view of their own
bookings: GET /bookings/hosting rather than
/hosts/{id}/bookings. The host is the authenticated caller, so putting their id in the
URL adds nothing and invites somebody to try another one.
Version from the first day
app.include_router(api_router, prefix=settings.api_v1_prefix)One line, and it buys the ability to run /api/v2 beside /api/v1 when a
breaking change becomes unavoidable. Retrofitting a version prefix once clients exist means every
one of them changes on the same day.
What counts as breaking is narrower than people assume. Adding an optional field, a new endpoint, or a new enum value a client can ignore is safe. Removing or renaming a field, changing a type, making an optional field required, or changing a status code is not. The asymmetry is worth internalising, because it means most evolution needs no new version at all.
Repeat-safety
Networks retry. A client that times out and retries a POST /bookings may create two
bookings, and it will not know it did.
GET, PUT and DELETE are idempotent by definition;
POST is not. StayHub gets away with it because the exclusion constraint makes a
duplicate booking for the same dates impossible — the retry gets a 409 rather than a second
row. That is worth noticing as a pattern: a uniqueness constraint on something the client
already knows is the cheapest idempotency you can get.
When the operation has no natural unique key, the general answer is an
Idempotency-Key header: the client generates one per logical operation, the server
stores it with the result, and a repeat returns the stored result instead of acting again. Stripe's
API is the reference implementation, and it is worth copying if you take payments.
When a collection is not a list endpoint
StayHub has both GET /properties/mine and GET /search, and they look
like the same kind of thing. They are not, and the split is worth understanding because most
applications eventually need it.
"""`GET /search` — the one read that does not come from Hasura (decision D2).
Everything else the frontends read is a GraphQL query. This is not, because the whole point of
maintaining an Elasticsearch index is to answer that question without touching Postgres, and
Hasura reads Postgres.
"""A list endpoint answers "the rows matching these exact filters", from the system of record, exactly. A search endpoint answers "the things most relevant to this", from an index, approximately — with ranking, fuzzy matching and a relevance score.
They differ in ways that show up in the API. Search results are ranked, so
sort=relevance is a real option that a database list cannot offer. Search is eventually
consistent, because the index is updated after the write. And search has a hard ceiling: the default
from + size window is capped at 10,000 results, which is why deep pagination is not
free there:
# Elasticsearch's default `from + size` window is capped at 10,000 results. Past that you need
# `search_after`; a demo never gets there, but the cap is why deep pagination is not free.
page: int = Field(default=1, ge=1, le=100)Trying to serve both from one endpoint means one of the two is done badly. Keep them separate and let each be honest about what it is.
Bulk operations
Sooner or later a client wants to change twenty things at once, and twenty round trips is the wrong answer. The design question is what happens when three of them fail.
The shape that causes least pain accepts a list and returns a result per item, with a 207-style body rather than a single status:
{"results": [
{"id": "a1b2...", "ok": true},
{"id": "c3d4...", "ok": false, "error": {"message": "Listing not found.", "fieldErrors": {}}},
{"id": "e5f6...", "ok": true}
]}The alternative — all-or-nothing in one transaction — is simpler and right when the items are genuinely one unit of work. It is wrong when they are independent, because one bad id then rejects nineteen good changes and the client has no way to know which one was the problem.
Whichever you choose, bound the list. A bulk endpoint with no maximum is a way to ask the server to do unbounded work in one request, and the timeout it eventually produces looks like an outage rather than a rejected request.
Documenting it for somebody else
The OpenAPI document is generated, so the question is not whether to write documentation but how much to shape what is already there.
Three things repay the effort. Tags, so endpoints group by resource rather than
appearing in one flat list. Docstrings, whose first line becomes the summary and
whose body renders as markdown — which is where a rule like "creates nothing" or a
⚠️ about what a parameter does not do belongs. And the error responses a caller
must branch on, since FastAPI can only infer the success case.
Application-level metadata is worth five minutes once:
app = FastAPI(
title=settings.app_name,
version="1.0.0",
description=(
"StayHub — the WRITE side of an Airbnb-style app.\n\n"
"Every create, update and delete lives here. Reads come from Hasura GraphQL "
"(http://localhost:8081) with ONE deliberate exception: `GET /api/v1/search`, which "
"queries Elasticsearch. Both accept the same JWT this API issues."
),
lifespan=lifespan,
docs_url="/docs",
)That description is doing real work. It tells a new consumer which API answers which question — the single thing about this system that is not guessable from the endpoint list, and the thing they would otherwise have to be told in a conversation.
One decision to make deliberately: whether /docs is public.
FastAPI(docs_url=None, redoc_url=None) turns it off, which is right for an internal
API where the route list is not something you want to publish. Behind authentication is the middle
option, and it needs a custom route since the built-in one has no dependency hook.
Small decisions worth making once
- 404 rather than 403 for somebody else's resource. A 403 confirms the id exists, which on a guessable identifier is a slow enumeration. StayHub's admin API is the exception — a documented surface whose existence is not a secret.
- Return the updated object from a PATCH or an action, not
204 No Content. It saves the client a follow-up GET and removes a window where its copy is stale. - Soft-delete returns 200 with a message, not 204, when something remains true
afterwards worth saying — StayHub's returns
{"message": "Listing removed."}. - Dates as ISO 8601 strings, always UTC. Let the client localise. A naive datetime in an API is a bug that only appears in another timezone.
- Money as a decimal string, not a float.
"797.40", not797.4. - Reads that need a rule are still reads. StayHub's
/properties/minereturns drafts, so it needs "only your own rows" — that makes it an endpoint rather than something a generic query layer can serve.
Next: one error shape for the whole API — because everything above assumes failures are as well designed as successes, and by default they are not.