En la lección anterior dejamos un proyecto con carpetas vacías, dependencias instaladas y configuración validada, pero incapaz de responder a nada. Hoy eso cambia: al terminar tendrás un servidor Express escuchando en http://localhost:3000 que sirve GET /v1/cafes y GET /v1/cafes/caf_001 con el formato exacto que fijamos en el módulo 2, un endpoint de salud, y un 404 que ya habla el idioma del contrato. Por el camino entenderemos la única idea que hay que entender de verdad para trabajar con Express —el middleware—, veremos por qué separar app.js de servidor.js es una decisión de arquitectura y no un capricho, y montaremos el Router bajo /v1, que es donde el versionado en la ruta decidido en 02-07 deja de ser un diagrama y se convierte en código.
Contenido
- Qué es Express y qué no es
- El concepto central: middleware
- El ciclo de vida de una petición
src/app.jsysrc/servidor.js: por qué van separados- Primer arranque y endpoint de salud
- Middleware integrado:
express.jsonyexpress.urlencoded - El
Routerde Express y el montaje de/v1 - Datos en memoria:
src/repositorios/cafes-memoria.js - Las primeras rutas de cafés
- Rutas con parámetros y
req.params - Enviar respuestas:
res.status,res.json,res.set,res.sendStatus - El 404 genérico con el formato de error del contrato
- Probarlo todo con
curl - Registro de peticiones:
morgany hasta dónde llegamos hoy
- Qué es Express y qué no es
Node incluye un módulo http con el que ya se puede montar un servidor:
// Servidor con el módulo http de Node, sin Express. Solo para ver la diferencia.
import { createServer } from 'node:http';
const servidor = createServer((req, res) => {
if (req.url === '/v1/cafes' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ datos: [], total: 0 }));
} else {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'no encontrado' }));
}
});
servidor.listen(3000);Funciona, y enseña algo importante: Express no hace nada que no pudieras hacer tú. Pero ese if/else crece hasta lo inmanejable en cuanto hay veinte rutas, parámetros de ruta, cuerpos JSON que parsear y comportamientos comunes a todas las peticiones.
Express es una capa fina sobre node:http que aporta exactamente cuatro cosas:
| Aporta | Sin Express | Con Express |
|---|---|---|
| Enrutado | if (req.url === ...) con expresiones regulares a mano |
app.get('/v1/cafes/:id', manejador) |
| Middleware | Encadenar funciones a mano | Una cadena ordenada con app.use() |
Utilidades en req |
Parsear la query y el cuerpo tú mismo | req.query, req.params, req.body |
Utilidades en res |
writeHead + end + JSON.stringify |
res.status(200).json(objeto) |
Y es igual de importante saber qué no es: Express no es un framework "con baterías incluidas". No trae ORM, ni validación, ni autenticación, ni estructura de carpetas obligatoria. Todo eso lo pones tú —y por eso el módulo tiene ocho lecciones—. A cambio, no hay magia: cada cosa que ocurre en una petición está escrita en algún fichero tuyo. Para aprender cómo funciona de verdad una API es la mejor elección posible. En 05-03 compararemos Express con Fastify, NestJS y otros.
- El concepto central: middleware
Un middleware es una función que recibe la petición, puede hacer algo con ella y decide si la pasa a la siguiente función de la cadena. Toda su definición cabe en una firma:
function miMiddleware(req, res, next) {
// 1. Puede leer o modificar req
// 2. Puede leer o escribir res
// 3. Llama a next() para ceder el turno... o responde y termina
next();
}| Parámetro | Qué es |
|---|---|
req |
Objeto de petición: URL, método, cabeceras, parámetros, cuerpo |
res |
Objeto de respuesta: estado, cabeceras, cuerpo a devolver |
next |
Función que cede el control al siguiente middleware de la cadena |
Y tres reglas que explican el 90 % de los problemas de un principiante con Express:
- Los middleware se ejecutan en el orden en que se registran. No hay prioridades ni magia: es una lista, de arriba abajo.
- Si un middleware no llama a
next()ni responde, la petición se queda colgada hasta que el cliente se cansa. Es el error más frecuente. - Si un middleware responde (
res.json(...)), la cadena termina ahí. Llamar anext()después provoca el famosoERR_HTTP_HEADERS_SENT.
Un ejemplo con tres middleware encadenados para ver el orden en vivo:
app.use((req, res, next) => {
console.log('1: entra la petición');
next(); // cede el turno
});
app.use((req, res, next) => {
console.log('2: sigo yo');
req.momento = Date.now(); // puedo enriquecer req para los siguientes
next();
});
app.get('/v1/cafes', (req, res) => {
console.log('3: manejador final');
res.json({ datos: [], total: 0 }); // respondo: la cadena acaba aquí
});Un manejador de ruta (app.get, app.post) es también un middleware; simplemente está condicionado a un método y una ruta. Esa uniformidad es la clave del diseño de Express: todo es la misma cosa. La validación de 03-04, la autenticación de 03-06 y el manejo de errores de 03-07 serán middleware con esta misma firma.
- El ciclo de vida de una petición
Este es el recorrido completo que tendrá una petición en Tienda Aroma al final del módulo. Hoy montamos las cajas grises; las demás llegan en las lecciones indicadas.
graph TD
A[Cliente: curl / SPA] --> B[express.json parsea el cuerpo]
B --> C[Router montado en /v1]
C --> D{¿Coincide alguna ruta?}
D -->|No| E[404 ruta_no_encontrada]
D -->|Sí| F[Validación 03-04 y autenticación 03-06]
F --> G[Controlador 03-03]
G --> H[Servicio y repositorio]
H --> I[res.status.json]
F -->|Error| J[Middleware de errores 03-07]
E --> J
J --> K[Respuesta con el formato del contrato]
Lo que hay que retener: una petición atraviesa la cadena de arriba abajo y sale por uno de dos sitios, una respuesta normal o el middleware de errores. Nada más.
src/app.js y src/servidor.js: por qué van separados
src/app.js y src/servidor.js: por qué van separadosCasi todos los tutoriales de Express meten en un único fichero la creación de la app y la llamada a listen(). Nosotros no, y el motivo es concreto:
| Fichero | Responsabilidad | Sabe de… |
|---|---|---|
src/app.js |
Construir la aplicación: middleware y rutas | Express |
src/servidor.js |
Arrancar el proceso: puerto, señales, cierre | Sistema operativo, red |
La ventaja aparece en 03-08. Supertest, la herramienta con la que probaremos la API, acepta el objeto app de Express y le lanza peticiones sin abrir ningún puerto TCP. Si app.js llamara a listen(), cada fichero de pruebas ocuparía el puerto 3000 y dos suites en paralelo chocarían con EADDRINUSE. Separando, la app es un objeto reutilizable y el puerto es un detalle de despliegue.
Empezamos por src/app.js:
// src/app.js
import express from 'express';
// Creamos la instancia de la aplicación. Todavía no escucha en ningún puerto.
export const app = express();
// Express añade por defecto la cabecera 'X-Powered-By: Express', que revela
// la tecnología del servidor sin aportar nada. Se quita siempre (ver 04-02).
app.disable('x-powered-by');
// Endpoint de salud: comprueba que el proceso está vivo y responde.
app.get('/salud', (req, res) => {
res.status(200).json({
estado: 'ok',
version: '1.0.0',
momento: new Date().toISOString(),
});
});Y src/servidor.js:
// src/servidor.js
import { app } from './app.js';
import { entorno } from './config/entorno.js';
// listen() abre el socket TCP y deja el proceso escuchando.
const servidor = app.listen(entorno.puerto, () => {
console.log(`API de Tienda Aroma escuchando en ${entorno.baseUrl}/v1`);
console.log(`Entorno: ${entorno.nodeEnv}`);
});
// Cierre ordenado: cuando el sistema pide que paremos (Ctrl+C o el
// orquestador al desplegar), dejamos de aceptar conexiones nuevas y
// esperamos a que terminen las peticiones en curso antes de salir.
function cerrarOrdenadamente(senal) {
console.log(`\nRecibida la señal ${senal}. Cerrando el servidor...`);
servidor.close(() => {
console.log('Servidor cerrado. Hasta luego.');
process.exit(0);
});
}
process.on('SIGINT', () => cerrarOrdenadamente('SIGINT')); // Ctrl+C
process.on('SIGTERM', () => cerrarOrdenadamente('SIGTERM')); // docker stop, kubernetesSobre el cierre ordenado: sin él, Ctrl+C corta de golpe y una petición a medio responder muere sin respuesta. Con servidor.close(), el socket deja de aceptar clientes nuevos pero las peticiones vivas terminan. En 03-05 añadiremos aquí el cierre de la base de datos y en 03-07 la captura de errores no controlados del proceso.
Fíjate en la dirección de los import: servidor.js importa app.js, nunca al revés.
- Primer arranque y endpoint de salud
En otra terminal:
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 71
ETag: W/"47-mQb..."
Date: Sat, 14 Mar 2026 10:30:00 GMT
Connection: keep-alive
{"estado":"ok","version":"1.0.0","momento":"2026-03-14T10:30:00.000Z"}Merece la pena detenerse en tres detalles de esa respuesta:
Content-Type: application/json; charset=utf-8lo ha puestores.json()automáticamente. Es lo que declaramos en el contrato.ETagtambién viene de serie. Es la base del caché condicional, que trataremos en 04-06.- No hay
X-Powered-By, gracias aapp.disable.
¿Por qué /salud está fuera de /v1? Porque no es parte de la API pública: no lo consumen la SPA ni Aroma Móvil, sino el balanceador de carga y la monitorización. No forma parte del contrato versionado, así que no debe versionarse. Es la excepción que confirma la regla de 02-07.
- Middleware integrado:
express.json y express.urlencoded
express.json y express.urlencodedCuando llega un POST con cuerpo JSON, el cuerpo viaja como un flujo de bytes. Sin ayuda, req.body es undefined. express.json() es un middleware que lee ese flujo, lo parsea y deja el objeto en req.body.
Añádelo a src/app.js, antes de las rutas:
// src/app.js (añadir tras app.disable)
// Parsea los cuerpos JSON y los deja en req.body.
app.use(
express.json({
// Límite de tamaño: por encima, responde 413. Sin límite, cualquiera
// puede tumbar el proceso enviando un cuerpo de 2 GB (04-02).
limit: '100kb',
// Qué Content-Type acepta como JSON. Añadimos merge-patch porque el
// contrato de 02-03 exige PATCH con application/merge-patch+json.
type: ['application/json', 'application/merge-patch+json'],
})
);
// Parsea formularios (Content-Type: application/x-www-form-urlencoded).
// Nuestra API es JSON, pero lo dejamos por si un formulario HTML llama a
// un endpoint: mejor un 400 claro que un req.body vacío e inexplicable.
app.use(express.urlencoded({ extended: false, limit: '10kb' }));| Opción | Qué controla | Valor elegido |
|---|---|---|
limit |
Tamaño máximo del cuerpo | 100kb (bastante para un pedido de 50 líneas) |
type |
Qué Content-Type procesa |
JSON y Merge Patch |
strict (por defecto true) |
Solo acepta objetos y arrays en la raíz | Se mantiene |
extended (en urlencoded) |
Permitir objetos anidados en el formulario | false, no los necesitamos |
Dos consecuencias que hay que conocer desde ya. La primera: si el cliente envía JSON mal formado, express.json() lanza un SyntaxError que hoy produce una página HTML de error fea; en 03-07 lo convertiremos en un 400 datos_invalidos del contrato. La segunda: si el cliente no envía Content-Type: application/json, el middleware no parsea nada y req.body queda como {}; es la causa número uno del "mi POST llega vacío".
- El
Router de Express y el montaje de /v1
Router de Express y el montaje de /v1Poner todas las rutas en app.js funciona con dos y es insostenible con veinticuatro. Un Router es una mini-aplicación de Express: tiene sus propias rutas y sus propios middleware, y se monta bajo un prefijo.
Vamos a crear dos ficheros. Primero, el router de cafés (src/rutas/cafes.js), que de momento solo declara sus rutas relativas:
// src/rutas/cafes.js
import { Router } from 'express';
export const rutasCafes = Router();
// Las rutas se declaran RELATIVAS al punto de montaje.
// '/' aquí acabará siendo '/v1/cafes' cuando se monte.
rutasCafes.get('/', (req, res) => {
res.json({ datos: [], total: 0 });
});Y ahora el agregador (src/rutas/index.js), que reúne todos los routers de la versión 1:
// src/rutas/index.js
import { Router } from 'express';
import { rutasCafes } from './cafes.js';
export const rutasV1 = Router();
// Cada colección del mapa de URIs de 02-02 se monta bajo su prefijo.
rutasV1.use('/cafes', rutasCafes);
// Según avance el módulo se irán añadiendo aquí:
// rutasV1.use('/clientes', rutasClientes); → 03-06
// rutasV1.use('/pedidos', rutasPedidos); → 03-03
// rutasV1.use('/sesiones', rutasSesiones); → 03-06Y se monta en src/app.js:
// src/app.js (añadir tras los parsers)
import { rutasV1 } from './rutas/index.js';
// AQUÍ vive el versionado en la ruta decidido en 02-07: todo lo que
// cuelgue de rutasV1 responde bajo /v1 y solo bajo /v1.
app.use('/v1', rutasV1);El resultado es una composición de prefijos en tres niveles:
| Nivel | Fichero | Prefijo aportado |
|---|---|---|
| Aplicación | app.js |
/v1 |
| Agregador | rutas/index.js |
/cafes |
| Router de recurso | rutas/cafes.js |
/ o /:id |
| Resultado | /v1/cafes, /v1/cafes/:id |
Y aquí está la ganancia real de 02-07 hecha código: el día que exista una v2, se crea src/rutas/v2/ y se añade app.use('/v2', rutasV2). Las dos versiones conviven en el mismo proceso, compartiendo servicios donde el comportamiento no cambie y divergiendo donde sí. Sin este montaje, "convivencia de versiones" sería una frase bonita imposible de implementar.
- Datos en memoria:
src/repositorios/cafes-memoria.js
src/repositorios/cafes-memoria.jsTodavía no tenemos base de datos —eso es 03-05—, así que los cafés vivirán en un array. Pero lo colocamos ya en la carpeta de repositorios y detrás de una interfaz, porque el objetivo declarado es sustituirlo por SQLite sin que el resto del código se entere.
// src/repositorios/cafes-memoria.js
/**
* Almacén en memoria de cafés.
*
* IMPORTANTE: este es el MODELO INTERNO, no la representación pública.
* El dinero se guarda en CÉNTIMOS ENTEROS (precioCentimos), como decidimos
* en 02-05: 14,50 € son 1450 céntimos. Nunca un float para dinero, porque
* 0.1 + 0.2 !== 0.3 en coma flotante y un céntimo perdido por pedido es
* una discrepancia contable al final del mes.
*/
const cafes = [
{
id: 'caf_001',
nombre: 'Etiopía Yirgacheffe',
origen: 'Etiopía',
tueste: 'claro',
precioCentimos: 1450,
stock: 120,
notasCata: ['cítrico', 'floral', 'té negro'],
descripcion: null,
fechaCreacion: '2026-01-15T09:00:00Z',
activo: true,
},
{
id: 'caf_002',
nombre: 'Colombia Huila',
origen: 'Colombia',
tueste: 'medio',
precioCentimos: 1290,
stock: 80,
notasCata: ['chocolate', 'caramelo', 'nuez'],
descripcion: null,
fechaCreacion: '2026-01-20T11:15:00Z',
activo: true,
},
];
export const repositorioCafes = {
/** Devuelve todos los cafés activos. Copia defensiva: nadie muta el array. */
buscarTodos() {
return cafes.filter((cafe) => cafe.activo).map((cafe) => ({ ...cafe }));
},
/** Devuelve un café por su id, o undefined si no existe. */
buscarPorId(id) {
const cafe = cafes.find((c) => c.id === id && c.activo);
return cafe ? { ...cafe } : undefined;
},
};Dos decisiones que parecen menores y no lo son:
- Los nombres de los métodos (
buscarTodos,buscarPorId) son la interfaz del repositorio. En 03-05 escribiremoscafes-sqlite.jscon exactamente los mismos nombres, y cambiar de uno a otro será cambiar unimport. - Se devuelven copias (
{ ...cafe }), no las referencias del array. Si devolviéramos la referencia, cualquier capa superior podría modificar el "almacén" por accidente. Con una base de datos real esto es imposible por construcción; en memoria hay que imponerlo a mano.
- Las primeras rutas de cafés
Ahora conectamos el repositorio con el router. Sustituye el contenido de src/rutas/cafes.js:
// src/rutas/cafes.js
import { Router } from 'express';
import { repositorioCafes } from '../repositorios/cafes-memoria.js';
export const rutasCafes = Router();
/**
* Convierte el modelo interno en la representación pública del contrato.
*
* PROVISIONAL: en 03-03 esta función se muda a src/servicios/mapeadores.js,
* que es su sitio. Aquí sirve para no adelantar capas antes de tiempo.
*/
function aRepresentacion(cafe) {
return {
id: cafe.id,
nombre: cafe.nombre,
origen: cafe.origen,
tueste: cafe.tueste,
// Céntimos → euros con dos decimales. Number() lo devuelve a número
// para que el JSON tenga 14.5 y no la cadena "14.50".
precioEuros: Number((cafe.precioCentimos / 100).toFixed(2)),
stock: cafe.stock,
notasCata: cafe.notasCata,
// Campos siempre presentes, con null si no hay valor (02-05).
descripcion: cafe.descripcion ?? null,
fechaCreacion: cafe.fechaCreacion,
_links: {
self: { href: `/v1/cafes/${cafe.id}` },
},
};
}
// GET /v1/cafes → colección con el envoltorio del contrato
rutasCafes.get('/', (req, res) => {
const cafes = repositorioCafes.buscarTodos();
res.status(200).json({
datos: cafes.map(aRepresentacion),
total: cafes.length,
});
});
// GET /v1/cafes/:id → elemento suelto, sin envoltorio
rutasCafes.get('/:id', (req, res) => {
const cafe = repositorioCafes.buscarPorId(req.params.id);
if (!cafe) {
// Comprobación mínima y respuesta a mano. En 03-07 esto será un
// throw de ErrorApi y lo formateará un único middleware de errores.
return res.status(404).json({
error: {
codigo: 'cafe_no_encontrado',
mensaje: `No existe ningún café con el identificador '${req.params.id}'.`,
detalles: [],
},
});
}
res.status(200).json(aRepresentacion(cafe));
});Detalles importantes de este fichero:
- El envoltorio solo está en la colección.
GET /v1/cafesdevuelve{datos, total};GET /v1/cafes/:iddevuelve el objeto desnudo. Es exactamente lo que decidimos en 02-05. return res.status(404)...: elreturnno devuelve nada útil a Express, pero corta la ejecución de la función. Sin él, seguiría hasta elres.status(200)y provocaríaERR_HTTP_HEADERS_SENT.totales hoy la longitud del array. Cuando en 03-03 haya filtros y paginación,totalserá el número de elementos que cumplen el filtro, no los devueltos en la página. Esa distinción se cobra bugs.?? null(fusión de nulos) devuelve el valor de la izquierda salvo que seanulloundefined. No confundir con||, que también sustituiría el0o la cadena vacía.
- Rutas con parámetros y
req.params
req.params'/:id' declara un segmento variable. Express captura lo que haya en esa posición y lo deja en req.params.id, siempre como cadena de texto.
| Ruta declarada | URL recibida | req.params |
|---|---|---|
/:id |
/v1/cafes/caf_001 |
{ id: 'caf_001' } |
/:id/resenas |
/v1/cafes/caf_001/resenas |
{ id: 'caf_001' } |
/:id/resenas/:resenaId |
/v1/cafes/caf_001/resenas/res_101 |
{ id: 'caf_001', resenaId: 'res_101' } |
Que nuestros identificadores sean cadenas con prefijo (caf_001) juega a favor: no hay que convertir nada ni preocuparse de un parseInt que devuelva NaN. Fue una decisión de 02-02 y aquí se cobra el primer dividendo.
Un aviso sobre el orden, que es la trampa clásica:
// MAL: '/destacados' nunca se alcanza. La ruta '/:id' coincide antes
// y el manejador recibe req.params.id === 'destacados'.
rutasCafes.get('/:id', manejadorPorId);
rutasCafes.get('/destacados', manejadorDestacados);
// BIEN: lo específico primero, lo genérico después.
rutasCafes.get('/destacados', manejadorDestacados);
rutasCafes.get('/:id', manejadorPorId);Express recorre las rutas en orden de declaración y se queda con la primera que coincide. Lo concreto va antes que lo variable.
Un router hermano muy útil es router.param(), que ejecuta código cada vez que aparece un parámetro concreto —por ejemplo, cargar el café y dejarlo en req.cafe—. Lo mencionamos para que lo conozcas; en este curso preferimos hacerlo explícito en el servicio.
- Enviar respuestas:
res.status, res.json, res.set, res.sendStatus
res.status, res.json, res.set, res.sendStatus| Método | Qué hace | Ejemplo |
|---|---|---|
res.status(code) |
Fija el código. No envía nada: es encadenable | res.status(201) |
res.json(obj) |
Serializa a JSON, pone el Content-Type y envía |
res.json({ id: 'caf_001' }) |
res.send(x) |
Envía texto, HTML o buffer adivinando el tipo | res.send('ok') |
res.set(n, v) |
Añade una cabecera de respuesta | res.set('Location', '/v1/cafes/caf_003') |
res.sendStatus(code) |
Fija el código y envía su texto estándar como cuerpo | res.sendStatus(204) |
res.end() |
Termina la respuesta sin cuerpo | res.status(204).end() |
Tres avisos prácticos:
// 1. res.status() por sí solo NO responde. Esto deja la petición colgada:
res.status(204);
// Correcto para un 204 (sin cuerpo, como el DELETE de 02-03):
res.status(204).end();
// 2. res.sendStatus(204) envía el cuerpo "No Content" como TEXTO PLANO.
// Para un 204 es inofensivo, pero acostumbrarse a él lleva a errores
// como res.sendStatus(404), que devuelve "Not Found" en text/plain
// en vez del formato de error del contrato. Evítalo en esta API.
// 3. Las cabeceras se fijan ANTES de enviar el cuerpo. Después, es tarde.
res.set('Location', '/v1/cafes/caf_003');
res.status(201).json(representacion);En este módulo usaremos casi siempre el mismo patrón: res.set(...) para las cabeceras del contrato y res.status(...).json(...) para el cuerpo.
- El 404 genérico con el formato de error del contrato
Si pides GET /v1/inexistente, ninguna ruta coincide y Express responde con su 404 por defecto: una página HTML con el texto Cannot GET /v1/inexistente. Para un consumidor que espera JSON, eso rompe el contrato en tres frentes: Content-Type equivocado, forma del cuerpo desconocida y filtración del framework.
Añadimos un middleware final en src/app.js, después de todas las rutas:
// src/app.js (al final, después de app.use('/v1', rutasV1))
// Si la petición llega hasta aquí, ninguna ruta ha coincidido.
// Al no llevar ruta, este app.use() se ejecuta para cualquier petición
// que no haya sido atendida antes: es el "cajón de sastre".
app.use((req, res) => {
res.status(404).json({
error: {
codigo: 'ruta_no_encontrada',
mensaje: `No existe el recurso ${req.method} ${req.originalUrl}.`,
detalles: [],
},
});
});Dos precisiones sobre el catálogo. El código ruta_no_encontrada no estaba en el catálogo de 02-04, que solo tenía los 404 específicos (cafe_no_encontrado, pedido_no_encontrado…). Lo añadimos ahora para el caso genérico —una URI que no existe en absoluto—, y eso es perfectamente legítimo: la regla que fijamos es que el catálogo solo crece; añadir un código es un cambio aditivo, retirarlo sería rompedor. Y hay que documentarlo en openapi.yaml, porque un código de error sin documentar no forma parte del contrato.
La diferencia entre los dos 404 conviene tenerla clara:
| Situación | Código | Significado para el consumidor |
|---|---|---|
GET /v1/cafes/caf_999 |
cafe_no_encontrado |
La ruta existe; ese café, no |
GET /v1/cafeses |
ruta_no_encontrada |
Esa URI no existe en la API. Revisa la documentación |
El orden es crítico. Este middleware debe registrarse el último de los normales; si estuviera antes de app.use('/v1', rutasV1), respondería 404 a absolutamente todo. En 03-07 añadiremos por detrás de él el middleware de errores, que es el único que va después.
El src/app.js completo queda así:
// src/app.js
import express from 'express';
import { rutasV1 } from './rutas/index.js';
export const app = express();
app.disable('x-powered-by');
// --- 1. Parsers del cuerpo ---
app.use(
express.json({
limit: '100kb',
type: ['application/json', 'application/merge-patch+json'],
})
);
app.use(express.urlencoded({ extended: false, limit: '10kb' }));
// --- 2. Endpoint de salud (fuera de /v1: no es parte del contrato) ---
app.get('/salud', (req, res) => {
res.status(200).json({
estado: 'ok',
version: '1.0.0',
momento: new Date().toISOString(),
});
});
// --- 3. API versionada ---
app.use('/v1', rutasV1);
// --- 4. Cajón de sastre: ninguna ruta ha coincidido ---
app.use((req, res) => {
res.status(404).json({
error: {
codigo: 'ruta_no_encontrada',
mensaje: `No existe el recurso ${req.method} ${req.originalUrl}.`,
detalles: [],
},
});
});
// --- 5. (03-07) Aquí irá el middleware de errores, siempre el último ---Esos cinco bloques numerados son el orden definitivo de la aplicación. Durante el resto del módulo solo insertaremos piezas entre ellos, nunca cambiaremos la secuencia.
- Probarlo todo con
curl
curlCon npm run dev en marcha, en otra terminal:
{"datos":[{"id":"caf_001","nombre":"Etiopía Yirgacheffe","origen":"Etiopía","tueste":"claro","precioEuros":14.5,"stock":120,"notasCata":["cítrico","floral","té negro"],"descripcion":null,"fechaCreacion":"2026-01-15T09:00:00Z","_links":{"self":{"href":"/v1/cafes/caf_001"}}},{"id":"caf_002",...}],"total":2}Si tienes jq instalado, la lectura mejora mucho:
# 2. Un elemento concreto: sin envoltorio
curl -s http://localhost:3000/v1/cafes/caf_001 | jq '.id, .precioEuros'Atención a 14.5. El contrato de 02-05 dice "euros con dos decimales", pero JSON no distingue 14.50 de 14.5: son el mismo número y así lo serializa JSON.stringify. Los dos decimales son una cuestión de formato de presentación, responsabilidad del cliente, no del transporte. Lo importante —y lo que sí controlamos— es que el valor sea exacto, y lo es porque por dentro son 1450 céntimos enteros. Si un consumidor necesitara literalmente la cadena "14.50", habría que serializar el precio como texto, y esa es una decisión de contrato que ya descartamos en 02-05.
HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8
{"error":{"codigo":"cafe_no_encontrado","mensaje":"No existe ningún café con el identificador 'caf_999'.","detalles":[]}}Cinco comprobaciones, cinco respuestas conformes al contrato. En 03-08 convertiremos exactamente estas llamadas en pruebas automáticas con Supertest, para que nadie tenga que ejecutarlas a mano nunca más.
- Registro de peticiones:
morgan y hasta dónde llegamos hoy
morgan y hasta dónde llegamos hoyAhora mismo la terminal no dice nada cuando llega una petición, y eso hace incómodo depurar. La solución mínima es un middleware de cinco líneas, que además sirve de ejemplo perfecto de la firma que hemos aprendido:
// src/app.js (justo después de app.disable, antes de los parsers)
app.use((req, res, next) => {
const inicio = Date.now();
// 'finish' se dispara cuando la respuesta se ha enviado del todo,
// así que aquí ya conocemos el código de estado y la duración.
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} → ${res.statusCode} (${Date.now() - inicio} ms)`);
});
next();
});La alternativa habitual es morgan (npm install morgan), un middleware de registro con formatos predefinidos:
import morgan from 'morgan';
app.use(morgan('dev')); // formato compacto y coloreado
app.use(morgan('combined')); // formato estándar de Apache, para producciónCualquiera de los dos vale para desarrollar. Pero que quede claro que esto no es observabilidad: no hay niveles de log, ni formato estructurado en JSON, ni identificador de correlación, ni métricas, ni trazas. console.log en producción es un log que nadie puede consultar ni agregar. La observabilidad seria —logs estructurados con pino, métricas, trazas distribuidas y el trazaId que emitiremos en los 500— es la lección 04-07. En 03-07 daremos el primer paso generando ese trazaId para correlacionar un error con su log.
Errores Comunes y Consejos
1. La petición se queda colgada para siempre. Un middleware ni llamó a next() ni respondió. Recorre la cadena de arriba abajo buscando el que no cierra ningún camino.
2. Error: Can't set headers after they are sent. Se respondió dos veces: un res.json() sin return seguido de otro, o un next() después de responder. Usa siempre return res.json(...) cuando la función pueda continuar.
3. Las rutas devuelven 404 aunque el fichero exista. Casi siempre es el montaje: recuerda que las rutas del router son relativas. Si escribes rutasCafes.get('/cafes', ...) y lo montas en /v1/cafes, la URL real es /v1/cafes/cafes.
4. req.body es undefined. Falta express.json() o está registrado después de las rutas. El orden manda.
5. req.body llega vacío aunque express.json() está puesto. El cliente no envió Content-Type: application/json. En curl, la opción es -H "Content-Type: application/json".
6. /:id declarada antes que una ruta fija. /destacados deja de existir. Lo específico, primero.
7. EADDRINUSE: address already in use :::3000. Hay otro proceso en el puerto, normalmente un npm run dev anterior que no murió. lsof -i :3000 lo identifica; kill <pid> lo cierra.
8. Meter la lógica de negocio dentro de la ruta. Hoy hemos consultado el repositorio directamente desde el router porque no hay nada más. Es el último día: en 03-03 se separa en controlador y servicio, y no volverá a ocurrir.
Consejo: define el orden de los middleware una vez, coméntalo con números como hemos hecho en app.js y respétalo. Los fallos más difíciles de diagnosticar en Express son siempre de orden.
Ejercicios
Ejercicio 1
Añade al router de cafés la ruta GET /v1/cafes/:id/resenas, que forma parte del mapa de URIs de 02-02. De momento no hay reseñas, así que debe devolver una colección vacía con el envoltorio del contrato, pero solo si el café existe; si no existe, debe devolver 404 cafe_no_encontrado. Explica por qué una colección vacía es 200 y no 404.
Ejercicio 2
Escribe un middleware llamado cabeceraServidor que añada a todas las respuestas la cabecera Aroma-Version: 1.0.0. Regístralo en src/app.js en la posición correcta y justifícala. Comprueba el resultado con curl -i. ¿Por qué Aroma- y no X-Aroma-?
Ejercicio 3
Este código tiene tres errores. Encuéntralos, explica qué le pasa a la petición con cada uno y escribe la versión corregida.
rutasCafes.get('/:id', (req, res, next) => {
const cafe = repositorioCafes.buscarPorId(req.params.id);
if (!cafe) {
res.status(404).json({ error: 'no encontrado' });
}
res.status(200);
res.json(aRepresentacion(cafe));
next();
});Soluciones
Solución 1
// src/rutas/cafes.js (añadir ANTES de la ruta '/:id' no es necesario aquí,
// porque '/:id/resenas' tiene dos segmentos y no colisiona con '/:id')
rutasCafes.get('/:id/resenas', (req, res) => {
const cafe = repositorioCafes.buscarPorId(req.params.id);
if (!cafe) {
return res.status(404).json({
error: {
codigo: 'cafe_no_encontrado',
mensaje: `No existe ningún café con el identificador '${req.params.id}'.`,
detalles: [],
},
});
}
// La colección existe (el café existe) pero no tiene elementos.
res.status(200).json({ datos: [], total: 0 });
});Por qué 200 y no 404: el recurso solicitado es la colección de reseñas del café caf_001, y esa colección existe; lo que ocurre es que está vacía. Un 404 significaría "esta URI no identifica ningún recurso", y obligaría al cliente a tratar el caso normal "aún no hay reseñas" como un error. La regla, que ya vimos en 02-04: colección vacía es 200 con {"datos": [], "total": 0}; recurso inexistente es 404. Coherente con la decisión de 02-05 de que los arrays vacíos se representan como [] y nunca como null ni ausentes.
Solución 2
// src/app.js (justo después de app.disable('x-powered-by'))
app.use((req, res, next) => {
res.set('Aroma-Version', '1.0.0');
next();
});Por qué esa posición: debe ir antes de cualquier cosa que pueda responder —rutas, 404, errores—, porque las cabeceras solo pueden fijarse mientras la respuesta no se haya enviado. Colocado el primero, la cabecera acompaña a todas las respuestas, incluidos los 404 y los 500.
Por qué Aroma- y no X-Aroma-: el prefijo X- para cabeceras no estándar quedó desaconsejado por el RFC 6648 en 2012. El problema histórico es que muchas cabeceras X- acababan estandarizándose y entonces convivían dos nombres para lo mismo (X-Forwarded-For es el ejemplo canónico). La recomendación actual es usar un prefijo propio del proveedor sin X-, y por eso el contrato de 02-05 fijó Aroma-.
Solución 3
| # | Error | Qué le pasa a la petición |
|---|---|---|
| 1 | Falta return antes del res.status(404) |
Con un id inexistente responde 404 y sigue ejecutando; al llegar a res.json(aRepresentacion(cafe)) con cafe a undefined, lanza un TypeError tras haber enviado ya las cabeceras → ERR_HTTP_HEADERS_SENT |
| 2 | El cuerpo del error no sigue el contrato | Devuelve {"error": "no encontrado"}, una cadena, en vez del objeto {error: {codigo, mensaje, detalles}} fijado en 02-04. Cualquier cliente que lea respuesta.error.codigo obtiene undefined |
| 3 | next() después de responder |
Cede el control al siguiente middleware —el cajón de sastre del 404— que intentará responder otra vez sobre una respuesta ya enviada |
Versión corregida:
rutasCafes.get('/:id', (req, res) => {
const cafe = repositorioCafes.buscarPorId(req.params.id);
if (!cafe) {
return res.status(404).json({
error: {
codigo: 'cafe_no_encontrado',
mensaje: `No existe ningún café con el identificador '${req.params.id}'.`,
detalles: [],
},
});
}
res.status(200).json(aRepresentacion(cafe));
});Nota sobre next: se ha eliminado del todo de la firma. Un manejador final de ruta no necesita next porque siempre responde; declararlo invita a llamarlo por error. En este módulo solo lo usaremos en los middleware intermedios y, a partir de 03-07, para propagar errores con next(error).
Conclusión
Ya hay un servidor. Y más allá de que responda, lo importante es lo que has entendido de él: que Express es una cadena ordenada de middleware con la firma (req, res, next), que cada pieza decide si cede el turno o responde, y que el orden de registro es el orden de ejecución, sin excepciones. Esa idea es el 90 % de Express, y con ella encajarán sin sorpresas la validación de 03-04, la autenticación de 03-06 y el manejo de errores de 03-07.
Has tomado además tres decisiones estructurales que sostienen el resto del módulo. app.js separado de servidor.js, para que la aplicación sea un objeto que las pruebas de 03-08 puedan usar sin abrir un puerto. El Router montado en /v1, que convierte el versionado en la ruta de 02-07 en un prefijo compuesto en tres niveles y hace que una futura v2 sea una línea de código, no una migración. Y el almacén tras una interfaz de repositorio con buscarTodos y buscarPorId, cuyos nombres reaparecerán idénticos en 03-05 cuando detrás haya SQLite. Además, las respuestas ya cumplen el contrato: envoltorio {datos, total} en la colección, objeto desnudo en el elemento, céntimos por dentro y euros por fuera, y un 404 con codigo, mensaje y detalles.
Lo que hoy chirría es que toda la lógica vive dentro del router, que la conversión a la representación pública es una función suelta con un comentario de "provisional", y que solo sabemos leer. En 03-03, Manejo de peticiones y respuestas, lo arreglamos: exprimiremos los objetos req y res, partiremos el código en rutas → controladores → servicios, escribiremos el mapeador de representación en su sitio, e implementaremos el contrato completo de escritura —POST con 201 y Location, PUT, PATCH con merge-patch+json y su 415, DELETE lógico— junto con los filtros, la ordenación, la paginación y la cabecera Link que diseñamos en 02-06.
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
