El servidor de la lección anterior responde, pero toda su lógica vive dentro del router y solo sabe leer. Hoy lo convertimos en una implementación seria del contrato: exprimiremos los objetos req y res para saber exactamente qué información trae una petición y qué controlamos de la respuesta, partiremos el código en tres capas —rutas, controladores y servicios— con una frontera clara entre ellas, escribiremos el mapeador que traduce el modelo interno con céntimos a la representación pública con euros y _links, y completaremos el ciclo de vida de los cafés: POST con 201 y Location, PUT, PATCH con application/merge-patch+json y su 415 cuando no lo es, y DELETE lógico con 204. Además implementaremos los parámetros de colección de 02-06 —filtros, búsqueda, ordenación y paginación— con la cabecera Link completa. Es la lección más larga del módulo y la que más contrato convierte en código.
Contenido
- Qué se refactoriza hoy y por qué
- El objeto
reqa fondo - El objeto
resa fondo - Las tres capas: rutas, controladores y servicios
- El mapeador de representación
- El repositorio en memoria, ampliado
- El servicio de cafés
- El controlador de cafés
- Las rutas, ya sin lógica
- Los parámetros de colección: filtros y búsqueda
- Ordenación con
-y desempate porid - Paginación y la cabecera
Link - Selección de campos con
campos - Crear:
POSTcon201yLocation - Reemplazar y modificar:
PUTyPATCH - Borrar:
DELETElógico con204 - Pedidos y los enlaces de acción según estado
async/awaity la trampa delthrowen Express 4
- Qué se refactoriza hoy y por qué
Estado de partida: src/rutas/cafes.js consulta el repositorio, transforma los datos y responde, todo en el mismo sitio. Funciona con dos rutas de lectura. Con veinticuatro URIs, filtros, validación y permisos, se convierte en un fichero de mil líneas imposible de probar.
Ficheros que se crean hoy:
| Fichero | Contenido |
|---|---|
src/servicios/mapeadores.js |
Traducción de modelo interno a representación pública |
src/servicios/cafes.js |
Lógica de negocio de cafés |
src/servicios/pedidos.js |
Lógica de negocio de pedidos |
src/controladores/cafes.js |
Traducción HTTP ↔ dominio para cafés |
src/controladores/pedidos.js |
Ídem para pedidos |
src/controladores/paginacion.js |
Construcción de la cabecera Link |
src/repositorios/pedidos-memoria.js |
Almacén de pedidos en memoria |
src/rutas/pedidos.js |
Router de /v1/pedidos |
Ficheros que se modifican: src/rutas/cafes.js (queda reducido a declaraciones), src/rutas/index.js (monta pedidos) y src/repositorios/cafes-memoria.js (gana métodos de escritura y búsqueda).
Dos avisos sobre lo que no hacemos hoy, para que no te sorprenda el código:
- No hay validación real. Comprobaremos lo imprescindible a mano y con mensajes provisionales. Los esquemas Zod y el middleware
validarllegan en 03-04. - No hay manejo de errores centralizado. Los servicios devolverán
nullcuando algo no exista y el controlador responderá el 404 a mano. En 03-07 los servicios lanzaránErrorApiy un único middleware se encargará de todo.
- El objeto
req a fondo
req a fondoreq es la petición HTTP convertida en objeto JavaScript. Estas son las propiedades que usaremos:
| Propiedad | Qué contiene | Ejemplo con GET /v1/cafes/caf_001?campos=id,nombre |
|---|---|---|
req.method |
Método HTTP en mayúsculas | 'GET' |
req.params |
Parámetros de ruta (:id). Siempre cadenas |
{ id: 'caf_001' } |
req.query |
Parámetros de consulta ya parseados. Siempre cadenas | { campos: 'id,nombre' } |
req.body |
Cuerpo parseado por express.json() |
{} (no hay cuerpo en un GET) |
req.headers |
Cabeceras, con los nombres en minúsculas | { host: 'localhost:3000', accept: '*/*' } |
req.get(nombre) |
Una cabecera, sin importar mayúsculas | req.get('Content-Type') |
req.path |
Ruta sin query string, relativa al montaje | '/caf_001' dentro del router |
req.originalUrl |
URL completa tal cual llegó, con query | '/v1/cafes/caf_001?campos=id,nombre' |
req.baseUrl |
Prefijo bajo el que está montado el router | '/v1/cafes' |
req.ip |
IP del cliente | '::1' |
req.protocol |
http o https |
'http' |
Cuatro cosas que conviene grabar a fuego:
Todo llega como texto. ?limite=20 produce req.query.limite === '20', la cadena. '20' + 1 es '201'. Convertir es obligatorio, y en 03-04 lo hará Zod con z.coerce.
req.query puede darte un array sin avisar. Si el cliente repite un parámetro:
Un código que asuma cadena hará tueste.toLowerCase() y reventará. El contrato de 02-06 dice que los filtros de Tienda Aroma son de valor único, así que un array debe producir 400 parametro_invalido.
Las cabeceras se leen en minúsculas en req.headers, o con req.get() que ignora mayúsculas. req.headers['Content-Type'] es undefined; req.headers['content-type'] funciona.
req.path no es req.originalUrl. Dentro de un router montado, req.path es relativo. Para construir la cabecera Link necesitaremos la URL completa, y ahí originalUrl es la buena.
Un middleware de diagnóstico que puedes pegar temporalmente en app.js para verlo todo en vivo:
app.use((req, res, next) => {
console.log({
method: req.method,
originalUrl: req.originalUrl,
path: req.path,
params: req.params,
query: req.query,
contentType: req.get('Content-Type'),
body: req.body,
});
next();
});
- El objeto
res a fondo
res a fondores es la respuesta en construcción. Ya conocemos status, json, set y end; añadimos las que necesita el contrato:
| Método | Para qué | Uso en el contrato |
|---|---|---|
res.status(n) |
Fija el código | 201, 204, 404… |
res.json(obj) |
Serializa y envía | Todas las respuestas con cuerpo |
res.set(n, v) |
Una cabecera | Location, Link, Allow, Accept-Patch |
res.set({...}) |
Varias cabeceras a la vez | Cómodo para Link + Allow |
res.location(url) |
Atajo para la cabecera Location |
Tras un POST |
res.links({...}) |
Construye la cabecera Link RFC 8288 |
Paginación |
res.vary(cabecera) |
Añade a Vary |
Negociación de contenido (02-05) |
res.end() |
Cierra sin cuerpo | Respuestas 204 |
res.links() merece una demostración, porque hace por nosotros el formato del RFC 8288:
res.links({
next: 'http://localhost:3000/v1/cafes?limite=20&desplazamiento=40',
prev: 'http://localhost:3000/v1/cafes?limite=20&desplazamiento=0',
});Link: <http://localhost:3000/v1/cafes?limite=20&desplazamiento=40>; rel="next", <http://localhost:3000/v1/cafes?limite=20&desplazamiento=0>; rel="prev"Exactamente el formato que fijamos en 02-06, con los ángulos, el rel entrecomillado y las comas. Un detalle: res.links() acumula si se llama varias veces, así que basta con una llamada que incluya todas las relaciones.
- Las tres capas: rutas, controladores y servicios
| Capa | Fichero | Responsabilidad | Puede tocar |
|---|---|---|---|
| Ruta | rutas/cafes.js |
Decir qué método y URI invocan a qué función | Nada más |
| Controlador | controladores/cafes.js |
Leer req, llamar al servicio, escribir res con estado y cabeceras |
req, res, servicios, mapeadores |
| Servicio | servicios/cafes.js |
Reglas de negocio y orquestación | Repositorios. Nunca req ni res |
| Repositorio | repositorios/cafes-*.js |
Guardar y recuperar datos | La fuente de datos |
La regla que lo resume: el servicio no debe saber que existe HTTP. Si en un servicio aparece un req, un res, un código de estado o una cabecera, la frontera está rota. Las tres ventajas concretas, todas dentro de este módulo:
- Se puede probar sin servidor (03-08): un test del servicio le pasa datos y comprueba el resultado, sin Supertest ni puertos.
- Se puede reutilizar: un script que importe pedidos desde un CSV llama al mismo servicio que la API. Si la lógica viviera en el controlador, habría que duplicarla.
- Se puede cambiar el almacenamiento (03-05) sin que el controlador se entere.
- El mapeador de representación
La conversión de modelo interno a representación pública es una responsabilidad propia y merece su fichero. Es donde se cumplen, en un solo sitio, todas las decisiones de 02-05.
// src/servicios/mapeadores.js
/** Convierte céntimos enteros a euros con dos decimales. 1450 → 14.5 */
export function centimosAEuros(centimos) {
return Number((centimos / 100).toFixed(2));
}
/** Convierte euros a céntimos enteros. 14.5 → 1450 */
export function eurosACentimos(euros) {
// Math.round evita que 14.5 * 100 dé 1449.9999999999998 por coma flotante.
return Math.round(euros * 100);
}
/**
* Modelo interno de café → representación pública.
* Aquí se concentran las reglas de 02-05:
* - camelCase en todos los campos
* - euros por fuera, céntimos por dentro
* - campos siempre presentes, con null si no hay valor
* - arrays vacíos como [], nunca null
* - _links con self (los cafés no tienen enlaces de acción)
*/
export function cafeARepresentacion(cafe) {
return {
id: cafe.id,
nombre: cafe.nombre,
origen: cafe.origen,
tueste: cafe.tueste,
precioEuros: centimosAEuros(cafe.precioCentimos),
stock: cafe.stock,
notasCata: cafe.notasCata ?? [],
descripcion: cafe.descripcion ?? null,
fechaCreacion: cafe.fechaCreacion,
_links: {
self: { href: `/v1/cafes/${cafe.id}` },
resenas: { href: `/v1/cafes/${cafe.id}/resenas` },
},
};
}
/**
* Enlaces de acción de un pedido SEGÚN SU ESTADO (02-05, hipermedia
* selectiva). Es la traducción literal de la tabla del contrato:
* pendiente_pago → self, cliente, pagar, anular
* pagado → self, cliente, factura, envio, anular
* enviado → self, cliente, factura, envio, devolver
*/
function enlacesDePedido(pedido) {
const base = `/v1/pedidos/${pedido.id}`;
const enlaces = {
self: { href: base },
cliente: { href: `/v1/clientes/${pedido.clienteId}` },
};
if (pedido.estado === 'pendiente_pago') {
enlaces.pagar = { href: `${base}/pago`, method: 'POST' };
enlaces.anular = { href: `${base}/anulacion`, method: 'POST' };
}
if (pedido.estado === 'pagado') {
enlaces.factura = { href: `${base}/factura` };
enlaces.envio = { href: `${base}/envio` };
enlaces.anular = { href: `${base}/anulacion`, method: 'POST' };
}
if (pedido.estado === 'enviado') {
enlaces.factura = { href: `${base}/factura` };
enlaces.envio = { href: `${base}/envio` };
enlaces.devolver = { href: `${base}/devolucion`, method: 'POST' };
}
return enlaces;
}
/** Modelo interno de pedido → representación pública. */
export function pedidoARepresentacion(pedido) {
return {
id: pedido.id,
clienteId: pedido.clienteId,
estado: pedido.estado,
// El precio de la línea va CONGELADO: es el que había el día de la
// compra, no el actual del catálogo (02-05, sección de incrustación).
lineas: pedido.lineas.map((linea) => ({
cafeId: linea.cafeId,
nombre: linea.nombre,
cantidad: linea.cantidad,
precioEuros: centimosAEuros(linea.precioCentimos),
})),
totalEuros: centimosAEuros(pedido.totalCentimos),
fechaCreacion: pedido.fechaCreacion,
fechaPago: pedido.fechaPago ?? null,
fechaEnvio: pedido.fechaEnvio ?? null,
_links: enlacesDePedido(pedido),
};
}
/**
* Versión reducida para las colecciones: en una lista, cada elemento solo
* lleva 'self' (02-05: "los elementos dentro de una colección no llevan
* _links completos, para no multiplicar el peso de la respuesta").
*/
export function aResumenDeColeccion(representacion) {
return { ...representacion, _links: { self: representacion._links.self } };
}Ahora tenemos un único punto donde cambiar si mañana el contrato añade un campo. Antes, esa lógica estaba repartida por el router y no había nada que impidiera que GET /v1/cafes y GET /v1/cafes/caf_001 devolvieran formas distintas del mismo café. Ese es el fallo de consistencia más frecuente en las APIs reales, y un mapeador compartido lo hace imposible.
- El repositorio en memoria, ampliado
El repositorio gana la capacidad de buscar con criterios y de escribir. La firma de buscar() está pensada mirando ya a 03-05: recibe criterios en unidades internas (céntimos) y devuelve elementos y total, porque con SQL serán dos consultas.
// src/repositorios/cafes-memoria.js (versión ampliada)
const cafes = [
/* ... caf_001 y caf_002 como en 03-02 ... */
];
/** Contador para generar ids nuevos. En 03-05 lo hará la base de datos. */
let siguienteId = 3;
function generarId() {
return `caf_${String(siguienteId++).padStart(3, '0')}`; // caf_003, caf_004...
}
export const repositorioCafes = {
/**
* Busca con filtros, ordenación y paginación.
* @returns {{elementos: object[], total: number}}
* 'total' es el número de coincidencias ANTES de paginar: el cliente
* necesita saber cuántas hay en total, no cuántas caben en la página.
*/
buscar(criterios = {}) {
const {
origen,
tueste,
precioMinCentimos,
precioMaxCentimos,
disponible,
q,
ordenar = [{ campo: 'id', descendente: false }],
limite = 20,
desplazamiento = 0,
} = criterios;
let resultado = cafes.filter((cafe) => cafe.activo);
// --- Filtros (Y lógico entre todos, como fijamos en 02-06) ---
if (origen !== undefined) {
resultado = resultado.filter((c) => c.origen.toLowerCase() === origen.toLowerCase());
}
if (tueste !== undefined) {
resultado = resultado.filter((c) => c.tueste === tueste);
}
if (precioMinCentimos !== undefined) {
resultado = resultado.filter((c) => c.precioCentimos >= precioMinCentimos);
}
if (precioMaxCentimos !== undefined) {
resultado = resultado.filter((c) => c.precioCentimos <= precioMaxCentimos);
}
if (disponible !== undefined) {
resultado = resultado.filter((c) => (disponible ? c.stock > 0 : c.stock === 0));
}
if (q !== undefined) {
const termino = q.toLowerCase();
resultado = resultado.filter(
(c) =>
c.nombre.toLowerCase().includes(termino) ||
c.origen.toLowerCase().includes(termino) ||
c.notasCata.some((nota) => nota.toLowerCase().includes(termino))
);
}
const total = resultado.length; // ← antes de paginar
// --- Ordenación multicampo ---
resultado = [...resultado].sort((a, b) => {
for (const { campo, descendente } of ordenar) {
const va = a[campo];
const vb = b[campo];
if (va === vb) continue;
const signo = va > vb ? 1 : -1;
return descendente ? -signo : signo;
}
return 0;
});
// --- Paginación ---
const elementos = resultado.slice(desplazamiento, desplazamiento + limite);
return { elementos: elementos.map((c) => ({ ...c })), total };
},
buscarPorId(id) {
const cafe = cafes.find((c) => c.id === id && c.activo);
return cafe ? { ...cafe } : undefined;
},
crear(datos) {
const cafe = {
id: generarId(),
...datos,
fechaCreacion: new Date().toISOString(),
activo: true,
};
cafes.push(cafe);
return { ...cafe };
},
/** Aplica cambios parciales sobre un café existente. */
actualizar(id, cambios) {
const indice = cafes.findIndex((c) => c.id === id && c.activo);
if (indice === -1) return undefined;
cafes[indice] = { ...cafes[indice], ...cambios };
return { ...cafes[indice] };
},
/** Borrado LÓGICO (02-03): el registro se conserva, deja de estar activo. */
borrar(id) {
const cafe = cafes.find((c) => c.id === id && c.activo);
if (!cafe) return false;
cafe.activo = false;
return true;
},
};Nótese que el repositorio habla en céntimos (precioMinCentimos) y no conoce los euros: la traducción es responsabilidad del mapeador y del controlador. La frontera de unidades está tan clara como la de capas.
- El servicio de cafés
// src/servicios/cafes.js
import { repositorioCafes } from '../repositorios/cafes-memoria.js';
import { eurosACentimos } from './mapeadores.js';
export const servicioCafes = {
/** Devuelve una página de cafés y el total de coincidencias. */
listar(criterios) {
return repositorioCafes.buscar(criterios);
},
/** Devuelve un café o undefined si no existe. */
obtener(id) {
return repositorioCafes.buscarPorId(id);
},
/** Crea un café. Recibe datos ya validados y en euros. */
crear(datos) {
return repositorioCafes.crear({
nombre: datos.nombre.trim(),
origen: datos.origen.trim(),
tueste: datos.tueste,
precioCentimos: eurosACentimos(datos.precioEuros),
stock: datos.stock,
notasCata: datos.notasCata ?? [],
descripcion: datos.descripcion ?? null,
});
},
/** Reemplaza por completo un café (PUT). undefined si no existe. */
reemplazar(id, datos) {
if (!repositorioCafes.buscarPorId(id)) return undefined;
return repositorioCafes.actualizar(id, {
nombre: datos.nombre.trim(),
origen: datos.origen.trim(),
tueste: datos.tueste,
precioCentimos: eurosACentimos(datos.precioEuros),
stock: datos.stock,
notasCata: datos.notasCata ?? [],
descripcion: datos.descripcion ?? null,
});
},
/** Modifica parcialmente un café (PATCH). Solo toca lo que llega. */
modificar(id, cambios) {
if (!repositorioCafes.buscarPorId(id)) return undefined;
const parciales = {};
if (cambios.nombre !== undefined) parciales.nombre = cambios.nombre.trim();
if (cambios.origen !== undefined) parciales.origen = cambios.origen.trim();
if (cambios.tueste !== undefined) parciales.tueste = cambios.tueste;
if (cambios.precioEuros !== undefined) {
parciales.precioCentimos = eurosACentimos(cambios.precioEuros);
}
if (cambios.stock !== undefined) parciales.stock = cambios.stock;
if (cambios.notasCata !== undefined) parciales.notasCata = cambios.notasCata;
// En Merge Patch, null significa "borra este campo" (02-03).
if (cambios.descripcion !== undefined) parciales.descripcion = cambios.descripcion;
return repositorioCafes.actualizar(id, parciales);
},
/** Borrado lógico. Devuelve true si borró algo. */
borrar(id) {
return repositorioCafes.borrar(id);
},
};Fíjate en lo que no hay en este fichero: ni un res, ni un 404, ni una cabecera. Solo objetos de dominio. Y en la diferencia de fondo entre reemplazar y modificar: el primero recibe el recurso completo y lo sustituye entero; el segundo solo toca los campos presentes, distinguiendo "ausente" (no tocar) de null (borrar el valor). Es exactamente la semántica de Merge Patch de 02-03.
- El controlador de cafés
El controlador es la aduana entre HTTP y el dominio. Empezamos por la lectura, que es donde vive el trabajo de 02-06.
// src/controladores/cafes.js
import { servicioCafes } from '../servicios/cafes.js';
import { cafeARepresentacion, aResumenDeColeccion, eurosACentimos } from '../servicios/mapeadores.js';
import { construirCabeceraLink } from './paginacion.js';
/** Campos por los que se permite ordenar. Lista blanca: nada más se admite. */
const CAMPOS_ORDENABLES = new Set(['nombre', 'precioEuros', 'stock', 'fechaCreacion', 'id']);
/** Traducción de campo público a campo interno para ordenar. */
const CAMPO_INTERNO = { precioEuros: 'precioCentimos' };
/**
* Interpreta el parámetro 'ordenar' de 02-06:
* ?ordenar=-precioEuros,nombre → precio descendente, luego nombre asc.
* Siempre se añade 'id' al final como desempate, para que la paginación
* sea estable: sin desempate, dos cafés al mismo precio pueden cambiar de
* orden entre la página 1 y la 2, y el cliente ve uno repetido y otro perdido.
*/
function interpretarOrdenar(texto) {
if (!texto) return [{ campo: 'id', descendente: false }];
const criterios = [];
for (const parte of texto.split(',')) {
const descendente = parte.startsWith('-');
const campoPublico = descendente ? parte.slice(1) : parte;
if (!CAMPOS_ORDENABLES.has(campoPublico)) {
return { error: `El campo de ordenación '${campoPublico}' no existe.` };
}
criterios.push({ campo: CAMPO_INTERNO[campoPublico] ?? campoPublico, descendente });
}
criterios.push({ campo: 'id', descendente: false });
return criterios;
}
/** Aplica ?campos=id,nombre sobre una representación ya construida. */
function proyectar(representacion, campos) {
if (!campos) return representacion;
const solicitados = new Set(campos.split(','));
solicitados.add('id'); // el id se devuelve siempre: sin él la respuesta es inútil
return Object.fromEntries(
Object.entries(representacion).filter(([clave]) => solicitados.has(clave))
);
}
export const controladorCafes = {
/** GET /v1/cafes */
listar(req, res) {
const { origen, tueste, precioMin, precioMax, disponible, q, ordenar, campos } = req.query;
// Paginación con sus valores por defecto y máximos (02-06).
const limite = req.query.limite === undefined ? 20 : Number(req.query.limite);
const desplazamiento =
req.query.desplazamiento === undefined ? 0 : Number(req.query.desplazamiento);
// Comprobaciones mínimas: en 03-04 las hará el middleware de validación.
if (!Number.isInteger(limite) || limite < 1 || limite > 100) {
return res.status(400).json({
error: {
codigo: 'parametro_invalido',
mensaje: "El parámetro 'limite' debe ser un entero entre 1 y 100.",
detalles: [{ campo: 'limite', codigo: 'fuera_de_rango', mensaje: 'Máximo 100.' }],
},
});
}
const criteriosOrden = interpretarOrdenar(ordenar);
if (criteriosOrden.error) {
return res.status(400).json({
error: {
codigo: 'parametro_invalido',
mensaje: criteriosOrden.error,
detalles: [{ campo: 'ordenar', codigo: 'valor_desconocido', mensaje: criteriosOrden.error }],
},
});
}
const { elementos, total } = servicioCafes.listar({
origen,
tueste,
// Los precios llegan en euros y el repositorio habla en céntimos.
precioMinCentimos: precioMin === undefined ? undefined : eurosACentimos(Number(precioMin)),
precioMaxCentimos: precioMax === undefined ? undefined : eurosACentimos(Number(precioMax)),
disponible: disponible === undefined ? undefined : disponible === 'true',
q,
ordenar: criteriosOrden,
limite,
desplazamiento,
});
const enlaces = construirCabeceraLink({ req, limite, desplazamiento, total });
if (Object.keys(enlaces).length > 0) res.links(enlaces);
res.status(200).json({
datos: elementos
.map(cafeARepresentacion)
.map(aResumenDeColeccion)
.map((r) => proyectar(r, campos)),
total,
});
},
/** GET /v1/cafes/:id */
obtener(req, res) {
const cafe = servicioCafes.obtener(req.params.id);
if (!cafe) return noEncontrado(res, req.params.id);
res.status(200).json(proyectar(cafeARepresentacion(cafe), req.query.campos));
},
};
/** Respuesta 404 del catálogo. Provisional: en 03-07 la centraliza ErrorApi. */
function noEncontrado(res, id) {
return res.status(404).json({
error: {
codigo: 'cafe_no_encontrado',
mensaje: `No existe ningún café con el identificador '${id}'.`,
detalles: [],
},
});
}Aparecen aquí dos decisiones de seguridad que conviene subrayar. La primera es la lista blanca de campos ordenables: si aceptáramos cualquier nombre, con SQL detrás (03-05) estaríamos concatenando texto del usuario dentro de un ORDER BY, que es inyección SQL de manual. La segunda es que limite no se recorta silenciosamente a 100, sino que devuelve 400: el contrato de 02-06 lo decidió así porque recortar en silencio hace creer al cliente que ha recibido todo lo que pidió.
- Las rutas, ya sin lógica
// src/rutas/cafes.js
import { Router } from 'express';
import { controladorCafes } from '../controladores/cafes.js';
export const rutasCafes = Router();
rutasCafes.get('/', controladorCafes.listar);
rutasCafes.post('/', controladorCafes.crear);
rutasCafes.get('/:id', controladorCafes.obtener);
rutasCafes.put('/:id', controladorCafes.reemplazar);
rutasCafes.patch('/:id', controladorCafes.modificar);
rutasCafes.delete('/:id', controladorCafes.borrar);Seis líneas que se leen como el mapa de URIs de 02-02. Cuando en 03-04 llegue la validación y en 03-06 la autenticación, se insertarán aquí como middleware antes del controlador, y el fichero seguirá siendo legible de un vistazo:
// Así quedará al final del módulo (adelanto):
rutasCafes.post('/', autenticar, exigirRol('empleado'), validar(esquemaCrearCafe), controladorCafes.crear);
- Los parámetros de colección: filtros y búsqueda
Con lo anterior en marcha, los filtros de 02-06 ya funcionan:
# Filtros combinados (Y lógico) y rango de precio
curl -s "http://localhost:3000/v1/cafes?tueste=claro&precioMax=15" | jq '.datos[].nombre'# Búsqueda de texto: mira nombre, origen y notas de cata
curl -s "http://localhost:3000/v1/cafes?q=chocolate" | jq '.datos[].nombre'Una precisión sobre ?q=: nuestra implementación en memoria es un includes() sin acentos ni tolerancia a erratas. Con SQLite (03-05) usaremos LIKE, y en una tienda real esto acabaría en un motor de búsqueda dedicado. El contrato de 02-06 fue deliberadamente prudente y solo promete "coincidencia parcial sobre nombre, origen y notas de cata", que es lo que podemos cumplir.
- Ordenación con
- y desempate por id
- y desempate por idcurl -s "http://localhost:3000/v1/cafes?ordenar=-precioEuros" | jq '.datos[] | {nombre, precioEuros}'{ "nombre": "Etiopía Yirgacheffe", "precioEuros": 14.5 }
{ "nombre": "Colombia Huila", "precioEuros": 12.9 }# Campo inexistente → 400 del catálogo, no se ignora en silencio
curl -s "http://localhost:3000/v1/cafes?ordenar=color" | jq .error{
"codigo": "parametro_invalido",
"mensaje": "El campo de ordenación 'color' no existe.",
"detalles": [
{ "campo": "ordenar", "codigo": "valor_desconocido", "mensaje": "El campo de ordenación 'color' no existe." }
]
}El desempate por id que añade interpretarOrdenar no es una manía: sin él, la ordenación por un campo con valores repetidos no está definida, y el orden puede variar entre dos consultas idénticas. Como la paginación trocea esa lista, el cliente vería elementos duplicados en una página y ausentes en otra. Toda ordenación paginada necesita un desempate por una clave única.
- Paginación y la cabecera
Link
Link// src/controladores/paginacion.js
/**
* Construye las relaciones de la cabecera Link (RFC 8288) para una
* colección paginada por offset, CONSERVANDO todos los filtros de la
* petición original: sin eso, el cliente que sigue 'next' pierde el
* filtro y recibe resultados que no había pedido.
*/
export function construirCabeceraLink({ req, limite, desplazamiento, total }) {
// Reconstruimos la URL absoluta de la petición actual.
const url = new URL(req.originalUrl, `${req.protocol}://${req.get('host')}`);
/** Devuelve la URL actual con otro desplazamiento. */
const conDesplazamiento = (valor) => {
const copia = new URL(url);
copia.searchParams.set('limite', String(limite));
copia.searchParams.set('desplazamiento', String(valor));
return copia.toString();
};
const enlaces = {};
const ultimoDesplazamiento = Math.max(0, Math.floor((total - 1) / limite) * limite);
if (desplazamiento + limite < total) {
enlaces.next = conDesplazamiento(desplazamiento + limite);
}
if (desplazamiento > 0) {
enlaces.prev = conDesplazamiento(Math.max(0, desplazamiento - limite));
}
if (total > limite) {
enlaces.first = conDesplazamiento(0);
enlaces.last = conDesplazamiento(ultimoDesplazamiento);
}
return enlaces;
}Puntos finos de esta función:
new URL(...)consearchParamshace la codificación por nosotros. Concatenar cadenas a mano rompe en cuanto un filtro lleva un espacio o unañ.nextsolo existe si hay más elementos yprevsolo si no estamos en la primera página. La ausencia de un enlace es información: significa "no hay más".firstylastsolo si hay más de una página, para no ensuciar la respuesta cuando no hacen falta.
Prueba con limite=1 para forzar varias páginas:
Link: <http://localhost:3000/v1/cafes?limite=1&ordenar=nombre&desplazamiento=1>; rel="next", <http://localhost:3000/v1/cafes?limite=1&ordenar=nombre&desplazamiento=0>; rel="first", <http://localhost:3000/v1/cafes?limite=1&ordenar=nombre&desplazamiento=1>; rel="last"Fíjate en que ordenar=nombre sobrevive en los tres enlaces. Y en el cuerpo, total sigue siendo 2 aunque datos traiga un solo elemento: es el número de coincidencias, no el de la página.
- Selección de campos con
campos
camposEl id aparece aunque no se haya pedido, porque una respuesta sin identificador no permite navegar a ningún sitio. Es la decisión de 02-05: campos recorta, pero nunca por debajo del mínimo utilizable.
- Crear:
POST con 201 y Location
POST con 201 y Location// src/controladores/cafes.js (añadir al objeto controladorCafes)
/** POST /v1/cafes */
crear(req, res) {
const datos = req.body;
// Comprobación provisional. En 03-04 la sustituye validar(esquemaCrearCafe).
const obligatorios = ['nombre', 'origen', 'tueste', 'precioEuros', 'stock'];
const faltantes = obligatorios.filter((campo) => datos?.[campo] === undefined);
if (faltantes.length > 0) {
return res.status(400).json({
error: {
codigo: 'datos_invalidos',
mensaje: 'Faltan campos obligatorios.',
detalles: faltantes.map((campo) => ({
campo,
codigo: 'requerido',
mensaje: `El campo '${campo}' es obligatorio.`,
})),
},
});
}
const cafe = servicioCafes.crear(datos);
const representacion = cafeARepresentacion(cafe);
// La cabecera Location es OBLIGATORIA en toda creación (02-04).
res.set('Location', `/v1/cafes/${cafe.id}`);
res.status(201).json(representacion);
},curl -i -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
-d '{
"nombre": "Kenia Nyeri",
"origen": "Kenia",
"tueste": "medio",
"precioEuros": 16.75,
"stock": 40,
"notasCata": ["grosella", "tomate", "cítrico"]
}'HTTP/1.1 201 Created
Location: /v1/cafes/caf_003
Content-Type: application/json; charset=utf-8
{"id":"caf_003","nombre":"Kenia Nyeri","origen":"Kenia","tueste":"medio","precioEuros":16.75,"stock":40,"notasCata":["grosella","tomate","cítrico"],"descripcion":null,"fechaCreacion":"2026-03-14T10:41:22.113Z","_links":{"self":{"href":"/v1/cafes/caf_003"},"resenas":{"href":"/v1/cafes/caf_003/resenas"}}}Comprueba la ida y vuelta de los céntimos: enviamos 16.75, el servicio guardó 1675 y el mapeador devuelve 16.75. Y observa que descripcion sale como null aunque no la enviamos: campos siempre presentes, como decidimos en 02-05.
Por qué el cuerpo del 201 lleva la representación completa en vez de estar vacío: le ahorra al cliente un GET inmediato para conocer el id y la fechaCreacion que ha generado el servidor. Location es obligatoria; el cuerpo es una cortesía muy rentable.
- Reemplazar y modificar:
PUT y PATCH
PUT y PATCH// src/controladores/cafes.js (continuación)
/** PUT /v1/cafes/:id — reemplazo completo */
reemplazar(req, res) {
const cafe = servicioCafes.reemplazar(req.params.id, req.body);
if (!cafe) return noEncontrado(res, req.params.id);
res.status(200).json(cafeARepresentacion(cafe));
},
/** PATCH /v1/cafes/:id — modificación parcial con Merge Patch */
modificar(req, res) {
const tipo = req.get('Content-Type') ?? '';
// El contrato de 02-03 admite SOLO merge-patch (y application/json por
// comodidad). JSON Patch (application/json-patch+json) se rechaza con
// 415 y la cabecera Accept-Patch que dice qué SÍ se admite.
const admitidos = ['application/merge-patch+json', 'application/json'];
const esAdmitido = admitidos.some((t) => tipo.startsWith(t));
if (!esAdmitido) {
res.set('Accept-Patch', 'application/merge-patch+json');
return res.status(415).json({
error: {
codigo: 'formato_no_soportado',
mensaje: `El tipo '${tipo}' no se admite en PATCH. Usa application/merge-patch+json.`,
detalles: [],
},
});
}
const cafe = servicioCafes.modificar(req.params.id, req.body);
if (!cafe) return noEncontrado(res, req.params.id);
res.status(200).json(cafeARepresentacion(cafe));
},# PATCH correcto: solo se toca el stock
curl -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
-H "Content-Type: application/merge-patch+json" \
-d '{"stock": 95}' | jq '{nombre, stock, precioEuros}'El nombre y el precio siguen intactos: eso es exactamente lo que promete PATCH y lo que lo distingue de PUT, que habría exigido reenviar el recurso entero.
# PATCH con JSON Patch: 415 + Accept-Patch
curl -i -s -X PATCH http://localhost:3000/v1/cafes/caf_001 \
-H "Content-Type: application/json-patch+json" \
-d '[{"op":"replace","path":"/stock","value":95}]' | head -5HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Content-Type: application/json; charset=utf-8La cabecera Accept-Patch es la que convierte un rechazo en una respuesta útil: no solo dice "esto no", dice "esto sí". Es la misma filosofía que el Allow en un 405.
- Borrar:
DELETE lógico con 204
DELETE lógico con 204// src/controladores/cafes.js (continuación)
/** DELETE /v1/cafes/:id — borrado lógico */
borrar(req, res) {
const borrado = servicioCafes.borrar(req.params.id);
if (!borrado) return noEncontrado(res, req.params.id);
// 204 No Content: sin cuerpo. Ni {} ni null: NADA.
res.status(204).end();
},curl -i -s -X DELETE http://localhost:3000/v1/cafes/caf_003 | head -2
curl -s http://localhost:3000/v1/cafes/caf_003 | jq .error.codigoEl registro sigue en el array con activo: false —eso es el borrado lógico de 02-03—, pero la API se comporta como si no existiera. Se conserva porque los pedidos históricos referencian ese café y borrarlo de verdad dejaría facturas huérfanas.
Un detalle sobre idempotencia: el segundo DELETE sobre el mismo id devuelve 404. Eso no rompe la idempotencia de 02-03: el estado del servidor es idéntico tras uno o cien DELETE, que es lo que exige la definición. El código de respuesta puede diferir.
- Pedidos y los enlaces de acción según estado
Añadimos el almacén de pedidos y su lectura, que es donde luce la hipermedia selectiva.
// src/repositorios/pedidos-memoria.js
const pedidos = [
{
id: 'ped_5001',
clienteId: 'cli_842',
estado: 'pendiente_pago',
lineas: [
{ cafeId: 'caf_001', nombre: 'Etiopía Yirgacheffe', cantidad: 2, precioCentimos: 1450 },
],
totalCentimos: 2900, // 29,00 € — la suma de las líneas, en céntimos
fechaCreacion: '2026-03-14T10:30:00Z',
fechaPago: null,
fechaEnvio: null,
},
];
export const repositorioPedidos = {
buscar({ clienteId, estado, limite = 20, desplazamiento = 0 } = {}) {
let resultado = [...pedidos];
if (clienteId !== undefined) resultado = resultado.filter((p) => p.clienteId === clienteId);
if (estado !== undefined) resultado = resultado.filter((p) => p.estado === estado);
const total = resultado.length;
// Orden por defecto de 02-06: los más recientes primero.
resultado.sort((a, b) => b.fechaCreacion.localeCompare(a.fechaCreacion) || a.id.localeCompare(b.id));
return {
elementos: resultado.slice(desplazamiento, desplazamiento + limite).map((p) => ({ ...p })),
total,
};
},
buscarPorId(id) {
const pedido = pedidos.find((p) => p.id === id);
return pedido ? { ...pedido } : undefined;
},
};// src/servicios/pedidos.js
import { repositorioPedidos } from '../repositorios/pedidos-memoria.js';
export const servicioPedidos = {
listar(criterios) {
return repositorioPedidos.buscar(criterios);
},
obtener(id) {
return repositorioPedidos.buscarPorId(id);
},
};// src/controladores/pedidos.js
import { servicioPedidos } from '../servicios/pedidos.js';
import { pedidoARepresentacion, aResumenDeColeccion } from '../servicios/mapeadores.js';
import { construirCabeceraLink } from './paginacion.js';
export const controladorPedidos = {
/** GET /v1/pedidos */
listar(req, res) {
const limite = req.query.limite === undefined ? 20 : Number(req.query.limite);
const desplazamiento =
req.query.desplazamiento === undefined ? 0 : Number(req.query.desplazamiento);
const { elementos, total } = servicioPedidos.listar({
clienteId: req.query.clienteId,
estado: req.query.estado,
limite,
desplazamiento,
});
const enlaces = construirCabeceraLink({ req, limite, desplazamiento, total });
if (Object.keys(enlaces).length > 0) res.links(enlaces);
res.status(200).json({
datos: elementos.map(pedidoARepresentacion).map(aResumenDeColeccion),
total,
});
},
/** GET /v1/pedidos/:id */
obtener(req, res) {
const pedido = servicioPedidos.obtener(req.params.id);
if (!pedido) {
return res.status(404).json({
error: {
codigo: 'pedido_no_encontrado',
mensaje: `No existe ningún pedido con el identificador '${req.params.id}'.`,
detalles: [],
},
});
}
res.status(200).json(pedidoARepresentacion(pedido));
},
};// src/rutas/pedidos.js
import { Router } from 'express';
import { controladorPedidos } from '../controladores/pedidos.js';
export const rutasPedidos = Router();
rutasPedidos.get('/', controladorPedidos.listar);
rutasPedidos.get('/:id', controladorPedidos.obtener);
// POST /v1/pedidos llega en 03-04 (validación) y 03-05 (transacción con stock).Y en src/rutas/index.js, una línea más:
Ahora la prueba interesante:
{
"estado": "pendiente_pago",
"totalEuros": 29,
"_links": {
"self": { "href": "/v1/pedidos/ped_5001" },
"cliente": { "href": "/v1/clientes/cli_842" },
"pagar": { "href": "/v1/pedidos/ped_5001/pago", "method": "POST" },
"anular": { "href": "/v1/pedidos/ped_5001/anulacion", "method": "POST" }
}
}Esto es la hipermedia selectiva de 01-05 y 02-05 funcionando: el pedido está pendiente_pago, así que ofrece pagar y anular, y no ofrece factura ni devolver, porque ahora mismo no son posibles. Cambia a mano el estado a 'enviado' en el repositorio, reinicia y verás aparecer factura, envio y devolver, y desaparecer pagar. La SPA de Tienda Aroma pinta sus botones a partir de este objeto y no necesita replicar la máquina de estados.
async/await y la trampa del throw en Express 4
async/await y la trampa del throw en Express 4Hoy todo es síncrono porque el almacén es un array. En 03-05, con base de datos, y en 03-06, con bcrypt, los manejadores serán async. Y ahí hay una trampa que conviene conocer antes de tropezar con ella.
// Manejador asíncrono que falla. Express 4 NO captura este error.
rutasCafes.get('/:id', async (req, res) => {
const cafe = await servicioCafes.obtener(req.params.id);
if (!cafe) throw new Error('no encontrado'); // ← se pierde
res.json(cafeARepresentacion(cafe));
});Qué ocurre exactamente: una función async no lanza, devuelve una promesa rechazada. Express 4 llama al manejador y descarta el valor devuelto, así que nadie está mirando esa promesa. El resultado es el peor posible: UnhandledPromiseRejection en la consola y la petición colgada hasta que el cliente agota su tiempo de espera. No hay 500, no hay respuesta: silencio.
Con código síncrono, en cambio, Express sí captura:
rutasCafes.get('/:id', (req, res) => {
throw new Error('esto sí lo captura Express'); // → 500 con la página HTML por defecto
});La solución es un envoltorio de cinco líneas:
/** Envuelve un manejador async y encamina cualquier rechazo hacia next(). */
export function asincrono(fn) {
return (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
}
// Uso:
rutasCafes.get('/:id', asincrono(controladorCafes.obtener));Promise.resolve(...) funciona tanto si fn es async como si no, y .catch(next) reenvía el error al middleware de errores. Escribiremos este fichero de verdad en 03-07, junto con la clase ErrorApi y el middleware que le da sentido; hoy quédate con el diagnóstico, que es lo que ahorra una tarde de depuración.
Como nota de futuro: Express 5 ya captura los rechazos de promesas de los manejadores automáticamente y hace innecesario el envoltorio. Es una de las razones de peso para migrar cuando el proyecto lo permita (02-07 sobre cambios y migraciones).
Errores Comunes y Consejos
1. Meter lógica de negocio en el controlador. Si el controlador calcula descuentos o comprueba stock, esa regla no se puede probar sin HTTP ni reutilizar desde un script. El controlador solo traduce.
2. Que el servicio devuelva códigos de estado. return { estado: 404, mensaje: '...' } desde un servicio es la frontera rota disfrazada. El servicio devuelve datos o lanza un error de dominio (03-07); el controlador decide el código.
3. Confundir total con la longitud de la página. total es el número de coincidencias del filtro; datos.length es lo que cabe en esta página. Devolver datos.length como total rompe el cálculo del número de páginas en todos los clientes.
4. Perder los filtros en la cabecera Link. Construir next como /v1/cafes?desplazamiento=20 a secas es un fallo clásico: el cliente sigue el enlace y recibe la colección sin filtrar. Parte siempre de req.originalUrl.
5. Olvidar Location en el 201. Es obligatoria en el contrato. Un cliente que crea un recurso y no sabe dónde está tiene que adivinar la URL.
6. Devolver cuerpo en un 204. res.status(204).json({}) es contradictorio: 204 significa "no hay contenido". Usa .end().
7. Convertir euros a céntimos con euros * 100. 16.75 * 100 puede dar 1674.9999999999998, y al truncar pierdes un céntimo. Usa Math.round, como hace eurosACentimos.
8. Aceptar cualquier campo en ordenar. Sin lista blanca, con SQL detrás es inyección directa. La lista blanca es innegociable.
Consejo: cuando dudes de en qué capa poner algo, hazte la pregunta "¿esto seguiría teniendo sentido si la API fuera una aplicación de escritorio?". Si la respuesta es sí, es del servicio. Si habla de cabeceras, códigos o URLs, es del controlador.
Ejercicios
Ejercicio 1
Implementa el filtro disponible de 02-06 con su comportamiento completo: ?disponible=true devuelve solo cafés con stock > 0, ?disponible=false solo los agotados, y cualquier otro valor (?disponible=si, ?disponible=1) debe producir 400 parametro_invalido en vez de tratarse como false. Explica por qué el silencio es peor que el error.
Ejercicio 2
El equipo de la SPA informa de un bug: al recorrer /v1/cafes?ordenar=tueste&limite=1 página a página, el café caf_002 aparece dos veces y otro no aparece nunca. Con 40 cafés en catálogo y solo tres valores de tueste, diagnostica la causa, explica por qué el orden puede cambiar entre dos consultas y di qué línea del código lo resuelve.
Ejercicio 3
Escribe el controlador de GET /v1/clientes/:id/pedidos (subrecurso del mapa de URIs de 02-02). Debe devolver los pedidos de ese cliente con el envoltorio y la paginación habituales, y 404 cliente_no_encontrado si el cliente no existe. Supón un servicioClientes.obtener(id) ya disponible. ¿Qué diferencia hay entre esta ruta y GET /v1/pedidos?clienteId=cli_842, y por qué el contrato tiene las dos?
Soluciones
Solución 1
En controladores/cafes.js, dentro de listar, antes de llamar al servicio:
let disponible;
if (req.query.disponible !== undefined) {
if (req.query.disponible !== 'true' && req.query.disponible !== 'false') {
return res.status(400).json({
error: {
codigo: 'parametro_invalido',
mensaje: "El parámetro 'disponible' solo admite 'true' o 'false'.",
detalles: [
{
campo: 'disponible',
codigo: 'valor_invalido',
mensaje: `Se recibió '${req.query.disponible}'.`,
},
],
},
});
}
disponible = req.query.disponible === 'true';
}Y se pasa disponible al servicio en lugar de la conversión inline.
Por qué el silencio es peor: con la conversión ingenua disponible === 'true', la petición ?disponible=si se interpreta como false y devuelve los cafés agotados, justo lo contrario de lo que el cliente pedía. El cliente recibe un 200 OK con datos incorrectos y no tiene forma de detectarlo; el bug se descubre semanas después con clientes reales viendo un catálogo vacío. Un 400 inmediato señala el error en la primera prueba del integrador. Es el principio de 02-06: parámetro o valor desconocido, error explícito, nunca interpretación creativa.
Solución 2
Causa: tueste solo tiene tres valores posibles (claro, medio, oscuro), así que con 40 cafés hay grupos enormes de empatados. Al ordenar solo por tueste, el orden dentro de cada grupo no está definido: Array.prototype.sort no garantiza estabilidad respecto a un orden previo si el array de partida cambia, y con SQL (03-05) el motor puede devolver las filas empatadas en el orden que le resulte más barato, que depende del plan de ejecución, de la caché y del estado de los índices.
Por qué falla la paginación: cada página es una consulta independiente. Si en la consulta de la página 1 caf_002 queda en la posición 10 y en la de la página 2 el motor lo coloca en la 25, ese café aparece en las dos páginas y otro cae en el hueco. Es la manifestación clásica del problema, y por eso es tan desconcertante: el fallo depende del reparto de datos y no se reproduce con dos cafés de prueba.
Qué lo resuelve: la última línea de interpretarOrdenar:
Al añadir el id —único— como último criterio, el orden total queda completamente determinado y es idéntico en todas las consultas. La lección general: cualquier ordenación que vaya a paginarse debe terminar en una clave única.
Solución 3
// src/controladores/clientes.js
import { servicioClientes } from '../servicios/clientes.js';
import { servicioPedidos } from '../servicios/pedidos.js';
import { pedidoARepresentacion, aResumenDeColeccion } from '../servicios/mapeadores.js';
import { construirCabeceraLink } from './paginacion.js';
export const controladorClientes = {
/** GET /v1/clientes/:id/pedidos */
listarPedidos(req, res) {
// Primero, que el cliente exista: si no, 404 del cliente, no lista vacía.
if (!servicioClientes.obtener(req.params.id)) {
return res.status(404).json({
error: {
codigo: 'cliente_no_encontrado',
mensaje: `No existe ningún cliente con el identificador '${req.params.id}'.`,
detalles: [],
},
});
}
const limite = req.query.limite === undefined ? 20 : Number(req.query.limite);
const desplazamiento =
req.query.desplazamiento === undefined ? 0 : Number(req.query.desplazamiento);
const { elementos, total } = servicioPedidos.listar({
clienteId: req.params.id, // ← el id viene de la RUTA, no de la query
estado: req.query.estado,
limite,
desplazamiento,
});
const enlaces = construirCabeceraLink({ req, limite, desplazamiento, total });
if (Object.keys(enlaces).length > 0) res.links(enlaces);
res.status(200).json({
datos: elementos.map(pedidoARepresentacion).map(aResumenDeColeccion),
total,
});
},
};Diferencia con GET /v1/pedidos?clienteId=cli_842:
/v1/clientes/cli_842/pedidos |
/v1/pedidos?clienteId=cli_842 |
|
|---|---|---|
| Cliente inexistente | 404 cliente_no_encontrado |
200 con lista vacía |
| Semántica | "los pedidos de este cliente" | "todos los pedidos, filtrados" |
| Consumidor típico | El área de cliente de la SPA | El panel interno |
| Permisos (03-06) | El propio cliente | Rol empleado |
Por qué existen las dos: son puntos de vista distintos sobre los mismos datos. El subrecurso expresa una relación de pertenencia, es lo que navega la SPA desde la ficha del cliente y permite una regla de autorización natural ("solo tú ves lo tuyo"). La colección con filtro es la herramienta del panel interno, que combina clienteId con estado y fechaDesde. Lo importante, y por eso este ejercicio, es que la implementación no se duplica: ambos controladores llaman al mismo servicioPedidos.listar. La duplicación sería el problema; dos rutas hacia un único servicio, no.
Conclusión
La API ya implementa el contrato de cafés de principio a fin. Sabes qué trae req —y que todo llega como texto, que un parámetro repetido se convierte en array y que las cabeceras se leen en minúsculas— y qué controlas de res, incluido res.links() para la cabecera Link del RFC 8288. Los parámetros de colección de 02-06 funcionan de verdad: filtros combinados con Y lógico, búsqueda ?q=, ordenación con - y desempate obligatorio por id, paginación con sus valores por defecto y su máximo que devuelve 400 en vez de recortar en silencio, y campos para recortar la representación. El ciclo de escritura está completo: 201 con Location, PUT de reemplazo, PATCH con Merge Patch y su 415 acompañado de Accept-Patch, y DELETE lógico con 204 sin cuerpo.
Pero lo que más va a rendir a partir de ahora es la separación en capas. Las rutas son seis líneas legibles; los controladores traducen HTTP y no saben de negocio; los servicios no han visto un req en su vida; el mapeador concentra en un solo fichero las reglas de representación de 02-05, incluidos los _links de acción que aparecen y desaparecen según el estado del pedido. Gracias a esa separación, en 03-05 podremos cambiar el array por SQLite tocando un import, y en 03-08 podremos probar los servicios sin levantar un servidor.
Quedan dos cosas evidentemente cojas, y las dos tienen lección propia. La primera son esas comprobaciones a mano —campos obligatorios, limite, ordenar— repetidas, incompletas y con mensajes escritos uno a uno: un cuerpo con precioEuros: "carísimo" entra sin resistencia y acaba guardado como NaN. En 03-04, Validación de datos de entrada, escribiremos los esquemas Zod de cafés y pedidos y un único middleware validar(esquema, origen) que rechace la entrada estricta, convierta los tipos de los query params y devuelva todos los fallos a la vez en detalles, exactamente como fijamos en 02-04.
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
