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. Objetivos
  2. 1. Recursos: sustantivos en plural, jerarquía corta
  3. 2. Errores: un formato, todos los endpoints
  4. 3. Paginación, filtros y ordenación
  5. 4. Identidad pública: UUIDs fuera, enteros dentro
  6. 5. Contrato antes que suerte: OpenAPI adelantado
  7. 6. Cabeceras: la mitad oculta del contrato REST
  8. 7. Idempotencia de los verbos, por la práctica
  9. Autoevaluación

Stack: Django REST Framework · Proyecto: TicketFlow Estado: Publicada Prerrequisito: Lección 12 — NoSQL


Objetivos

  1. Diseñar la API de TicketFlow como recursos consistentes (nombres, jerarquía, verbos).
  2. Definir un formato de error único (RFC 7807) que el frontend pueda tratar como contrato.
  3. Paginar, filtrar y ordenar con criterio (offset vs cursor).
  4. Decidir identidad pública: UUIDs expuestos, IDs internos nunca.

1. Recursos: sustantivos en plural, jerarquía corta

GET    /api/events/                    listado público
POST   /api/events/                    (organizador) crear
GET    /api/events/{uuid}/             detalle
PATCH  /api/events/{uuid}/             editar parcial
GET    /api/events/{uuid}/seats/       asientos de un evento
GET    /api/events/{uuid}/availability/  disponibilidad (cacheada)
POST   /api/reservations/              crear reserva (los asientos van en el cuerpo)
GET    /api/reservations/{uuid}/       mi reserva
DELETE /api/reservations/{uuid}/       cancelar (idempotente)
POST   /api/reservations/{uuid}/payments/  iniciar pago de esa reserva
POST   /api/payments/webhook/          webhook de la pasarela

Reglas del oficio: verbos en el body, no en la ruta (la acción es el verbo HTTP; si de verdad es una acción de dominio, sub-recurso o POST corto como /payments/webhook/); sin verbos tipo /getEvents/; anidación máxima 1 nivel (más profundo = URLs frágiles); plurales consistentes.

2. Errores: un formato, todos los endpoints

RFC 7807 (application/problem+json) — el contrato de error:

json
{
  "type": "https://docs.ticketflow.app/errors/seat-already-reserved",
  "title": "El asiento ya está reservado",
  "status": 409,
  "detail": "El asiento A-12 acaba de reservarse por otra persona",
  "instance": "/api/reservations/",
  "errors": [{"field": "seats", "index": 1, "code": "taken"}]
}

En DRF: un exception handler centralizado (Lección 26 lo sistematiza; aquí el diseño): todo error —validación de serializer, negocio, auth— sale con esta forma. type es un URI documentado (el frontend puede mapear códigos a textos/iconos); errors lleva el detalle por campo. Los códigos HTTP correctos (Lección 01) + este cuerpo = contrato completo.

3. Paginación, filtros y ordenación

  • Offset (?page=2&page_size=25): simple, pero salta filas si insertan mientras paginas, y page=99999 es caro (OFFSET escanea). Para paneles de admin: suficiente.
  • Cursor (?cursor=eyJpZCI6MTIzfQ): estable ante inserciones y O(1) por página; opaco y solo-adelante. Para listados públicos de alto tráfico (feed de eventos): el correcto.
  • Filtros: ?state=PUBLISHED&starts_after=2027-01-01 — nombres de campo reales, sin DSL inventado; django-filter lo estandariza. Rango: _after/_before mejor que _min/_max cuando es temporal.
  • Orden: ?ordering=-starts_at con lista blanca de campos (nunca ordering=; DROP — el serializer no es SQL, pero el whitelist es higiene).

4. Identidad pública: UUIDs fuera, enteros dentro

La API expone uuid (no secuencial: no filtra volumen de negocio ni permite enumerar recursos); la PK entera queda interna. En DRF: lookup_field = "uuid" en el router/viewset, y SERIALIZE el uuid, no el id. Bonus de seguridad por oscuridad justificada: enumerar reservas ajenas probando IDs consecutivos deja de ser viable (la auth de la 18-21 es la barrera real; esto es higiene).

5. Contrato antes que suerte: OpenAPI adelantado

Con drf-spectacular, el schema OpenAPI se genera del código: los 15/26 consolidan esta práctica (validación y errores centralizados). Desde hoy: cada endpoint nuevo de TicketFlow se añade con su serializer documentado — el contrato se despliega con el código, no se escribe a mano después.

6. Cabeceras: la mitad oculta del contrato REST

El REST que se diseña en el cuerpo ignora que muchas decisiones viven en CABECERAS — y son parte del contrato exactamente igual:

CabeceraQué prometeLección que la explota
ETag / If-None-Match"si no cambió, 304 sin body"38/39: ahorro de ancho en el browse
Vary: Accept-Encoding"la respuesta depende del encoding"39: el caché no sirve comprimido a quien no lo pide
Cache-Control"quién puede cachear y hasta cuándo"38: public para el catálogo, no-store para lo personal
Retry-After"cuándo vale la pena reintentar"26/54: el 429 y el 503 se acompañan de segundos
X-Request-ID"el hilo de esta petición"45: el trace_id que el cliente puede reportar
X-RateLimit-Remaining"cuántas peticiones le quedan"23: el cliente ajusta su ritmo sin recibir 429
Location"dónde quedó el recurso creado"14: el 202 del pago apunta al endpoint de estado

Las dos reglas del proyecto: (1) si una cabecera cambia el comportamiento del cliente, entra en el test de contrato (35: los headers se verifican como el body); (2) Location en el 201 SIEMPRE — el cliente que crea no adivina la URL del recurso nuevo.

7. Idempotencia de los verbos, por la práctica

La tabla de verbos con su promesa de idempotencia (la que hace posible el retry de la 54): GET idempotente y seguro (no cambia nada); PUT idempotente (la misma entidad, el mismo resultado); DELETE idempotente (borrar lo ya borrado → 404 o 204, pero el estado final es el mismo); POST NO idempotente (por eso el Idempotency-Key de la 14 existe); PATCH depende del tipo de parche (un SET status=X es idempotente; un incremento NO). El diseño de TicketFlow respeta la tabla: el cobro es POST con clave de idempotencia; la cancelación de reserva es DELETE sobre la reserva (idempotente: cancelar dos veces no doble-cancela) — y el job de la 29 puede reintentarla sin miedo.


Autoevaluación

  1. ¿Por qué POST /api/reservations/ con asientos en el cuerpo y no POST /api/events/{id}/reserve/? ¿Cuándo sí justificarías el sub-recurso de acción?
  2. ¿Qué gana el frontend con type (URI) en cada error, además del title?
  3. Un listado público tendrá picos de 100 rps y crece sin fin: ¿offset o cursor y por qué?
  4. ¿Qué ataca el uso de UUIDs públicos y por qué NO sustituye a la autenticación/autorización?
  5. ¿Por qué el whitelist de campos de ordering es obligatorio aunque DRF escape el parámetro?

Continúa con los ejercicios. Las soluciones solo tras intentarlo.