Hagamos inventario de la deuda acumulada. En controladores/cafes.js hay un res.status(404).json({error: {...}}) repetido tres veces; en controladores/pedidos.js, otro casi idéntico; el middleware de validación construye su propio 400; el de autenticación, dos variantes de 401 y un 403; el servicio de autenticación devuelve {error: 'email_ya_registrado'} y el de pedidos {noEncontrado: true}, dos convenios distintos para lo mismo; y desde que bcrypt trajo controladores async, una excepción inesperada deja la petición colgada sin respuesta. Hoy todo eso se sustituye por dos piezas: una clase de error de dominio, ErrorApi, que los servicios lanzan sin saber que existe HTTP, y un único middleware de errores que la traduce al formato del contrato. Al terminar, ningún fichero fuera de src/middleware/errores.js construirá una respuesta de error.
Contenido
- Por qué centralizar
- La clase
ErrorApi - Las fábricas de errores del catálogo
- Servicios que lanzan en vez de devolver
- El envoltorio
asincrono(fn) - El middleware de errores de Express
ErrorApifrente a error inesperado- El
trazaIdy su correlación con los logs - Traducir el
ZodError - Traducir los errores de SQLite
- Traducir los errores de
express.json() - El 404 de rutas y el 405 con
Allow - El orden definitivo de
src/app.js - Qué no exponer nunca en un error
- Errores no capturados del proceso y apagado ordenado
- Tabla de referencia: situación → excepción → respuesta
problem+jsony por qué mantenemos el formato propio
- Por qué centralizar
Los cuatro problemas del enfoque disperso, en orden de gravedad:
| Problema | Consecuencia |
|---|---|
| Formato inconsistente | Un 404 con detalles y otro sin él; los clientes tienen que programar defensivamente |
| Cambios imposibles | Añadir trazaId a todos los 5xx obliga a tocar veinte ficheros |
| Fugas accidentales | Un res.json({ error: err.message }) publica una ruta del sistema o una consulta SQL |
| Capas contaminadas | El servicio decide códigos HTTP y deja de ser reutilizable fuera de la API |
La solución tiene dos mitades que hay que entender juntas:
- Los servicios lanzan errores de dominio. Dicen qué ha pasado (
el café no existe), no cómo se responde. No mencionan códigos HTTP. - Un middleware traduce. Es el único que conoce el formato de la respuesta de error, y por tanto el único que puede garantizar que siempre es el mismo.
graph TD S[Servicio: throw ErrorApi] --> C[Controlador] C -->|no captura| E[Middleware de errores] V[Zod: ZodError] --> E B[SQLite: SqliteError] --> E J[express.json: SyntaxError] --> E X[Bug inesperado: TypeError] --> E E --> R[Respuesta única del contrato]
- La clase
ErrorApi
ErrorApi// src/errores/error-api.js
/**
* Error de dominio de la API.
*
* Lleva la información necesaria para construir la respuesta, pero se lanza
* desde los servicios sin que estos sepan nada de Express: 'estado' es un
* número, no una llamada a res.status().
*/
export class ErrorApi extends Error {
/**
* @param {number} estado Código HTTP (404, 409, ...)
* @param {string} codigo Código del catálogo de 02-04 ('cafe_no_encontrado')
* @param {string} mensaje Mensaje legible para el consumidor
* @param {object[]} detalles Lista de problemas concretos; SIEMPRE presente
*/
constructor(estado, codigo, mensaje, detalles = []) {
super(mensaje);
// Sin esto, error.name sería 'Error' y los logs perderían información.
this.name = 'ErrorApi';
this.estado = estado;
this.codigo = codigo;
this.detalles = detalles;
// Marca explícita: el middleware la usa para distinguir un error
// previsto del catálogo de un fallo inesperado del programa.
this.esErrorApi = true;
// Recorta la traza para que empiece donde se lanzó, no en el constructor.
Error.captureStackTrace?.(this, ErrorApi);
}
}Dos decisiones. Extender Error conserva la traza de la pila y hace que la clase funcione con throw, try/catch y las herramientas de depuración. Y la marca esErrorApi en vez de fiarse solo de instanceof: si por cualquier motivo hubiera dos copias del módulo cargadas —algo que pasa con ciertas configuraciones de pruebas o con dependencias duplicadas—, instanceof fallaría mientras que la propiedad sigue ahí.
- Las fábricas de errores del catálogo
Escribir new ErrorApi(404, 'cafe_no_encontrado', '...') en veinte sitios reintroduce el problema que queremos resolver: nada garantiza que el código y el estado casen. Las fábricas lo garantizan.
// src/errores/error-api.js (continuación)
export const errores = {
// --- 400 ---
datosInvalidos(detalles = []) {
return new ErrorApi(
400,
'datos_invalidos',
'El cuerpo de la petición contiene errores de validación.',
detalles
);
},
parametroInvalido(detalles = []) {
return new ErrorApi(
400,
'parametro_invalido',
'Los parámetros de la petición contienen errores.',
detalles
);
},
// --- 401 ---
noAutenticado(mensaje = 'Se requiere autenticación para esta operación.') {
return new ErrorApi(401, 'no_autenticado', mensaje);
},
tokenCaducado() {
return new ErrorApi(401, 'token_caducado', 'El token ha caducado. Inicia sesión de nuevo.');
},
// --- 403 ---
permisoDenegado(rolesPermitidos = []) {
const detalle =
rolesPermitidos.length > 0
? ` Se requiere uno de estos roles: ${rolesPermitidos.join(', ')}.`
: '';
return new ErrorApi(403, 'permisos_insuficientes', `No tienes permiso.${detalle}`);
},
// --- 404 ---
noEncontrado(tipo, id) {
// Mapa entidad → código del catálogo. Un tipo nuevo se añade aquí.
const codigos = {
cafe: 'cafe_no_encontrado',
cliente: 'cliente_no_encontrado',
pedido: 'pedido_no_encontrado',
resena: 'resena_no_encontrada',
carrito: 'carrito_no_encontrado',
};
return new ErrorApi(
404,
codigos[tipo] ?? 'ruta_no_encontrada',
`No existe ning${tipo === 'resena' ? 'una' : 'ún'} ${tipo} con el identificador '${id}'.`
);
},
rutaNoEncontrada(metodo, url) {
return new ErrorApi(404, 'ruta_no_encontrada', `No existe el recurso ${metodo} ${url}.`);
},
// --- 405 / 409 / 413 / 415 ---
metodoNoPermitido(metodo, ruta) {
return new ErrorApi(405, 'metodo_no_permitido', `${metodo} no está permitido sobre ${ruta}.`);
},
conflicto(codigo, mensaje, detalles = []) {
return new ErrorApi(409, codigo, mensaje, detalles);
},
cuerpoDemasiadoGrande() {
return new ErrorApi(413, 'cuerpo_demasiado_grande', 'El cuerpo supera el tamaño máximo (100 kB).');
},
formatoNoSoportado(tipo) {
return new ErrorApi(
415,
'formato_no_soportado',
`El tipo de contenido '${tipo}' no se admite en esta operación.`
);
},
// --- 500 ---
errorInterno() {
return new ErrorApi(500, 'error_interno', 'Se ha producido un error inesperado.');
},
};Fíjate en conflicto(codigo, ...): los 409 comparten estado pero no código —stock_insuficiente, pedido_ya_pagado, conflicto_version, carrito_vacio—, así que la fábrica recibe el código y garantiza solo el 409. Lo importante es que el catálogo de 02-04 y este fichero son la misma cosa; si un código no está aquí, no existe.
- Servicios que lanzan en vez de devolver
Ahora los servicios se limpian. Antes:
// ANTES: convenios ad hoc que el controlador tenía que conocer
obtener(id) {
return repositorioCafes.buscarPorId(id); // undefined si no existe
},Después:
// src/servicios/cafes.js (modificado)
import { errores } from '../errores/error-api.js';
export const servicioCafes = {
/** Devuelve un café. Lanza si no existe. */
obtener(id) {
const cafe = repositorioCafes.buscarPorId(id);
if (!cafe) throw errores.noEncontrado('cafe', id);
return cafe;
},
reemplazar(id, datos) {
this.obtener(id); // lanza 404 si no existe: sin duplicar el mensaje
return repositorioCafes.actualizar(id, { /* ...campos... */ });
},
borrar(id) {
if (!repositorioCafes.borrar(id)) throw errores.noEncontrado('cafe', id);
},
};Y el servicio de pedidos, con sus errores de negocio:
// src/servicios/pedidos.js (modificado)
import { errores } from '../errores/error-api.js';
import { crearPedidoAtomico } from '../repositorios/pedidos-sqlite.js';
export const servicioPedidos = {
obtenerPara(id, usuario) {
const pedido = repositorioPedidos.buscarPorId(id);
const esPersonal = ['empleado', 'administrador'].includes(usuario.rol);
// Pedido inexistente y pedido ajeno dan EXACTAMENTE el mismo error,
// para no filtrar la existencia (03-06, sección 14).
if (!pedido || (pedido.clienteId !== usuario.id && !esPersonal)) {
throw errores.noEncontrado('pedido', id);
}
return pedido;
},
crear({ clienteId, lineas }) {
try {
const id = crearPedidoAtomico({ clienteId, lineas });
return repositorioPedidos.buscarPorId(id);
} catch (error) {
// El repositorio lanza errores con 'codigoDominio' (03-05); aquí se
// convierten en ErrorApi. La traducción vive en el servicio porque
// es él quien decide que un stock insuficiente es un 409.
if (error.codigoDominio === 'stock_insuficiente') {
throw errores.conflicto('stock_insuficiente', error.message);
}
if (error.codigoDominio === 'cafe_no_encontrado') {
throw errores.noEncontrado('cafe', 'de alguna línea del pedido');
}
throw error; // no es nuestro: que lo trate el middleware como 500
}
},
pagar(id, usuario) {
const pedido = this.obtenerPara(id, usuario);
if (pedido.estado === 'pagado') {
throw errores.conflicto('pedido_ya_pagado', `El pedido '${id}' ya está pagado.`);
}
// ...marcar como pagado...
},
};Ese último throw error es importante: lo que no sabemos traducir, se propaga. Tragarse un error desconocido para "no romper nada" es la forma más eficaz de que un fallo grave pase inadvertido.
Y el controlador queda reducido a su papel real:
// src/controladores/cafes.js (modificado)
obtener(req, res) {
const cafe = servicioCafes.obtener(req.params.id); // si falla, lanza
res.status(200).json(proyectar(cafeARepresentacion(cafe), req.query.campos));
},Una línea de trabajo y una de respuesta. No hay if (!cafe), no hay 404 escrito a mano, no hay mensaje duplicado.
- El envoltorio
asincrono(fn)
asincrono(fn)Con controladores síncronos, Express 4 captura el throw y lo lleva al middleware de errores. Con controladores async —los de clientes y sesiones ya lo son, y todos lo serían con PostgreSQL— no, tal como diagnosticamos en 03-03: la función devuelve una promesa rechazada que nadie observa, y la petición se queda colgada.
// src/middleware/asincrono.js
/**
* Envuelve un manejador para que cualquier rechazo de promesa acabe en
* next(error) y, por tanto, en el middleware de errores.
*
* Promise.resolve() funciona igual si fn es síncrona (devuelve una promesa
* ya resuelta) o asíncrona, así que se puede envolver TODO sin pensarlo.
*/
export function asincrono(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}Uso en las rutas:
// src/rutas/cafes.js (modificado)
import { asincrono } from '../middleware/asincrono.js';
rutasCafes.get('/', validar(esquemaConsultaCafes, 'query'), asincrono(controladorCafes.listar));
rutasCafes.get('/:id', validar(esquemaIdCafe, 'params'), asincrono(controladorCafes.obtener));
rutasCafes.post(
'/',
autenticar,
exigirRol('empleado', 'administrador'),
validar(esquemaCrearCafe),
asincrono(controladorCafes.crear)
);
// ...y así en todasEnvuelve todos los manejadores, síncronos incluidos. El coste es nulo y elimina la clase de bug más difícil de detectar: el día que alguien convierta un controlador en async sin acordarse del envoltorio, la ruta dejaría de responder ante cualquier error y nadie lo notaría hasta producción.
Existen paquetes que hacen esto (express-async-errors, que parchea Express al importarlo) y Express 5 ya lo hace de serie: un manejador async que rechaza va directo al middleware de errores. Es la razón más práctica para migrar. Nuestro envoltorio es explícito, son cuatro líneas y no depende de nada.
- El middleware de errores de Express
// src/middleware/errores.js
import { ZodError } from 'zod';
import { ErrorApi, errores } from '../errores/error-api.js';
import { entorno } from '../config/entorno.js';
/**
* Middleware de errores.
*
* LA FIRMA DE CUATRO ARGUMENTOS ES OBLIGATORIA. Express distingue un
* middleware de errores de uno normal contando los parámetros de la
* función: con tres es normal, con cuatro es de errores. Por eso 'next'
* debe declararse aunque no se use, y por eso ESLint necesitaba la regla
* argsIgnorePattern: '^_' que configuramos en 03-01.
*/
// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
const errorApi = traducir(err);
// --- Registro para el equipo ---
if (errorApi.estado >= 500) {
// Los 5xx son fallos NUESTROS: se registran enteros, con traza.
console.error(
JSON.stringify({
nivel: 'error',
trazaId: req.trazaId,
metodo: req.method,
ruta: req.originalUrl,
usuario: req.usuario?.id ?? null,
mensaje: err.message,
pila: err.stack,
})
);
} else {
// Los 4xx son fallos del cliente: basta una línea informativa.
console.warn(
JSON.stringify({
nivel: 'aviso',
trazaId: req.trazaId,
metodo: req.method,
ruta: req.originalUrl,
codigo: errorApi.codigo,
})
);
}
// --- Cabeceras que exige el contrato según el caso ---
if (errorApi.estado === 401) {
res.set('WWW-Authenticate', 'Bearer realm="api.tiendaaroma.example"');
}
if (errorApi.estado === 405 && err.metodosPermitidos) {
res.set('Allow', err.metodosPermitidos.join(', '));
}
if (errorApi.codigo === 'formato_no_soportado' && req.method === 'PATCH') {
res.set('Accept-Patch', 'application/merge-patch+json');
}
// --- Cuerpo del contrato (02-04) ---
const cuerpo = {
error: {
codigo: errorApi.codigo,
mensaje: errorApi.mensaje ?? errorApi.message,
detalles: errorApi.detalles ?? [],
},
};
// trazaId SOLO en los 5xx, como decidimos en 02-04.
if (errorApi.estado >= 500) {
cuerpo.error.trazaId = req.trazaId;
// En desarrollo ayuda ver la causa; en producción, JAMÁS.
if (entorno.nodeEnv !== 'produccion') {
cuerpo.error.depuracion = err.message;
}
}
res.status(errorApi.estado).json(cuerpo);
}Y la función que decide qué es cada error:
// src/middleware/errores.js (continuación)
/** Convierte cualquier excepción en un ErrorApi del catálogo. */
function traducir(err) {
// 1. Ya es nuestro: se usa tal cual.
if (err?.esErrorApi) return err;
// 2. Validación de Zod que se ha escapado del middleware de validación.
if (err instanceof ZodError) {
return errores.datosInvalidos(
err.issues.map((i) => ({
campo: i.path.join('.') || '(cuerpo)',
codigo: 'valor_invalido',
mensaje: i.message,
}))
);
}
// 3. JSON mal formado, detectado por express.json().
if (err instanceof SyntaxError && 'body' in err) {
return new ErrorApi(400, 'datos_invalidos', 'El cuerpo no es JSON válido.', [
{ campo: '(cuerpo)', codigo: 'json_mal_formado', mensaje: err.message },
]);
}
// 4. Cuerpo demasiado grande (limit de express.json).
if (err?.type === 'entity.too.large') return errores.cuerpoDemasiadoGrande();
// 5. Errores de SQLite.
const deSqlite = traducirSqlite(err);
if (deSqlite) return deSqlite;
// 6. Cualquier otra cosa es un bug nuestro: 500 sin filtrar nada.
return errores.errorInterno();
}
ErrorApi frente a error inesperado
ErrorApi frente a error inesperadoLa distinción del paso 6 es el corazón del middleware:
ErrorApi (previsto) |
Error inesperado (bug) | |
|---|---|---|
| Origen | Lanzado a propósito por un servicio | TypeError, ReferenceError, fallo de librería |
| Estado | El que dice el error: 400, 404, 409… | Siempre 500 |
codigo |
Del catálogo | Siempre error_interno |
| Mensaje al cliente | Específico y útil | Genérico: "Se ha producido un error inesperado" |
trazaId |
No | Sí |
| Log | Aviso de una línea | Error completo con pila |
| ¿Culpa de quién? | Del cliente | Nuestra |
Un ejemplo del segundo caso. Si un día un bug produce TypeError: Cannot read properties of undefined (reading 'precioCentimos'), el cliente recibe:
{
"error": {
"codigo": "error_interno",
"mensaje": "Se ha producido un error inesperado.",
"detalles": [],
"trazaId": "trz_8f4a1c92"
}
}Y en los logs del servidor queda el detalle completo, con el fichero, la línea, el usuario y la ruta. El consumidor recibe lo que necesita para pedir ayuda; el equipo, lo que necesita para arreglarlo. Esa asimetría es intencionada y es una medida de seguridad, no solo de estética.
- El
trazaId y su correlación con los logs
trazaId y su correlación con los logs// src/middleware/traza.js
import { randomBytes } from 'node:crypto';
/**
* Asigna un identificador único a cada petición.
* Si viene de un proxy o gateway que ya lo generó, se respeta: así la
* traza es continua a lo largo de todo el sistema (04-07).
*/
export function asignarTrazaId(req, res, next) {
const heredado = req.get('Aroma-Traza-Id');
req.trazaId = heredado ?? `trz_${randomBytes(4).toString('hex')}`;
// Se devuelve siempre, no solo en los errores: permite al cliente
// referenciar cualquier petición al abrir una incidencia.
res.set('Aroma-Traza-Id', req.trazaId);
next();
}El flujo cuando algo falla es este: el usuario ve trazaId: "trz_8f4a1c92", abre una incidencia con ese identificador, y el equipo busca esa cadena en los logs y encuentra la petición exacta, con su método, su ruta, su usuario y la pila del error. Sin trazaId, la investigación empieza por "¿a qué hora fue, más o menos?".
Fíjate en el prefijo: Aroma-Traza-Id, no X-Traza-Id, por la decisión de 02-05 y el RFC 6648.
Esto es el primer escalón de la observabilidad. Los logs estructurados con niveles y pino, las métricas, las trazas distribuidas con OpenTelemetry y la correlación entre servicios son la lección 04-07; aquí basta con que cada petición tenga nombre y que ese nombre aparezca en las dos puntas.
- Traducir el
ZodError
ZodErrorEl middleware validar de 03-04 construía su propia respuesta. Ahora lanza y deja de saber de HTTP:
// src/middleware/validacion.js (modificado)
import { errores } from '../errores/error-api.js';
export function validar(esquema, origen = 'body') {
return (req, res, next) => {
const resultado = esquema.safeParse(req[origen]);
if (!resultado.success) {
const detalles = aDetalles(resultado.error);
// El middleware de errores se encarga del formato y del registro.
return next(
origen === 'body' ? errores.datosInvalidos(detalles) : errores.parametroInvalido(detalles)
);
}
req[origen] = resultado.data;
next();
};
}next(error) con un argumento salta todos los middleware normales y va directo al primer middleware de errores. Es el mecanismo estándar de Express para propagar fallos desde un middleware, y la contrapartida de throw en los manejadores.
Las funciones aDetalles y traducirCodigo se quedan donde estaban: siguen siendo responsabilidad de la validación, porque conocen la estructura de Zod. Lo que cambia es que ya no deciden el formato de la respuesta.
El paso 2 de traducir() es una red de seguridad para los ZodError que se lancen fuera del middleware —por ejemplo, un parse() dentro de un servicio—. No debería ocurrir, y si ocurre, el resultado sigue siendo correcto.
- Traducir los errores de SQLite
better-sqlite3 lanza errores con un campo code muy informativo:
// src/middleware/errores.js (continuación)
/** Errores de SQLite → errores del catálogo. Devuelve null si no lo es. */
function traducirSqlite(err) {
const codigo = err?.code;
if (typeof codigo !== 'string' || !codigo.startsWith('SQLITE_')) return null;
switch (codigo) {
case 'SQLITE_CONSTRAINT_UNIQUE':
case 'SQLITE_CONSTRAINT_PRIMARYKEY':
// Alguien intentó duplicar un valor único (un email, por ejemplo).
return new ErrorApi(409, 'recurso_duplicado', 'Ya existe un recurso con ese valor único.');
case 'SQLITE_CONSTRAINT_FOREIGNKEY':
// Se referencia algo que no existe: un pedido de un cliente inexistente.
return new ErrorApi(
409,
'referencia_invalida',
'La operación referencia un recurso que no existe.'
);
case 'SQLITE_CONSTRAINT_CHECK':
// Un CHECK del esquema (tueste inválido, stock negativo). Si llega
// aquí es que la validación de 03-04 tiene un hueco: se registra
// como 5xx para que el equipo lo vea, aunque la culpa parezca del cliente.
return new ErrorApi(400, 'datos_invalidos', 'Los datos no cumplen una restricción del modelo.');
case 'SQLITE_BUSY':
return new ErrorApi(503, 'servicio_no_disponible', 'La base de datos está ocupada. Reintenta.');
default:
return null; // desconocido: que sea un 500 con su log completo
}
}Dos advertencias. La primera: el mensaje que se devuelve no es el de SQLite. El original diría algo como UNIQUE constraint failed: clientes.email, revelando el nombre de la tabla y de la columna; es justo el tipo de dato que un atacante usa para mapear tu esquema. La segunda: esta traducción es una red de seguridad, no la primera línea. El email duplicado ya se comprueba en el servicio de registro (03-06) y devuelve un 409 email_ya_registrado con un mensaje mucho más útil. Que además exista la traducción cubre la carrera entre dos registros simultáneos con el mismo correo, en la que la comprobación previa puede pasar y la base de datos ser la única que se entere.
Un apunte para PostgreSQL: los códigos son distintos —23505 para unicidad, 23503 para clave foránea— pero la estructura de la función sería idéntica.
- Traducir los errores de
express.json()
express.json()Dos casos frecuentes que hoy producen respuestas HTML de Express:
# JSON mal formado: falta una comilla
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
-d '{"nombre": "Kenia, "origen": "Kenia"}' | jq{
"error": {
"codigo": "datos_invalidos",
"mensaje": "El cuerpo no es JSON válido.",
"detalles": [
{
"campo": "(cuerpo)",
"codigo": "json_mal_formado",
"mensaje": "Unexpected token o in JSON at position 22"
}
]
}
}# Cuerpo de 2 MB contra un límite de 100 kB
curl -s -X POST http://localhost:3000/v1/cafes \
-H "Content-Type: application/json" \
--data-binary @gigante.json | jq .error.codigoLa comprobación err instanceof SyntaxError && 'body' in err merece explicación: SyntaxError es una clase estándar de JavaScript y puede venir de cualquier sitio, pero el paquete body-parser que usa Express añade una propiedad body con el texto recibido. Esa propiedad es lo que distingue "el cliente envió JSON roto" de "hay un eval mal escrito en el código".
Sobre el mensaje: exponer "Unexpected token o in JSON at position 22" es aceptable porque describe la entrada del propio cliente, no nuestro sistema, y le dice exactamente dónde mirar. Es la excepción que confirma la regla de la sección 14.
- El 404 de rutas y el 405 con
Allow
AllowEl cajón de sastre de 03-02 ahora delega:
// src/middleware/no-encontrado.js
import { errores } from '../errores/error-api.js';
/** Ninguna ruta ha coincidido: 404 ruta_no_encontrada. */
export function manejadorNoEncontrado(req, res, next) {
next(errores.rutaNoEncontrada(req.method, req.originalUrl));
}El 405 es distinto y más sutil: la URI existe, pero no para ese método. DELETE /v1/cafes (sobre la colección) no está en el mapa de URIs, pero GET y POST sí. Devolver 404 sería mentir; lo correcto es 405 con la cabecera Allow, obligatoria según el RFC 9110.
// src/middleware/no-encontrado.js (continuación)
import { ErrorApi } from '../errores/error-api.js';
/**
* Devuelve un manejador que responde 405 con Allow.
* Se registra con router.all() al final de cada grupo de rutas, así que
* solo se alcanza si la URI coincidió pero ningún método lo hizo.
*/
export function metodoNoPermitido(...metodosPermitidos) {
return (req, res, next) => {
const error = new ErrorApi(
405,
'metodo_no_permitido',
`${req.method} no está permitido sobre ${req.baseUrl}${req.path}.`
);
// El middleware de errores leerá esta propiedad para poner Allow.
error.metodosPermitidos = [...metodosPermitidos, 'OPTIONS'];
next(error);
};
}// src/rutas/cafes.js (al final del fichero, tras todas las rutas)
import { metodoNoPermitido } from '../middleware/no-encontrado.js';
rutasCafes.all('/', metodoNoPermitido('GET', 'POST'));
rutasCafes.all('/:id', metodoNoPermitido('GET', 'PUT', 'PATCH', 'DELETE'));HTTP/1.1 405 Method Not Allowed
Allow: GET, POST, OPTIONS
Content-Type: application/json; charset=utf-8
{"error":{"codigo":"metodo_no_permitido","mensaje":"DELETE no está permitido sobre /v1/cafes.","detalles":[]}}router.all() intercepta cualquier método sobre esa ruta, y debe declararse después de las rutas específicas: si estuviera antes, se comería también los GET legítimos. Es la misma regla de orden de 03-02 aplicada al final de la lista.
- El orden definitivo de
src/app.js
src/app.js// src/app.js (versión final del módulo)
import express from 'express';
import { rutasV1 } from './rutas/index.js';
import { asignarTrazaId } from './middleware/traza.js';
import { manejadorNoEncontrado } from './middleware/no-encontrado.js';
import { manejadorErrores } from './middleware/errores.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Identidad de la petición: lo PRIMERO, para que todo pueda usarla ---
app.use(asignarTrazaId);
// --- 2. Registro de peticiones (04-07 lo sustituirá por logs estructurados) ---
app.use((req, res, next) => {
const inicio = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inicio} ms)`);
});
next();
});
// --- 3. Parsers del cuerpo ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 4. Salud (fuera de /v1: no forma parte del contrato) ---
app.get('/salud', (req, res) => {
res.status(200).json({ estado: 'ok', version: '1.0.0', momento: new Date().toISOString() });
});
// --- 5. API versionada ---
app.use('/v1', rutasV1);
// --- 6. Ninguna ruta ha coincidido ---
app.use(manejadorNoEncontrado);
// --- 7. Middleware de errores: SIEMPRE EL ÚLTIMO ---
app.use(manejadorErrores);Por qué el middleware de errores va el último, sin excepciones. Express recorre la cadena en orden; cuando alguien llama a next(error), busca hacia adelante el siguiente middleware con cuatro argumentos. Si lo registraras antes de las rutas, un error lanzado en una ruta no encontraría ningún manejador por delante y caería en el manejador por defecto de Express: una página HTML con el stack completo en desarrollo. Un fallo de orden aquí filtra la traza de tu código a Internet.
- Qué no exponer nunca en un error
| No expongas | Ejemplo | Por qué |
|---|---|---|
| Trazas de pila | at /home/aroma/src/servicios/pedidos.js:42 |
Revela rutas del sistema, estructura del proyecto y tu nombre de usuario |
| Consultas SQL | SELECT * FROM clientes WHERE email = ... |
Mapa del esquema servido al atacante |
| Nombres de tabla o columna | UNIQUE constraint failed: clientes.email |
Ídem |
| Versiones | Express 4.18.2, SQLite 3.45 |
Permite buscar vulnerabilidades conocidas de esa versión exacta |
| Mensajes internos de librerías | ECONNREFUSED 10.0.3.14:5432 |
Revela topología de red e IPs internas |
| Existencia de recursos ajenos | 403 en vez de 404 |
Permite enumerar (03-06) |
| Distinguir usuario de contraseña | "Ese correo no existe" | Permite enumerar cuentas |
La regla operativa es la asimetría entre las dos audiencias:
| Respuesta al consumidor | Log para el equipo | |
|---|---|---|
| Audiencia | Cualquiera, incluido un atacante | Personas con acceso al sistema |
| Contenido | Código, mensaje, detalles, trazaId |
Todo: pila, SQL, usuario, cabeceras |
| Objetivo | Que sepa qué hacer | Que el equipo pueda arreglarlo |
El trazaId es lo que hace posible que las dos vistas sean tan distintas sin perder la conexión entre ellas.
Y un aviso sobre el campo depuracion que añadimos en desarrollo: está condicionado a entorno.nodeEnv !== 'produccion'. Que esa condición esté bien escrita es crítico; si NODE_ENV no está definido en producción, se filtrarían los mensajes internos. Es un argumento más para la validación de configuración al arrancar que montamos en 03-01.
- Errores no capturados del proceso y apagado ordenado
El middleware solo ve lo que ocurre dentro de una petición. Un fallo en un setTimeout, en un manejador de eventos o en una promesa suelta escapa a Express y llega al proceso:
// src/servidor.js (versión final del módulo)
import { app } from './app.js';
import { entorno } from './config/entorno.js';
import { cerrarBaseDatos } from './config/base-datos.js';
const servidor = app.listen(entorno.puerto, () => {
console.log(`API de Tienda Aroma escuchando en ${entorno.baseUrl}/v1`);
});
let cerrando = false;
function cerrarOrdenadamente(motivo, codigoSalida = 0) {
if (cerrando) return; // dos señales seguidas no deben duplicar el cierre
cerrando = true;
console.log(`Cerrando por: ${motivo}`);
servidor.close(() => {
cerrarBaseDatos();
console.log('Servidor y base de datos cerrados.');
process.exit(codigoSalida);
});
// Red de seguridad: si en 10 s no ha terminado, se fuerza la salida.
// Sin esto, una conexión abierta puede impedir el apagado para siempre.
setTimeout(() => {
console.error('Cierre forzado tras el tiempo de espera.');
process.exit(1);
}, 10000).unref();
}
process.on('SIGINT', () => cerrarOrdenadamente('SIGINT'));
process.on('SIGTERM', () => cerrarOrdenadamente('SIGTERM'));
/**
* Excepción no capturada: el proceso está en un estado DESCONOCIDO.
* Se registra y se sale. Intentar continuar es peor: puede haber
* transacciones a medias, ficheros abiertos y estado corrupto.
*/
process.on('uncaughtException', (error) => {
console.error(
JSON.stringify({ nivel: 'fatal', tipo: 'uncaughtException', mensaje: error.message, pila: error.stack })
);
cerrarOrdenadamente('uncaughtException', 1);
});
/** Promesa rechazada sin catch. Desde Node 15, también termina el proceso. */
process.on('unhandledRejection', (razon) => {
console.error(
JSON.stringify({ nivel: 'fatal', tipo: 'unhandledRejection', mensaje: String(razon) })
);
cerrarOrdenadamente('unhandledRejection', 1);
});Por qué salir en vez de seguir. Es contraintuitivo: parece más robusto "aguantar". No lo es. Una excepción no capturada significa que el código llegó a un punto que nadie previó, y a partir de ahí no se puede razonar sobre el estado del proceso: puede haber una transacción sin cerrar, un fichero bloqueado o una variable corrupta que produzca respuestas incorrectas —peor que ninguna respuesta—. Lo correcto es registrar todo, cerrar ordenadamente y dejar que el supervisor levante un proceso limpio. Ese supervisor (systemd, Docker con restart: always, Kubernetes) es parte del despliegue y se trata en 05-05.
Y por eso el apagado ordenado importa tanto: entre SIGTERM y la muerte del proceso, servidor.close() deja de aceptar conexiones nuevas pero termina las que están en curso. Sin él, cada despliegue cortaría a media respuesta a los clientes que estuvieran esperando.
- Tabla de referencia: situación → excepción → respuesta
| Situación | Se lanza | HTTP | codigo |
Cabecera extra |
|---|---|---|---|---|
| Cuerpo con campos inválidos | errores.datosInvalidos(detalles) |
400 | datos_invalidos |
— |
| Query param desconocido o fuera de rango | errores.parametroInvalido(detalles) |
400 | parametro_invalido |
— |
| JSON mal formado | SyntaxError de body-parser |
400 | datos_invalidos |
— |
Falta Authorization |
errores.noAutenticado() |
401 | no_autenticado |
WWW-Authenticate |
| Token caducado | errores.tokenCaducado() |
401 | token_caducado |
WWW-Authenticate |
| Rol insuficiente | errores.permisoDenegado([...]) |
403 | permisos_insuficientes |
— |
| Café inexistente | errores.noEncontrado('cafe', id) |
404 | cafe_no_encontrado |
— |
| Pedido ajeno | errores.noEncontrado('pedido', id) |
404 | pedido_no_encontrado |
— |
| URI inexistente | errores.rutaNoEncontrada(...) |
404 | ruta_no_encontrada |
— |
| Método no admitido en esa URI | errores.metodoNoPermitido(...) |
405 | metodo_no_permitido |
Allow |
| Sin stock | errores.conflicto('stock_insuficiente', ...) |
409 | stock_insuficiente |
— |
| Segundo pago | errores.conflicto('pedido_ya_pagado', ...) |
409 | pedido_ya_pagado |
— |
| Versión desfasada | errores.conflicto('conflicto_version', ...) |
409 | conflicto_version |
— |
| Cuerpo de 2 MB | entity.too.large de body-parser |
413 | cuerpo_demasiado_grande |
— |
| PATCH con JSON Patch | errores.formatoNoSoportado(tipo) |
415 | formato_no_soportado |
Accept-Patch |
| Bug del programa | TypeError y similares |
500 | error_interno |
— (y trazaId en el cuerpo) |
| Base de datos ocupada | SQLITE_BUSY |
503 | servicio_no_disponible |
Retry-After |
Esta tabla es el resumen ejecutable del catálogo de 02-04 y la referencia que se consulta al añadir un endpoint nuevo. La columna de la derecha es la que más se olvida.
problem+json y por qué mantenemos el formato propio
problem+json y por qué mantenemos el formato propioEn 02-04 comparamos nuestro formato con el estándar RFC 9457 (application/problem+json), que se vería así:
{
"type": "https://api.tiendaaroma.example/errores/stock-insuficiente",
"title": "Stock insuficiente",
"status": 409,
"detail": "Solo quedan 3 unidades de 'Etiopía Yirgacheffe' y se piden 5.",
"instance": "/v1/pedidos"
}problem+json |
Formato de Tienda Aroma | |
|---|---|---|
| Estándar | Sí, RFC 9457 | No |
| Identificador de máquina | URI en type |
codigo en snake_case |
| Lista de fallos de validación | Extensión propia | detalles de serie |
Content-Type |
application/problem+json |
application/json |
| Soporte en clientes | Creciente | Requiere leer la documentación |
Tienda Aroma mantiene su formato, y las razones siguen siendo las de 02-04: codigo en snake_case es más cómodo para un switch que comparar URIs largas; detalles como campo de primera clase encaja con la decisión de devolver todos los fallos de validación a la vez; y usar application/json evita que los clientes tengan que negociar un tipo distinto. Añadido ahora un argumento de implementación: cambiar de formato sería trivial —se toca únicamente src/middleware/errores.js—, y esa es precisamente la prueba de que centralizar mereció la pena. Si mañana un socio exige problem+json, se puede incluso emitir según el Accept, con Vary: Accept, sin tocar ni un servicio.
Lo que no es opcional, uses el formato que uses: código HTTP correcto, un identificador estable para máquinas, un mensaje útil para personas, y jamás filtrar el interior del sistema.
Errores Comunes y Consejos
1. Declarar el middleware de errores con tres argumentos. Express lo trata como un middleware normal y nunca se ejecuta. Los cuatro parámetros son obligatorios, aunque next no se use.
2. Registrarlo antes de las rutas. No captura nada y los errores caen en el manejador por defecto de Express, que en desarrollo devuelve el stack en HTML.
3. Olvidar asincrono() en un controlador async. La petición se queda colgada sin respuesta ni error visible. Envuelve todos los manejadores.
4. Usar throw dentro de un setTimeout o un callback. No lo captura ni Express ni el envoltorio: acaba en uncaughtException. Dentro de callbacks, propaga con next(error).
5. Devolver err.message sin filtrar. Publica rutas, SQL, IPs y versiones. Solo los ErrorApi llevan mensaje propio; el resto, mensaje genérico.
6. Responder y además llamar a next(). ERR_HTTP_HEADERS_SENT. Un camino u otro, nunca los dos.
7. Tragarse errores desconocidos. Un catch que devuelve null convierte un fallo grave en datos ausentes. Lo que no sepas traducir, relánzalo.
8. Poner trazaId en los 4xx. El contrato lo reserva para los 5xx, que son los únicos que requieren investigación por nuestra parte.
9. No cerrar el proceso tras un uncaughtException. El estado es desconocido; seguir sirviendo peticiones puede producir respuestas incorrectas, que es peor que no responder.
Consejo: provoca un error a propósito de vez en cuando —un throw new Error('prueba') temporal en un controlador— y comprueba que la respuesta es un 500 limpio con trazaId y que el log contiene la pila completa. Es la única forma de saber que el camino de error funciona; nadie lo prueba hasta que lo necesita.
Ejercicios
Ejercicio 1
Implementa el middleware exigirClaveIdempotencia que el contrato de 02-03 exige en POST /v1/pedidos y POST /v1/pedidos/{id}/pago: si falta la cabecera Idempotency-Key, debe producir 400 clave_idempotencia_requerida; si la clave ya se usó con un cuerpo distinto, 422 clave_idempotencia_reutilizada. Usa ErrorApi y explica por qué el segundo caso es 422 y no 409.
Ejercicio 2
Un compañero escribe este controlador y en producción aparecen peticiones que nunca reciben respuesta. Diagnostica los tres problemas y escribe la versión correcta.
rutasPedidos.post('/', autenticar, async (req, res) => {
try {
const pedido = await servicioPedidos.crear(req.body);
res.status(201).json(pedidoARepresentacion(pedido));
} catch (error) {
res.status(500).json({ error: error.message });
}
});Ejercicio 3
Diseña la respuesta completa —código, cabeceras y cuerpo— para estas cuatro situaciones, indicando qué fábrica de errores la produce y qué se registra en el log:
PATCH /v1/cafes/caf_001conContent-Type: application/json-patch+json.GET /v1/pedidos/ped_5001con el token de un cliente que no es el propietario.POST /v1/cafescon un token válido de rolcliente.- Un
TypeErrordentro decafeARepresentacionporque un café sembrado tienenotas_cataaNULL.
Soluciones
Solución 1
// src/middleware/idempotencia.js
import { createHash } from 'node:crypto';
import { ErrorApi, errores } from '../errores/error-api.js';
import { baseDatos } from '../config/base-datos.js';
// Tabla necesaria (migración 003):
// CREATE TABLE claves_idempotencia (
// clave TEXT PRIMARY KEY, huella TEXT NOT NULL,
// respuesta TEXT, fecha TEXT NOT NULL
// );
const buscarClave = baseDatos.prepare('SELECT * FROM claves_idempotencia WHERE clave = ?');
const guardarClave = baseDatos.prepare(
'INSERT INTO claves_idempotencia (clave, huella, fecha) VALUES (?, ?, ?)'
);
export function exigirClaveIdempotencia(req, res, next) {
const clave = req.get('Idempotency-Key');
if (!clave) {
return next(
new ErrorApi(
400,
'clave_idempotencia_requerida',
'Esta operación requiere la cabecera Idempotency-Key.'
)
);
}
// Huella del cuerpo: identifica "la misma petición".
const huella = createHash('sha256').update(JSON.stringify(req.body ?? {})).digest('hex');
const registro = buscarClave.get(clave);
if (registro) {
if (registro.huella !== huella) {
// Misma clave, cuerpo distinto: contradicción del cliente.
return next(
new ErrorApi(
422,
'clave_idempotencia_reutilizada',
'Esa Idempotency-Key ya se usó con un cuerpo diferente.'
)
);
}
if (registro.respuesta) {
// Reintento legítimo: se devuelve la respuesta original.
return res.status(200).json(JSON.parse(registro.respuesta));
}
return next(
new ErrorApi(409, 'operacion_en_curso', 'Una petición idéntica se está procesando.')
);
}
guardarClave.run(clave, huella, new Date().toISOString());
req.claveIdempotencia = clave;
next();
}rutasPedidos.post(
'/',
autenticar,
exigirRol('cliente', 'empleado', 'administrador'),
exigirClaveIdempotencia,
validar(esquemaCrearPedido),
asincrono(controladorPedidos.crear)
);Por qué 422 y no 409: los dos códigos indican que la petición no se puede procesar, pero señalan cosas distintas. El 409 Conflict dice que la petición era correcta y choca con el estado actual del recurso: no hay stock, el pedido ya estaba pagado. El 422 Unprocessable Content dice que la petición está sintácticamente bien formada pero es semánticamente contradictoria en sí misma: el cliente afirma con la clave "esta es la misma petición que antes" y a la vez envía un cuerpo distinto. No hay ningún recurso en conflicto; la contradicción está dentro de la propia petición. Por eso el contrato de 02-04 reservó el 422 exclusivamente para este caso, y todo lo demás va por 400 o 409.
Solución 2
| # | Problema | Consecuencia |
|---|---|---|
| 1 | Todo error se convierte en 500 |
Un stock insuficiente, que es 409 stock_insuficiente, se reporta como fallo del servidor. El cliente reintenta creyendo que es temporal, la monitorización se llena de falsos 5xx y el ErrorApi que el servicio lanzó cuidadosamente se pierde |
| 2 | Se devuelve error.message en crudo |
Fuga de información: puede contener rutas del sistema o mensajes de SQLite. Además el formato es {error: "texto"}, no {error: {codigo, mensaje, detalles}}, así que rompe el contrato |
| 3 | Sin asincrono() y con un catch que puede fallar |
Si pedidoARepresentacion lanza después del await, el error ocurre dentro del try y entra en el catch… pero si el propio res.json ya envió cabeceras, el catch intentará responder otra vez y lanzará ERR_HTTP_HEADERS_SENT fuera de cualquier captura. Esa segunda excepción no la ve nadie: promesa rechazada, petición colgada |
Versión correcta:
// src/controladores/pedidos.js
crear: async (req, res) => {
const pedido = await servicioPedidos.crear({
clienteId: req.usuario.id, // del token, NUNCA del cuerpo (03-06)
lineas: req.body.lineas,
});
res.set('Location', `/v1/pedidos/${pedido.id}`);
res.status(201).json(pedidoARepresentacion(pedido));
},// src/rutas/pedidos.js
rutasPedidos.post(
'/',
autenticar,
exigirClaveIdempotencia,
validar(esquemaCrearPedido),
asincrono(controladorPedidos.crear)
);Sin try/catch. El controlador se ocupa del camino feliz; asincrono() encamina cualquier rechazo hacia manejadorErrores, que ya sabe distinguir un ErrorApi de un bug, poner el código correcto, no filtrar nada y registrar lo que corresponda. Un try/catch en un controlador solo se justifica cuando hay que traducir un error a otro más específico —como hace servicioPedidos.crear con los errores del repositorio—, nunca para "que no se rompa".
Solución 3
1. PATCH con JSON Patch
HTTP/1.1 415 Unsupported Media Type
Accept-Patch: application/merge-patch+json
Aroma-Traza-Id: trz_1a2b3c4d
{"error":{"codigo":"formato_no_soportado","mensaje":"El tipo de contenido 'application/json-patch+json' no se admite en esta operación.","detalles":[]}}Fábrica: errores.formatoNoSoportado(tipo), lanzada desde el controlador de PATCH. La cabecera Accept-Patch la añade el middleware de errores al ver codigo === 'formato_no_soportado' en un PATCH. Log: aviso de una línea, es un error del cliente.
2. Pedido ajeno
HTTP/1.1 404 Not Found
Aroma-Traza-Id: trz_5e6f7a8b
{"error":{"codigo":"pedido_no_encontrado","mensaje":"No existe ningún pedido con el identificador 'ped_5001'.","detalles":[]}}Fábrica: errores.noEncontrado('pedido', id) desde servicioPedidos.obtenerPara. 404 y no 403, y con exactamente el mismo cuerpo que si el pedido no existiera, para no confirmar su existencia (03-06). Log: aviso; conviene registrar usuario y el id solicitado, porque una racha de estos es un indicio de enumeración y en 04-07 será una alerta.
3. POST /v1/cafes con rol cliente
HTTP/1.1 403 Forbidden
Aroma-Traza-Id: trz_9c0d1e2f
{"error":{"codigo":"permisos_insuficientes","mensaje":"No tienes permiso. Se requiere uno de estos roles: empleado, administrador.","detalles":[]}}Fábrica: errores.permisoDenegado(['empleado', 'administrador']) desde exigirRol. 403 y no 404, porque /v1/cafes es público y su existencia no es ningún secreto: aquí ser explícito ayuda al integrador sin filtrar nada. Log: aviso.
4. TypeError en el mapeador
HTTP/1.1 500 Internal Server Error
Aroma-Traza-Id: trz_3a4b5c6d
{"error":{"codigo":"error_interno","mensaje":"Se ha producido un error inesperado.","detalles":[],"trazaId":"trz_3a4b5c6d"}}Fábrica: ninguna; es el caso por defecto de traducir(), que devuelve errores.errorInterno(). El cliente no ve el TypeError, ni el nombre de la función, ni la línea. En el log queda todo:
{"nivel":"error","trazaId":"trz_3a4b5c6d","metodo":"GET","ruta":"/v1/cafes/caf_007","usuario":null,
"mensaje":"Cannot read properties of null (reading 'map')",
"pila":"TypeError: ... at cafeARepresentacion (/app/src/servicios/mapeadores.js:31:29) ..."}Y el diagnóstico de fondo: la causa real es que la columna notas_cata admitió NULL pese a estar declarada NOT NULL DEFAULT '[]', probablemente por una inserción manual. La corrección duradera no es un ?? [] en el mapeador —eso es tapar el síntoma—, sino asegurar el dato en el repositorio y comprobar por qué se saltó la restricción. El trazaId es lo que permite llegar desde la queja del usuario hasta esa conclusión.
Conclusión
Ya no hay respuestas de error escritas a mano repartidas por el proyecto. Los servicios lanzan ErrorApi diciendo qué ha pasado con un código del catálogo de 02-04, sin mencionar HTTP ni tocar res; las fábricas garantizan que estado y código casan siempre; y un único middleware de cuatro argumentos, registrado el último, traduce todo lo que llega —ErrorApi, ZodError, errores de SQLite, JSON mal formado, cuerpos demasiado grandes y bugs inesperados— al mismo formato {"error": {"codigo", "mensaje", "detalles"}}, con trazaId solo en los 5xx, WWW-Authenticate en los 401, Allow en los 405 y Accept-Patch en los 415. El envoltorio asincrono() cierra por fin el agujero de las promesas rechazadas que arrastrábamos desde 03-03, y el proceso sabe morir bien: cierre ordenado ante SIGTERM, registro completo y salida ante uncaughtException.
Lo más valioso de esta lección no es el código, sino la asimetría que establece: al consumidor se le da un código estable, un mensaje útil y un identificador con el que reclamar; al equipo se le da la pila completa, la consulta, el usuario y la ruta. Nunca al revés. Y como todo pasa por un solo fichero, mañana se puede añadir un campo a todos los errores, emitir problem+json según el Accept o enviar los 5xx a un sistema de alertas cambiando un sitio.
Con esto, la API de Tienda Aroma está completa: rutas, representaciones, validación, persistencia, autenticación y errores. Falta lo único que convierte "funciona en mi máquina" en "cumple el contrato": comprobarlo. En 03-08, Pruebas y validación, cerramos el módulo con la pirámide de pruebas aplicada a esta API: pruebas unitarias de los servicios y del mapeador de céntimos a euros usando el repositorio en memoria como doble —posible gracias a la separación por capas de 03-03—, pruebas de integración con Supertest sobre el objeto app sin abrir puerto —posible gracias a la separación de 03-02—, verificación de códigos, cabeceras Location, Link y Allow y forma exacta del cuerpo, generación de tokens válidos en las propias pruebas, aislamiento con una base SQLite temporal, cobertura con el runner nativo, y una lista de verificación del contrato antes de dar la API por buena.
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
