En la lección anterior, 06-01, cerramos la memoria técnica de la API de Tienda Aroma con una lista de certezas: paginación por desplazamiento, contadores exactos, autorización basada en la propiedad del recurso, colecciones envueltas en datos con su total, y tiempo real como accesorio del panel interno. Todas esas decisiones eran correctas para aquel dominio. Ahora cambiamos de dominio sin cambiar de estilo arquitectónico: diseñamos CafeSocial, la red de catadores que Tienda Aroma quiere lanzar, y una a una vamos a ver caer esas certezas. No porque estuvieran mal, sino porque estaban atadas a un contexto que ya no existe. Ese contraste —la misma disciplina REST produciendo diseños opuestos— es el verdadero contenido de esta lección.
Contenido
- El escenario: CafeSocial, requisitos y consumidores
- Modelado del grafo de relaciones con REST
- La línea de tiempo: fan-out, recurso derivado y cursor opaco
- Volumen y escala: contadores aproximados, caché y colas
- Contenido generado por usuarios: subida, moderación y denuncias
- Privacidad y autorización a nivel de recurso
- Notificaciones y tiempo real
- Tienda Aroma frente a CafeSocial, decisión a decisión
- Por qué aquí GraphQL sí es defendible
- Errores comunes y consejos
- Ejercicios y soluciones
- El escenario: CafeSocial, requisitos y consumidores
CafeSocial es una red social vertical de catadores. Un usuario publica una cata: una foto del café, la variedad, el origen, el método de preparación y una nota del 0 al 100. Otros usuarios comentan, dan "me gusta", siguen a los catadores que les interesan y reciben una línea de tiempo con las publicaciones de la gente a la que siguen. Hay hashtags (#geisha, #v60), notificaciones y mensajes directos.
Los identificadores mantienen la convención del curso: usr_10, usr_77, pub_2100, com_310, not_55. El JSON sigue siendo camelCase, los errores siguen el catálogo de {"error": {"codigo","mensaje","detalles":[]}} y la versión sigue en la ruta (/v1). Cambia el dominio, no las convenciones: eso es justamente lo que permite comparar.
Requisitos que condicionan el diseño
| Requisito | Cifra objetivo (ficticia) | Consecuencia de diseño |
|---|---|---|
| Proporción lectura/escritura | ~500:1 | Todo se optimiza para lectura; la escritura puede ser más cara |
| Latencia de la línea de tiempo | p95 < 150 ms | Precalcular, no calcular en la petición |
| Tamaño del grafo | 2 M usuarios, 180 M aristas de seguimiento | La relación es un recurso de primer nivel, no un campo |
| Distribución de seguidores | Cola larga: el 0,01 % supera 100 000 seguidores | Un único algoritmo de reparto no sirve |
| Contenido de usuarios | 40 000 publicaciones con foto al día | Moderación y almacenamiento fuera de la API |
| Consistencia | Eventual aceptable en línea de tiempo y contadores | Se puede desacoplar con colas |
Consumidores
- App móvil (iOS/Android): el consumidor principal, con ancho de banda y batería limitados. Le duele cada byte y cada ida y vuelta.
- Web pública: perfiles y publicaciones indexables por buscadores cuando son públicos.
- Panel de moderación interno: pocos usuarios, permisos amplios, necesita colas de trabajo.
- Integración con Tienda Aroma: cuando una publicación menciona un café del catálogo, la ficha enlaza a
/v1/cafes/caf_001de la API de la tienda. Son dos APIs distintas que se enlazan por hipermedia, no una sola.
A diferencia de la tienda, aquí no hay dinero en la petición. Nada exige la exactitud transaccional que en 06-01 nos obligó a resolver la sobreventa dentro del
UPDATE. Esa libertad es la que hace posible casi todo lo que viene a continuación.
- Modelado del grafo de relaciones con REST
En Tienda Aroma casi todo era una relación de contención simple: un pedido pertenece a un cliente, una línea pertenece a un pedido. Aquí la relación es la entidad interesante.
2.1 Las dos colecciones del grafo
GET /v1/usuarios/usr_10/seguidores?limite=20 HTTP/1.1
GET /v1/usuarios/usr_10/siguiendo?limite=20 HTTP/1.1Son dos vistas de la misma arista, desde los dos extremos. Ninguna es "la buena": la app necesita las dos y con cardinalidades muy distintas (un usuario sigue a 300, pero puede tener 300 000 seguidores).
2.2 La relación como recurso: PUT frente a POST /seguir
La tentación es crear un verbo: POST /v1/seguir con {"seguidoId": "usr_77"}. Funciona, y es exactamente lo que en 02-02 llamábamos convertir una acción en recurso sin necesidad. La alternativa es tratar la arista como un recurso direccionable:
PUT /v1/usuarios/usr_10/siguiendo/usr_77 HTTP/1.1
Authorization: Bearer <token de usr_10>
HTTP/1.1 204 No Content
Aroma-Grafo-Estado: siguiendoLas ventajas, recuperando la tabla de métodos de 02-03:
| Aspecto | PUT /siguiendo/{id} |
POST /seguir |
|---|---|---|
| Idempotencia | Sí: pulsar "seguir" cinco veces deja el mismo estado | No garantizada; hay que deduplicar |
| Reintento tras timeout | Seguro por definición | Requiere Idempotency-Key |
| Consulta del estado | GET del mismo URI (204/404) |
Hay que inventar otro endpoint |
| Deshacer | DELETE del mismo URI |
Otro verbo: POST /dejar-de-seguir |
| Cacheable / enlazable | Sí, tiene URI propia | No |
En una app móvil con red inestable, la idempotencia no es elegancia teórica: es la diferencia entre un botón que se queda "a medias" y uno que no. El cliente puede reintentar el PUT sin pensar.
GET /v1/usuarios/usr_10/siguiendo/usr_77 devuelve 204 si la relación existe y 404 si no, lo que da al cliente una comprobación barata para pintar el botón.
2.3 "Me gusta": ¿usuario en la ruta o en el token?
Las dos formas son legítimas y conviene entender el compromiso:
PUT /v1/publicaciones/pub_2100/megusta/usr_10 # A: sujeto explícito
PUT /v1/publicaciones/pub_2100/megusta # B: sujeto implícito en el token| Criterio | A (explícito) | B (implícito) |
|---|---|---|
| URI autodescriptiva | Sí | No: el mismo URI significa cosas distintas según quién llame |
| Riesgo de suplantación | Hay que validar que la ruta coincide con el token | Imposible por construcción |
| Actuar en nombre de otro (admin, importación) | Directo | Necesita una cabecera o endpoint aparte |
| Listar quién dio "me gusta" | GET /publicaciones/pub_2100/megusta natural |
Igual de natural |
| Caché | Cacheable por URI | Necesita Vary: Authorization |
En CafeSocial elegimos A para el grafo de seguimiento y para "me gusta", por coherencia y porque el panel de moderación necesita poder retirar un "me gusta" de otro usuario. La regla que aplicamos: si algún consumidor legítimo puede actuar sobre la relación de un tercero, el sujeto va en la ruta. Cuando la ruta y el token no coinciden y el llamante no tiene el ámbito adecuado, respondemos 403 con permisos_insuficientes.
2.4 Cuándo la relación necesita cuerpo propio
Una arista con atributos deja de ser un simple "existe o no". Seguir a alguien puede tener preferencias asociadas:
PUT /v1/usuarios/usr_10/siguiendo/usr_77 HTTP/1.1
Content-Type: application/json
{"notificaciones": "silenciadas", "verRepublicaciones": false}HTTP/1.1 200 OK
Content-Type: application/json
ETag: "w/rel-usr10-usr77-3"
{
"usuarioId": "usr_10",
"seguidoId": "usr_77",
"creadoEn": "2026-03-04T10:22:11Z",
"notificaciones": "silenciadas",
"verRepublicaciones": false,
"_links": {
"self": {"href": "/v1/usuarios/usr_10/siguiendo/usr_77"},
"seguido": {"href": "/v1/usuarios/usr_77"}
}
}Regla práctica: sin atributos, 204 y cuerpo vacío; con atributos, 200 y representación completa con ETag, y entonces las modificaciones parciales (PATCH) y la concurrencia optimista de 03-06 vuelven a aplicar. No conviene inventar atributos "por si acaso": una arista con cuerpo cuesta una fila más ancha multiplicada por 180 millones.
- La línea de tiempo: fan-out, recurso derivado y cursor opaco
3.1 El problema del fan-out
Cuando usr_77 publica, ¿quién paga el coste de que aparezca en la línea de tiempo de sus seguidores?
graph LR
A[usr_77 publica pub_2100] --> B{Estrategia}
B -->|Fan-out en escritura| C[Cola de reparto]
C --> D[Insertar en 300.000 buzones]
D --> E[GET /lineatiempo lee un buzon: rapido]
B -->|Fan-out en lectura| F[Escritura barata: 1 fila]
F --> G[GET /lineatiempo consulta a 300 seguidos y mezcla]
G --> H[Latencia alta y variable]
| Criterio | Fan-out en escritura (push) | Fan-out en lectura (pull) |
|---|---|---|
| Coste de publicar | Alto: O(seguidores) | Mínimo: O(1) |
| Coste de leer la línea | Mínimo: lectura secuencial de un buzón | Alto: O(seguidos), mezcla ordenada |
| Latencia p95 de lectura | Baja y estable | Alta y dependiente del usuario |
| Almacenamiento | Enorme (duplicado por seguidor) | Mínimo |
| Publicar con 1 M de seguidores | Un millón de escrituras por publicación | Sin coste extra |
| Borrar una publicación | Hay que limpiar todos los buzones | Desaparece sola |
| Encaja con | Mayoría de usuarios normales | Cuentas muy seguidas |
Con 500 lecturas por cada escritura, el fan-out en escritura gana casi siempre. El problema es la cola larga: si usr_77 es un catador famoso con un millón de seguidores, cada publicación suya dispara un millón de inserciones y la cola se atasca durante minutos.
Solución híbrida, que es la que adopta CafeSocial:
- Los usuarios con menos de 10 000 seguidores usan push: al publicar, una tarea asíncrona inserta la referencia en el buzón de cada seguidor.
- Los usuarios por encima de ese umbral se marcan como cuentas de alcance y usan pull: no reparten nada.
GET /v1/lineatiempolee el buzón del usuario y mezcla en el momento las publicaciones recientes de las pocas cuentas de alcance que sigue (normalmente menos de 20), ordenando por marca temporal.
Es más código y más complejidad, pero es el único diseño que sobrevive a las dos puntas de la distribución.
3.2 /lineatiempo no es una colección
En Tienda Aroma, /cafes era una colección: se podía crear (POST), contar (total), filtrar y saltar a la página 7. La línea de tiempo no es nada de eso: es un recurso derivado, una vista calculada que solo existe para un usuario y en un instante.
| Propiedad de una colección normal | En /lineatiempo |
|---|---|
POST para crear un elemento |
No existe: se publica en /publicaciones, no en la línea |
total estable |
Imposible: no hay un conjunto cerrado que contar |
| Saltar a la página N | No tiene sentido: la página 7 de hace 3 s ya no es la misma |
| Filtros arbitrarios | Muy limitados (?soloConFoto=true), no es un buscador |
DELETE de un elemento |
No: se oculta o se deja de seguir al autor |
Dicho de otro modo: /lineatiempo responde "¿qué hay de nuevo para mí?", no "¿qué elementos contiene este conjunto?". Igual que en 02-06 distinguíamos búsqueda de listado, aquí distinguimos flujo de colección.
3.3 Por qué la paginación por desplazamiento no sirve
Recuperemos el mecanismo de 02-06: ?limite=20&desplazamiento=20 traduce a LIMIT 20 OFFSET 20. Eso presupone que el conjunto ordenado no cambia entre peticiones. En una línea de tiempo ordenada por fecha descendente, cambia cada segundo.
Ejemplo numérico. La línea de usr_10 contiene, ordenadas de más nueva a más antigua, las publicaciones pub_2100 (posición 1) hasta pub_2001 (posición 100).
Caso de duplicados. El cliente pide la primera página:
Mientras el usuario lee, llegan 3 publicaciones nuevas. Ahora todo se ha desplazado 3 posiciones. El cliente pide la segunda página:
GET /v1/lineatiempo?limite=20&desplazamiento=20
→ posiciones 21..40 del conjunto NUEVO = pub_2084 ... pub_2065pub_2084, pub_2083 y pub_2082 ya se habían mostrado en la primera página. El usuario ve tres publicaciones repetidas.
Caso de saltos. Con el mismo estado inicial, entre la primera y la segunda petición se borran 3 publicaciones de las 20 primeras. Al pedir desplazamiento=20, las posiciones 21..40 del conjunto reducido corresponden a lo que antes eran las posiciones 24..43: pub_2078 y las dos anteriores nunca se muestran. El usuario no las verá jamás, y no hay ningún error visible que lo delate.
Añádase que OFFSET 200000 obliga al motor a recorrer y descartar 200 000 filas: el coste crece con la profundidad. Duplicados, huecos silenciosos y coste creciente: tres razones independientes, cada una suficiente.
3.4 El cursor opaco
En Tienda Aroma el cursor era una opción que aplicamos a /pedidos. Aquí es obligatorio. El cursor implementa paginación por clave (keyset): en vez de "sáltate 20 filas", dice "dame lo anterior a este punto exacto".
Contenido del cursor: marca temporal + id de desempate. La marca sola no basta porque dos publicaciones pueden compartir milisegundo; el id rompe el empate y garantiza un orden total.
// utilidades/cursor.js — codificación y decodificación del cursor opaco
const CLAVE_VERSION = 'v1';
function codificarCursor({ creadoEn, id }) {
const carga = JSON.stringify({ v: CLAVE_VERSION, t: creadoEn, i: id });
return Buffer.from(carga, 'utf8').toString('base64url');
}
function decodificarCursor(cursor) {
let datos;
try {
datos = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'));
} catch {
throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
}
if (datos.v !== CLAVE_VERSION || !datos.t || !datos.i) {
throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
}
// Límite de antigüedad: un cursor viejo apunta a un buzón ya recortado.
const antiguedadDias = (Date.now() - Date.parse(datos.t)) / 86_400_000;
if (antiguedadDias > 30) {
throw new ErrorApi(410, 'cursor_caducado', 'Vuelve a empezar desde el principio.');
}
return { creadoEn: datos.t, id: datos.i };
}La consulta correspondiente usa la comparación de tuplas, que aprovecha el índice compuesto (usuario_id, creado_en DESC, publicacion_id DESC):
-- Pedimos limite+1 filas para saber si hay página siguiente sin contar el total
SELECT p.publicacion_id, p.autor_id, p.creado_en
FROM buzon b
JOIN publicaciones p ON p.publicacion_id = b.publicacion_id
WHERE b.usuario_id = :usuarioId
AND (p.creado_en, p.publicacion_id) < (:cursorFecha, :cursorId)
ORDER BY p.creado_en DESC, p.publicacion_id DESC
LIMIT :limite + 1;La respuesta no lleva total —no existe— y expone el enlace siguiente tanto en el cuerpo como en la cabecera Link de RFC 8288, igual que en la tienda:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: private, max-age=0, must-revalidate
Vary: Authorization
Link: </v1/lineatiempo?limite=20&cursor=eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0>; rel="next"
{
"datos": [
{
"id": "pub_2100",
"autor": {"id": "usr_77", "alias": "catadora_geisha"},
"nota": 92,
"metodo": "v60",
"megusta": 1240,
"megustaAproximado": true,
"_links": {"self": {"href": "/v1/publicaciones/pub_2100"}}
}
],
"paginacion": {
"siguiente": "eyJ2IjoidjEiLCJ0IjoiMjAyNi0wOC0xNFQwOToxMjozMy40MTJaIiwiaSI6InB1Yl8yMDgxIn0",
"hayMas": true
}
}Por qué opaco. El cursor es base64url de un JSON, no cifrado: cualquiera puede leerlo. La opacidad es un contrato, no una medida de seguridad: al no documentar su interior, podemos cambiar mañana de (fecha, id) a un identificador de posición en un índice distribuido sin romper a ningún cliente. Lo que sí garantizamos es la validación: si el cliente lo manipula, la decodificación falla (400 cursor_invalido) o los valores no superan la comprobación de tipo. Y como el cursor no contiene el identificador del usuario —ese sale siempre del token—, manipularlo no permite leer el buzón de otro. Ese punto es esencial: nunca metas en el cursor información de autorización.
Antigüedad máxima. Los buzones se recortan a las últimas 800 entradas y a 30 días. Un cursor más antiguo apunta a un vacío, así que respondemos 410 Gone con cursor_caducado y el cliente vuelve al principio, en lugar de devolver una lista vacía que la app interpretaría como "fin del contenido".
- Volumen y escala
4.1 Contadores aproximados a propósito
En Tienda Aroma, el stock tenía que ser exacto: de ahí salió la condición dentro del UPDATE que resolvía la sobreventa. Aquí, SELECT COUNT(*) FROM megusta WHERE publicacion_id = 'pub_2100' en cada lectura de la línea de tiempo significa 20 consultas de conteo por pantalla, multiplicadas por millones de pantallas.
La decisión: contador denormalizado, actualizado de forma asíncrona y declarado como aproximado.
// servicios/megusta.js
async function darMeGusta(publicacionId, usuarioId) {
const creado = await repoMeGusta.insertarSiNoExiste(publicacionId, usuarioId);
if (creado) {
// El contador no se actualiza aquí: se agrega por lotes cada 5 segundos.
await cola.publicar('contadores.megusta', { publicacionId, delta: 1 });
}
return creado; // permite responder 201 la primera vez y 204 en reintentos
}Las consecuencias hay que asumirlas de forma explícita, no esconderlas:
- La respuesta marca
"megustaAproximado": truecuando el contador supera 1 000. Por debajo se recalcula en el momento y es exacto: los usuarios notan un desfase en 12 pero no en 12 480. - Un trabajo nocturno reconcilia los contadores con la tabla real.
- El propio usuario siempre ve reflejado su propio "me gusta" al instante (lectura de tus propias escrituras), aunque el número global tarde: es lo que evita que la interfaz parezca rota.
4.2 Caché de perfiles y el problema de "depende de quién pregunta"
Retomando 04-06, el perfil es el candidato ideal a caché: se lee constantemente y cambia poco.
GET /v1/usuarios/usr_77 HTTP/1.1
If-None-Match: "perf-usr77-v18"
HTTP/1.1 304 Not Modified
ETag: "perf-usr77-v18"
Cache-Control: private, max-age=60
Vary: AuthorizationLo delicado es qué se puede cachear compartidamente:
| Recurso | Directiva | Motivo |
|---|---|---|
| Perfil público, sin sesión | public, max-age=300 |
Igual para todo el mundo |
| Perfil visto por un usuario autenticado | private, max-age=60 + Vary: Authorization |
Incluye "¿le sigo?", "¿me bloqueó?" |
| Línea de tiempo | private, max-age=0, must-revalidate |
Única por usuario y por instante |
| Imagen de publicación (CDN) | public, max-age=31536000, immutable |
URL con huella de contenido |
Vary: Authorization es correcto pero, en la práctica, destruye la caché compartida: cada token genera una entrada distinta. Por eso la estrategia real es partir la respuesta en dos: los datos objetivos del perfil (alias, biografía, foto) se sirven cacheables y públicos, y lo relativo al observador (siguiendoAEsteUsuario, meBloquea) se pide aparte o se marca private. Cachear la mezcla es el error clásico, y su versión más grave es un proxy devolviendo el perfil "visto por otro usuario" a quien no debe.
4.3 Colas, 202 y recursos de tarea
El reparto del fan-out y otras operaciones largas no caben en el ciclo de la petición. Igual que en 04-08 con los informes de la tienda:
POST /v1/usuarios/usr_10/exportacion HTTP/1.1
HTTP/1.1 202 Accepted
Location: /v1/tareas/tar_9001
Retry-After: 10
{"id": "tar_9001", "estado": "en_proceso",
"_links": {"self": {"href": "/v1/tareas/tar_9001"}}}Publicar es lo mismo pero al revés: POST /v1/publicaciones responde 201 inmediatamente con la publicación creada —el autor la ve ya en su perfil— mientras el reparto a los buzones ocurre por detrás. Es la consistencia eventual hecha contrato: un seguidor puede tardar unos segundos en verla, y eso está documentado.
4.4 El coste de la denormalización
Denormalizar no es gratis. En CafeSocial pagamos: almacenamiento multiplicado (una publicación aparece en cientos de miles de buzones), un camino de escritura más frágil (si la cola falla, hay buzones incompletos y hace falta un proceso de reparación), y dos fuentes de verdad que pueden divergir. La regla es que la fuente canónica sigue siendo /publicaciones/{id}; los buzones son caché reconstruible. Todo lo que se pueda regenerar desde la fuente canónica es aceptable denormalizarlo; lo que no, no.
- Contenido generado por usuarios
5.1 Subida de imágenes con URL prefirmada
La API no recibe los megabytes de la foto. Se emite una autorización de subida directa al almacenamiento de objetos:
sequenceDiagram
participant App as App movil
participant API as API CafeSocial
participant Alm as Almacenamiento
App->>API: POST /v1/publicaciones/pub_2100/imagenes {tipo, tamano}
API-->>App: 201 {urlSubida, campos, expiraEn, imagenId}
App->>Alm: PUT urlSubida (bytes de la foto)
Alm-->>App: 200 OK
App->>API: POST /v1/publicaciones/pub_2100/imagenes/img_44/confirmacion
API->>Alm: Verificar tipo, tamano y cabecera del fichero
API-->>App: 200 {estado: "pendiente_moderacion"}
POST /v1/publicaciones/pub_2100/imagenes HTTP/1.1
Content-Type: application/json
{"tipoContenido": "image/jpeg", "tamanoBytes": 2411520}HTTP/1.1 201 Created
Location: /v1/publicaciones/pub_2100/imagenes/img_44
{
"id": "img_44",
"urlSubida": "https://almacen.cafesocial.example/subidas/img_44?firma=...",
"metodo": "PUT",
"expiraEn": "2026-08-14T09:27:00Z",
"tamanoMaximoBytes": 5242880,
"estado": "pendiente_subida"
}Las razones para no pasar los bytes por la API son concretas:
- Un proceso Node ocupado 8 segundos con una subida de 5 MB es un proceso que no atiende a nadie más.
- El escalado del tráfico de subida se desacopla del escalado de la lógica de negocio.
- El almacenamiento y el CDN ya resuelven reanudación, multiparte y distribución geográfica.
- Los tiempos de espera de proxies y balanceadores dejan de ser un problema.
Validación. La URL prefirmada limita tipo y tamaño, pero eso no basta: el cliente declara image/jpeg y sube cualquier cosa. Por eso la confirmación es obligatoria y comprueba en servidor los bytes reales (números mágicos de la cabecera del fichero, dimensiones, tamaño), reprocesa la imagen a varios tamaños y descarta los metadatos EXIF —que incluyen coordenadas GPS: publicar la ubicación exacta de la casa de un usuario sería una fuga de datos personales. Una imagen sin confirmar en 15 minutos se borra y la publicación queda como borrador.
5.2 Moderación
El estado replica el de las reseñas de Tienda Aroma: pendiente_moderacion | publicada | rechazada. Lo que cambia es la escala, que obliga a dos etapas:
- Automática, en el momento: clasificador de imagen y texto. Puntuación baja, publicación directa; puntuación media, cola humana; puntuación alta, rechazo inmediato con posibilidad de recurso.
- Humana, sobre una cola de trabajo:
GET /v1/moderacion/pendientes?limite=50&cursor=... HTTP/1.1
Authorization: Bearer <token con ambito moderacion:leer>
POST /v1/moderacion/publicaciones/pub_2100/aprobacion HTTP/1.1
POST /v1/moderacion/publicaciones/pub_2100/rechazo HTTP/1.1
Content-Type: application/json
{"motivo": "contenido_no_relacionado", "notificarAutor": true}Detalles de diseño que importan:
- La cola de moderación vive bajo
/moderacion/*, un espacio de nombres propio con sus ámbitos (moderacion:leer,moderacion:escribir). Mezclarla con/publicacionesobligaría a que el mismo endpoint se comportara de forma radicalmente distinta según el ámbito, que es justo lo que hace las pruebas ingobernables. - Aprobación y rechazo son recursos-acción (
POSTa un subrecurso) porque no son idempotentes en sus efectos: disparan notificaciones y quedan auditados. Cada decisión se guarda con moderador, marca temporal y motivo. - La cola se pagina por cursor: crece y se consume al mismo tiempo.
5.3 Denuncias
POST /v1/publicaciones/pub_2100/denuncias HTTP/1.1
{"motivo": "spam", "comentario": "Publica el mismo enlace en todas las catas"}Se responde 202 y no 201 con el veredicto: la denuncia se acepta, no se resuelve. El denunciante no debe poder consultar el estado detallado ni la identidad del moderador, y el denunciado no debe conocer al denunciante. Varias denuncias sobre la misma publicación se agregan en un único caso.
Advertencia imprescindible. Todo lo anterior es la parte de ingeniería, que es la fácil. Un servicio real con contenido de usuarios tiene obligaciones legales que no son decisiones técnicas: bases de licitud y derechos RGPD, plazos de retirada de contenido ilícito, protección de menores y verificación de edad, retención y entrega de datos a autoridades, transparencia de la moderación y vías de recurso. Cambian por país y con el tiempo. Diséñalo con asesoría legal y de compliance desde el principio; un endpoint de borrado que no cumple el plazo legal es un problema jurídico, no un
TODOdel backlog.
- Privacidad y autorización a nivel de recurso
En Tienda Aroma la pregunta era "¿de quién es esto?": el pedido ped_5001 es de cli_842, luego solo cli_842 y los administradores lo ven. Binario y sencillo.
En CafeSocial la pregunta es "¿quién pregunta y qué le dejamos ver?", y la respuesta ya no es sí o no, sino una representación distinta:
Quién pregunta por usr_77 (perfil privado) |
Qué recibe |
|---|---|
El propio usr_77 |
Todo, incluidos borradores y correo |
| Un seguidor aceptado | Perfil completo y publicaciones |
| Un usuario autenticado que no le sigue | Alias, foto, biografía, contadores; sin publicaciones |
Un usuario bloqueado por usr_77 |
404 usuario_no_encontrado |
| Sin autenticar | 404 si el perfil es privado; ficha reducida si es público |
6.1 Proyección por visibilidad
El patrón: la capa de servicio devuelve la entidad completa y una proyección decide qué campos sobreviven según la relación entre observador y observado.
// presentacion/proyecciones/usuario.js
const CAMPOS = {
propietario: ['id','alias','nombre','bio','foto','correo','seguidores','siguiendo','privado'],
seguidor: ['id','alias','nombre','bio','foto','seguidores','siguiendo','privado'],
publico: ['id','alias','foto','bio','seguidores','privado'],
};
function proyectarUsuario(usuario, contexto) {
const nivel = calcularNivelVisibilidad(usuario, contexto); // propietario|seguidor|publico|oculto
if (nivel === 'oculto') throw new ErrorApi(404, 'usuario_no_encontrado', 'No encontrado.');
const salida = {};
for (const campo of CAMPOS[nivel]) salida[campo] = usuario[campo];
salida._links = enlacesSegunNivel(usuario, nivel);
return salida;
}Tres reglas que evitan los fallos habituales:
- Lista blanca, nunca lista negra. Un campo nuevo en la entidad no debe filtrarse solo por haberse añadido.
- La proyección se aplica también dentro de las listas.
GET /usuarios/usr_77/seguidoresrecorta a los usuarios bloqueados y a los privados: la lista devuelta puede ser más corta que el contador que muestra el perfil, y eso es correcto. - La visibilidad se decide en la consulta, no después. Filtrar en memoria tras traer 50 filas rompe la paginación por cursor: pedirías 20 y devolverías 14.
6.2 404 en vez de 403
Si usr_77 bloquea a usr_10 y este pide GET /v1/usuarios/usr_77, un 403 permisos_insuficientes sería técnicamente honesto y filtraría información: confirmaría que la cuenta existe, y por diferencia de respuestas se podría enumerar quién ha bloqueado a quién o descubrir qué alias están registrados. Cuando la mera existencia del recurso es información sensible, se responde 404.
El criterio general: 403 cuando el recurso es conocido por el llamante o su existencia no revela nada (el moderador que no tiene el ámbito adecuado); 404 cuando revelar la existencia ya es una fuga. Y hay que ser consistente en tiempos de respuesta: un 404 que tarda 5 ms cuando el recurso no existe y 40 ms cuando existe pero está bloqueado vuelve a filtrar la información por un canal lateral.
6.3 Efecto sobre caché y pruebas
- Caché: cualquier respuesta cuya forma dependa del observador es
privatey conVary: Authorization. Una CDN compartida solo puede servir lo objetivamente público. Esto reduce el rendimiento y es un coste asumido conscientemente. - Pruebas: la matriz crece de golpe. Cada endpoint sensible necesita casos para propietario, seguidor, no seguidor, bloqueado y anónimo. En CafeSocial se resuelve con una tabla de casos parametrizada:
describe('GET /v1/usuarios/usr_77 según observador', () => {
const casos = [
{ observador: 'propietario', estado: 200, incluye: ['correo'], excluye: [] },
{ observador: 'seguidor', estado: 200, incluye: ['nombre'], excluye: ['correo'] },
{ observador: 'extrano', estado: 200, incluye: ['alias'], excluye: ['nombre','correo'] },
{ observador: 'bloqueado', estado: 404, incluye: [], excluye: [] },
];
for (const c of casos) it(`observador ${c.observador} → ${c.estado}`, async () => { /* ... */ });
});
- Notificaciones y tiempo real
En Tienda Aroma, SSE era un extra del panel interno. Aquí, la inmediatez es el producto: una red social en la que la notificación llega dos minutos tarde se percibe como rota.
REST puro obligaría al sondeo: la app pregunta GET /v1/notificaciones cada 10 segundos. Con 500 000 usuarios activos son 50 000 peticiones por segundo, de las cuales el 99 % devuelven lo mismo. Ni con ETag y 304 (que ahorran ancho de banda, no peticiones) sale la cuenta.
El diseño combina tres piezas:
GET /v1/notificaciones?limite=30&cursor=... HTTP/1.1
{"datos": [
{"id": "not_55", "tipo": "megusta", "leida": false,
"actor": {"id": "usr_77", "alias": "catadora_geisha"},
"recurso": {"href": "/v1/publicaciones/pub_2100"},
"creadaEn": "2026-08-14T09:12:33Z"}
],
"noLeidas": 4,
"paginacion": {"siguiente": "eyJ2IjoidjEi...", "hayMas": true}}POST /v1/notificaciones/lectura HTTP/1.1
{"hasta": "not_55"} # marca como leídas hasta un punto, idempotenteEl histórico se pagina por cursor (mismo mecanismo que la línea de tiempo) y el canal en vivo solo transporta avisos de que hay algo nuevo, no el contenido completo: el cliente recibe la señal y recarga por REST. Así el canal en tiempo real no se convierte en una segunda API que mantener en paralelo.
| Mecanismo | Dirección | Cuándo elegirlo en CafeSocial | Coste |
|---|---|---|---|
Sondeo con ETag/304 |
Cliente→servidor | Respaldo cuando falla todo lo demás; datos poco urgentes | Peticiones constantes |
| Sondeo largo | Cliente→servidor | Compatibilidad con redes hostiles | Conexiones retenidas |
| SSE | Servidor→cliente | Contador de no leídas, publicaciones nuevas: es lo que usa la web | Unidireccional; reconexión automática y Last-Event-ID de serie |
| WebSockets | Bidireccional | Mensajes directos con "escribiendo…" y confirmación de lectura | Infraestructura propia, estado por conexión, más difícil de escalar |
| Webhooks (01-07) | Servidor→servidor | Integraciones: avisar a Tienda Aroma de una cata con nota alta | Reintentos, firma HMAC, entrega "al menos una vez" |
| Notificaciones push (APNs/FCM) | Servidor→dispositivo | App cerrada o en segundo plano | Dependencia de plataformas externas |
CafeSocial usa SSE para la web y el contador de notificaciones, WebSockets solo en la pantalla de mensajes directos, push cuando la app no está activa y webhooks firmados con HMAC-SHA256 hacia Tienda Aroma, reutilizando exactamente el mecanismo de 06-01.
- Tienda Aroma frente a CafeSocial, decisión a decisión
Esta es la tabla que da sentido a las dos lecciones juntas.
| Decisión | Tienda Aroma | CafeSocial | Qué lo cambia |
|---|---|---|---|
| Proporción lectura/escritura | ~10:1 | ~500:1 | Justifica precalcular y denormalizar |
| Paginación | limite/desplazamiento en cafés; cursor en pedidos |
Cursor obligatorio en todo flujo | El conjunto cambia entre peticiones |
total en colecciones |
Sí, útil y barato | No existe en flujos | No hay conjunto cerrado que contar |
| Consistencia | Fuerte y transaccional (stock, pagos) | Eventual y documentada | No hay dinero en la petición |
| Contadores | Exactos por definición | Aproximados por diseño | Coste de contar frente a valor de la exactitud |
| Autorización | "¿de quién es?" → 200/403 |
"¿quién pregunta?" → proyecciones y 404 |
La existencia del recurso es información |
| Forma de la respuesta | Estable para todos | Variable según observador | Privacidad y bloqueos |
| Caché | public con ETag en catálogo |
Casi todo private + Vary |
Depende del observador |
| Escrituras | Sincrónicas, con concurrencia optimista | Asíncronas, cola y 202 |
El fan-out no cabe en la petición |
| Coste del fan-out | Inexistente | Dominante: híbrido push/pull | La distribución de seguidores tiene cola larga |
| Ficheros | Sin subidas relevantes | URL prefirmada, fuera de la API | Volumen y tiempo de ocupación |
| Tiempo real | SSE como extra del panel | SSE + WebSockets como producto | La inmediatez es el valor percibido |
| Moderación | Reseñas, volumen bajo, revisión manual | Dos etapas, cola dedicada, denuncias | 40 000 elementos diarios |
| Relaciones | Contención simple (pedido→cliente) | Grafo: la arista es recurso con PUT/DELETE |
180 M de aristas con semántica propia |
| Idempotencia | Idempotency-Key en pagos |
PUT idempotente por diseño en el grafo |
Reintentos en redes móviles |
La conclusión no es que un diseño sea mejor. Es que no existe un diseño REST universal: existen decisiones dependientes del dominio, y la competencia profesional consiste en saber cuál aplica y poder justificarlo. Si alguien te propone "la forma correcta de paginar" sin preguntar por el dominio, te está vendiendo una respuesta antes de haber oído la pregunta.
- Por qué aquí GraphQL sí es defendible
En 01-07 concluimos que para Tienda Aroma GraphQL habría sido complejidad sin retorno: pocos tipos de pantalla, alto valor de la caché HTTP en el catálogo, un consumidor principal muy estable. En CafeSocial los argumentos cambian de signo:
- Pantallas con datos heterogéneos. El detalle de una publicación necesita publicación, autor, si lo sigo, primeros cinco comentarios con sus autores, contador de "me gusta", si yo di "me gusta", hashtags y café enlazado del catálogo. En REST son 5-7 peticiones, o un endpoint compuesto a medida que envejece mal.
- Clientes móviles con ancho de banda limitado. La lista pide 6 campos por publicación; el detalle, 30. Con REST se acaba inventando
?campos=o?vista=resumen, que es GraphQL mal hecho. - Evolución rápida del cliente. Un rediseño de la línea de tiempo cada trimestre implica, en REST, negociar cambios de contrato con el backend cada trimestre.
- Un grafo se consulta como un grafo. "Los últimos comentarios de la gente a la que sigo en publicaciones que también les gustaron a mis seguidores" es una consulta natural en GraphQL y un endpoint retorcido en REST.
Y lo que se pierde, que hay que poner en la misma balanza:
| Se gana | Se pierde |
|---|---|
| Una petición por pantalla | La caché HTTP intermedia: todo es POST /graphql |
| El cliente elige los campos | ETag/304 y CDN dejan de servir |
| Evolución sin versionar rutas | Códigos de estado: casi todo es 200 con errors |
| Esquema tipado y autodocumentado | El cliente puede construir consultas carísimas: hacen falta límites de profundidad, complejidad y consultas persistidas |
| Un único punto de entrada | Observabilidad y rate limiting por endpoint dejan de funcionar tal cual |
| — | Problema N+1 en los resolutores: obliga a DataLoader desde el primer día |
La decisión realista de CafeSocial —y la que veréis en muchas empresas— es híbrida: GraphQL para las pantallas de la app móvil, REST para lo público e indexable (perfiles y publicaciones cacheables en CDN), para las integraciones con terceros y para los webhooks. Elegir GraphQL no es abandonar lo aprendido: los recursos, los estados, la idempotencia, la paginación por cursor y la autorización por observador siguen siendo exactamente los mismos problemas, solo cambia la capa de transporte.
Errores Comunes y Consejos
- Usar
POST /seguirporque "es una acción". Pierdes idempotencia, consulta y borrado gratuitos. Si la acción crea o destruye una relación entre dos entidades identificables, esa relación tiene URI:PUT/DELETE(02-03). - Meter información de autorización en el cursor. Un cursor con
usuarioIddentro es una escalada de privilegios esperando a ocurrir. El sujeto sale siempre del token. - Confundir opaco con seguro. Base64url no cifra nada. La opacidad es libertad para cambiar el formato, no protección: valida siempre el contenido decodificado.
- Devolver
totalen un flujo. Obliga a unCOUNTcaro sobre un conjunto que cambia, y el número resultante es falso en cuanto se envía. UsahayMas. - Filtrar por visibilidad después de paginar. Pides 20, ocultas 6 y devuelves 14: el cliente cree que está llegando al final. La visibilidad va en la consulta.
- Lista negra de campos en las proyecciones. El día que añadas
correoRecuperaciona la entidad, se publicará solo. Lista blanca siempre. 403donde la existencia ya filtra información. Y ojo también con los tiempos de respuesta y los mensajes de error, que filtran por canales laterales.- Cachear como
publicuna respuesta que depende del observador. Es la vía más rápida a que un proxy sirva el perfil privado de un usuario a otro. Ante la duda,private. - Subir ficheros grandes a través de la API. Ocupa procesos, choca con los tiempos de espera de los proxies y no escala. URL prefirmada y confirmación posterior.
- Fiarte del
Content-Typedeclarado por el cliente. Verifica los bytes reales, reprocesa la imagen y elimina los metadatos EXIF antes de publicarla. - Diseñar el fan-out con un solo algoritmo. Push puro muere con las cuentas muy seguidas; pull puro muere con la latencia. El híbrido es feo y es el que funciona.
- Consejo de proceso: escribe la tabla comparativa del apartado 8 antes de programar. Obliga a justificar cada decisión frente a una alternativa concreta y es la mejor defensa contra copiar el diseño del último proyecto por inercia.
Ejercicios
Ejercicio 1 — El grafo como recurso. Diseña los endpoints para "silenciar" a un usuario al que sigues (dejas de ver sus publicaciones en tu línea de tiempo, pero sigues siendo su seguidor). Indica método, URI, códigos de estado y si necesita cuerpo. Justifica por qué no lo modelas como POST /v1/silenciar.
Ejercicio 2 — Cursor a prueba de manipulación. Un cliente envía ?cursor=eyJ2IjoidjEiLCJ0IjoiMjAzMC0wMS0wMVQwMDowMDowMFoiLCJpIjoicHViXzk5OTk5In0 (una fecha en el futuro). Explica qué devuelve el sistema, por qué eso no es un fallo de seguridad, y añade a decodificarCursor la validación que falta.
Ejercicio 3 — Proyección y paginación juntas. GET /v1/usuarios/usr_77/seguidores?limite=20 debe ocultar a los usuarios que han bloqueado al observador. Explica por qué filtrar en memoria rompe la paginación y esboza la consulta SQL correcta con cursor.
Soluciones
Solución 1. Silenciar es un atributo de una relación existente, no una relación nueva:
PATCH /v1/usuarios/usr_10/siguiendo/usr_77
Content-Type: application/json
If-Match: "w/rel-usr10-usr77-3"
{"silenciado": true}
HTTP/1.1 200 OK
ETag: "w/rel-usr10-usr77-4"
{"usuarioId":"usr_10","seguidoId":"usr_77","silenciado":true,"creadoEn":"2026-03-04T10:22:11Z"}Códigos: 200 correcto; 404 si no sigues a ese usuario (no hay relación que modificar); 412 si el If-Match no coincide; 422/400 con datos_invalidos si el cuerpo no valida. No se usa POST /v1/silenciar porque la relación ya tiene URI: crear un verbo paralelo duplicaría el recurso, perdería la idempotencia del PATCH sobre un estado concreto y obligaría a inventar POST /v1/dessilenciar. Alternativa igual de válida: PUT sobre la relación completa con todos sus atributos, si prefieres evitar PATCH.
Solución 2. El sistema decodifica correctamente el JSON (v válido, t e i presentes), la comprobación de antigüedad no salta porque la fecha es futura, y la consulta WHERE (creado_en, id) < ('2030-01-01', 'pub_99999') devuelve simplemente la primera página, ya que todo es anterior a esa fecha. No es un fallo de seguridad porque el usuarioId del WHERE procede del token, no del cursor: manipularlo solo permite reposicionarse dentro del propio buzón, algo que el usuario ya puede hacer paginando. Aun así conviene rechazarlo para detectar clientes rotos:
const t = Date.parse(datos.t);
if (Number.isNaN(t)) {
throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
}
if (t > Date.now() + 60_000) { // margen de 1 min por desfase de relojes
throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
}
if (typeof datos.i !== 'string' || !/^pub_[0-9]+$/.test(datos.i)) {
throw new ErrorApi(400, 'cursor_invalido', 'El cursor no es válido.');
}Solución 3. Filtrar en memoria rompe la paginación porque el LIMIT se aplica antes del filtro: pides 20 filas, descartas las 6 de usuarios que te han bloqueado y devuelves 14, mientras el cursor avanza como si hubieras entregado 20. El cliente ve páginas irregulares y, si una página entera queda vacía, interpreta que se acabó el contenido. El filtro debe ir en la consulta:
SELECT s.seguidor_id, s.creado_en
FROM seguidores s
WHERE s.seguido_id = :perfilId
AND NOT EXISTS (
SELECT 1 FROM bloqueos b
WHERE b.bloqueador_id = s.seguidor_id
AND b.bloqueado_id = :observadorId )
AND (s.creado_en, s.seguidor_id) < (:cursorFecha, :cursorId)
ORDER BY s.creado_en DESC, s.seguidor_id DESC
LIMIT :limite + 1;Consecuencia que hay que documentar: el número de elementos recorridos puede no coincidir con el contador seguidores del perfil, porque ese contador es global y la lista es relativa al observador. Y como la respuesta depende del observador, es private con Vary: Authorization.
Conclusión
Hemos diseñado CafeSocial con las mismas herramientas que Tienda Aroma —recursos, métodos, códigos de estado, cabeceras, hipermedia— y hemos obtenido un diseño casi opuesto. La relación de seguimiento dejó de ser un campo para convertirse en un recurso con PUT idempotente y DELETE. La línea de tiempo dejó de ser una colección para ser un recurso derivado sin POST, sin total y sin página N, sostenido por un fan-out híbrido y accesible solo mediante un cursor opaco de marca temporal más id. Los contadores dejaron de ser exactos porque contar dejó de merecer la pena. Las imágenes salieron de la API. La autorización dejó de preguntar de quién es el recurso para preguntar quién observa, con proyecciones por visibilidad y 404 allí donde la existencia ya es información. Y el tiempo real dejó de ser un adorno para ser el producto.
Si esta lección deja una sola idea, que sea la de la tabla del apartado 8: no hay un diseño REST universal, hay decisiones dependientes del dominio, y un profesional se distingue por poder nombrar la alternativa que descartó y el motivo. Esa es también la razón por la que GraphQL, indefendible para el catálogo de la tienda, es aquí una opción seria, siempre que se acepte el precio: perder la caché HTTP, los códigos de estado y el control del coste de las consultas.
Nos queda un último asunto, y es el que separa una API bien diseñada de una API que sigue viva a los tres años: qué ocurre después de publicarla. En 06-03, Evolución y mantenimiento de una API en producción, veremos qué hacer cuando un cambio aparentemente inocuo rompe a un consumidor en producción, cómo se escribe un post-mortem que sirva para algo, cómo se acumula y se paga la deuda de contrato, y cómo se planifica una migración a v2 con su periodo de deprecación y su gobierno. Porque diseñar bien una API es difícil, pero cambiarla sin romper a quien ya la usa es más difícil todavía.
Curso de REST API: Principios de Diseño y Desarrollo de APIs RESTful
Módulo 1: Introducción a las APIs RESTful
- ¿Qué es una API?
- Historia y evolución de las APIs
- Fundamentos de HTTP para APIs
- Principios básicos de REST
- Modelo de madurez de Richardson y HATEOAS
- REST vs. SOAP
- REST frente a GraphQL, gRPC y webhooks
Módulo 2: Diseño de APIs RESTful
- Principios de diseño de APIs RESTful
- Recursos y URIs
- Métodos HTTP
- Códigos de estado HTTP
- Representaciones, cabeceras y negociación de contenido
- Filtrado, ordenación, paginación y búsqueda
- Versionado de APIs
- Documentación de APIs
Módulo 3: Desarrollo de APIs RESTful
- Configuración del entorno de desarrollo
- Creación de un servidor básico
- Manejo de peticiones y respuestas
- Validación de datos de entrada
- Persistencia y capa de acceso a datos
- Autenticación y autorización
- Manejo de errores
- Pruebas y validación
Módulo 4: Buenas Prácticas y Seguridad
- Buenas prácticas en el diseño de APIs
- Seguridad en APIs RESTful
- OAuth 2.0 y OpenID Connect en la práctica
- Rate limiting y throttling
- CORS y políticas de seguridad
- Caché HTTP y rendimiento
- Observabilidad: logs, métricas y trazas
Módulo 5: Herramientas y Frameworks
- Postman para pruebas de APIs
- Swagger y OpenAPI para documentación
- Frameworks populares para APIs RESTful
- Contratos, mocks y pruebas automatizadas de API
- Integración continua y despliegue
- API gateways y portales de desarrollador
