RestFul APIs
¿Qué es una RESTful API y por qué deberías entenderla?
Imagina que las aplicaciones pudieran hablar entre sí como si fueran personas. Una RESTful API es justo eso: un conjunto de reglas que permite que un programa se comunique con otro de manera clara, ordenada y eficiente. Es uno de los estilos más utilizados para crear APIs, y entender cómo funciona te abre las puertas al desarrollo de aplicaciones conectadas, escalables y fáciles de mantener.
¿Cómo funciona una RESTful API?
Cuando dos sistemas distintos necesitan intercambiar información, no lo hacen directamente. Usan intermediarios: las APIs. REST es una forma popular de diseñarlas. Piensa en REST como un conjunto de normas para que esa comunicación sea limpia y coherente.
Una API basada en REST organiza la información en "recursos" (por ejemplo, usuarios, libros o pedidos), y cada uno de estos recursos tiene una dirección web única (URL). Así, si quieres ver un usuario con ID 5, haces una petición a /usuarios/5.
Principios esenciales de REST
Comunicación sencilla y ordenada
REST impone una interfaz uniforme. Esto significa que todas las operaciones siguen las mismas reglas, lo cual reduce la complejidad. No importa si trabajas con usuarios o productos: los métodos y rutas se comportan de forma predecible.
Por ejemplo, si vas a /usuarios/1, sabes que estás viendo a un usuario. Si haces un DELETE a esa misma ruta, estás eliminándolo.
Sin memoria en el servidor
Cada vez que haces una petición, el servidor no recuerda nada de lo que hiciste antes. Todo lo que necesita para responder debe venir en esa misma solicitud. Esta forma de trabajar, sin guardar "el estado" del cliente, hace que el sistema sea más escalable.
Respuestas que se pueden guardar
REST permite que algunas respuestas se almacenen temporalmente. Por ejemplo, si pides la lista de libros, el servidor puede decir: "puedes guardar esto durante 5 minutos". Así, si haces la misma consulta en poco tiempo, no es necesario repetirla.
Arquitectura por capas
REST también permite construir la API por capas. Puedes tener un servidor, detrás otro servidor que actúa como filtro o proxy, y luego otro que maneja la base de datos. Esto facilita tareas como balancear la carga o implementar seguridad.
Cómo se organizan los recursos (endpoints)
Un recurso en una API REST es algo que puede identificarse y manipularse, como un libro, un usuario o un comentario. Cada recurso tiene su propia URL. Por ejemplo:
/librosrepresenta la colección completa./libros/10representa un libro específico./libros/10/autoreste da los autores de ese libro.
Este esquema jerárquico hace que la estructura de la API sea intuitiva y fácil de navegar.
Los métodos HTTP: las acciones que puedes hacer
REST usa los métodos estándar de la web para operar sobre los recursos. Cada uno tiene un propósito claro.
- GET: sirve para leer datos. Si haces un GET a
/usuarios/2, obtendrás la información del usuario 2. - POST: crea un nuevo recurso. Enviar un POST a
/usuarioscon datos de un nuevo usuario lo añade a la base de datos. - PUT: actualiza o crea un recurso específico. Si haces PUT a
/usuarios/2, modificarás ese usuario o lo crearás si no existe. - DELETE: elimina un recurso. Un DELETE a
/usuarios/2borrará al usuario con ese ID. - PATCH y OPTIONS: se usan en casos más específicos. PATCH actualiza parcialmente, y OPTIONS te dice qué operaciones están disponibles.
Cómo saber si la API respondió bien: códigos de estado
Las APIs no solo devuelven datos, también te dicen cómo salió la operación con un número llamado código de estado HTTP. Algunos ejemplos útiles:
- 200 OK: Todo salió bien.
- 201 Created: Se creó un nuevo recurso (útil después de un POST).
- 204 No Content: Se realizó correctamente, pero no hay nada que devolver (como tras un DELETE).
- 400 Bad Request: El cliente envió mal los datos.
- 401 Unauthorized: Falta autenticación.
- 403 Forbidden: Aunque estés autenticado, no tienes permiso.
- 404 Not Found: El recurso no existe.
- 500 Internal Server Error: Algo falló en el servidor.
Estos códigos ayudan al cliente a reaccionar de forma correcta ante cualquier situación.
Implementación práctica: API RESTful para biblioteca con Node.js puro
Vamos a crear una API RESTful completa para gestionar libros usando solo Node.js con ES Modules, sin frameworks.
Estructura del proyecto
biblioteca-api/
├── server.js
├── dataStore.js
├── bodyParser.js
├── httpUtils.js
├── librosRoutes.js
├── package.json
└── data/
└── libros.json
Primero nos creamos la data necesaria para el ejemplo:
data/libros.json
{
"libros": [
{
"id": 1,
"titulo": "Dune",
"autor": "Frank Herbert",
"fechaPublicacion": "1965-08-01",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
},
{
"id": 2,
"titulo": "El Señor de los Anillos: La Comunidad del Anillo",
"autor": "J.R.R. Tolkien",
"fechaPublicacion": "1954-07-29",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
},
{
"id": 3,
"titulo": "Fundación",
"autor": "Isaac Asimov",
"fechaPublicacion": "1951-06-01",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T18:10:30.123Z"
},
{
"id": 4,
"titulo": "El nombre del viento",
"autor": "Patrick Rothfuss",
"fechaPublicacion": "2007-03-27",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
},
{
"id": 5,
"titulo": "Neuromante",
"autor": "William Gibson",
"fechaPublicacion": "1984-07-01",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
}
]
}
Acceso a los datos y guardas en JSON
dataStore.js
// dataStore.js
import { readFile, writeFile } from 'node:fs/promises';
// Ruta del archivo JSON donde guardamos los libros
const DATA_FILE = './data/libros.json';
/**
* Lee los datos desde el archivo JSON.
* Si el archivo no existe o hay error, devuelve una estructura vacía.
*/
export async function leerDatos() {
try {
const data = await readFile(DATA_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
// Si el archivo no existe o hay problema, devolvemos un objeto con lista vacía
return { libros: [] };
}
}
/**
* Guarda los datos en el archivo JSON de forma formateada.
*/
export async function guardarDatos(datos) {
await writeFile(DATA_FILE, JSON.stringify(datos, null, 2), 'utf8');
}
Lectura y parseo del cuerpo de la petición
bodyParser.js
// bodyParser.js
/**
* Parsea el cuerpo de la petición HTTP asumiendo que viene en JSON.
*
* - Acumula los chunks que llegan por 'data'.
* - Cuando se dispara 'end', intenta hacer JSON.parse().
* - Si el cuerpo viene vacío, devuelve {}.
* - Si el JSON es inválido, lanza un error.
*/
export async function parsearCuerpo(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => {
body += chunk.toString();
});
req.on('end', () => {
try {
resolve(body ? JSON.parse(body) : {});
} catch (error) {
reject(new Error('JSON inválido'));
}
});
req.on('error', reject);
});
}
Funciones auxiliares relacionadas con HTTP (Respuestas y CORS)
httpUtils.js
// httpUtils.js
/**
* Envía una respuesta JSON al cliente, incluyendo cabeceras CORS básicas.
*/
export function enviarRespuesta(res, statusCode, datos = null) {
res.writeHead(statusCode, {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
if (datos) {
res.end(JSON.stringify(datos));
} else {
res.end();
}
}
/**
* Maneja las peticiones CORS preflight (método OPTIONS).
*/
export function manejarCORS(res) {
res.writeHead(200, {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
});
res.end();
}
Lógica de la API de libros (Routing + CRUD)
librosRoutes.js
// librosRoutes.js
import { leerDatos, guardarDatos } from './dataStore.js';
import { parsearCuerpo } from './bodyParser.js';
import { enviarRespuesta } from './httpUtils.js';
/**
* Extrae el ID del libro desde la ruta.
* Ejemplo: "/libros/3" → 3
*/
function extraerIdLibro(pathname) {
const partes = pathname.split('/');
return partes[2] ? parseInt(partes[2]) : null;
}
/**
* Maneja las rutas de la API de libros: GET, POST, PUT, DELETE.
*/
export async function manejarRutaLibros(req, res, parsedUrl) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
const id = extraerIdLibro(pathname);
try {
const datos = await leerDatos();
switch (metodo) {
case 'GET':
if (id) {
// GET /libros/:id - Obtener un libro específico
const libro = datos.libros.find(l => l.id === id);
if (libro) {
enviarRespuesta(res, 200, libro);
} else {
enviarRespuesta(res, 404, { error: 'Libro no encontrado' });
}
} else {
// GET /libros - Obtener todos los libros
enviarRespuesta(res, 200, datos.libros);
}
break;
case 'POST':
// POST /libros - Crear un nuevo libro (no debe llevar id en la URL)
if (id) {
enviarRespuesta(res, 400, { error: 'ID no permitido en POST' });
return;
}
const nuevoLibro = await parsearCuerpo(req);
// Validar datos requeridos
if (!nuevoLibro.titulo || !nuevoLibro.autor) {
enviarRespuesta(res, 400, {
error: 'Los campos titulo y autor son requeridos'
});
return;
}
// Generar nuevo ID incremental
const maxId = datos.libros.reduce(
(max, libro) => Math.max(max, libro.id),
0
);
nuevoLibro.id = maxId + 1;
nuevoLibro.fechaCreacion = new Date().toISOString();
datos.libros.push(nuevoLibro);
await guardarDatos(datos);
enviarRespuesta(res, 201, nuevoLibro);
break;
case 'PUT':
// PUT /libros/:id - Actualizar un libro completo
if (!id) {
enviarRespuesta(res, 400, { error: 'ID requerido' });
return;
}
const index = datos.libros.findIndex(l => l.id === id);
if (index === -1) {
enviarRespuesta(res, 404, { error: 'Libro no encontrado' });
return;
}
const libroActualizado = await parsearCuerpo(req);
// Preservar ID y fecha de creación original
libroActualizado.id = id;
libroActualizado.fechaCreacion = datos.libros[index].fechaCreacion;
libroActualizado.fechaActualizacion = new Date().toISOString();
datos.libros[index] = libroActualizado;
await guardarDatos(datos);
enviarRespuesta(res, 200, libroActualizado);
break;
case 'DELETE':
// DELETE /libros/:id - Eliminar un libro
if (!id) {
enviarRespuesta(res, 400, { error: 'ID requerido' });
return;
}
const deleteIndex = datos.libros.findIndex(l => l.id === id);
if (deleteIndex === -1) {
enviarRespuesta(res, 404, { error: 'Libro no encontrado' });
return;
}
datos.libros.splice(deleteIndex, 1);
await guardarDatos(datos);
// 204 No Content
enviarRespuesta(res, 204);
break;
default:
enviarRespuesta(res, 405, { error: 'Método no permitido' });
}
} catch (error) {
console.error('Error:', error);
enviarRespuesta(res, 500, { error: 'Error interno del servidor' });
}
}
Archivo principal. Carga del servidor y del router.
server.js
// server.js
import http from 'node:http';
import { URL } from 'node:url';
import { manejarRutaLibros } from './librosRoutes.js';
import { manejarCORS, enviarRespuesta } from './httpUtils.js';
// Puerto de escucha del servidor
const PORT = 3000;
/**
* Servidor HTTP principal.
* Solo:
* - construye la URL
* - controla CORS preflight
* - decide a qué módulo de rutas mandar la petición
*/
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
// Manejo de OPTIONS (preflight CORS)
if (req.method === 'OPTIONS') {
manejarCORS(res);
return;
}
// Router básico por pathname
if (parsedUrl.pathname.startsWith('/libros')) {
await manejarRutaLibros(req, res, parsedUrl);
} else {
enviarRespuesta(res, 404, { error: 'Ruta no encontrada' });
}
});
// Arrancar el servidor
server.listen(PORT, () => {
console.log(`Servidor RESTful ejecutándose en http://localhost:${PORT}`);
console.log('Endpoints disponibles:');
console.log(' GET /libros - Obtener todos los libros');
console.log(' GET /libros/:id - Obtener un libro específico');
console.log(' POST /libros - Crear un nuevo libro');
console.log(' PUT /libros/:id - Actualizar un libro completo');
console.log(' DELETE /libros/:id - Eliminar un libro');
});
// Cierre ordenado en caso de SIGTERM
process.on('SIGTERM', () => {
console.log('Apagando servidor...');
server.close(() => {
console.log('Servidor apagado correctamente');
});
});
Instala dependencias
Este proyecto no usa nada externo, solo módulos nativos de Node.js, así que no necesitas instalar nada.
Solo asegúrate de que package.json tenga:
{
"name": "biblioteca-rest-api",
"type": "module",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
}
}
Ejecutar el servidor
Opciones: En modo normal o en desarrollo
En PowerShell o CMD:
npm start o npm run dev
Comprobar que arranca
Debes ver:
Servidor RESTful ejecutándose en http://localhost:3000
Endpoints disponibles:
GET /libros
GET /libros/:id
POST /libros
PUT /libros/:id
DELETE /libros/:id
Si ves eso, el servidor está funcionando.
Ahora verás cómo lanzar peticiones POST, PUT y DELETE a tu API http://localhost:3000/libros usando Hoppscotch.
Voy a suponer que tu servidor ya está arrancado con npm start o npm run dev.
1. POST: crear un libro nuevo
Objetivo: enviar un JSON para crear un nuevo libro en POST /libros.
-
Abre Hoppscotch en el navegador.
-
Arriba a la izquierda, selecciona el método
POSTen el desplegable. -
En la barra de URL escribe:
http://localhost:3000/libros -
Ve a la pestaña Body.
-
Selecciona tipo JSON.
-
En el cuadro de texto del body, escribe algo como:
{
"id": 6,
"titulo": "Guia del autoestopista intergalactico",
"autor": "Douglas Adams",
"fechaPublicacion": "1978-08-01",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
} -
Asegúrate de que la cabecera
Content-Type: application/jsonesté marcada.Normalmente Hoppscotch la añade solo cuando eliges Body JSON, pero si no, añádela en la pestaña Headers:
- Key:
Content-Type - Value:
application/json
- Key:
-
Pulsa el botón Send.
-
Deberías ver:
- Código de estado
201 Created. - En el cuerpo de respuesta, el libro creado con un
idyfechaCreacion.
- Código de estado
2. PUT: actualizar un libro existente
Objetivo: modificar un libro completo en PUT /libros/:id.
Imaginemos que quieres actualizar el libro con id = 1.
-
En Hoppscotch, cambia el método a PUT.
-
En la URL escribe:
http://localhost:3000/libros/1 -
Ve a la pestaña Body y selecciona JSON.
-
Escribe un cuerpo completo con los datos del libro actualizado:
{
"id": 1,
"titulo": "Ready Player One",
"autor": "Ernest Cline",
"fechaPublicacion": "2011-08-01",
"fechaCreacion": "2025-01-02T15:04:05.000Z",
"fechaActualizacion": "2025-11-19T17:22:00.000Z"
}Recuerda: tu servidor espera un objeto con los campos necesarios. El
idy las fechas especiales las reconstruye en el servidor. -
Verifica en Headers que tienes:
Content-Type: application/json
-
Pulsa Send.
-
Si todo va bien:
- Verás código
200 OK. - La respuesta incluirá el libro actualizado, con
idy fechas (fechaCreacion,fechaActualizacion).
- Verás código
Si recibes 404 es que no existe un libro con ese id. Si recibes 400, probablemente falta el id en la URL.
3. DELETE: eliminar un libro
Objetivo: borrar un libro en DELETE /libros/:id.
Supón que quieres borrar el libro con id = 2.
-
Cambia el método a DELETE en Hoppscotch.
-
En la URL escribe:
http://localhost:3000/libros/2 -
En este caso no necesitas body ni headers especiales.
-
Pulsa Send.
-
Si se borra correctamente:
- Verás código
204 No Content. - No habrá cuerpo de respuesta.
- Verás código
-
Si el libro no existe:
- Verás código
404. - En el cuerpo:
{ "error": "Libro no encontrado" }.
- Verás código
Reflexión final
Crear una API RESTful con Node.js puro te permite apreciar la elegancia y simplicidad de los principios REST. Cada línea de código tiene un propósito claro, y comprendes exactamente cómo se procesa cada petición HTTP. Esta comprensión fundamental es invaluable cuando trabajas con APIs en cualquier contexto.
La próxima vez que uses una API REST, ya sea como consumidor o creador, entenderás la conversación estructurada que ocurre entre cliente y servidor, y podrás diseñar sistemas que se comuniquen de manera clara, eficiente y confiable.
Opcional: Query Parameters para Búsquedas y Filtros
El Poder de los Query Parameters
Los query parameters transforman una API básica en una herramienta poderosa de consulta. Permiten a los clientes:
- Filtrar resultados específicos
- Buscar por términos
- Paginarlos para mejor rendimiento
- Ordenarlos por criterios
- Seleccionar campos específicos
Cambios en la estructura del proyecto. Agregamos las siguientes carpetas.
biblioteca-api/
├── server.js
├── dataStore.js
├── bodyParser.js
├── httpUtils.js
├── librosRoutes.js
├── utils/
│ ├── queryHandler.js
│ ├── filtrosHandler.js
│ └── paginacionHandler.js
├── package.json
└── data/
└── libros.json
La idea:
server.jssigue solo arrancando el servidor y delegando.librosRoutes.jssigue siendo el controlador de la “feature” libros.utils/queryHandler.js→ toda la lógica de leer y normalizar query params.utils/filtrosHandler.js→ toda la lógica de filtrado de colecciones.utils/paginacionHandler.js→ ordenación, paginación y selección de campos.
Adaptar tus handlers a ES Modules
utils/queryHandler.js
// utils/queryHandler.js
export class QueryHandler {
/**
* Procesa todos los query parameters de una URL
* @param {URL} parsedUrl - Objeto URL parseado
* @returns {Object} - Parámetros procesados y normalizados
*/
static procesarQueryParams(parsedUrl) {
const params = {};
for (const [key, value] of parsedUrl.searchParams) {
// Manejar parámetros con múltiples valores ?campo=1&campo=2
if (params[key]) {
if (Array.isArray(params[key])) {
params[key].push(this.procesarValorParametro(value));
} else {
params[key] = [params[key], this.procesarValorParametro(value)];
}
} else {
params[key] = this.procesarValorParametro(value);
}
}
return this.normalizarParametros(params);
}
/**
* Convierte valores de string a tipos de datos apropiados
*/
static procesarValorParametro(valor) {
if (typeof valor !== 'string') return valor;
// Booleanos
if (valor.toLowerCase() === 'true') return true;
if (valor.toLowerCase() === 'false') return false;
// Números
if (!isNaN(valor) && valor.trim() !== '') {
return Number(valor);
}
// Fechas (formato ISO)
if (this.esFechaISO(valor)) {
return new Date(valor);
}
// Arrays implícitos (valor1,valor2,valor3)
if (valor.includes(',')) {
return valor
.split(',')
.map(v => this.procesarValorParametro(v.trim()));
}
// String por defecto
return valor;
}
/**
* Detecta si un string es una fecha ISO válida
*/
static esFechaISO(valor) {
const regex = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}(\.\d{3})?Z?)?$/;
return regex.test(valor) && !isNaN(Date.parse(valor));
}
/**
* Normaliza y valida los parámetros comunes de APIs REST
*/
static normalizarParametros(params) {
const normalizados = { ...params };
// Paginación
if (normalizados.page) {
normalizados.page = Math.max(1, parseInt(normalizados.page) || 1);
} else {
normalizados.page = 1;
}
if (normalizados.limit) {
normalizados.limit = Math.min(
100,
Math.max(1, parseInt(normalizados.limit) || 10)
);
} else {
normalizados.limit = 10; // Valor por defecto
}
// Ordenamiento
if (normalizados.sort) {
normalizados.sort = this.procesarParametroOrdenamiento(
normalizados.sort
);
}
// Campos a seleccionar
if (normalizados.fields) {
normalizados.fields = Array.isArray(normalizados.fields)
? normalizados.fields
: [normalizados.fields];
}
return normalizados;
}
/**
* Procesa parámetros de ordenamiento complejos
* Ej: "nombre,-fecha" -> [{ campo: 'nombre', orden: 'ASC' }, { campo: 'fecha', orden: 'DESC' }]
*/
static procesarParametroOrdenamiento(sortParam) {
const campos = Array.isArray(sortParam) ? sortParam : [sortParam];
return campos.flatMap(campo => {
if (typeof campo === 'string') {
return campo.split(',').map(c => {
const orden = c.startsWith('-') ? 'DESC' : 'ASC';
const nombreCampo = c.startsWith('-') ? c.slice(1) : c;
return { campo: nombreCampo, orden };
});
}
return [];
});
}
}
utils/filtrosHandler.js
// utils/filtrosHandler.js
export class FiltrosHandler {
/**
* Aplica todos los filtros a una colección de datos
*/
static aplicarFiltros(datos, queryParams) {
let resultados = [...datos];
// Búsqueda textual global ?q=...
if (queryParams.q) {
resultados = this.buscarTexto(resultados, queryParams.q);
}
// Filtros específicos por campo (?autor=Cervantes, ?titulo=Quijote, etc.)
resultados = this.aplicarFiltrosPorCampo(resultados, queryParams);
// Filtros de rango (?min_id=2&max_id=10)
resultados = this.aplicarFiltrosRango(resultados, queryParams);
// Filtros booleanos (?is_activo=true, etc.)
resultados = this.aplicarFiltrosBooleanos(resultados, queryParams);
return resultados;
}
/**
* Búsqueda de texto en múltiples campos
*/
static buscarTexto(datos, terminoBusqueda) {
if (!terminoBusqueda) return datos;
const termino = terminoBusqueda.toString().toLowerCase();
return datos.filter(item => {
// Buscar en todos los campos string del objeto
return Object.values(item).some(valor => {
if (typeof valor === 'string') {
return valor.toLowerCase().includes(termino);
}
return false;
});
});
}
/**
* Filtros por igualdad en campos específicos
*/
static aplicarFiltrosPorCampo(datos, queryParams) {
const camposFiltro = Object.keys(queryParams).filter(key =>
!['q', 'page', 'limit', 'sort', 'fields', 'range', 'min', 'max'].includes(
key
)
);
return datos.filter(item => {
return camposFiltro.every(campo => {
const valorFiltro = queryParams[campo];
const valorItem = item[campo];
if (valorItem === undefined) return false;
// Manejar arrays en el filtro ?autor=Cervantes&autor=Borges
if (Array.isArray(valorFiltro)) {
return valorFiltro.includes(valorItem);
}
// Manejar arrays en el item
if (Array.isArray(valorItem)) {
return valorItem.includes(valorFiltro);
}
// Comparación normal
return valorItem == valorFiltro;
});
});
}
/**
* Filtros por rango (min_/max_)
*/
static aplicarFiltrosRango(datos, queryParams) {
let resultados = [...datos];
Object.keys(queryParams).forEach(key => {
if (key.startsWith('min_')) {
const campo = key.slice(4);
const minValor = queryParams[key];
resultados = resultados.filter(item =>
item[campo] !== undefined && item[campo] >= minValor
);
}
if (key.startsWith('max_')) {
const campo = key.slice(4);
const maxValor = queryParams[key];
resultados = resultados.filter(item =>
item[campo] !== undefined && item[campo] <= maxValor
);
}
});
return resultados;
}
/**
* Filtros para campos booleanos (prefijo is_)
*/
static aplicarFiltrosBooleanos(datos, queryParams) {
let resultados = [...datos];
Object.keys(queryParams).forEach(key => {
if (key.startsWith('is_')) {
const campo = key.slice(3);
const valor = queryParams[key];
resultados = resultados.filter(item =>
item[campo] !== undefined &&
Boolean(item[campo]) === Boolean(valor)
);
}
});
return resultados;
}
}
utils/paginacionHandler.js
// utils/paginacionHandler.js
export class PaginacionHandler {
/**
* Aplica paginación a los resultados
*/
static aplicarPaginacion(datos, queryParams) {
const page = queryParams.page || 1;
const limit = queryParams.limit || 10;
const offset = (page - 1) * limit;
const datosPaginados = datos.slice(offset, offset + limit);
return {
datos: datosPaginados,
paginacion: {
page,
limit,
total: datos.length,
pages: Math.ceil(datos.length / limit),
hasNext: offset + limit < datos.length,
hasPrev: page > 1
}
};
}
/**
* Aplica ordenamiento a los resultados
*/
static aplicarOrdenamiento(datos, sortConfig) {
if (!sortConfig || !Array.isArray(sortConfig)) return datos;
return [...datos].sort((a, b) => {
for (const sort of sortConfig) {
const valorA = a[sort.campo];
const valorB = b[sort.campo];
if (valorA === undefined || valorB === undefined) continue;
let comparacion = 0;
if (typeof valorA === 'string' && typeof valorB === 'string') {
comparacion = valorA.localeCompare(valorB);
} else {
comparacion = valorA < valorB ? -1 : valorA > valorB ? 1 : 0;
}
if (comparacion !== 0) {
return sort.orden === 'DESC' ? -comparacion : comparacion;
}
}
return 0;
});
}
/**
* Selecciona campos específicos de los resultados
*/
static seleccionarCampos(datos, campos) {
if (!campos || !Array.isArray(campos)) return datos;
return datos.map(item => {
const itemFiltrado = {};
campos.forEach(campo => {
if (item[campo] !== undefined) {
itemFiltrado[campo] = item[campo];
}
});
return itemFiltrado;
});
}
}
Cómo se usan desde librosRoutes.js
La integración natural es en el GET /libros (sin id). Ahí es donde lees query params, filtras, ordenas, paginas, etc.
Importaciones en librosRoutes.js
// librosRoutes.js
import { leerDatos, guardarDatos } from './dataStore.js';
import { parsearCuerpo } from './bodyParser.js';
import { enviarRespuesta } from './httpUtils.js';
import { QueryHandler } from './utils/queryHandler.js';
import { FiltrosHandler } from './utils/filtrosHandler.js';
import { PaginacionHandler } from './utils/paginacionHandler.js';
Dentro de manejarRutaLibros, en el caso GET sin id
Solo te muestro la parte que cambia para que se vea claro.
export async function manejarRutaLibros(req, res, parsedUrl) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
const id = extraerIdLibro(pathname);
try {
const datos = await leerDatos();
switch (metodo) {
case 'GET':
if (id) {
// GET /libros/:id - Obtener un libro específico
const libro = datos.libros.find(l => l.id === id);
if (libro) {
enviarRespuesta(res, 200, libro);
} else {
enviarRespuesta(res, 404, { error: 'Libro no encontrado' });
}
} else {
// GET /libros con filtros, orden, paginación, etc.
// 1. Procesar todos los query params de la URL
const queryParams = QueryHandler.procesarQueryParams(parsedUrl);
// 2. Aplicar filtros (q, campos específicos, rangos, booleanos)
let resultados = FiltrosHandler.aplicarFiltros(
datos.libros,
queryParams
);
// 3. Aplicar ordenamiento si procede (?sort=titulo,-fechaPublicacion)
resultados = PaginacionHandler.aplicarOrdenamiento(
resultados,
queryParams.sort
);
// 4. Aplicar paginación (?page=2&limit=5)
let { datos: datosPaginados, paginacion } =
PaginacionHandler.aplicarPaginacion(
resultados,
queryParams
);
// 5. Seleccionar solo ciertos campos (?fields=titulo,autor)
if (queryParams.fields) {
datosPaginados = PaginacionHandler.seleccionarCampos(
datosPaginados,
queryParams.fields
);
}
// 6. Respuesta incluyendo datos y metadatos de paginación
enviarRespuesta(res, 200, {
data: datosPaginados,
paginacion
});
}
break;
// el resto de casos (POST, PUT, DELETE) quedan igual que antes...
Con esto mantienes:
- La lógica de “libros” dentro de
librosRoutes.js. - La lógica genérica de query, filtros y paginación en módulos reutilizables.
server.jssigue limpio: solo crea el servidor y delega enmanejarRutaLibros.
Si quieres, en el siguiente paso podemos:
- Escribir ejemplos de URLs reales con estos parámetros (
q,page,limit,sort,min_,max_,fields) para que veas cómo quedan en Hoppscotch.
Te voy a mostrar cómo probar cada uno de los nuevos parámetros avanzados usando Hoppscotch, de forma práctica y clara, igual que lo harías en clase.
Los ejemplos incluyen:
- Búsqueda textual (
q) - Filtro por campos (
autor=Cervantes) - Filtros múltiples (
autor=Cervantes&autor=Borges) - Filtros por rangos (
min_id,max_id) - Filtros booleanos (
is_activo=true) - Ordenamiento (
sort) - Selección de campos (
fields) - Paginación (
page,limit)
Y todo con URL completas listas para copiar en Hoppscotch.
Cómo usar Hoppscotch para probar tus nuevos Query Params avanzados
Abre:
https://hoppscotch.io/
Asegúrate de que el servidor está arrancado:
npm run dev
Método siempre:
GET
En Hoppscotch:
- Arriba a la izquierda selecciona GET.
- En el campo de URL pega las URLs de ejemplo.
- Pulsa Send.
No necesitas Headers ni Body para los GET.
Búsqueda textual global (q)
Busca libros que contengan el texto "quijote" en cualquier campo string.
URL para Hoppscotch:
http://localhost:3000/libros?q=dune
✔ Mostrará los libros cuyo título, autor, etc., contengan "quijote".
Comparación sin mayúsculas.
Búsqueda por varios términos
http://localhost:3000/libros?q=anillos
Encuentra libros con "soledad".
Filtro por campo exacto (autor o título)
Filtrar por autor:
http://localhost:3000/libros?autor=Frank%20Herbert
Filtrar por título:
http://localhost:3000/libros?titulo=El%20nombre%20del%20viento
Filtros múltiples por campo (arrays)
Filtrar libros cuyo autor sea Cervantes o García Márquez:
http://localhost:3000/libros?autor=Patrick%20Rothfuss&autor=Isaac%20Asimov
Hoppscotch maneja automáticamente los parámetros repetidos.
Filtros por rango (min_ y max_)
Filtrar libros por ID mayor o igual a 2:
http://localhost:3000/libros?min_id=2
Filtrar libros por ID máximo de 1:
http://localhost:3000/libros?max_id=1
Rango combinado:
http://localhost:3000/libros?min_id=1&max_id=2
Filtros booleanos (is_)
Supongamos que tus libros tienen un campo is_publicado.
http://localhost:3000/libros?is_publicado=true
Ordenamiento (sort)
Orden ascendente por título:
http://localhost:3000/libros?sort=titulo
Orden descendente (con prefijo "-"):
http://localhost:3000/libros?sort=-titulo
Orden múltiple:
http://localhost:3000/libros?sort=titulo,-fechaPublicacion
Ordenar desde múltiples parámetros separados por coma:
http://localhost:3000/libros?sort=titulo,autor
Seleccionar solo ciertos campos (fields)
Quedarse solo con título y autor:
http://localhost:3000/libros?fields=titulo&fields=autor
O usando coma:
http://localhost:3000/libros?fields=titulo,autor
El handler soporta ambos.
Paginación avanzada (page y limit)
Página 1 con 5 libros por página:
http://localhost:3000/libros?page=1&limit=5
Página 2:
http://localhost:3000/libros?page=2&limit=5
Combinando filtros, orden y paginación
Filtrar libros cuyo autor contenga “gabriel”, ordenados por título descendente, y devolver solo 1 libro por página.
http://localhost:3000/libros?q=gabriel&sort=-titulo&page=1&limit=1
Otro ejemplo más completo:
http://localhost:3000/libros?q=quijote&fields=titulo,autor&sort=-autor&page=1&limit=2
Búsquedas por múltiples condiciones reales
Todos los libros publicados después de 1950, cuyo título contenga “años”, ordenados por fecha:
http://localhost:3000/libros?q=años&min_fechaPublicacion=1950-01-01&sort=fechaPublicacion
Hoppscotch lo aceptará sin configurar nada más.
Opcional: Headers HTTP Importantes para APIs RESTful
Ahora, vamos a integrar los Headers sin destrozar lo que ya tienes y manteniendo todo modular.
Voy a hacerlo en tres niveles:
- Dónde colocar físicamente estos nuevos módulos.
- Crear los archivos
HeadersManageryEnhancedResponseHandlerde forma modular. - Usarlos desde tu API de libros (GET /libros con paginación y caché avanzada).
Voy a asumir esta base que ya teníamos:
biblioteca-api/
├── server.js
├── dataStore.js
├── bodyParser.js
├── httpUtils.js // versión sencilla que ya usabas
├── librosRoutes.js
├── utils/
│ ├── queryHandler.js
│ ├── filtrosHandler.js
│ └── paginacionHandler.js
├── package.json
└── data/
└── libros.json
Te propongo añadir:
biblioteca-api/
├── http/
│ ├── headersManager.js
│ └── enhancedResponseHandler.js
├── utils/
│ └── cacheUtils.js
Y luego hacer que librosRoutes.js use estas piezas para las respuestas “avanzadas”.
Nuevo módulo: http/headersManager.js
Aquí metemos toda la lógica de cabeceras, exactamente la que traes en el ANEXO, pero como ES Module.
// http/headersManager.js
export class HeadersManager {
static get headersBase() {
return {
'Content-Type': 'application/json; charset=utf-8',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'X-XSS-Protection': '1; mode=block'
};
}
static get headersCORS() {
return {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS, HEAD',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-API-Version, X-Request-ID',
'Access-Control-Expose-Headers': 'X-Total-Count, X-Page-Count, X-Current-Page',
'Access-Control-Max-Age': '86400'
};
}
static getCacheHeaders(estrategia = 'no-cache', maxAge = null) {
const headers = {};
switch (estrategia) {
case 'no-cache':
headers['Cache-Control'] = 'no-cache, no-store, must-revalidate';
headers['Pragma'] = 'no-cache';
headers['Expires'] = '0';
break;
case 'public':
headers['Cache-Control'] = `public, max-age=${maxAge || 300}`;
break;
case 'private':
headers['Cache-Control'] = `private, max-age=${maxAge || 60}`;
break;
case 'immutable':
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
break;
}
return headers;
}
static getRateLimitHeaders(limites) {
return {
'X-RateLimit-Limit': limites.limit,
'X-RateLimit-Remaining': limites.remaining,
'X-RateLimit-Reset': limites.reset,
'X-RateLimit-Policy': `${limites.limit};w=60`
};
}
static getPaginationHeaders(paginacion) {
return {
'X-Total-Count': paginacion.total,
'X-Page-Count': paginacion.pages,
'X-Current-Page': paginacion.page,
'X-Per-Page': paginacion.limit,
'X-Has-Next': paginacion.hasNext,
'X-Has-Prev': paginacion.hasPrev
};
}
static getSecurityHeaders() {
return {
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
'Content-Security-Policy': "default-src 'self'",
'Referrer-Policy': 'strict-origin-when-cross-origin',
'Permissions-Policy': 'geolocation=(), microphone=()'
};
}
static getVersionHeaders(version = '1.0.0', deprecated = false) {
const headers = {
'X-API-Version': version,
'X-API-Build': process.env.NODE_ENV || 'development'
};
if (deprecated) {
headers['Warning'] = '299 - "This API version is deprecated"';
}
return headers;
}
static procesarRequestHeaders(req) {
const headers = {
version: req.headers['x-api-version'] || '1.0.0',
authorization: req.headers['authorization'],
apiKey: req.headers['x-api-key'],
cacheControl: req.headers['cache-control'],
ifNoneMatch: req.headers['if-none-match'],
ifModifiedSince: req.headers['if-modified-since'],
acceptEncoding: req.headers['accept-encoding'],
accept: req.headers['accept'] || 'application/json',
userAgent: req.headers['user-agent'],
requestId: req.headers['x-request-id'] || this.generarRequestId()
};
return headers;
}
static generarRequestId() {
return `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
static determinarEstrategiaCache(ruta, metodo) {
if (metodo !== 'GET') return 'no-cache';
const rutasCacheables = {
'/libros': 'public',
'/libros/': 'private'
};
for (const [rutaCache, estrategia] of Object.entries(rutasCacheables)) {
if (ruta.startsWith(rutaCache)) {
return estrategia;
}
}
return 'no-cache';
}
}
Nuevo módulo: http/enhancedResponseHandler.js
Aquí va el manejador avanzado que construye todas las cabeceras y envía la respuesta.
// http/enhancedResponseHandler.js
import { HeadersManager } from './headersManager.js';
export class EnhancedResponseHandler {
static enviarRespuesta(res, options) {
const {
statusCode = 200,
datos = null,
metadata = {},
headersPersonalizados = {},
estrategiaCache = 'no-cache',
maxAgeCache = null,
versionAPI = '1.0.0',
deprecated = false,
rateLimit = null,
pagination = null
} = options;
let headers = {
...HeadersManager.headersBase,
...HeadersManager.headersCORS,
...HeadersManager.getVersionHeaders(versionAPI, deprecated),
...HeadersManager.getSecurityHeaders()
};
const estrategia = estrategiaCache === 'auto'
? HeadersManager.determinarEstrategiaCache(metadata.ruta, metadata.metodo)
: estrategiaCache;
headers = {
...headers,
...HeadersManager.getCacheHeaders(estrategia, maxAgeCache)
};
if (rateLimit) {
headers = { ...headers, ...HeadersManager.getRateLimitHeaders(rateLimit) };
}
if (pagination) {
headers = { ...headers, ...HeadersManager.getPaginationHeaders(pagination) };
}
headers = { ...headers, ...headersPersonalizados };
let cuerpo = null;
if (datos !== null) {
const respuesta = metadata.paginacion
? { datos, metadata: { paginacion: metadata.paginacion } }
: datos;
cuerpo = JSON.stringify(respuesta);
headers['Content-Length'] = Buffer.byteLength(cuerpo);
}
res.writeHead(statusCode, headers);
res.end(cuerpo);
this.logRespuesta(statusCode, metadata, headers);
}
static enviarError(res, error) {
const statusCode = error.statusCode || 500;
const cuerpoError = {
error: {
codigo: error.codigo || 'INTERNAL_ERROR',
mensaje: error.mensaje || 'Error interno del servidor',
detalles: error.detalles,
timestamp: new Date().toISOString(),
requestId: error.requestId
}
};
this.enviarRespuesta(res, {
statusCode,
datos: cuerpoError,
estrategiaCache: 'no-cache'
});
}
static enviarNoModificado(res, etag) {
const headers = {
...HeadersManager.headersCORS,
'ETag': etag,
'Cache-Control': 'public, max-age=300'
};
res.writeHead(304, headers);
res.end();
}
static logRespuesta(statusCode, metadata, headers) {
if (process.env.NODE_ENV === 'development') {
console.log(
`[RESPONSE] ${statusCode} | ${metadata.metodo} ${metadata.ruta} | Headers:`,
{
'Content-Type': headers['Content-Type'],
'Cache-Control': headers['Cache-Control'],
'X-API-Version': headers['X-API-Version'],
'X-Total-Count': headers['X-Total-Count']
}
);
}
}
}
Nuevo módulo: utils/cacheUtils.js
Metemos aquí el cálculo de ETag y la lógica de 304.
// utils/cacheUtils.js
import { createHash } from 'node:crypto';
import { EnhancedResponseHandler } from '../http/enhancedResponseHandler.js';
export function calcularETag(datos) {
const str = JSON.stringify(datos);
return createHash('md5').update(str).digest('hex');
}
export function manejarCacheCondicional(req, res, datos, etag) {
const clientETag = req.headers['if-none-match'];
const clientModifiedSince = req.headers['if-modified-since'];
if (clientETag === etag) {
EnhancedResponseHandler.enviarNoModificado(res, etag);
return true;
}
if (clientModifiedSince && datos.ultimaModificacion) {
const clientDate = new Date(clientModifiedSince);
const serverDate = new Date(datos.ultimaModificacion);
if (serverDate <= clientDate) {
EnhancedResponseHandler.enviarNoModificado(res, etag);
return true;
}
}
return false;
}
Integración en server.js
Aquí solo queremos dos cosas nuevas:
- Procesar headers de la petición con
HeadersManager.procesarRequestHeaders. - Usar
EnhancedResponseHandlerpara OPTIONS y para errores globales.
Un ejemplo de server.js simplificado quedaría así:
// server.js
import http from 'node:http';
import { URL } from 'node:url';
import { manejarRutaLibros } from './librosRoutes.js';
import { HeadersManager } from './http/headersManager.js';
import { EnhancedResponseHandler } from './http/enhancedResponseHandler.js';
const PORT = 3000;
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const requestHeaders = HeadersManager.procesarRequestHeaders(req);
console.log(
`[REQUEST] ${req.method} ${parsedUrl.pathname} | Client: ${requestHeaders.userAgent} | Version: ${requestHeaders.version}`
);
if (req.method === 'OPTIONS') {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: null,
versionAPI: requestHeaders.version,
metadata: {
ruta: parsedUrl.pathname,
metodo: req.method
}
});
return;
}
try {
if (parsedUrl.pathname.startsWith('/libros')) {
await manejarRutaLibros(req, res, parsedUrl, requestHeaders);
} else {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta no encontrada' },
versionAPI: requestHeaders.version,
metadata: {
ruta: parsedUrl.pathname,
metodo: req.method
}
});
}
} catch (error) {
console.error('Error no controlado:', error);
EnhancedResponseHandler.enviarError(res, {
statusCode: 500,
codigo: 'INTERNAL_SERVER_ERROR',
mensaje: 'Error interno del servidor',
requestId: requestHeaders.requestId
});
}
});
server.listen(PORT, () => {
console.log(`Servidor RESTful avanzado en http://localhost:${PORT}`);
});
Fíjate que ahora manejarRutaLibros recibe un cuarto parámetro requestHeaders. Vamos a ajustarlo.
Integración en librosRoutes.js (GET /libros)
Aquí es donde aprovechamos:
- QueryHandler, FiltrosHandler, PaginacionHandler.
- ETag y 304.
- EnhancedResponseHandler con headers de paginación.
Solo te muestro las partes clave.
// librosRoutes.js
import { leerDatos, guardarDatos } from './dataStore.js';
import { parsearCuerpo } from './bodyParser.js';
import { QueryHandler } from './utils/queryHandler.js';
import { FiltrosHandler } from './utils/filtrosHandler.js';
import { PaginacionHandler } from './utils/paginacionHandler.js';
import { EnhancedResponseHandler } from './http/enhancedResponseHandler.js';
import { calcularETag, manejarCacheCondicional } from './utils/cacheUtils.js';
function extraerIdLibro(pathname) {
const partes = pathname.split('/');
return partes[2] ? parseInt(partes[2]) : null;
}
export async function manejarRutaLibros(req, res, parsedUrl, requestHeaders) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
const id = extraerIdLibro(pathname);
try {
const datos = await leerDatos();
switch (metodo) {
case 'GET':
if (id) {
const libro = datos.libros.find(l => l.id === id);
if (!libro) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Libro no encontrado' },
versionAPI: requestHeaders.version,
estrategiaCache: 'no-cache',
metadata: { ruta: pathname, metodo }
});
return;
}
const etag = calcularETag(libro);
if (manejarCacheCondicional(req, res, datos, etag)) return;
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: libro,
versionAPI: requestHeaders.version,
estrategiaCache: 'private',
metadata: { ruta: pathname, metodo },
headersPersonalizados: {
'ETag': etag,
'Last-Modified': new Date().toUTCString()
}
});
} else {
const queryParams = QueryHandler.procesarQueryParams(parsedUrl);
let resultados = FiltrosHandler.aplicarFiltros(
datos.libros,
queryParams
);
resultados = PaginacionHandler.aplicarOrdenamiento(
resultados,
queryParams.sort
);
let { datos: datosPaginados, paginacion } =
PaginacionHandler.aplicarPaginacion(
resultados,
queryParams
);
if (queryParams.fields) {
datosPaginados = PaginacionHandler.seleccionarCampos(
datosPaginados,
queryParams.fields
);
}
const etag = calcularETag(datosPaginados);
if (manejarCacheCondicional(req, res, datos, etag)) return;
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: datosPaginados,
versionAPI: requestHeaders.version,
estrategiaCache: 'public',
maxAgeCache: 300,
pagination: paginacion,
metadata: {
ruta: pathname,
metodo,
paginacion
},
headersPersonalizados: {
'ETag': etag,
'Last-Modified': new Date().toUTCString()
}
});
}
break;
// POST, PUT, DELETE podrían seguir usando tu enviarRespuesta simple,
// o migrarse poco a poco a EnhancedResponseHandler con menos opciones.
default:
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 405,
datos: { error: 'Método no permitido' },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
}
} catch (error) {
console.error('Error en manejarRutaLibros:', error);
EnhancedResponseHandler.enviarError(res, {
statusCode: 500,
codigo: 'INTERNAL_ERROR',
mensaje: 'Error interno del servidor en /libros',
requestId: requestHeaders.requestId
});
}
}
Cómo probar que todo sigue funcionando
No cambia la forma de ejecutar:
npm run dev
Y en Hoppscotch puedes seguir usando las mismas URLs que antes:
GET http://localhost:3000/librosGET http://localhost:3000/libros?page=2&limit=5&sort=tituloGET http://localhost:3000/libros?q=quijote- etc.
La diferencia es que ahora en la pestaña Headers de la respuesta verás:
X-API-VersionX-Total-Count,X-Page-Count,X-Current-PageETagCache-Control, etc.
Opcional: Middleware de Autenticación Básica
Voy a dividirlo en tres bloques para que sea legible:
- Nueva estructura de carpetas y visión general.
- Nuevos módulos de autenticación:
authManageryuserManager. - Cómo engancharlo en tu
server.jsy en las rutas de libros (librosRoutes.js).
Si luego quieres, hacemos una pasada para dejar también las respuestas de auth usando tu EnhancedResponseHandler.
1. Estructura del proyecto con autenticación
Partiendo de la estructura modular que ya tenías (con headers avanzados y query handlers), añadimos una carpeta auth:
biblioteca-api/
├── server.js
├── dataStore.js
├── bodyParser.js
├── httpUtils.js // si lo sigues usando para cosas sencillas
├── librosRoutes.js
├── http/
│ ├── headersManager.js
│ └── enhancedResponseHandler.js
├── auth/
│ ├── authManager.js
│ └── userManager.js
├── utils/
│ ├── queryHandler.js
│ ├── filtrosHandler.js
│ ├── paginacionHandler.js
│ └── cacheUtils.js
├── package.json
└── data/
└── libros.json
Más adelante podríamos añadir auth/authRoutes.js para separar las rutas /auth/..., pero vamos paso a paso.
Nuevo módulo auth/authManager.js
Primero, creamos el gestor de autenticación general tal y como tienes en el anexo, adaptado a ES Modules y a tu proyecto.
Crea el archivo:
auth/authManager.js
import { createHmac, randomBytes } from "node:crypto";
class AuthManager {
constructor() {
this.apiKeys = new Map();
this.users = new Map();
this.sessions = new Map();
this.jwtSecret =
process.env.JWT_SECRET ||
"clave-secreta-por-defecto-cambiar-en-produccion";
this.inicializarDatosEjemplo();
}
inicializarDatosEjemplo() {
this.apiKeys.set("ak_test_123456", {
id: "test-client",
name: "Cliente de Pruebas",
permissions: ["read:libros", "write:libros"],
rateLimit: 1000,
createdAt: new Date(),
});
this.apiKeys.set("ak_admin_789012", {
id: "admin-client",
name: "Administrador",
permissions: [
"read:libros",
"write:libros",
"delete:libros",
"manage:users",
],
rateLimit: 5000,
createdAt: new Date(),
});
this.users.set("admin@biblioteca.com", {
id: "user_001",
email: "admin@biblioteca.com",
passwordHash: this.hashPassword("admin123"),
role: "admin",
permissions: [
"read:libros",
"write:libros",
"delete:libros",
"manage:users",
],
isActive: true,
createdAt: new Date(),
});
this.users.set("usuario@ejemplo.com", {
id: "user_002",
email: "usuario@ejemplo.com",
passwordHash: this.hashPassword("usuario123"),
role: "user",
permissions: ["read:libros"],
isActive: true,
createdAt: new Date(),
});
}
middlewareAutenticacion(req, res, permisosRequeridos = []) {
return new Promise((resolve, reject) => {
const authHeader = req.headers["authorization"];
const apiKey = req.headers["x-api-key"];
if (apiKey) {
const resultado = this.verificarApiKey(apiKey, permisosRequeridos);
if (resultado.valido) {
req.auth = {
tipo: "api_key",
cliente: resultado.cliente,
permisos: resultado.cliente.permissions,
};
resolve(req);
return;
} else {
this.denegarAcceso(res, "API_KEY_INVALIDA", resultado.mensaje);
reject(new Error("API Key inválida"));
return;
}
}
if (authHeader && authHeader.startsWith("Bearer ")) {
const token = authHeader.slice(7);
const resultado = this.verificarBearerToken(token, permisosRequeridos);
if (resultado.valido) {
req.auth = {
tipo: "bearer_token",
usuario: resultado.usuario,
permisos: resultado.usuario.permissions,
sessionId: resultado.sessionId,
};
resolve(req);
return;
} else {
this.denegarAcceso(res, "TOKEN_INVALIDO", resultado.mensaje);
reject(new Error("Token inválido"));
return;
}
}
if (authHeader && authHeader.startsWith("JWT ")) {
const token = authHeader.slice(4);
const resultado = this.verificarJWT(token, permisosRequeridos);
if (resultado.valido) {
req.auth = {
tipo: "jwt",
usuario: resultado.usuario,
permisos: resultado.usuario.permissions,
};
resolve(req);
return;
} else {
this.denegarAcceso(res, "JWT_INVALIDO", resultado.mensaje);
reject(new Error("JWT inválido"));
return;
}
}
this.denegarAcceso(
res,
"NO_AUTENTICADO",
"Se requiere autenticación para acceder a este recurso"
);
reject(new Error("No autenticado"));
});
}
verificarApiKey(apiKey, permisosRequeridos) {
const cliente = this.apiKeys.get(apiKey);
if (!cliente) {
return { valido: false, mensaje: "API Key no encontrada" };
}
if (permisosRequeridos.length > 0) {
const tienePermisos = permisosRequeridos.every((permiso) =>
cliente.permissions.includes(permiso)
);
if (!tienePermisos) {
return {
valido: false,
mensaje: "Permisos insuficientes para esta operación",
};
}
}
return { valido: true, cliente };
}
verificarBearerToken(token, permisosRequeridos) {
const session = this.sessions.get(token);
if (!session) {
return { valido: false, mensaje: "Token de sesión no encontrado" };
}
if (session.expiresAt < new Date()) {
this.sessions.delete(token);
return { valido: false, mensaje: "Sesión expirada" };
}
const usuario = this.users.get(session.userEmail);
if (!usuario || !usuario.isActive) {
return {
valido: false,
mensaje: "Usuario no encontrado o inactivo",
};
}
if (permisosRequeridos.length > 0) {
const tienePermisos = permisosRequeridos.every((permiso) =>
usuario.permissions.includes(permiso)
);
if (!tienePermisos) {
return {
valido: false,
mensaje: "Permisos insuficientes para esta operación",
};
}
}
session.expiresAt = new Date(Date.now() + 30 * 60 * 1000);
return {
valido: true,
usuario,
sessionId: token,
};
}
verificarJWT(token, permisosRequeridos) {
try {
const partes = token.split(".");
if (partes.length !== 3) {
return { valido: false, mensaje: "Formato JWT inválido" };
}
const payload = JSON.parse(Buffer.from(partes[1], "base64").toString());
if (payload.exp && payload.exp < Date.now() / 1000) {
return { valido: false, mensaje: "JWT expirado" };
}
const usuario = this.users.get(payload.email);
if (!usuario || !usuario.isActive) {
return {
valido: false,
mensaje: "Usuario no encontrado o inactivo",
};
}
if (permisosRequeridos.length > 0) {
const tienePermisos = permisosRequeridos.every((permiso) =>
usuario.permissions.includes(permiso)
);
if (!tienePermisos) {
return {
valido: false,
mensaje: "Permisos insuficientes para esta operación",
};
}
}
return { valido: true, usuario };
} catch (error) {
return { valido: false, mensaje: "JWT inválido" };
}
}
hashPassword(password) {
return createHmac("sha256", this.jwtSecret).update(password).digest("hex");
}
crearSesion(email) {
const usuario = this.users.get(email);
if (!usuario) {
throw new Error("Usuario no encontrado");
}
const sessionId = randomBytes(32).toString("hex");
const session = {
sessionId,
userEmail: email,
userId: usuario.id,
createdAt: new Date(),
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
};
this.sessions.set(sessionId, session);
return sessionId;
}
generarJWT(email) {
const usuario = this.users.get(email);
if (!usuario) {
throw new Error("Usuario no encontrado");
}
const header = {
alg: "HS256",
typ: "JWT",
};
const payload = {
email: usuario.email,
userId: usuario.id,
role: usuario.role,
permissions: usuario.permissions,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 24 * 60 * 60,
};
const headerEncoded = Buffer.from(JSON.stringify(header)).toString(
"base64"
);
const payloadEncoded = Buffer.from(JSON.stringify(payload)).toString(
"base64"
);
return `${headerEncoded}.${payloadEncoded}.firma_simulada`;
}
denegarAcceso(res, codigoError, mensaje) {
res.writeHead(401, {
"Content-Type": "application/json",
"WWW-Authenticate": 'Bearer realm="Biblioteca API"',
});
res.end(
JSON.stringify({
error: {
codigo: codigoError,
mensaje,
timestamp: new Date().toISOString(),
},
})
);
}
protegerRuta(permisosRequeridos = []) {
return (req, res) => {
return this.middlewareAutenticacion(req, res, permisosRequeridos);
};
}
}
export const authManager = new AuthManager();
Nuevo módulo auth/userManager.js
Ahora creamos el gestor de usuarios y API Keys, que usa internamente a authManager.
auth/userManager.js
// auth/userManager.js
import { randomBytes } from 'node:crypto';
import { authManager } from './authManager.js';
class UserManager {
constructor(authManagerInstance) {
this.authManager = authManagerInstance;
}
async registrarUsuario(datosUsuario) {
const { email, password, nombre, role = 'user' } = datosUsuario;
if (this.authManager.users.has(email)) {
throw new Error('El usuario ya existe');
}
const usuario = {
id: `user_${Date.now()}`,
email,
passwordHash: this.authManager.hashPassword(password),
nombre,
role,
permissions: this.obtenerPermisosPorRol(role),
isActive: true,
createdAt: new Date(),
updatedAt: new Date()
};
this.authManager.users.set(email, usuario);
return {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre
};
}
async autenticarUsuario(email, password) {
const usuario = this.authManager.users.get(email);
if (!usuario) {
throw new Error('Usuario no encontrado');
}
if (!usuario.isActive) {
throw new Error('Usuario inactivo');
}
const passwordHash = this.authManager.hashPassword(password);
if (usuario.passwordHash !== passwordHash) {
throw new Error('Contraseña incorrecta');
}
const sessionToken = this.authManager.crearSesion(email);
const jwtToken = this.authManager.generarJWT(email);
return {
usuario: {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
role: usuario.role,
permissions: usuario.permissions
},
tokens: {
sessionToken,
jwtToken,
expiresIn: 3600
}
};
}
generarApiKey(nombreCliente, permisosPersonalizados = null, rateLimit = 1000) {
const apiKey = `ak_${randomBytes(16).toString('hex')}`;
const cliente = {
id: `client_${Date.now()}`,
name: nombreCliente,
permissions: permisosPersonalizados || ['read:libros'],
rateLimit,
createdAt: new Date(),
lastUsed: null
};
this.authManager.apiKeys.set(apiKey, cliente);
return {
apiKey,
cliente: {
id: cliente.id,
name: cliente.name,
permissions: cliente.permissions,
rateLimit: cliente.rateLimit
},
advertencia:
'Guarda esta API Key de forma segura. No se podrá recuperar si se pierde.'
};
}
revocarApiKey(apiKey) {
return this.authManager.apiKeys.delete(apiKey);
}
obtenerPermisosPorRol(role) {
const permisosPorRol = {
admin: [
'read:libros',
'write:libros',
'delete:libros',
'manage:users',
'manage:api_keys'
],
user: ['read:libros'],
moderator: ['read:libros', 'write:libros', 'delete:libros']
};
return permisosPorRol[role] || permisosPorRol.user;
}
listarApiKeys() {
const keys = [];
for (const [apiKey, cliente] of this.authManager.apiKeys) {
keys.push({
apiKey: `${apiKey.substring(0, 8)}...`,
cliente: {
id: cliente.id,
name: cliente.name,
permissions: cliente.permissions,
rateLimit: cliente.rateLimit,
createdAt: cliente.createdAt,
lastUsed: cliente.lastUsed
}
});
}
return keys;
}
}
// instancia única
export const userManager = new UserManager(authManager);
Seguimos con la autenticación dentro de tu proyecto modularizado.
Voy a hacerlo en tres piezas claras:
- Crear las rutas de autenticación (
authRoutes.js). - Proteger las rutas de libros (
librosRoutes.js) usando permisos. - Actualizar
server.jspara enrutar/authy/libros.
Así no se hace gigante un solo archivo y puedes leerlo con calma.
Rutas de autenticación: auth/authRoutes.js
Estas rutas gestionan:
- Registro de nuevos usuarios:
POST /auth/registro - Login de usuarios:
POST /auth/login - Gestión de API Keys:
POST /auth/api-keysyGET /auth/api-keys
Se apoyan en:
userManager(registro, login, API keys)authManager(comprobar permisos para gestionar API keys)parsearCuerpo(ya lo tienes)EnhancedResponseHandler(para respuestas consistentes)
Crea el archivo:
auth/authRoutes.js
// auth/authRoutes.js
import { userManager } from './userManager.js';
import { authManager } from './authManager.js';
import { parsearCuerpo } from '../bodyParser.js';
import { EnhancedResponseHandler } from '../http/enhancedResponseHandler.js';
/**
* Maneja las rutas relacionadas con autenticación y gestión de usuarios/API keys
*
* - POST /auth/registro
* - POST /auth/login
* - POST /auth/api-keys
* - GET /auth/api-keys
*/
export async function manejarRutasAuth(req, res, parsedUrl, requestHeaders) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
try {
if (metodo === 'POST' && pathname === '/auth/registro') {
// Registro de usuario básico
const datos = await parsearCuerpo(req);
const resultado = await userManager.registrarUsuario(datos);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 201,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'POST' && pathname === '/auth/login') {
// Login con email + password
const { email, password } = await parsearCuerpo(req);
const resultado = await userManager.autenticarUsuario(
email,
password
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'POST' && pathname === '/auth/api-keys') {
// Crear nueva API Key (requiere permiso manage:api_keys)
await authManager.middlewareAutenticacion(req, res, [
'manage:api_keys'
]);
const { nombre, permisos, rateLimit } = await parsearCuerpo(req);
const resultado = userManager.generarApiKey(
nombre,
permisos,
rateLimit
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 201,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'GET' && pathname === '/auth/api-keys') {
// Listar API Keys (solo administradores con manage:api_keys)
await authManager.middlewareAutenticacion(req, res, [
'manage:api_keys'
]);
const apiKeys = userManager.listarApiKeys();
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: apiKeys,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
// Si no coincide ninguna ruta de auth
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta de autenticación no encontrada' },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
} catch (error) {
console.error('Error en rutas de autenticación:', error);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: error.message },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
}
}
Con esto ya tienes un módulo limpio de rutas de autenticación separado del resto.
Proteger las rutas de libros en librosRoutes.js
Ahora hacemos que tus rutas de /libros exijan permisos según el método:
- GET →
read:libros - POST →
write:libros - PUT/DELETE →
write:librosydelete:libros(o lo que quieras)
Lo más limpio es integrar el middleware de authManager al principio de la función manejarRutaLibros.
Te muestro una versión resumida centrada en la parte de autenticación y en el arranque del switch. El resto de tu lógica (query params, filtros, ETag, etc.) puedes mantenerla igual y solo adaptarla a EnhancedResponseHandler si aún no lo estaba.
Fragmento actualizado de librosRoutes.js
import { leerDatos, guardarDatos } from "./dataStore.js";
import { parsearCuerpo } from "./bodyParser.js";
import { QueryHandler } from "./utils/queryHandler.js";
import { FiltrosHandler } from "./utils/filtrosHandler.js";
import { PaginacionHandler } from "./utils/paginacionHandler.js";
import { EnhancedResponseHandler } from "./http/enhancedResponseHandler.js";
import { calcularETag, manejarCacheCondicional } from "./utils/cacheUtils.js";
import { authManager } from "./auth/authManager.js";
function extraerIdLibro(pathname) {
const partes = pathname.split("/");
return partes[2] ? parseInt(partes[2]) : null;
}
export async function manejarRutaLibros(req, res, parsedUrl, requestHeaders) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
const id = extraerIdLibro(pathname);
try {
let permisosRequeridos = [];
if (metodo === "GET") {
permisosRequeridos = ["read:libros"];
} else if (metodo === "POST") {
permisosRequeridos = ["write:libros"];
} else if (metodo === "PUT") {
permisosRequeridos = ["write:libros"];
} else if (metodo === "DELETE") {
permisosRequeridos = ["delete:libros"];
}
if (permisosRequeridos.length > 0) {
await authManager.middlewareAutenticacion(req, res, permisosRequeridos);
}
const datos = await leerDatos();
switch (metodo) {
case "GET":
if (id) {
const libro = datos.libros.find((l) => l.id === id);
if (!libro) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: "Libro no encontrado" },
versionAPI: requestHeaders.version,
estrategiaCache: "no-cache",
metadata: { ruta: pathname, metodo },
});
return;
}
const etag = calcularETag(libro);
if (manejarCacheCondicional(req, res, datos, etag)) return;
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: libro,
versionAPI: requestHeaders.version,
estrategiaCache: "private",
metadata: { ruta: pathname, metodo },
headersPersonalizados: {
ETag: etag,
"Last-Modified": new Date().toUTCString(),
},
});
} else {
const queryParams = QueryHandler.procesarQueryParams(parsedUrl);
let resultados = FiltrosHandler.aplicarFiltros(
datos.libros,
queryParams
);
resultados = PaginacionHandler.aplicarOrdenamiento(
resultados,
queryParams.sort
);
let { datos: datosPaginados, paginacion } =
PaginacionHandler.aplicarPaginacion(resultados, queryParams);
if (queryParams.fields) {
datosPaginados = PaginacionHandler.seleccionarCampos(
datosPaginados,
queryParams.fields
);
}
const etag = calcularETag(datosPaginados);
if (manejarCacheCondicional(req, res, datos, etag)) return;
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: datosPaginados,
versionAPI: requestHeaders.version,
estrategiaCache: "public",
maxAgeCache: 300,
pagination: paginacion,
metadata: {
ruta: pathname,
metodo,
paginacion,
},
headersPersonalizados: {
ETag: etag,
"Last-Modified": new Date().toUTCString(),
},
});
}
break;
case "POST": {
if (id) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: "ID no permitido en POST" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
const nuevoLibro = await parsearCuerpo(req);
if (!nuevoLibro.titulo || !nuevoLibro.autor) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: {
error: "Los campos titulo y autor son requeridos",
},
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
const maxId = datos.libros.reduce(
(max, libro) => Math.max(max, libro.id),
0
);
nuevoLibro.id = maxId + 1;
nuevoLibro.fechaCreacion = new Date().toISOString();
nuevoLibro.creadoPor =
req.auth?.usuario?.email || req.auth?.cliente?.name || null;
datos.libros.push(nuevoLibro);
await guardarDatos(datos);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 201,
datos: nuevoLibro,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
break;
}
case "PUT": {
if (!id) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: "ID requerido" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
const index = datos.libros.findIndex((l) => l.id === id);
if (index === -1) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: "Libro no encontrado" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
const libroActualizado = await parsearCuerpo(req);
libroActualizado.id = id;
libroActualizado.fechaCreacion = datos.libros[index].fechaCreacion;
libroActualizado.fechaActualizacion = new Date().toISOString();
libroActualizado.actualizadoPor =
req.auth?.usuario?.email || req.auth?.cliente?.name || null;
datos.libros[index] = libroActualizado;
await guardarDatos(datos);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: libroActualizado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
break;
}
case "DELETE": {
if (!id) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: "ID requerido" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
const deleteIndex = datos.libros.findIndex((l) => l.id === id);
if (deleteIndex === -1) {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: "Libro no encontrado" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
return;
}
datos.libros.splice(deleteIndex, 1);
await guardarDatos(datos);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 204,
datos: null,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
break;
}
default:
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 405,
datos: { error: "Método no permitido" },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo },
});
}
} catch (error) {
console.error("Error en manejarRutaLibros:", error);
if (!error.message.toLowerCase().includes("autentic")) {
EnhancedResponseHandler.enviarError(res, {
statusCode: 500,
codigo: "INTERNAL_ERROR",
mensaje: "Error interno del servidor en /libros",
requestId: requestHeaders.requestId,
});
}
}
}
Con esto, toda la API de /libros queda protegida.
Para probar en Hoppscotch:
- Añade header
x-api-key: ak_test_123456para tener permisos de lectura y escritura. - O
x-api-key: ak_admin_789012para permisos de administrador.
Actualizar server.js para enrutar /auth y /libros
Por último, solo falta que el servidor principal conozca estas nuevas rutas.
Ejemplo de server.js integrado:
// server.js
import http from 'node:http';
import { URL } from 'node:url';
import { manejarRutaLibros } from './librosRoutes.js';
import { manejarRutasAuth } from './auth/authRoutes.js';
import { HeadersManager } from './http/headersManager.js';
import { EnhancedResponseHandler } from './http/enhancedResponseHandler.js';
const PORT = 3000;
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const requestHeaders = HeadersManager.procesarRequestHeaders(req);
console.log(
`[REQUEST] ${req.method} ${parsedUrl.pathname} | Client: ${requestHeaders.userAgent} | Version: ${requestHeaders.version}`
);
// OPTIONS global
if (req.method === 'OPTIONS') {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: null,
versionAPI: requestHeaders.version,
metadata: { ruta: parsedUrl.pathname, metodo: req.method }
});
return;
}
try {
// Rutas de autenticación
if (parsedUrl.pathname.startsWith('/auth')) {
await manejarRutasAuth(req, res, parsedUrl, requestHeaders);
return;
}
// Rutas de libros (protegidas con auth en librosRoutes)
if (parsedUrl.pathname.startsWith('/libros')) {
await manejarRutaLibros(req, res, parsedUrl, requestHeaders);
return;
}
// Ruta no encontrada
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta no encontrada' },
versionAPI: requestHeaders.version,
metadata: { ruta: parsedUrl.pathname, metodo: req.method }
});
} catch (error) {
console.error('Error no controlado en server:', error);
EnhancedResponseHandler.enviarError(res, {
statusCode: 500,
codigo: 'INTERNAL_SERVER_ERROR',
mensaje: 'Error interno del servidor',
requestId: requestHeaders.requestId
});
}
});
server.listen(PORT, () => {
console.log(`Servidor RESTful avanzado escuchando en http://localhost:${PORT}`);
});
Con esto, tu API queda:
- Modularizada en capas (datos, HTTP, headers, auth, rutas).
- Protegida con API Keys / Bearer / JWT.
- Con headers avanzados (seguridad, caché, paginación, etc.).
- Con filtros, ordenación, paginación y selección de campos en
/libros.
Pruebas con Hoppscotch
Voy a usar siempre http://localhost:3000 y tus datos de ejemplo:
- Usuario admin:
admin@biblioteca.com/admin123 - Usuario normal:
usuario@ejemplo.com/usuario123 - API Key de ejemplo:
ak_test_123456(permisos de lectura/escritura de libros) - API Key admin:
ak_admin_789012(permisos avanzados)
Antes de empezar: arranca el servidor
En PowerShell o CMD, dentro de la carpeta del proyecto:
npm run dev
Si todo va bien verás algo como:
Servidor RESTful avanzado escuchando en http://localhost:3000
Luego abre Hoppscotch:
https://hoppscotch.io/
Todos los pasos siguientes se hacen ahí.
Escenario 1: Ver que /libros exige autenticación
Objetivo: comprobar que sin credenciales obtienes 401 y con API Key sí puedes entrar.
-
Comprobar acceso sin autenticación
- Método:
GET - URL:
http://localhost:3000/libros - No añadas headers especiales.
- Pulsa
Send. - Resultado esperado:
-
Status:
401 Unauthorized -
Cuerpo: algo como
{
"error": {
"codigo": "NO_AUTENTICADO",
"mensaje": "Se requiere autenticación para acceder a este recurso",
"timestamp": "..."
}
}
-
- Método:
-
Acceso con API Key de prueba
- Método:
GET - URL:
http://localhost:3000/libros - Pestaña
Headers→ añade:- Key:
x-api-key - Value:
ak_test_123456
- Key:
- Pulsa
Send. - Resultado esperado:
- Status:
200 OK - Cuerpo: array de libros desde
libros.json. - Pestaña
Headersde respuesta: verás cosas comoX-API-Version,Cache-Control,X-Total-Count(si tienes paginación activada), etc.
- Status:
- Método:
-
Probar error cambiando la API Key
- Cambia el valor de
x-api-keyaak_test_xxxxx. - Vuelve a enviar.
- Resultado esperado:
401con códigoAPI_KEY_INVALIDA.
- Cambia el valor de
Escenario 2: Crear un libro usando API Key
Objetivo: comprobar un POST /libros protegido con permisos write:libros.
-
En Hoppscotch:
-
Método:
POST -
URL:
http://localhost:3000/libros -
Pestaña
Headers:x-api-key: ak_test_123456Content-Type: application/json(si no lo pone solo, añádelo).
-
Pestaña
Body→ seleccionaJSONe introduce por ejemplo:{
"titulo": "La ciudad y los perros",
"autor": "Mario Vargas Llosa",
"fechaPublicacion": "1963-01-01"
} -
Pulsa
Send.
-
-
Resultado esperado:
-
Status:
201 Created -
Respuesta JSON con algo así:
{
"id": 3,
"titulo": "La ciudad y los perros",
"autor": "Mario Vargas Llosa",
"fechaPublicacion": "1963-01-01",
"fechaCreacion": "2025-...Z",
"creadoPor": "Cliente de Pruebas"
}
-
-
Verificar que se ha guardado
- Cambia a método
GET - URL:
http://localhost:3000/libros - Deja
x-api-key: ak_test_123456. - Pulsa
Send. - Deberías ver el nuevo libro en el listado.
- Cambia a método
Escenario 3: Flujo de usuario con login y Bearer token
Ahora vamos a usar las rutas /auth/... para autenticarnos como usuario y luego usar el token.
3.1 Login con usuario de ejemplo
- Login del admin
-
Método:
POST -
URL:
http://localhost:3000/auth/login -
Headers:
Content-Type: application/json
-
Body → JSON:
{
"email": "admin@biblioteca.com",
"password": "admin123"
} -
Pulsa
Send.
-
- Resultado esperado:
-
Status:
200 OK -
Cuerpo similar a:
{
"usuario": {
"id": "user_001",
"email": "admin@biblioteca.com",
"nombre": "...",
"role": "admin",
"permissions": [
"read:libros",
"write:libros",
"delete:libros",
"manage:users"
]
},
"tokens": {
"sessionToken": "cadena_larga_hex",
"jwtToken": "cabecera.payload.firma_simulada",
"expiresIn": 3600
}
} -
Copia el valor de
sessionTokeny el dejwtTokenporque los usaremos a continuación.
-
3.2 Usar Bearer token para acceder a /libros
- Método:
GET-
URL:
http://localhost:3000/libros -
En
Headersborrax-api-keysi lo tenías y añade:-
Authorization: Bearer <sessionToken>(sustituye
<sessionToken>por el valor real que copiaste).
-
-
Pulsa
Send.
-
- Resultado esperado:
- Status:
200 OK. - Listado de libros.
- Internamente, la ruta sabe quién eres a través de
req.auth.usuario.
- Status:
3.3 Usar JWT para acceder a un libro concreto
- Método:
GET-
URL:
http://localhost:3000/libros/1 -
Headers:
-
Authorization: JWT <jwtToken>(pon el token completo devuelto en el login).
-
-
Pulsa
Send.
-
- Resultado esperado:
- Status:
200 OKsi existe el libro 1. - Si no existe, devolverá
404 Libro no encontrado.
- Status:
Escenario 4: Gestión de usuarios y API Keys vía /auth
Aquí usas authRoutes.js más en serio.
4.1 Registrar un nuevo usuario
- Método:
POST-
URL:
http://localhost:3000/auth/registro -
Headers:
Content-Type: application/json
-
Body:
{
"email": "nuevo@ejemplo.com",
"password": "clave123",
"nombre": "Nuevo Usuario",
"role": "user"
} -
Pulsa
Send.
-
- Resultado esperado:
201 Createdcon los datos básicos del nuevo usuario (sin tokens aún).
Puedes luego hacer login con él igual que con el admin, cambiando email y password.
4.2 Crear una API Key nueva como admin
Esta operación requiere el permiso manage:api_keys, por eso usamos el admin.
-
Primero asegúrate de tener un token válido de admin (
sessionTokenojwtToken).Podemos usar la API Key admin de ejemplo para crear la nueva key, que también tiene
manage:api_keys. -
Crear API Key con API Key admin
-
Método:
POST -
URL:
http://localhost:3000/auth/api-keys -
Headers:
x-api-key: ak_admin_789012Content-Type: application/json
-
Body:
{
"nombre": "Cliente Frontend React",
"permisos": ["read:libros"],
"rateLimit": 2000
} -
Pulsa
Send.
-
-
Resultado esperado:
-
201 Created -
Cuerpo con algo así:
{
"apiKey": "ak_abcd1234efgh5678...",
"cliente": {
"id": "client_...",
"name": "Cliente Frontend React",
"permissions": ["read:libros"],
"rateLimit": 2000
},
"advertencia": "Guarda esta API Key de forma segura..."
}
-
Guarda esta nueva API Key y pruébala en un GET /libros igual que hiciste con ak_test_123456.
4.3 Listar API Keys (solo admin)
- Método:
GET-
URL:
http://localhost:3000/auth/api-keys -
Headers:
-
x-api-key: ak_admin_789012o bien
Authorization: Bearer <sessionToken_admin>
-
-
Pulsa
Send.
-
- Resultado esperado:
200 OK- Listado de API Keys truncadas (solo ves parte de la clave por seguridad).
Escenario 5: Comprobar errores típicos
Algunos tests rápidos que son interesantes para alumnos:
GET /librossinx-api-keyniAuthorization→ 401.POST /libroscon una API Key que solo tengaread:libros→ debería devolver error de permisos insuficientes (según cómo tengas definidos los permisos).GET /libroscon headerIf-None-Matchigual alETagdevuelto en una respuesta anterior → deberías ver un304 Not Modified.
Si quieres, el siguiente paso puede ser convertir este guion en:
- Un documento tipo práctica guiada para alumnos (enunciado + soluciones).
- O un “checklist” breve que ellos tengan que ir marcando mientras usan Hoppscotch.
Opcional: Autenticación de dos factores 2FA
Ahora, vamos a integrar el 2FA igual que hicimos con headers y auth, pero sin que el proyecto se convierta en un monstruo.
Voy a dividirlo en dos bloques para que sea legible:
- Bloque A: nuevos módulos (
twoFactorAuth,authWith2FA, rutas 2FA). - Bloque B: cómo encajarlos en
authRoutes.jsyserver.js, indicando exactamente qué tocar.
Puedes copiar/pegar por archivos sin perderte.
Bloque A · Nuevos módulos de 2FA
1) auth/twoFactorAuth.js
Este módulo implementa la lógica de TOTP, códigos de respaldo, intentos, dispositivos confiables, etc., pero adaptado al servidor (sin navigator ni cosas de navegador).
Crea el archivo:
auth/twoFactorAuth.js
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { authManager } from "./authManager.js";
class TOTPGenerator {
constructor() {
this.step = 30;
this.window = 1;
}
generarSecret() {
return randomBytes(20).toString("hex");
}
generarCodigo(secret) {
const time = Math.floor(Date.now() / 1000 / this.step);
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(time), 0);
const secretKey = Buffer.from(secret, "hex");
const hmac = createHmac("sha1", secretKey);
hmac.update(timeBuffer);
const hmacResult = hmac.digest();
const offset = hmacResult[hmacResult.length - 1] & 0xf;
const code =
(((hmacResult[offset] & 0x7f) << 24) |
((hmacResult[offset + 1] & 0xff) << 16) |
((hmacResult[offset + 2] & 0xff) << 8) |
(hmacResult[offset + 3] & 0xff)) %
1000000;
return code.toString().padStart(6, "0");
}
verificarCodigo(token, secret) {
for (let i = -this.window; i <= this.window; i++) {
const time = Math.floor(Date.now() / 1000 / this.step) + i;
const timeBuffer = Buffer.alloc(8);
timeBuffer.writeBigInt64BE(BigInt(time), 0);
const secretKey = Buffer.from(secret, "hex");
const hmac = createHmac("sha1", secretKey);
hmac.update(timeBuffer);
const hmacResult = hmac.digest();
const offset = hmacResult[hmacResult.length - 1] & 0xf;
const code =
(((hmacResult[offset] & 0x7f) << 24) |
((hmacResult[offset + 1] & 0xff) << 16) |
((hmacResult[offset + 2] & 0xff) << 8) |
(hmacResult[offset + 3] & 0xff)) %
1000000;
const expectedToken = code.toString().padStart(6, "0");
const tokenBuffer = Buffer.from(token);
const expectedBuffer = Buffer.from(expectedToken);
if (
tokenBuffer.length === expectedBuffer.length &&
timingSafeEqual(tokenBuffer, expectedBuffer)
) {
return true;
}
}
return false;
}
generarQRUrl(usuario, secret, issuer = "Biblioteca API") {
const encodedUsuario = encodeURIComponent(usuario);
const encodedIssuer = encodeURIComponent(issuer);
const encodedSecret = encodeURIComponent(secret);
return `otpauth://totp/${encodedIssuer}:${encodedUsuario}?secret=${encodedSecret}&issuer=${encodedIssuer}&algorithm=SHA1&digits=6&period=30`;
}
}
class TwoFactorAuth {
constructor(authManagerInstance) {
this.authManager = authManagerInstance;
this.totp = new TOTPGenerator();
this.codesBackup = new Map();
this.pending2FA = new Map();
this.trustedDevices = new Map();
this.config = {
requerir2FAParaAdmin: true,
permitirDispositivosConfiables: true,
duracionDispositivoConfiable: 30 * 24 * 60 * 60 * 1000,
maxIntentos2FA: 3,
bloqueoTemporal2FA: 15 * 60 * 1000,
};
}
generarCodigosRespaldo(cantidad = 8) {
const codes = [];
for (let i = 0; i < cantidad; i++) {
const code = randomBytes(5).toString("hex").toUpperCase();
codes.push(code);
}
return codes;
}
hashCode(code) {
return createHmac("sha256", "2fa-backup-codes").update(code).digest("hex");
}
async habilitar2FA(email) {
const usuario = this.authManager.users.get(email);
if (!usuario) throw new Error("Usuario no encontrado");
const secret = this.totp.generarSecret();
const backupCodes = this.generarCodigosRespaldo();
usuario.twoFactorAuth = {
enabled: true,
secret,
backupCodes: backupCodes.map((code) => ({
code: this.hashCode(code),
used: false,
usedAt: null,
})),
enabledAt: new Date(),
lastUsed: null,
};
const qrUrl = this.totp.generarQRUrl(email, secret);
return {
secret,
qrUrl,
backupCodes,
mensaje: "Guarda los códigos de respaldo en un lugar seguro",
};
}
async deshabilitar2FA(email, password) {
const usuario = this.authManager.users.get(email);
if (!usuario) throw new Error("Usuario no encontrado");
const passwordHash = this.authManager.hashPassword(password);
if (usuario.passwordHash !== passwordHash) {
throw new Error("Contraseña incorrecta");
}
if (!usuario.twoFactorAuth?.enabled) {
throw new Error("2FA no está habilitado");
}
usuario.twoFactorAuth.enabled = false;
usuario.twoFactorAuth.disabledAt = new Date();
return { mensaje: "2FA deshabilitado correctamente" };
}
verificarCodigoRespaldo(email, code) {
const usuario = this.authManager.users.get(email);
const backupCodes = usuario.twoFactorAuth.backupCodes;
const codeHash = this.hashCode(code);
const idx = backupCodes.findIndex(
(backup) => !backup.used && backup.code === codeHash
);
if (idx !== -1) {
backupCodes[idx].used = true;
backupCodes[idx].usedAt = new Date();
return true;
}
return false;
}
verificarIntentos2FA(email) {
const ahora = Date.now();
const intentos = this.pending2FA.get(email) || [];
const intentosRecientes = intentos.filter(
(intento) => ahora - intento.timestamp < 60 * 60 * 1000
);
const intentosFallidos = intentosRecientes.filter((i) => !i.exitoso);
if (intentosFallidos.length >= this.config.maxIntentos2FA) {
const ultimoIntento = Math.max(
...intentosFallidos.map((i) => i.timestamp)
);
if (ahora - ultimoIntento < this.config.bloqueoTemporal2FA) {
return false;
}
}
return true;
}
registrarIntento2FA(email, exitoso, metodo, contexto) {
const intentos = this.pending2FA.get(email) || [];
intentos.push({
timestamp: Date.now(),
exitoso,
metodo,
ip: contexto.ip,
userAgent: contexto.userAgent,
deviceId: contexto.deviceId,
});
if (intentos.length > 50) {
intentos.splice(0, intentos.length - 50);
}
this.pending2FA.set(email, intentos);
}
registrarDispositivoConfiable(email, deviceId, userAgent) {
const dispositivos = this.trustedDevices.get(email) || [];
const existente = dispositivos.find((d) => d.deviceId === deviceId);
if (existente) {
existente.lastUsed = new Date();
} else {
dispositivos.push({
deviceId,
firstUsed: new Date(),
lastUsed: new Date(),
userAgent,
});
}
this.trustedDevices.set(email, dispositivos);
}
esDispositivoConfiable(email, deviceId) {
if (!this.config.permitirDispositivosConfiables) return false;
const dispositivos = this.trustedDevices.get(email) || [];
const dispositivo = dispositivos.find((d) => d.deviceId === deviceId);
if (!dispositivo) return false;
const expiracion = new Date(
dispositivo.lastUsed.getTime() + this.config.duracionDispositivoConfiable
);
return new Date() <= expiracion;
}
generarDeviceId(req) {
const components = [
req.headers["user-agent"] || "",
req.headers["accept-language"] || "",
req.socket.remoteAddress || "",
];
return createHmac("sha256", "device-id")
.update(components.join("|"))
.digest("hex")
.substring(0, 16);
}
verificarCodigo2FA(email, token, contexto) {
const usuario = this.authManager.users.get(email);
if (!usuario?.twoFactorAuth?.enabled) {
throw new Error("2FA no configurado");
}
if (!this.verificarIntentos2FA(email)) {
throw new Error(
"Demasiados intentos fallidos. Intenta de nuevo más tarde."
);
}
let valido = false;
let metodo = "";
if (token.length === 6 && /^\d+$/.test(token)) {
valido = this.totp.verificarCodigo(token, usuario.twoFactorAuth.secret);
metodo = "totp";
} else if (token.length === 10 && /^[A-Z0-9]+$/.test(token)) {
valido = this.verificarCodigoRespaldo(email, token);
metodo = "backup";
}
if (valido) {
this.registrarIntento2FA(email, true, metodo, contexto);
usuario.twoFactorAuth.lastUsed = new Date();
if (contexto.trustDevice && this.config.permitirDispositivosConfiables) {
this.registrarDispositivoConfiable(
email,
contexto.deviceId,
contexto.userAgent
);
}
return { valido: true, metodo };
}
this.registrarIntento2FA(email, false, metodo, contexto);
throw new Error("Código 2FA inválido");
}
obtenerUbicacionAproximada(req) {
const ip = req.socket.remoteAddress || "";
const ubicaciones = {
"127.0.0.1": "Localhost",
"192.168.": "Red Local",
"10.0.": "Red Privada",
};
for (const [prefix, location] of Object.entries(ubicaciones)) {
if (ip.startsWith(prefix)) return location;
}
return "Ubicación desconocida";
}
async middleware2FA(req, res, usuario) {
if (!usuario.twoFactorAuth?.enabled) {
return true;
}
const deviceId = this.generarDeviceId(req);
if (this.esDispositivoConfiable(usuario.email, deviceId)) {
console.log(
`Dispositivo confiable detectado para ${usuario.email} (${deviceId})`
);
return true;
}
const session2FA = {
email: usuario.email,
deviceId,
timestamp: Date.now(),
intentos: 0,
contexto: {
ip: req.socket.remoteAddress,
userAgent: req.headers["user-agent"],
location: this.obtenerUbicacionAproximada(req),
},
};
const sessionId = randomBytes(32).toString("hex");
this.pending2FA.set(sessionId, session2FA);
this.requerir2FA(res, sessionId, usuario.email);
return false;
}
async manejarVerificacion2FA(sessionId, token, trustDevice = false) {
const session2FA = this.pending2FA.get(sessionId);
if (!session2FA) throw new Error("Sesión 2FA no encontrada o expirada");
if (Date.now() - session2FA.timestamp > 10 * 60 * 1000) {
this.pending2FA.delete(sessionId);
throw new Error("Sesión 2FA expirada");
}
if (session2FA.intentos >= 3) {
this.pending2FA.delete(sessionId);
throw new Error("Demasiados intentos fallidos");
}
try {
const contexto = {
...session2FA.contexto,
trustDevice,
deviceId: session2FA.deviceId,
};
const resultado = this.verificarCodigo2FA(
session2FA.email,
token,
contexto
);
if (resultado.valido) {
this.pending2FA.delete(sessionId);
const sessionToken = this.authManager.crearSesion(session2FA.email);
const jwtToken = this.authManager.generarJWT(session2FA.email);
return {
verificado: true,
tokens: {
sessionToken,
jwtToken,
expiresIn: 3600,
},
metodo2FA: resultado.metodo,
esDispositivoNuevo: !trustDevice,
};
}
} catch (error) {
session2FA.intentos++;
throw error;
}
}
requerir2FA(res, sessionId, email) {
const usuario = this.authManager.users.get(email);
const metodosDisponibles = [];
if (usuario.twoFactorAuth?.enabled) {
metodosDisponibles.push("authenticator");
const backupDisponibles =
usuario.twoFactorAuth.backupCodes?.filter((c) => !c.used).length || 0;
if (backupDisponibles > 0) {
metodosDisponibles.push("backup_codes");
}
}
res.writeHead(200, {
"Content-Type": "application/json",
});
res.end(
JSON.stringify({
requiere2FA: true,
sessionId,
email,
metodosDisponibles,
mensaje: "Se requiere autenticación de dos factores",
trustedDeviceEnabled: this.config.permitirDispositivosConfiables,
})
);
}
obtenerEstadisticas2FA(email) {
const usuario = this.authManager.users.get(email);
if (!usuario?.twoFactorAuth) return { habilitado: false };
const backupDisponibles = usuario.twoFactorAuth.backupCodes.filter(
(code) => !code.used
).length;
const dispositivos = this.trustedDevices.get(email) || [];
const activos = dispositivos.filter((device) => {
const expiracion = new Date(
device.lastUsed.getTime() + this.config.duracionDispositivoConfiable
);
return new Date() <= expiracion;
});
return {
habilitado: usuario.twoFactorAuth.enabled,
habilitadoDesde: usuario.twoFactorAuth.enabledAt,
ultimoUso: usuario.twoFactorAuth.lastUsed,
backupCodesDisponibles: backupDisponibles,
totalBackupCodes: usuario.twoFactorAuth.backupCodes.length,
dispositivosConfiables: activos.length,
metodosDisponibles: ["authenticator", "backup_codes"],
};
}
revocarDispositivoConfiable(email, deviceId) {
const dispositivos = this.trustedDevices.get(email) || [];
const nuevos = dispositivos.filter((d) => d.deviceId !== deviceId);
this.trustedDevices.set(email, nuevos);
return { mensaje: "Dispositivo revocado correctamente" };
}
generarNuevosCodigosRespaldo(email, password) {
const usuario = this.authManager.users.get(email);
if (!usuario) throw new Error("Usuario no encontrado");
const passwordHash = this.authManager.hashPassword(password);
if (usuario.passwordHash !== passwordHash) {
throw new Error("Contraseña incorrecta");
}
if (!usuario.twoFactorAuth?.enabled) {
throw new Error("2FA no está habilitado");
}
const nuevosCodigos = this.generarCodigosRespaldo();
usuario.twoFactorAuth.backupCodes = nuevosCodigos.map((code) => ({
code: this.hashCode(code),
used: false,
usedAt: null,
}));
return {
backupCodes: nuevosCodigos,
mensaje:
"Nuevos códigos de respaldo generados. Los anteriores dejan de ser válidos.",
};
}
}
export const twoFactorAuth = new TwoFactorAuth(authManager);
2) auth/authWith2FA.js
Este módulo adapta el flujo de login para que:
- Primero compruebe email + password.
- Luego decida si debe requerir 2FA (y devuelve
requiere2FA: true). - O devuelva directamente los tokens si no se requiere 2FA o el dispositivo es confiable.
auth/authWith2FA.js
// auth/authWith2FA.js
import { authManager } from './authManager.js';
import { twoFactorAuth } from './twoFactorAuth.js';
class AuthWith2FA {
constructor(authManagerInstance, twoFactorInstance) {
this.authManager = authManagerInstance;
this.twoFactorAuth = twoFactorInstance;
}
async loginCon2FA(email, password, req, res) {
const usuario = this.authManager.users.get(email);
if (!usuario) throw new Error('Usuario no encontrado');
const passwordHash = this.authManager.hashPassword(password);
if (usuario.passwordHash !== passwordHash) {
throw new Error('Contraseña incorrecta');
}
if (!usuario.isActive) {
throw new Error('Usuario inactivo');
}
const requiere2FA = await this.twoFactorAuth.middleware2FA(
req,
res,
usuario
);
if (!requiere2FA) {
return { requiere2FA: true };
}
const sessionToken = this.authManager.crearSesion(email);
const jwtToken = this.authManager.generarJWT(email);
return {
requiere2FA: false,
usuario: {
id: usuario.id,
email: usuario.email,
nombre: usuario.nombre,
role: usuario.role,
permissions: usuario.permissions,
twoFactorEnabled: usuario.twoFactorAuth?.enabled || false
},
tokens: {
sessionToken,
jwtToken,
expiresIn: 3600
}
};
}
async verificar2FALogin(sessionId, token, trustDevice = false) {
return await this.twoFactorAuth.manejarVerificacion2FA(
sessionId,
token,
trustDevice
);
}
async middlewareAuthCon2FA(req, res, permisosRequeridos = []) {
await this.authManager.middlewareAutenticacion(
req,
res,
permisosRequeridos
);
// Aquí podrías forzar re-2FA para operaciones muy sensibles.
return req;
}
}
export const authWith2FA = new AuthWith2FA(authManager, twoFactorAuth);
3) Rutas específicas de 2FA: auth/twoFactorRoutes.js
Aquí gestionamos:
/2fa/enable/2fa/disable/2fa/status/2fa/regenerate-backup-codes/2fa/trusted-devices(GET / DELETE)/auth/verify-2fapara completar login con 2FA.
auth/twoFactorRoutes.js
// auth/twoFactorRoutes.js
import { twoFactorAuth } from './twoFactorAuth.js';
import { authWith2FA } from './authWith2FA.js';
import { authManager } from './authManager.js';
import { parsearCuerpo } from '../bodyParser.js';
import { EnhancedResponseHandler } from '../http/enhancedResponseHandler.js';
/**
* Rutas /2fa/... (requieren estar autenticado con sesión normal)
*/
export async function manejarRutas2FA(req, res, parsedUrl, requestHeaders) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
try {
await authManager.middlewareAutenticacion(req, res);
const email =
req.auth?.usuario?.email || req.auth?.cliente?.email || null;
if (!email) {
throw new Error('No se pudo determinar el usuario autenticado');
}
if (metodo === 'POST' && pathname === '/2fa/enable') {
const { password } = await parsearCuerpo(req);
const resultado = await twoFactorAuth.habilitar2FA(email);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'POST' && pathname === '/2fa/disable') {
const { password } = await parsearCuerpo(req);
const resultado = await twoFactorAuth.deshabilitar2FA(
email,
password
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'GET' && pathname === '/2fa/status') {
const stats = twoFactorAuth.obtenerEstadisticas2FA(email);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: stats,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (
metodo === 'POST' &&
pathname === '/2fa/regenerate-backup-codes'
) {
const { password } = await parsearCuerpo(req);
const resultado =
twoFactorAuth.generarNuevosCodigosRespaldo(email, password);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'GET' && pathname === '/2fa/trusted-devices') {
const dispositivos =
twoFactorAuth.trustedDevices.get(email) || [];
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: { dispositivos },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (
metodo === 'DELETE' &&
pathname.startsWith('/2fa/trusted-devices/')
) {
const partes = pathname.split('/');
const deviceId = partes[partes.length - 1];
const resultado = twoFactorAuth.revocarDispositivoConfiable(
email,
deviceId
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta 2FA no encontrada' },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
} catch (error) {
console.error('Error en rutas 2FA:', error);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: error.message },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
}
}
/**
* POST /auth/verify-2fa
* Completa el login después de introducir el código 2FA
*/
export async function manejarVerificacion2FALogin(
req,
res,
parsedUrl,
requestHeaders
) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
if (metodo !== 'POST' || pathname !== '/auth/verify-2fa') {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 405,
datos: { error: 'Método no permitido' },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
try {
const { sessionId, token, trustDevice = false } = await parsearCuerpo(
req
);
const resultado = await authWith2FA.verificar2FALogin(
sessionId,
token,
trustDevice
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
} catch (error) {
console.error('Error en verificación 2FA login:', error);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: error.message },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
}
}
Bloque B · Integración con authRoutes.js y server.js
4) Cambiar el login en auth/authRoutes.js para usar 2FA
Ahora solo tenemos que modificar la parte de /auth/login para que use authWith2FA.loginCon2FA. El resto de rutas (/auth/registro, /auth/api-keys) pueden quedarse igual.
En tu auth/authRoutes.js, ajusta la ruta de login así:
// auth/authRoutes.js
import { userManager } from './userManager.js';
import { authManager } from './authManager.js';
import { authWith2FA } from './authWith2FA.js';
import { parsearCuerpo } from '../bodyParser.js';
import { EnhancedResponseHandler } from '../http/enhancedResponseHandler.js';
export async function manejarRutasAuth(req, res, parsedUrl, requestHeaders) {
const metodo = req.method;
const pathname = parsedUrl.pathname;
try {
if (metodo === 'POST' && pathname === '/auth/registro') {
const datos = await parsearCuerpo(req);
const resultado = await userManager.registrarUsuario(datos);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 201,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'POST' && pathname === '/auth/login') {
const { email, password } = await parsearCuerpo(req);
const resultado = await authWith2FA.loginCon2FA(
email,
password,
req,
res
);
if (resultado.requiere2FA) {
// El middleware 2FA ya ha enviado la respuesta JSON
return;
}
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'POST' && pathname === '/auth/api-keys') {
await authManager.middlewareAutenticacion(req, res, [
'manage:api_keys'
]);
const { nombre, permisos, rateLimit } = await parsearCuerpo(req);
const resultado = userManager.generarApiKey(
nombre,
permisos,
rateLimit
);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 201,
datos: resultado,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
if (metodo === 'GET' && pathname === '/auth/api-keys') {
await authManager.middlewareAutenticacion(req, res, [
'manage:api_keys'
]);
const apiKeys = userManager.listarApiKeys();
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: apiKeys,
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
return;
}
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta de autenticación no encontrada' },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
} catch (error) {
console.error('Error en rutas de autenticación:', error);
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 400,
datos: { error: error.message },
versionAPI: requestHeaders.version,
metadata: { ruta: pathname, metodo }
});
}
}
5) Integrar rutas 2FA en server.js
Solo tienes que enchufar las nuevas rutas manejarRutas2FA y manejarVerificacion2FALogin.
En tu server.js:
// server.js
import http from 'node:http';
import { URL } from 'node:url';
import { manejarRutaLibros } from './librosRoutes.js';
import { manejarRutasAuth } from './auth/authRoutes.js';
import {
manejarRutas2FA,
manejarVerificacion2FALogin
} from './auth/twoFactorRoutes.js';
import { HeadersManager } from './http/headersManager.js';
import { EnhancedResponseHandler } from './http/enhancedResponseHandler.js';
const PORT = 3000;
const server = http.createServer(async (req, res) => {
const parsedUrl = new URL(req.url, `http://${req.headers.host}`);
const requestHeaders = HeadersManager.procesarRequestHeaders(req);
console.log(
`[REQUEST] ${req.method} ${parsedUrl.pathname} | Client: ${requestHeaders.userAgent} | Version: ${requestHeaders.version}`
);
if (req.method === 'OPTIONS') {
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 200,
datos: null,
versionAPI: requestHeaders.version,
metadata: { ruta: parsedUrl.pathname, metodo: req.method }
});
return;
}
try {
// Login + registro + API keys
if (parsedUrl.pathname.startsWith('/auth')) {
// POST /auth/verify-2fa se gestiona aparte
if (
parsedUrl.pathname === '/auth/verify-2fa' &&
req.method === 'POST'
) {
await manejarVerificacion2FALogin(
req,
res,
parsedUrl,
requestHeaders
);
return;
}
await manejarRutasAuth(req, res, parsedUrl, requestHeaders);
return;
}
// Rutas de gestión de 2FA (habilitar, deshabilitar, status, backup, devices)
if (parsedUrl.pathname.startsWith('/2fa/')) {
await manejarRutas2FA(req, res, parsedUrl, requestHeaders);
return;
}
// Rutas de libros
if (parsedUrl.pathname.startsWith('/libros')) {
await manejarRutaLibros(req, res, parsedUrl, requestHeaders);
return;
}
EnhancedResponseHandler.enviarRespuesta(res, {
statusCode: 404,
datos: { error: 'Ruta no encontrada' },
versionAPI: requestHeaders.version,
metadata: { ruta: parsedUrl.pathname, metodo: req.method }
});
} catch (error) {
console.error('Error no controlado en server:', error);
EnhancedResponseHandler.enviarError(res, {
statusCode: 500,
codigo: 'INTERNAL_SERVER_ERROR',
mensaje: 'Error interno del servidor',
requestId: requestHeaders.requestId
});
}
});
server.listen(PORT, () => {
console.log(`Servidor RESTful avanzado con 2FA en http://localhost:${PORT}`);
});
Pruebas con Hoppscotch
Voy a suponer:
- Servidor levantado en
http://localhost:3000 - Usuario admin de ejemplo:
email: admin@biblioteca.compassword: admin123
- El código de 2FA ya está integrado como te expliqué en los módulos anteriores.
La idea es probar este flujo:
- Login normal sin 2FA
- Habilitar 2FA
- Volver a hacer login → ahora pedirá 2FA
- Verificar 2FA con
/auth/verify-2fa - Consultar estado, códigos de respaldo y dispositivos de confianza
- Deshabilitar 2FA (opcional para pruebas)
Todo con Hoppscotch.
0. Arrancar el servidor
En PowerShell o CMD, dentro del proyecto:
npm run dev
Comprueba que sale algo tipo:
Servidor RESTful avanzado con 2FA en http://localhost:3000
Luego abre Hoppscotch:
- URL:
https://hoppscotch.io/ - Trabajaremos siempre con
http://localhost:3000.
1. Login normal sin 2FA (estado inicial)
Objetivo: confirmar que el usuario entra sin que se pida 2FA.
-
En Hoppscotch:
- Método:
POST - URL:
http://localhost:3000/auth/login
- Método:
-
Pestaña
Headers- Añade:
Content-Type: application/json
- Añade:
-
Pestaña
Body→ seleccionaJSONy pon:{
"email": "admin@biblioteca.com",
"password": "admin123"
} -
Pulsa
Send. -
Resultado esperado (si el usuario aún no tiene 2FA):
-
Status:
200 OK -
Respuesta parecida a:
{
"requiere2FA": false,
"usuario": {
"id": "user_001",
"email": "admin@biblioteca.com",
"nombre": "Admin",
"role": "admin",
"permissions": ["read:libros", "write:libros", "delete:libros", "manage:users", "manage:api_keys"],
"twoFactorEnabled": false
},
"tokens": {
"sessionToken": "cadena_larga...",
"jwtToken": "cabecera.payload.firma",
"expiresIn": 3600
}
}
-
-
Guarda el
sessionToken, lo necesitaremos para habilitar 2FA.
2. Habilitar 2FA para el usuario
Ahora vamos a activar 2FA para el admin.
Esta ruta exige estar autenticado, así que usaremos el sessionToken en el header Authorization.
-
En Hoppscotch:
- Método:
POST - URL:
http://localhost:3000/2fa/enable
- Método:
-
Pestaña
Headers:-
Content-Type: application/json -
Authorization: Bearer TU_SESSION_TOKEN(sustituye
TU_SESSION_TOKENpor el valor real recibido en el login)
-
-
Pestaña
Body→JSON:Aunque la implementación actual de
habilitar2FAno usa la contraseña que recibimos en la ruta, seguimos el contrato original:{
"password": "admin123"
} -
Pulsa
Send. -
Resultado esperado:
-
Status:
200 OK -
Cuerpo de respuesta similar a:
{
"secret": "SECRETO_HEX_DEMO",
"qrUrl": "otpauth://totp/Biblioteca%20API:admin%40biblioteca.com?secret=SECRETO_HEX_DEMO&...",
"backupCodes": [
"C0D1G0A1",
"C0D1G0B2",
"...",
"..."
],
"mensaje": "Guarda los códigos de respaldo en un lugar seguro"
}
-
-
Qué hacer con esto:
qrUrl: puedes copiarlo en un generador de QR online y escanearlo con Google Authenticator / Authy / Aegis, etc.secret: también sirve para configurarlo manualmente si la app lo permite.backupCodes: guárdalos en algún sitio (son tu “plan B” si pierdes el móvil).
A partir de ahora, el usuario tiene 2FA habilitado.
3. Volver a probar login: ahora debe pedir 2FA
Repetimos el login para ver que ya no devuelve tokens directamente, sino que pide el código 2FA.
-
En Hoppscotch:
- Método:
POST - URL:
http://localhost:3000/auth/login
- Método:
-
Headers:Content-Type: application/json
-
Body→JSON:{
"email": "admin@biblioteca.com",
"password": "admin123"
} -
Pulsa
Send. -
Ahora hay dos posibilidades:
-
Si es un dispositivo nuevo (no confiable), el middleware 2FA responde directamente algo como:
{
"requiere2FA": true,
"sessionId": "ID_DE_LA_SESION_2FA",
"email": "admin@biblioteca.com",
"metodosDisponibles": ["authenticator", "backup_codes"],
"mensaje": "Se requiere autenticación de dos factores",
"trustedDeviceEnabled": true
}En este caso,
authWith2FA.loginCon2FAdevuelve{ requiere2FA: true }y la respuesta real es la del middleware, que Hoppscotch ya está viendo. -
Si se considera dispositivo confiable (por pruebas previas o lógica concreta), podría seguir devolviendo
requiere2FA: falsey tokens como antes.
Para el escenario didáctico, nos interesa el primer caso:
requiere2FA: true. -
-
Copia el
sessionIdque aparece en la respuesta. Lo usaremos en el siguiente paso.
4. Verificar el código 2FA con /auth/verify-2fa
Ahora simulamos lo que haría la interfaz de usuario cuando te pide el código del autenticador.
-
Abre tu app de autenticación (Google Authenticator, Authy, etc.) donde hayas añadido la cuenta usando la
qrUrlo elsecret. -
Obtén el código de 6 dígitos que aparece (cambia cada 30 segundos).
-
En Hoppscotch:
- Método:
POST - URL:
http://localhost:3000/auth/verify-2fa
- Método:
-
Headers:Content-Type: application/json
-
Body→JSON:{
"sessionId": "EL_SESSION_ID_QUE_COPIASTE",
"token": "123456",
"trustDevice": true
}Sustituye:
"EL_SESSION_ID_QUE_COPIASTE"por el valor real"123456"por el código actual que te muestra el autenticador
-
Pulsa
Send. -
Resultado esperado:
-
Status:
200 OK -
Cuerpo tipo:
{
"verificado": true,
"tokens": {
"sessionToken": "session_token_nuevo",
"jwtToken": "jwt_nuevo",
"expiresIn": 3600
},
"metodo2FA": "totp",
"esDispositivoNuevo": true
}
-
-
Copia el nuevo
sessionToken. A partir de aquí, puedes usarlo como siempre para:GET http://localhost:3000/librosconAuthorization: Bearer session_token_nuevo- Cualquier ruta protegida, etc.
Si pones un token incorrecto, deberías recibir un 400 con Código 2FA inválido y el sistema irá contando intentos.
5. Consultar estado de 2FA, backup codes y dispositivos confiables
Ahora probamos las rutas de gestión de 2FA. Todas requieren autenticación normal (Bearer o JWT).
5.1 Ver estado de 2FA
-
Método:
GET- URL:
http://localhost:3000/2fa/status
- URL:
-
Headers:Authorization: Bearer session_token_nuevoContent-Type: application/json(opcional en GET)
-
Pulsa
Send. -
Resultado esperado:
{
"habilitado": true,
"habilitadoDesde": "2025-...Z",
"ultimoUso": "2025-...Z",
"backupCodesDisponibles": 8,
"totalBackupCodes": 8,
"dispositivosConfiables": 1,
"metodosDisponibles": ["authenticator", "backup_codes"]
}
5.2 Regenerar códigos de respaldo
-
Método:
POST- URL:
http://localhost:3000/2fa/regenerate-backup-codes
- URL:
-
Headers:Authorization: Bearer session_token_nuevoContent-Type: application/json
-
Body:{
"password": "admin123"
} -
Send. -
Resultado esperado:
{
"backupCodes": [
"NUEVO1",
"NUEVO2",
"...",
"..."
],
"mensaje": "Nuevos códigos de respaldo generados. Los códigos anteriores ya no son válidos."
}
5.3 Ver dispositivos confiables
-
Método:
GET- URL:
http://localhost:3000/2fa/trusted-devices
- URL:
-
Headers:Authorization: Bearer session_token_nuevo
-
Send. -
Respuesta esperada:
{
"dispositivos": [
{
"deviceId": "abcdef1234567890",
"firstUsed": "2025-...Z",
"lastUsed": "2025-...Z",
"userAgent": "Mozilla/5.0 ..."
}
]
}
5.4 Revocar un dispositivo confiable
-
Coge uno de los
deviceIdde la respuesta anterior. -
Método:
DELETE-
URL:
http://localhost:3000/2fa/trusted-devices/abcdef1234567890(sustituye por el
deviceIdreal)
-
-
Headers:Authorization: Bearer session_token_nuevo
-
Send. -
Resultado:
{
"mensaje": "Dispositivo revocado correctamente"
}
6. Deshabilitar 2FA (limpiar para pruebas)
Si quieres “volver atrás” y dejar el usuario sin 2FA:
-
Método:
POST- URL:
http://localhost:3000/2fa/disable
- URL:
-
Headers:Authorization: Bearer session_token_nuevoContent-Type: application/json
-
Body:{
"password": "admin123"
} -
Send. -
Resultado:
{
"mensaje": "2FA deshabilitado correctamente"
} -
Si ahora repites el login en
/auth/login, el usuario volverá a entrar sinrequiere2FA.