Módulo 3 · Diseño de APIs

Lección 13 — REST bien hecho

Recursos, verbos, errores consistentes, paginación, filtros y ordenación.

Publicada
En esta lección
  1. Ejercicio 1 — Superficie
  2. Ejercicio 2 — Handler
  3. Ejercicio 3 — Paginación
  4. Ejercicio 4 — UUID
  5. Resumen del profesor

Ejercicio 1 — Superficie

POST /api/auth/register/            público        201
POST /api/auth/login/               público        200
GET  /api/events/                   público        200
POST /api/events/                   organizador    201
GET  /api/events/{uuid}/            público        200
PATCH /api/events/{uuid}/           organizador    200
GET  /api/events/{uuid}/seats/      público        200
GET  /api/events/{uuid}/availability/ público      200 (cacheada)
POST /api/reservations/             autenticado    201 / 409
GET  /api/reservations/{uuid}/      dueño          200
DELETE /api/reservations/{uuid}/    dueño          204 (idempotente)
POST /api/reservations/{uuid}/payments/ dueño     202 (cola) / 201
POST /api/payments/webhook/         sistema (firma) 200
POST /api/gift-cards/{code}/redeem/ autenticado    200 / 409

Crítica típica: POST /api/events/{id}/reserve/ (verbo en ruta) se convierte en POST /api/reservations/ con {event, seats} en el cuerpo — el recurso creado es la reserva. Sub-recurso de acción sí para payments/ (crea un pago DENTRO de la reserva: es creación de sub-recurso real, no verbo disfrazado).

Ejercicio 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 lo lleva el logger (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 desde reservar() produce exactamente el cuerpo diseñado — el 409 de la 10 ahora habla contrato. 3. Mínimo obligatorio: type, title, status (detail opcional: hay errores que solo dicen "no" — el frontend mapea por type).

Ejercicio 3 — Paginación

  1. DEFAULT_PAGINATION_CLASS por vista con pagination_class override: CursorPagination en el listado público (orden estable por starts_at), PageNumberPagination en admin.
  2. page=999 en offset escanea y descarta 24.975 filas (OFFSET es O(n)); el cursor sigue siendo seek por índice: O(log n) + 25. Con 500 eventos no se nota; con 500k es la diferencia entre 300ms y 3ms.
  3. django-filter: filterset_fields o FilterSet explícito; OrderingFilter con ordering_fields = ["starts_at", "title"] — lo que no está en la lista, no ordena.

Ejercicio 4 — UUID

  1. Migración: AddField UUID default uuid4 nullable → backfill (11: por lotes) → unique. O default uuid4 unique de golpe (dataloader de Django llena las filas nuevas; en filas existentes el default aplica en Postgres moderno).
  2. lookup_field = "uuid" en viewsets; serializers exponen uuid = UUIDField(read_only=True). El entero deja de ser direccionable.
  3. Sí sería enumerable a priori (UUID no protege por sí): la capa que lo impide es la autorización (dueño o staff — Lección 21) + scopes del token. UUID = higiene anti-enumeración, no control de acceso.

Resumen del profesor

  • Recursos en plural, jerarquía corta, acciones como sub-recursos de creación.
  • Un formato de error (RFC 7807) con type mapeable: el frontend deja de adivinar.
  • Cursor para lo público que crece; offset para el panel; whitelist en ordering.
  • UUID público como higiene; la barrera real es la autorización.

Después: Lección 14 — Idempotencia y versionado.