Stack: Django REST Framework · Project: TicketFlow Status: Published Prerequisite: Lesson 12 — NoSQL
Objectives
- Design TicketFlow's API as consistent resources (naming, hierarchy, verbs).
- Define a single error format (RFC 7807) the frontend can treat as a contract.
- Paginate, filter and sort with judgement (offset vs cursor).
- Decide public identity: exposed UUIDs, internal IDs never.
1. Resources: plural nouns, short hierarchy
GET /api/events/ public listing
POST /api/events/ (organizer) create
GET /api/events/{uuid}/ detail
PATCH /api/events/{uuid}/ partial edit
GET /api/events/{uuid}/seats/ an event's seats
GET /api/events/{uuid}/availability/ availability (cached)
POST /api/reservations/ create reservation (seats go in the body)
GET /api/reservations/{uuid}/ my reservation
DELETE /api/reservations/{uuid}/ cancel (idempotent)
POST /api/reservations/{uuid}/payments/ start that reservation's payment
POST /api/payments/webhook/ gateway webhookRules of the craft: verbs in the body, not in the path (the action is the HTTP verb; if it truly is a domain action, use a sub-resource or a short POST like /payments/webhook/); no /getEvents/-style verbs; nesting at most 1 level (deeper = fragile URLs); consistent plurals.
2. Errors: one format, every endpoint
RFC 7807 (application/problem+json) — the error contract:
{
"type": "https://docs.ticketflow.app/errors/seat-already-reserved",
"title": "The seat is already reserved",
"status": 409,
"detail": "Seat A-12 was just reserved by someone else",
"instance": "/api/reservations/",
"errors": [{"field": "seats", "index": 1, "code": "taken"}]
}In DRF: a centralized exception handler (Lesson 26 systematizes it; here the design): every error —serializer validation, business, auth— comes out in this shape. type is a documented URI (the frontend can map codes to texts/icons); errors carries the per-field detail. Correct HTTP codes (Lesson 01) + this body = a complete contract.
3. Pagination, filters and ordering
- Offset (
?page=2&page_size=25): simple, but skips rows if inserts happen while you page, andpage=99999is expensive (OFFSET scans). For admin panels: enough. - Cursor (
?cursor=eyJpZCI6MTIzfQ): stable under inserts and O(1) per page; opaque and forward-only. For high-traffic public listings (the events feed): the right one. - Filters:
?state=PUBLISHED&starts_after=2027-01-01— real field names, no invented DSL;django-filterstandardizes it. Ranges:_after/_beforebeats_min/_maxfor time. - Ordering:
?ordering=-starts_atwith a field whitelist (neverordering=; DROP— the serializer is not SQL, but the whitelist is hygiene).
4. Public identity: UUIDs out, integers in
The API exposes uuid (non-sequential: it doesn't reveal business volume nor allow enumerating resources); the integer PK stays internal. In DRF: lookup_field = "uuid" on the router/viewset, and SERIALIZIZE the uuid, not the id. A justified security-by-obscurity bonus: enumerating other people's reservations by probing consecutive IDs stops being viable (auth in Lessons 18-21 is the real barrier; this is hygiene).
5. Contract before luck: OpenAPI preview
With drf-spectacular, the OpenAPI schema is generated from code: Lessons 15/26 consolidate this practice (validation and centralized errors). From today: every new TicketFlow endpoint lands with its documented serializer — the contract deploys with the code, it is not hand-written afterwards.
6. Headers: the hidden half of the REST contract
The REST designed in the body forgets that many decisions live in HEADERS — and they are part of the contract exactly the same:
| Header | What it promises | Lesson that exploits it |
|---|---|---|
ETag / If-None-Match | "if unchanged, 304 with no body" | 38/39: bandwidth savings while browsing |
Vary: Accept-Encoding | "the response depends on the encoding" | 39: the cache doesn't serve compressed to those who didn't ask |
Cache-Control | "who may cache and until when" | 38: public for the catalog, no-store for personal data |
Retry-After | "when retrying is worth it" | 26/54: 429 and 503 come with seconds |
X-Request-ID | "this request's thread" | 45: the trace_id the client can report |
X-RateLimit-Remaining | "how many requests you have left" | 23: the client paces itself without receiving 429 |
Location | "where the created resource lives" | 14: the payment's 202 points at the status endpoint |
The project's two rules: (1) if a header changes client behavior, it enters the contract test (35: headers are verified like the body); (2) Location on the 201 ALWAYS — the client that creates does not guess the new resource's URL.
7. Verb idempotency, in practice
The verb table with its idempotency promise (the one that makes Lesson 54's retry possible): GET idempotent and safe (changes nothing); PUT idempotent (same entity, same result); DELETE idempotent (deleting the deleted → 404 or 204, but the final state is the same); POST NOT idempotent (that is why Lesson 14's Idempotency-Key exists); PATCH depends on the patch type (a SET status=X is idempotent; an increment is NOT). TicketFlow's design respects the table: charging is a POST with an idempotency key; cancelling a reservation is DELETE on it (idempotent: cancelling twice doesn't double-cancel) — and Lesson 29's job can retry it fearlessly.
Self-assessment
- Why
POST /api/reservations/with seats in the body instead ofPOST /api/events/{id}/reserve/? When WOULD the action sub-resource be justified? - What does the frontend gain from
type(a URI) on every error, beyond thetitle? - A public listing will spike at 100 rps and grows without end: offset or cursor, and why?
- What does using public UUIDs defend against, and why does it NOT replace authentication/authorization?
- Why is the
orderingfield whitelist mandatory even though DRF escapes the parameter?
Continue with the exercises. The solutions only after trying it yourself.