Module 3 · API design

Lesson 13 — REST done right

Resources, verbs, consistent errors, pagination, filtering and ordering.

Published
In this lesson
  1. Exercise 1 — Surface
  2. Exercise 2 — Handler
  3. Exercise 3 — Pagination
  4. Exercise 4 — UUID
  5. Professor's summary

Exercise 1 — Surface

POST /api/auth/register/            public         201
POST /api/auth/login/               public         200
GET  /api/events/                   public         200
POST /api/events/                   organizer      201
GET  /api/events/{uuid}/            public         200
PATCH /api/events/{uuid}/           organizer      200
GET  /api/events/{uuid}/seats/      public         200
GET  /api/events/{uuid}/availability/ public       200 (cached)
POST /api/reservations/             authenticated  201 / 409
GET  /api/reservations/{uuid}/      owner          200
DELETE /api/reservations/{uuid}/    owner          204 (idempotent)
POST /api/reservations/{uuid}/payments/ owner     202 (queue) / 201
POST /api/payments/webhook/         system (signature) 200
POST /api/gift-cards/{code}/redeem/ authenticated  200 / 409

Typical critique: POST /api/events/{id}/reserve/ (verb in path) becomes POST /api/reservations/ with {event, seats} in the body — the created resource is the reservation. An action sub-resource IS right for payments/ (it creates a payment WITHIN the reservation: real sub-resource creation, not a disguised verb).

Exercise 2 — Handler

python
from rest_framework.views import exception_handler

def problem_handler(exc, context):
    response = exception_handler(exc, context)
    if response is None:
        if isinstance(exc, DomainError):
            return Response(
                {"type": f"https://docs.ticketflow.app/errors/{exc.code}",
                 "title": exc.title, "status": exc.status, "detail": exc.detail},
                status=exc.status)
        return None  # 500 is the logger's business (26)
    response.data = {"type": f"https://docs.ticketflow.app/errors/{response.status_code}",
                     "title": TITLES.get(response.status_code, "Error"),
                     "status": response.status_code,
                     "detail": response.data.get("detail", ""),
                     "errors": response.data.get("errors", [])}
    return response

DomainError raised from reserve() produces exactly the designed body — Lesson 10's 409 now speaks the contract. 3. Mandatory minimum: type, title, status (detail optional: some errors only say "no" — the frontend maps by type).

Exercise 3 — Pagination

  1. DEFAULT_PAGINATION_CLASS per view with a pagination_class override: CursorPagination on the public listing (stable order by starts_at), PageNumberPagination in admin.
  2. page=999 on offset scans and discards 24,975 rows (OFFSET is O(n)); the cursor remains an index seek: O(log n) + 25. With 500 events you won't notice; with 500k it is the difference between 300ms and 3ms.
  3. django-filter: filterset_fields or an explicit FilterSet; OrderingFilter with ordering_fields = ["starts_at", "title"] — anything not listed does not sort.

Exercise 4 — UUID

  1. Migration: AddField UUID default uuid4 nullable → backfill (11: in batches) → unique. Or default uuid4 unique in one go (Django's data loader fills new rows; on existing rows the default applies on modern Postgres).
  2. lookup_field = "uuid" on viewsets; serializers expose uuid = UUIDField(read_only=True). The integer stops being addressable.
  3. It would be enumerable a priori (UUID doesn't protect by itself): the layer that prevents it is authorization (owner or staff — Lesson 21) + token scopes. UUID = anti-enumeration hygiene, not access control.

Professor's summary

  • Plural resources, short hierarchy, actions as creation sub-resources.
  • One error format (RFC 7807) with a mappable type: the frontend stops guessing.
  • Cursor for the growing public side; offset for the panel; whitelist on ordering.
  • Public UUID as hygiene; the real barrier is authorization.

Next: Lesson 14 — Idempotency and versioning.