Apariencia
📙 Clase 17 — Crear una API con TypeScript y Express, paso a paso
TypeScript · 2026-08-07 · Carpeta:
02-Ejercicios/API⬅️ Volver al índice de clases
🎯 Qué aprendí
- Qué es Node.js en realidad (un runtime, no un framework) y en qué se diferencia de Express.
- Por qué TypeScript encaja tan bien con Express: tipar los datos que fluyen por la API reduce errores en tiempo de ejecución y mejora el autocompletado.
- El flujo completo de un proyecto API desde cero:
npm init -y→ instalar dependencias → configurartsconfig.json→ escribir el servidor → compilar y correr. - Cada opción del
tsconfig.jsonexplicada una por una (target,module,outDir,rootDir,strict,esModuleInterop). - Qué es un middleware en Express, y qué hacen
cors,express.json()ydotenvcomo piezas concretas. - Cómo conectar la API a una base de datos real y persistente con
node:sqlite— el módulo de SQLite que ya viene incluido en Node.js (sin instalar ningún paquete), usando la misma tablaempleadosde mi curso de SQL. - Por qué los prepared statements (
?en vez de concatenar strings) previenen inyección SQL — lo comprobé metiendo un intento de inyección real. - Los cuatro verbos REST (
GET/POST/PUT/DELETE) implementados de verdad sobre un recurso real (/empleados), no como pseudocódigo — con sus códigos de estado (200,201,204,404) verificados uno por uno. - La diferencia entre
typeeinterface, y cuándo conviene cada uno en una API. - 🐛 Cuatro incidentes reales que me salieron armando este proyecto — un typo de un carácter que abortó toda la instalación,
ts-noderoto por una versión de TypeScript sin fijar, el mismorootDirobligatorio de la Clase 16, y unJWT_SECRETque llegabaundefinedpor el orden en que Node ejecuta losimport. Los agrupé al final (sección 12) para no interrumpir el hilo de la clase. - Cómo seguir haciendo crecer el mismo proyecto hasta convertirlo en algo cercano a producción: autenticación con API key primero y JWT después (Ejercicios 18-21), contraseñas con
bcrypt(19), rate limiting contra fuerza bruta (22), logging conmorgan(23), validación declarativa conzod(24), documentación interactiva con Swagger (25), tests automatizados conjest/supertest(26), configuración por entorno (27), subida de archivos conmulter(28), cache en memoria con invalidación (29), y healthcheck + apagado ordenado para un contenedor (30) — 30 ejercicios en total, todos sobre la misma carpeta02-Ejercicios/API, sin crear ningún proyecto nuevo.
📚 Definiciones clave
| Término | Qué es | Ejemplo de esta clase |
|---|---|---|
| Middleware | Función que se ejecuta entre que llega una petición y se envía la respuesta | app.use(cors()) |
esModuleInterop | Le permite a TypeScript usar import x from 'paquete' con paquetes escritos en CommonJS | import express from 'express' |
rootDir | La carpeta que contiene TODOS tus archivos fuente — obligatoria junto con outDir desde TS 6.0 | "rootDir": "./src" |
| REST | Convención de usar los verbos HTTP (GET/POST/PUT/DELETE) para representar operaciones sobre datos | app.get('/recursos', ...) |
node:sqlite | Módulo de SQLite incluido en Node.js — sin instalar ningún paquete externo | import { DatabaseSync } from 'node:sqlite' |
| Prepared statement | Consulta SQL con "huecos" (?) que se llenan por separado — el motor nunca confunde datos con código SQL | db.prepare('... WHERE id = ?').get(id) |
📖 PARTE TEÓRICA
🖥️ 1. ¿Qué es Node.js, exactamente?
Node.js no es un framework — es un runtime: un entorno que ejecuta código JavaScript fuera del navegador, en el servidor. Está construido sobre el motor V8 (el mismo que interpreta JavaScript dentro de Chrome), y viene con APIs propias para manejar archivos, redes y procesos que no existen en el navegador.
🧪 Entrevista: ¿Cuál es la diferencia entre Node.js y Express? Node.js es el runtime que ejecuta JavaScript en el servidor — Express es un framework que corre sobre Node.js, dándote herramientas para manejar rutas HTTP sin escribir esa lógica desde cero con el módulo nativo
httpde Node.
🤝 2. Por qué crear APIs con TypeScript
Una API es, en el fondo, una fábrica de datos: recibe peticiones, y responde con información. Sin tipos, esos datos son una caja negra — no sabes qué forma tienen hasta que los imprimes o hasta que algo truena en producción. Con TypeScript:
- Defines de antemano la forma exacta de lo que entra y sale de cada ruta.
- El editor te avisa si accedes a una propiedad que no existe, antes de correr nada.
- El código queda autodocumentado: leer el tipo de un parámetro te dice qué esperar, sin tener que adivinar.
🧪 Entrevista: ¿Qué ventaja concreta da TypeScript en el backend, más allá del autocompletado? Detecta errores de forma/tipo de datos en tiempo de compilación — antes de que un dato mal formado llegue a la base de datos o rompa un cliente que consume tu API.
🏗️ 3. Inicializar el proyecto
bash
mkdir API
cd API
npm init -ynpm init -y crea un package.json mínimo, aceptando todos los valores por defecto sin preguntarte nada (-y = yes a todo):
json
{
"name": "api",
"version": "1.0.0",
"main": "index.js",
"scripts": { "test": "..." },
"type": "commonjs"
}💡
"type": "commonjs"ya viene explícito en versiones recientes de npm — antes se omitía y CommonJS era el valor por defecto silencioso. Esto importa: es lo que determina si tus archivos.jsfinales usanrequire()(CommonJS) oimport(ESM) — y tiene que ser consistente con elmoduleque pongas entsconfig.json.
📦 4. Instalar dependencias
bash
npm install express cors dotenv
npm install typescript ts-node @types/node @types/express @types/cors --save-devQué hace cada paquete:
| Paquete | Para qué sirve |
|---|---|
express | El framework: define rutas, procesa peticiones, envía respuestas |
cors | Cross-Origin Resource Sharing — permite que un navegador en un dominio distinto pueda llamar a tu API |
dotenv | Lee variables de un archivo .env y las mete en process.env |
typescript | El compilador — convierte .ts en .js |
ts-node | Ejecuta .ts directamente, sin un paso de compilación manual (ver el incidente 2, sección 12) |
@types/node, @types/express, @types/cors | Las "traducciones" de tipos para paquetes escritos originalmente en JavaScript puro |
⚙️ 5. tsconfig.json, opción por opción
json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true
}
}| Opción | Qué hace |
|---|---|
"target": "es6" | A qué versión de JavaScript se traduce tu código — ES6 (2015) es compatible con cualquier Node.js moderno |
"module": "commonjs" | El sistema de módulos del .js de salida — commonjs es el que Node.js entiende de forma nativa con require() |
"outDir": "./dist" | A dónde va el .js compilado — el mismo patrón que ya viste en la Clase 13 |
"rootDir": "./src" | La carpeta que contiene TODOS tus .ts — obligatoria junto con outDir desde TypeScript 6.0 (ver el incidente 3, sección 12) |
"strict": true | Activa TODAS las revisiones estrictas de tipos a la vez (incluye strictNullChecks, que ya viste en la Clase 14) |
"esModuleInterop": true | Permite import express from 'express' con paquetes CommonJS — ver el detalle completo abajo |
¿Qué pasa si omito esModuleInterop?
El ecosistema de Node.js tradicionalmente usa CommonJS (require(...)), mientras que TypeScript prefiere el estándar moderno de ES Modules (import ... from ...). Express exporta sus funcionalidades de una forma que no es nativamente compatible con el estándar estricto de ES Modules.
Con esModuleInterop: true, TypeScript actúa como un "puente diplomático": envuelve automáticamente las importaciones CommonJS para que se comporten como módulos modernos. Sin esta opción, import express from 'express' fallaría, obligándote a escribir la sintaxis antigua:
typescript
// sin esModuleInterop, tendrías que escribir esto:
import * as express from 'express';
// con esModuleInterop: true, esto funciona:
import express from 'express';Verificado en el .js compilado — así es exactamente el "puente" que genera esModuleInterop para cada import:
javascript
// dist/server.js (generado por tsc)
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
const express_1 = __importDefault(require("express"));🧪 Entrevista: ¿Qué problema resuelve
esModuleInterop? La incompatibilidad entre CommonJS (el sistema de módulos histórico de Node.js) y ES Modules (el estándar moderno que usa TypeScript) — sin él, importar paquetes CommonJS con sintaxisimportmoderna no compila.
🗂️ 6. Estructura del proyecto
API/
├── src/
│ └── server.ts
├── dist/ ← generado por tsc, no se toca a mano
├── package.json
├── tsconfig.json
└── .gitignore🧩 6.1 Cómo se relacionan las piezas — diagrama de arquitectura
Antes de entrar al código pieza por pieza, esta es la vista completa de cómo encajan Node.js, TypeScript, Express, la cadena de middlewares, los routers, los servicios y SQLite — el mapa al que puedes volver cada vez que te pierdas entre tantas capas:
📐 Diagrama de arquitectura (vista por capas)

Y esta es la misma arquitectura, pero siguiendo una petición real de principio a fin — un POST /empleados con JWT, con sus dos bifurcaciones de error (token inválido, body inválido) y el camino feliz hasta 201 Created:
🔁 Diagrama de secuencia (flujo de una petición real)

💡 Vale la pena releer este diagrama después de los Ejercicios 18-21 (sección de ejercicios, más abajo) — ahí es donde se construye, paso a paso, cada pieza de la cadena de middlewares que aparece aquí.
✍️ 7. El servidor: express, cors, dotenv y los middlewares
typescript
// src/server.ts
import express from 'express';
import cors from 'cors';
import dotenv from 'dotenv';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors());
app.use(express.json());
app.get('/hello', (req, res) => {
res.send('Hola desde la API con TypeScript');
});
app.listen(PORT, () => {
console.log(`Servidor corriendo en http://localhost:${PORT}`);
});🔍 Línea por línea — qué hace cada comando y para qué sirve
Este bloque son solo 15 líneas, pero cada una hace algo concreto. Vale la pena entender exactamente qué aporta cada una, porque son las mismas piezas que vas a ver repetidas en cualquier servidor Express que armes de aquí en adelante:
Los import — traen cada pieza que vas a usar:
import express from 'express'— trae el framework en sí. Sin este import,expressno existiría como palabra en el archivo.express()(más abajo) crea la aplicación (app), el objeto sobre el que defines rutas y arrancas el servidor.import cors from 'cors'— trae la función que habilita CORS (Cross-Origin Resource Sharing). Sin ella, un sitio web en otro dominio que intente llamar a tu API sería bloqueado por el navegador del que hace la petición — no por tu servidor, el navegador la corta antes.import dotenv from 'dotenv'— trae el lector de variables de entorno. Por sí solo esteimportno hace nada todavía — solo pone la funcióndotenv.configdisponible para usarla en la siguiente línea.
dotenv.config(); — la línea que realmente ejecuta la lectura: busca un archivo .env en la raíz del proyecto y copia cada línea (CLAVE=valor) a process.env, la tabla de variables de entorno de Node.js. A partir de esta línea, cualquier process.env.LO_QUE_SEA que pongas más abajo en el archivo puede encontrar un valor real. El orden importa: si esta línea corriera después de leer alguna variable, esa variable llegaría undefined (es exactamente el bug real que documento en el Incidente 4, sección 12).
const app = express(); — ejecuta la función que trajiste con el import y crea la aplicación Express en sí. app es el objeto central de todo el servidor: es sobre app que vas a registrar middlewares (app.use), rutas (app.get, app.post...) y arrancar el servidor (app.listen). Todo lo que sigue en el archivo trabaja a través de este objeto.
const PORT = process.env.PORT || 3000; — decide en qué puerto va a escuchar el servidor. Lee process.env.PORT (que dotenv.config() ya dejó disponible si existe en .env); si no existe (undefined), el operador || cae al valor por defecto 3000. Así el mismo código funciona tanto si definiste PORT en .env como si no definiste nada.
app.use(cors()); — registra el middleware de CORS para todas las rutas del servidor. cors() (con paréntesis) llama a la función que importaste y esa llamada es la que realmente devuelve el middleware que app.use necesita — no es lo mismo pasar cors que pasar cors().
app.use(express.json()); — registra el middleware que lee el body de una petición (cuando llega como JSON) y lo deja disponible en req.body. Sin esta línea, req.body sería undefined en cualquier POST/PUT que recibas, sin importar qué mande el cliente.
app.get('/hello', (req, res) => { ... }); — define la primera ruta real: cuando llega una petición GET a /hello, Express ejecuta esta función. req trae los datos de la petición (headers, query, body...); res es lo que usas para responder. res.send(...) envía el texto de vuelta al cliente y cierra la respuesta.
app.listen(PORT, () => { ... }); — la línea que realmente arranca el servidor: le dice al sistema operativo "empieza a escuchar conexiones en este puerto". Todo lo anterior (app.use, app.get) solo configuró la aplicación — hasta este app.listen, el servidor no estaba corriendo de verdad. La función que le pasas como segundo argumento se ejecuta una vez, apenas el servidor queda listo — por eso es el lugar típico para el console.log de confirmación.
💡 Fíjate en el orden:
imports →dotenv.config()→ crearapp→ leer config (PORT) → middlewares (app.use) → rutas (app.get) →app.listen. No es arbitrario — cada línea depende de que la anterior ya haya corrido (no puedes hacerapp.useantes de queappexista, ni leerprocess.env.PORTantes dedotenv.config()).
🔐 .env en profundidad: para qué sirve, cómo se usa, y por qué hay uno por entorno
¿Qué es un archivo .env?
Un archivo de texto plano, junto al código pero fuera de él, con pares CLAVE=valor — uno por línea, sin comillas, sin tipos:
bash
# .env
PORT=3000
JWT_SECRET=un-secreto-largo-y-aleatorio
API_KEY=clave-secreta-123dotenv.config() lee ese archivo al arrancar y copia cada línea a process.env — desde ese momento, process.env.PORT existe en cualquier parte del código, como cualquier variable de entorno del sistema operativo.
¿Para qué sirve, exactamente?
Separar configuración de código. Sin .env, cambiar el puerto o rotar una API key significaría editar server.ts y volver a compilar. Con .env, es editar una línea de texto y reiniciar el proceso — el código no se toca. Además, es el lugar donde viven los secretos: credenciales de base de datos, claves de API de terceros, el secreto con el que se firman los JWT (sección 20) — datos que nunca deberían quedar escritos en el código fuente, porque el código fuente casi siempre termina en un repositorio compartido.
Cómo se usa (el patrón completo)
npm install dotenv.dotenv.config()lo primero que se ejecuta, antes de leer cualquierprocess.env.X(ver el incidente real de la sección 12 sobre qué pasa si no es así).- Leer valores con
process.env.NOMBRE— siempre llegan como string, o comoundefinedsi la clave no existe. Si necesitas un número (process.env.PORT), hay que convertirlo explícitamente. .envva en.gitignore— nunca se sube al repositorio.
Consejos prácticos
| Consejo | Por qué |
|---|---|
Nunca subas .env a git | Si tiene secretos, quedan expuestos para siempre en el historial, aunque los borres después |
Sube un .env.example (sin valores reales) | Documenta qué variables espera el proyecto, sin filtrar ningún secreto — quien clona el repo sabe qué copiar |
| Falla rápido si falta una variable obligatoria | Un process.env.JWT_SECRET como undefined no truena hasta que alguien intenta firmar un token — mejor validar al arrancar y salir con un mensaje claro |
No hagas console.log(process.env) completo | Es fácil que ese log termine en un archivo o servicio de logging, filtrando todos los secretos de una vez |
Nombres en MAYUSCULAS_CON_GUION_BAJO | Convención universal — así se distinguen a simple vista de variables normales del código |
¿Por qué existen .env distintos por entorno (desarrollo, QA, producción)?
Porque la configuración cambia según dónde corre el servidor, aunque el código sea exactamente el mismo:
- En desarrollo, la base de datos es un archivo local, los logs son detallados (todo te interesa mientras programas), y no importa si el secreto es predecible.
- En QA/staging, apuntas a una base de datos de pruebas separada, y quieres que el entorno se parezca lo más posible a producción para atrapar bugs antes de que lleguen ahí.
- En producción, los logs deben ser más discretos (menos ruido, más estructura), los secretos son reales y rotan periódicamente, y un error de configuración puede costar dinero de verdad.
Si todo viviera en un único .env, cambiar de entorno significaría editar ese archivo a mano cada vez — propenso a error, y fácil de dejar mal configurado por accidente. El patrón estándar (que implemento en el Ejercicio 27) es: un .env base con los secretos compartidos, y un .env.development / .env.production con lo que sí cambia por entorno (puerto, nivel de log), seleccionado automáticamente según NODE_ENV.
⚠️ En un despliegue real (Docker, un PaaS como Render/Railway, Kubernetes), lo normal es que ni siquiera exista un archivo
.enven el servidor — las variables de entorno las inyecta la plataforma directamente (variables de contenedor, un secret manager). El archivo.enves sobre todo una herramienta de desarrollo local; en producción, el mecanismo cambia, pero el principio (config fuera del código) es el mismo.
🧪 Entrevista: ¿Por qué separar la configuración del código con variables de entorno? Porque el mismo build puede correr en distintos entornos (dev/QA/prod) sin recompilar — solo cambia la configuración que se le inyecta al arrancar. Es además el único lugar seguro para secretos: el código termina en un repositorio compartido, las variables de entorno no.
¿Qué es un middleware?
Un middleware es una función que Express ejecuta entre que llega una petición y se genera la respuesta final — una especie de "estación de paso" obligatoria antes de llegar a tu ruta. Cada middleware puede leer o modificar la petición, y decide si la pasa al siguiente (next()) o corta la cadena ahí mismo.
typescript
app.use(cors()); // middleware: agrega los headers de CORS a cada respuesta
app.use(express.json()); // middleware: lee el body de la petición y lo convierte en objeto JSapp.use(...) registra un middleware para todas las rutas. En este servidor:
cors()agrega automáticamente el headerAccess-Control-Allow-Origina cada respuesta — verificado concurl -I, aparece en la respuesta real.express.json()intercepta el cuerpo de la petición (si viene como JSON) y lo deja disponible enreq.body— sin este middleware,req.bodyseríaundefineden una peticiónPOST.
🧪 Entrevista: ¿Qué es un middleware en Express? Una función que se ejecuta entre la petición y la respuesta, con acceso a
req,resy una funciónnext()para pasar el control al siguiente middleware o ruta — se usa para tareas transversales (autenticación, logs, CORS, parseo del body) que no quieres repetir en cada ruta.
Verificado en terminal, sirviendo el servidor real:
$ curl http://localhost:3000/hello
Hola desde la API con TypeScript
$ curl -I http://localhost:3000/hello | grep -i access-control
Access-Control-Allow-Origin: *🚀 8. Scripts de package.json
json
"scripts": {
"start": "node dist/server.js",
"dev": "ts-node src/server.ts",
"build": "tsc"
}buildcompila todo el proyecto contsc.startcorre la versión ya compilada — para producción.devdebería ejecutar el.tsdirectamente conts-node, sin paso de compilación — para desarrollo.
🗄️ 9. Conectar la API a una base de datos real: SQLite con node:sqlite
Todo lo anterior respondía datos fijos, en memoria. Una API de verdad necesita guardar algo — aquí conecto el servidor a una base de datos SQLite real, usando la misma tabla empleados (empresa "Nexus") de mi curso de SQL, para poder practicar SELECT/FROM con datos que llegan por HTTP en vez de un cliente de SQL suelto.
¿Por qué SQLite para aprender?
SQLite guarda toda la base de datos en un solo archivo — no hay que instalar ni levantar un servidor de base de datos aparte (a diferencia de PostgreSQL o MySQL). Es la opción más simple para practicar consultas reales sin montar infraestructura.
node:sqlite — sin instalar ningún paquete
Verificado en este equipo: Node.js 22.5+ trae un cliente de SQLite integrado, node:sqlite — y en la versión instalada aquí (Node 24.18.0) funciona sin ninguna bandera experimental y sin warnings:
$ node -e "const { DatabaseSync } = require('node:sqlite'); console.log('funciona sin instalar nada');"
funciona sin instalar nada📝 Sí necesitas
@types/nodeactualizado (ya lo tienes desde la sección 4) para que TypeScript reconozcaimport { DatabaseSync } from 'node:sqlite'— verificado compilando contscdentro de este mismo proyecto, sin errores.
src/db.ts — la conexión y la tabla
typescript
// src/db.ts
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = path.join(__dirname, '..', 'data');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR);
}
export const db = new DatabaseSync(path.join(DATA_DIR, 'nexus.db'));
db.exec(`
CREATE TABLE IF NOT EXISTS empleados (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
cargo TEXT NOT NULL,
salario REAL NOT NULL
)
`);
const { total } = db.prepare('SELECT COUNT(*) AS total FROM empleados').get() as { total: number };
if (total === 0) {
const insertar = db.prepare('INSERT INTO empleados (nombre, cargo, salario) VALUES (?, ?, ?)');
insertar.run('Ana Torres', 'Desarrolladora', 3200);
insertar.run('Luis Peña', 'Diseñador', 2800);
insertar.run('Carla Ríos', 'Gerente de Proyecto', 4100);
}CREATE TABLE IF NOT EXISTS— no truena si la tabla ya existe (el servidor se reinicia muchas veces durante el desarrollo).- La semilla (
INSERTde los tres empleados) solo corre una vez — la protege elif (total === 0), revisando cuántas filas hay antes de insertar. import { db } from './db'enserver.tses todo lo que hace falta para que cualquier ruta pueda leer o escribir en la base de datos.
Prepared statements: por qué ? en vez de concatenar texto
db.prepare('SELECT * FROM empleados WHERE id = ?').get(req.params.id) separa la consulta (fija, escrita por ti) de los datos (variables, escritos por quien llama a tu API). El motor de SQLite nunca confunde uno con el otro.
Lo comprobé metiendo el intento de inyección clásico ("Robert'); DROP TABLE") como si fuera el nombre de un empleado nuevo:
$ curl -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"Robert\"); DROP TABLE empleados; --","cargo":"Hacker","salario":0}'
{"id":5,"nombre":"Robert\"); DROP TABLE empleados; --", ...}
$ curl localhost:3000/empleados
(la tabla sigue intacta — el texto quedó guardado tal cual, como un nombre más)⚠️ Si en vez de un prepared statement hubiera escrito la consulta concatenando texto (
`INSERT INTO empleados (nombre) VALUES ('${nombre}')`), ese mismonombrehabría cerrado elVALUES(...)y ejecutado un segundo comando SQL — justo lo que el ataque intenta. Con?, el texto completo se trata siempre como un único valor, sin importar qué caracteres traiga.
🧪 Entrevista: ¿Cómo prevenís inyección SQL en Node.js? Con prepared statements (
?como placeholder, valores pasados aparte) en vez de construir la consulta concatenando strings — es la defensa estándar, disponible en prácticamente cualquier librería de base de datos.
🧪 Pruébalo tú mismo — mismo empleados, sin salir de esta página
Este cuadro corre SQLite de verdad dentro de tu navegador (compilado a WebAssembly con sql.js) — no es una simulación ni llama a ningún servidor. Trae precargada la misma tabla empleados con los mismos tres datos de arriba. Prueba tus propias consultas SELECT/WHERE/ORDER BY, igual que en mi curso de SQL — o rompe algo (DROP TABLE, un UPDATE sin WHERE…), total, vive solo en la memoria de tu pestaña. El botón "Reiniciar datos" regresa todo al estado inicial.
🗄️ empleados — NexusCargando motor SQLite…
🌐 10. Los cuatro verbos REST, implementados de verdad
El material los muestra como pseudocódigo (/* lógica */). Aquí están completos y verificados, operando sobre un recurso real: empleados, usando la conexión db que acabas de montar en la sección 9.
typescript
// GET — listar todos
app.get('/empleados', (req, res) => {
const empleados = db.prepare('SELECT * FROM empleados').all();
res.json(empleados);
});
// GET — uno por id
app.get('/empleados/:id', (req, res) => {
const empleado = db.prepare('SELECT * FROM empleados WHERE id = ?').get(req.params.id);
if (!empleado) {
res.status(404).json({ error: 'Empleado no encontrado' });
return;
}
res.json(empleado);
});
// POST — crear
app.post('/empleados', (req, res) => {
const { nombre, cargo, salario } = req.body;
const insertar = db.prepare('INSERT INTO empleados (nombre, cargo, salario) VALUES (?, ?, ?)');
const info = insertar.run(nombre, cargo, salario);
res.status(201).json({ id: Number(info.lastInsertRowid), nombre, cargo, salario });
});
// PUT — actualizar
app.put('/empleados/:id', (req, res) => {
const { nombre, cargo, salario } = req.body;
const actualizar = db.prepare('UPDATE empleados SET nombre = ?, cargo = ?, salario = ? WHERE id = ?');
const info = actualizar.run(nombre, cargo, salario, req.params.id);
if (info.changes === 0) {
res.status(404).json({ error: 'Empleado no encontrado' });
return;
}
res.json({ id: Number(req.params.id), nombre, cargo, salario });
});
// DELETE — eliminar
app.delete('/empleados/:id', (req, res) => {
const eliminar = db.prepare('DELETE FROM empleados WHERE id = ?');
const info = eliminar.run(req.params.id);
if (info.changes === 0) {
res.status(404).json({ error: 'Empleado no encontrado' });
return;
}
res.status(204).send();
});Cada verbo devuelve un código de estado distinto según lo que hizo: 200 (éxito, con datos), 201 (se creó algo nuevo), 204 (éxito, sin nada que devolver — típico de DELETE), 404 (no existe el recurso pedido).
Los cinco, verificados uno por uno contra el servidor real:
$ curl http://localhost:3000/empleados
[{"id":1,"nombre":"Ana Torres",...},{"id":2,"nombre":"Luis Peña",...},{"id":3,"nombre":"Carla Ríos",...}]
$ curl -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"Marco Díaz","cargo":"QA Tester","salario":2600}'
{"id":4,"nombre":"Marco Díaz","cargo":"QA Tester","salario":2600}
$ curl -X PUT localhost:3000/empleados/4 -H "Content-Type: application/json" -d '{"nombre":"Marco Díaz","cargo":"QA Senior","salario":3000}'
{"id":4,"nombre":"Marco Díaz","cargo":"QA Senior","salario":3000}
$ curl -w " [%{http_code}]" -X DELETE localhost:3000/empleados/2
[204]
$ curl -w " [%{http_code}]" localhost:3000/empleados/999
{"error":"Empleado no encontrado"} [404]💡 También verifiqué la persistencia: apagué el servidor (
Ctrl+C) y lo volví a levantar — los datos seguían exactamente igual, incluyendo elDELETEy elPUTde arriba. ConPOST /echo(sección 7), en cambio, nada se guarda — cada respuesta se calcula al vuelo y se olvida apenas termina la petición.
🆚 11. type vs interface, en resumen
Ambos definen la forma de un objeto — la diferencia importa cuando el proyecto crece:
interface | type | |
|---|---|---|
| Extender | interface B extends A {} | Solo con intersección: type C = A & B |
| Uniones | ❌ No puede | ✅ type A = string | number |
| Declaración múltiple | Se combinan automáticamente (declaration merging) | ❌ Error si repites el nombre |
💡 Ya viste
interfacea fondo en la Clase 5. En una API, es común usarinterfacepara la forma de los datos (interface Usuario { ... }) ytypepara uniones (type Metodo = 'GET' | 'POST' | 'PUT' | 'DELETE').
🐛 12. Incidentes reales: tres errores que me salieron y cómo los diagnostiqué
Nada de lo que sigue viene del material del curso — son problemas que aparecieron armando este proyecto, en este equipo. Los agrupo aquí, al final de la teoría, para no interrumpir el hilo de "cómo se construye una API": si estás siguiendo la clase por primera vez, puedes saltarlos y volver cuando te topes con uno.
🐛 Incidente 1: un typo de un carácter que abortó toda la instalación
Al escribir el segundo comando de instalación, tipeé @type/node (sin la "s") en vez de @types/node. El resultado no fue "instaló todo menos ese paquete" — fue que npm abortó el comando completo, sin instalar ninguno de los cinco paquetes:
$ npm install typescript ts-node @type/node @types/express @types/cors --save-dev
npm error code E404
npm error 404 Not Found - GET https://registry.npmjs.org/@type%2fnode - Not found
npm error 404 The requested resource '@type/node@*' could not be foundConfirmé que ninguno de los 5 paquetes quedó instalado (ni typescript, ni ts-node, ni siquiera los @types/* bien escritos) — npm install con varios paquetes a la vez es todo o nada: si uno no existe, el comando entero falla.
La corrección: un solo carácter, @type → @types.
bash
npm install typescript ts-node @types/node @types/express @types/cors --save-dev🐛 Incidente 2: ts-node se rompió, luego se arregló solo — sin tocar nada
La primera vez que corrí npm run dev, truena:
$ npm run dev
TypeError: Cannot read properties of undefined (reading 'fileExists')
at readConfig (.../ts-node/dist/configuration.js:91:33)Causa real, confirmada revisando las versiones instaladas: ese primer npm install typescript ... --save-dev (sin fijar versión) resolvió typescript@7.0.2 — y ts-node (versión 10.9.2) espera una forma interna específica del compilador que esa versión de TypeScript cambió. ts-node intenta leer ts.sys y ya no está donde lo espera.
Reinstalé el proyecto desde cero (borré package.json/node_modules y volví a correr los mismos comandos) y, esta vez, npm install typescript resolvió typescript@5.9.3 — una versión distinta, sin haber pedido ninguna en particular. Con esa versión, npm run dev corre perfecto:
$ npm run dev
Servidor corriendo en http://localhost:3000
$ curl http://localhost:3000/hello
Hola desde la API con TypeScript💡 La lección real no es "
ts-nodeestá roto" — es quenpm install typescriptsin fijar versión te da lo que sea que sealatesten ese instante, y eso puede cambiar entre una instalación y otra del mismo comando, en el mismo proyecto, el mismo día. Si necesitas reproducibilidad (que tu proyecto se comporte igual en tu máquina, en la de un compañero, y en el servidor), conviene fijar una versión exacta:bashnpm install --save-dev typescript@5.9.3
Si de todos modos te encuentras esta combinación rota (una versión de TypeScript demasiado nueva para tu versión de ts-node), tsx es un reemplazo moderno verificado, basado en esbuild, que no depende de la forma interna del compilador:
bash
npm install --save-dev tsx
npx tsx watch src/server.ts # con recarga automática al guardar🐛 Incidente 3: el mismo rootDir obligatorio de la Clase 16
Corrí npm run build (tsc), y me encontré el mismo error TS5011 que ya había visto con Angular en la Clase 16:
$ npm run build
tsconfig.json(3,5): error TS5011: The common source directory of 'tsconfig.json' is './src'. The 'rootDir' setting must be explicitly set...Y peor: aunque el build "terminó" pese al error, el archivo compilado quedó en dist/src/server.js — no en dist/server.js, que es justo lo que el script "start": "node dist/server.js" espera. Con rootDir faltante, ni siquiera la estructura de salida coincide con lo que el resto del proyecto asume.
El fix, la misma línea que en la Clase 16:
json
{ "compilerOptions": { "outDir": "./dist", "rootDir": "./src", ... } }Verificado — con rootDir presente, npm run build genera exactamente dist/server.js, y npm start funciona sin ajustar nada más:
$ npm run build
$ find dist -type f
dist/server.js
$ npm start
Servidor corriendo en http://localhost:3000💡 Dos clases distintas (Angular en la 16, Express aquí) chocaron con el mismo cambio de TypeScript 6+. No es una rareza de un framework — es una regla nueva del compilador que afecta a cualquier proyecto con
outDirsinrootDir.
🐛 Incidente 4: JWT_SECRET llegaba undefined — el orden de import vs dotenv.config()
Armando el login del Ejercicio 20, la ruta POST /login tronaba con un error que no tenía nada que ver con las credenciales:
$ curl -X POST localhost:3000/login -H "Content-Type: application/json" -d '{"email":"ana@nexus.com","password":"clave123"}'
{"error":"Error interno del servidor"}El log del servidor sí tenía la pista completa:
[error] secretOrPrivateKey must have a valueCausa real: en auth.ts había una constante a nivel de módulo, const SECRETO = process.env.JWT_SECRET as string;, calculada al importar el archivo. El problema es el orden en que Node ejecuta las cosas: todos los import de un archivo se resuelven y ejecutan antes que cualquier línea de código normal de ese archivo — incluida la línea dotenv.config(). Como server.ts importaba authRouter (de auth.ts) antes de llamar a dotenv.config(), auth.ts se ejecutaba, calculaba SECRETO leyendo un process.env.JWT_SECRET que todavía no existía, y lo dejaba fijo en undefined para siempre — aunque dotenv.config() corriera un instante después.
typescript
// server.ts (con el bug)
import { authRouter } from './routes/auth'; // ← esto YA ejecuta auth.ts completo
import dotenv from 'dotenv';
dotenv.config(); // ← demasiado tarde: auth.ts ya leyó JWT_SECRET como undefinedEl fix: no leer process.env.JWT_SECRET en una constante de módulo — leerlo dentro de la función que lo usa, en el momento en que se llama (cuando dotenv.config() ya corrió hace rato):
typescript
// en vez de una constante de módulo...
const token = jwt.sign(payload, process.env.JWT_SECRET as string, { expiresIn: '1h' });Verificado — con el fix, el mismo login funciona:
$ curl -X POST localhost:3000/login -H "Content-Type: application/json" -d '{"email":"ana@nexus.com","password":"clave123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}💡 La lección generaliza más allá de
dotenv: cualquier valor que dependa de código que corre "al importar" (dotenv.config(), leer un archivo de config, conectar a una base de datos) es frágil si otro módulo lo lee en una constante top-level — el orden de imports decide si ya estaba listo o no. Es más seguro leer ese valor perezosamente, dentro de la función que lo necesita, que confiar en el orden de ejecución de losimport.
💻 PARTE PRÁCTICA
Carpeta trabajada: 02-Ejercicios/API/ (creada con los comandos reales del material, con los tres hallazgos de arriba diagnosticados y corregidos).
bash
cd 02-Ejercicios/API
npm run build # tsc → dist/server.js
npm start # node dist/server.jsVerificado — servidor real respondiendo, con la base de datos SQLite ya conectada:
$ npm start
Servidor corriendo en http://localhost:3000
$ curl http://localhost:3000/hello
Hola desde la API con TypeScript
$ curl http://localhost:3000/empleados
[{"id":1,"nombre":"Ana Torres","cargo":"Desarrolladora","salario":3200},{"id":2,"nombre":"Luis Peña","cargo":"Diseñador","salario":2800},{"id":3,"nombre":"Carla Ríos","cargo":"Gerente de Proyecto","salario":4100}]Todo verificado y corriendo — incluyendo el CRUD completo (GET/POST/PUT/DELETE) contra la base de datos real, con persistencia confirmada tras reiniciar el servidor. ✅
🏋️ EJERCICIOS CON SOLUCIÓN
🗂️ Los 30 ejercicios son acumulativos, sobre la MISMA carpeta
02-Ejercicios/API— no crees un proyecto nuevo para ninguno. Cada solución da por hecho que ya aplicaste la del ejercicio anterior: el código de esta clase, tal como quedó en el repositorio, es el resultado acumulado de los 30. Si vienes siguiendo la clase por primera vez, resuélvelos en orden; si ya tienes el proyecto de una sesión anterior, sigue exactamente donde lo dejaste — nada aquí depende de empezar de cero. A partir del Ejercicio 18 el proyecto se vuelve cada vez más parecido a una API real de producción (autenticación, validación, tests, observability), así que el orden importa más todavía: cada pieza nueva se apoya en la anterior.
Ejercicio 1 — Usar una variable de entorno real con dotenv
Crea un archivo .env con PORT=4321, y confirma que el servidor arranca en ese puerto en vez del 3000.
💡 ¿Sabías que…? — `.env` nunca se sube al repositorio
Por eso el .gitignore de un proyecto con dotenv siempre incluye .env — suele contener contraseñas, API keys o configuración sensible por entorno (desarrollo, producción).
Ver solución
bash
echo "PORT=4321" > .env
npm startServidor corriendo en http://localhost:4321Ejercicio 2 — Escribir un middleware propio
Agrega un middleware (con app.use) que imprima en consola el método y la ruta de cada petición que llegue, antes de cualquier ruta.
💡 ¿Sabías que…? — el orden de `app.use()` importa
Express ejecuta los middlewares en el orden en que los registras. Un middleware de logging debe ir antes de las rutas para capturar todas las peticiones; uno de manejo de errores suele ir al final.
Ver solución
typescript
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});💡
next()es obligatorio — sin llamarlo, la petición se queda "atascada" en este middleware y nunca llega a la ruta real.
Ejercicio 3 — Comparar type y interface con el mismo objeto
Define la forma de un Usuario (nombre: string, edad: number) primero como interface, y luego como type. ¿En qué se diferencia la sintaxis?
💡 ¿Sabías que…? — para objetos simples, el resultado es casi idéntico
La diferencia real aparece cuando necesitas extender o combinar tipos — para un objeto plano y simple, interface y type son intercambiables en la práctica.
Ver solución
typescript
interface UsuarioInterface {
nombre: string;
edad: number;
}
type UsuarioType = {
nombre: string;
edad: number;
};Ambos se usan igual: const u: UsuarioInterface = { nombre: "Ana", edad: 25 };
Ejercicio 4 — Ruta con parámetro dinámico (:id)
Agrega app.get('/recursos/:id', ...) que responda con el id recibido en la URL.
💡 ¿Sabías que…? — los parámetros de ruta llegan como `string`, siempre
Aunque tu URL sea /recursos/42, req.params.id llega como "42" (string), no como número — si necesitas compararlo o hacer aritmética, hay que convertirlo con Number(req.params.id) primero.
Ver solución
typescript
app.get('/recursos/:id', (req, res) => {
res.json({ id: req.params.id });
});$ curl localhost:3000/recursos/42
{"id":"42"}Ejercicio 5 — Validar datos antes de guardarlos
Antes de insertar en POST /empleados, valida que salario sea un número positivo. Si no lo es, responde 400 con un mensaje de error, sin tocar la base de datos.
💡 ¿Sabías que…? — validar ANTES de la consulta evita datos basura
Un prepared statement te protege de inyección SQL (sección 9), pero no valida que los datos tengan sentido — db.prepare(...).run('texto', 'texto', -500) insertaría un salario negativo sin quejarse. La validación de reglas de negocio (números positivos, campos obligatorios) es responsabilidad tuya, en la ruta, antes de tocar la base de datos.
Ver solución
typescript
app.post('/empleados', (req, res) => {
const { nombre, cargo, salario } = req.body;
if (typeof salario !== 'number' || salario < 0) {
res.status(400).json({ error: 'El salario debe ser un número positivo' });
return;
}
const insertar = db.prepare('INSERT INTO empleados (nombre, cargo, salario) VALUES (?, ?, ?)');
const info = insertar.run(nombre, cargo, salario);
res.status(201).json({ id: Number(info.lastInsertRowid), nombre, cargo, salario });
});Verificado:
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"Test","cargo":"Test","salario":-500}'
{"error":"El salario debe ser un número positivo"} [400]
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"Test","cargo":"Test","salario":1000}'
{"id":6,...} [201]Ejercicio 6 — Buscar empleados por nombre con LIKE
Agrega soporte para GET /empleados?nombre=Torres — si viene ese query param, filtra con LIKE; si no viene, devuelve todos como hasta ahora.
💡 ¿Sabías que…? — `req.query` y `req.params` no son lo mismo
req.params lee segmentos de la ruta (/empleados/:id → req.params.id). req.query lee lo que va después del ? en la URL (?nombre=Torres → req.query.nombre) — se usa para filtros y opciones que no cambian cuál recurso pides, solo cómo lo quieres.
Ver solución
typescript
app.get('/empleados', (req, res) => {
const { nombre } = req.query;
if (nombre) {
const resultado = db.prepare('SELECT * FROM empleados WHERE nombre LIKE ?').all(`%${nombre}%`);
res.json(resultado);
return;
}
res.json(db.prepare('SELECT * FROM empleados').all());
});$ curl "localhost:3000/empleados?nombre=Torres"
[{"id":1,"nombre":"Ana Torres","cargo":"Desarrolladora","salario":3200}]⚠️ El
?de un prepared statement no protege contra usarLIKEmal — pero sigue siendo el mismo?, así que el texto de búsqueda nunca se interpreta como SQL.
Ejercicio 7 — El gotcha real: rutas fijas vs. rutas dinámicas
Agrega GET /empleados/estadisticas (una ruta fija) después de GET /empleados/:id (la que ya existe). Pruébala. ¿Qué responde, y por qué?
💡 ¿Sabías que…? — Express prueba las rutas EN ORDEN, y se queda con la primera que calce
:id es un comodín — "estadisticas" calza perfecto ahí. Express no sabe que "estadisticas" iba a ser una ruta fija más adelante; solo ve que la primera ruta registrada (/empleados/:id) ya coincide, y ejecuta esa.
Ver solución
typescript
// así, en este orden, NO funciona como esperas:
app.get('/empleados/:id', (req, res) => { /* ... */ });
app.get('/empleados/estadisticas', (req, res) => { /* nunca se alcanza */ });Verificado — estadisticas termina tratado como un id:
$ curl localhost:3000/empleados/estadisticas
{"ruta":"dinamica","id":"estadisticas"}El fix: registrar la ruta fija antes que la dinámica.
typescript
app.get('/empleados/estadisticas', (req, res) => { /* ... */ }); // primero
app.get('/empleados/:id', (req, res) => { /* ... */ }); // después$ curl localhost:3000/empleados/estadisticas
{"ruta":"estadisticas"}
$ curl localhost:3000/empleados/42
{"ruta":"dinamica","id":"42"}Ejercicio 8 — Endpoint de estadísticas con funciones de agregación
Usando el orden correcto del Ejercicio 12, implementa GET /empleados/estadisticas con COUNT, AVG y MAX sobre salario.
💡 ¿Sabías que…? — estas son las mismas funciones de tu curso de SQL
COUNT(*), AVG(columna) y MAX(columna) son funciones de agregación — resumen muchas filas en un solo resultado. Nada nuevo respecto a lo que ya practicaste con SELECT/FROM; la única diferencia es que ahora el resultado llega envuelto en JSON, vía HTTP.
Ver solución
typescript
app.get('/empleados/estadisticas', (req, res) => {
const stats = db.prepare(
'SELECT COUNT(*) AS total, AVG(salario) AS promedio, MAX(salario) AS maximo FROM empleados'
).get();
res.json(stats);
});$ curl localhost:3000/empleados/estadisticas
{"total":3,"promedio":3433.33,"maximo":4100}Ejercicio 9 — Ordenar resultados sin abrir la puerta a inyección SQL
Agrega GET /empleados?orden=salario — pero sin meter req.query.orden directo en el SQL (eso sería una inyección esperando a pasar).
💡 ¿Sabías que…? — los prepared statements NO cubren nombres de columna
? solo funciona para valores (WHERE id = ?) — SQLite no permite usar ? para el nombre de una columna o tabla en un ORDER BY. Si necesitas que ese nombre venga de fuera, la única forma segura es compararlo contra una lista blanca de valores permitidos, escrita por ti.
Ver solución
typescript
const COLUMNAS_VALIDAS = ['nombre', 'salario', 'cargo'];
app.get('/empleados', (req, res) => {
const columna = String(req.query.orden ?? 'id');
if (!COLUMNAS_VALIDAS.includes(columna)) {
res.status(400).json({ error: 'Columna de orden inválida' });
return;
}
res.json(db.prepare(`SELECT * FROM empleados ORDER BY ${columna} ASC`).all());
});⚠️ Ese
${columna}concatenado se ve igual de peligroso que el ejemplo de inyección de la sección 9 — la diferencia es que antes de llegar ahí, ya lo comparamos contraCOLUMNAS_VALIDAS. Si alguien manda?orden=id;DROP+TABLE+empleados, ese texto completo no está en la lista, y la ruta responde400sin tocar la base de datos.
Ejercicio 10 — Paginación con LIMIT/OFFSET
Agrega GET /empleados?limite=2&pagina=1 para traer resultados de a poco, en vez de la tabla completa siempre.
💡 ¿Sabías que…? — la paginación junta varias piezas de la sección 9
req.query para leer los parámetros, valores por defecto razonables si no vienen, ? para pasarlos de forma segura al prepared statement, y la fórmula OFFSET = (pagina - 1) * limite — el mismo patrón detrás de la paginación de cualquier API real (redes sociales, catálogos de tienda, etc.).
Ver solución
typescript
app.get('/empleados', (req, res) => {
const limite = Number(req.query.limite ?? 10);
const pagina = Number(req.query.pagina ?? 1);
const offset = (pagina - 1) * limite;
const resultado = db
.prepare('SELECT * FROM empleados LIMIT ? OFFSET ?')
.all(limite, offset);
res.json(resultado);
});Verificado — con limite=2, la primera página trae los dos primeros:
$ curl "localhost:3000/empleados?limite=2&pagina=1"
[{"id":1,"nombre":"Ana Torres",...},{"id":3,"nombre":"Carla Ríos",...}]Ejercicio 11 — Tipar las respuestas con una interface
Hasta ahora las rutas devuelven lo que salga de la base de datos, sin tipo. Crea interface Empleado y haz que las consultas devuelvan Empleado[]. Anticipa: ¿te deja TypeScript castear el resultado directamente?
💡 ¿Sabías que…? — SQLite no sabe qué forma tienen tus filas
db.prepare(...).all() devuelve Record<string, SQLOutputValue>[] — un objeto genérico "clave → valor", porque el driver no puede adivinar tus columnas en tiempo de compilación. Convertirlo a tu interface es responsabilidad tuya, y TypeScript te va a exigir que seas explícito al respecto.
Ver solución
El intento directo no compila — error real:
error TS2352: Conversion of type 'Record<string, SQLOutputValue>[]' to type 'Empleado[]' may be a mistake because neither type sufficiently overlaps with the other. If this was intentional, convert the expression to 'unknown' first.Hay dos salidas, y conviene entender el precio de cada una:
typescript
export interface Empleado {
id: number;
nombre: string;
cargo: string;
salario: number;
}
// Opción A — doble aserción: rápida, pero NO valida nada en ejecución
export function listarEmpleados(): Empleado[] {
return db.prepare('SELECT * FROM empleados').all() as unknown as Empleado[];
}
// Opción B — mapeo explícito: más largo, pero convierte de verdad
export function listarSeguro(): Empleado[] {
return db.prepare('SELECT * FROM empleados').all().map(f => ({
id: Number(f.id),
nombre: String(f.nombre),
cargo: String(f.cargo),
salario: Number(f.salario),
}));
}💡 El
as unknown as Xes el escape que el propio mensaje de error sugiere — el mismounknownque viste en la Clase 2. Funciona, pero le estás diciendo a TypeScript "confía en mí" sin ninguna garantía. La opción B es la que usarías en un proyecto real donde los datos importan.
Ejercicio 12 — PATCH: actualizar solo algunos campos
PUT obliga a mandar el objeto completo. Implementa PATCH /empleados/:id que acepte solo los campos que quieras cambiar y arme el UPDATE dinámicamente.
💡 ¿Sabías que…? — `PUT` reemplaza, `PATCH` modifica
Es la diferencia semántica entre los dos verbos: PUT significa "aquí está el recurso completo, reemplázalo"; PATCH significa "aquí van solo los cambios". Por eso PATCH necesita construir el SET de la consulta sobre la marcha, según qué campos llegaron.
Ver solución
typescript
app.patch('/empleados/:id', (req, res) => {
const permitidos = ['nombre', 'cargo', 'salario'];
const campos = Object.keys(req.body).filter(k => permitidos.includes(k));
if (campos.length === 0) {
res.status(400).json({ error: 'Nada que actualizar' });
return;
}
const asignaciones = campos.map(c => `${c} = ?`).join(', ');
const valores = campos.map(c => req.body[c]);
const info = db
.prepare(`UPDATE empleados SET ${asignaciones} WHERE id = ?`)
.run(...valores, req.params.id);
if (info.changes === 0) {
res.status(404).json({ error: 'No encontrado' });
return;
}
res.json(db.prepare('SELECT * FROM empleados WHERE id = ?').get(req.params.id));
});Verificado — mandando solo salario, el resto queda intacto:
$ curl -X PATCH localhost:3000/empleados/1 -H "Content-Type: application/json" -d '{"salario":9999}'
{"id":1,"nombre":"Ana Torres","cargo":"Desarrolladora","salario":9999}⚠️ El
${asignaciones}va concatenado al SQL — y eso sería peligroso, pero cada nombre de columna pasó antes por el filtro depermitidos(la misma lista blanca del Ejercicio 9). Los valores, en cambio, siguen viajando por?.
Ejercicio 13 — Un middleware de validación reutilizable
En el Ejercicio 5 validaste dentro de la ruta. Ahora escribe una función que genere middlewares: requiereCampos('nombre', 'cargo') debe devolver un middleware que rechace la petición si falta alguno.
💡 ¿Sabías que…? — un middleware puede ser generado por otra función
Como un middleware es solo una función (req, res, next) => {}, nada impide que otra función la construya y la devuelva. Ese patrón (una "fábrica de middlewares") te deja configurar el mismo middleware distinto en cada ruta, sin duplicar la lógica de validación.
Ver solución
typescript
import { Request, Response, NextFunction } from 'express';
function requiereCampos(...campos: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
const faltantes = campos.filter(c => req.body[c] === undefined);
if (faltantes.length > 0) {
res.status(400).json({ error: `Faltan campos: ${faltantes.join(', ')}` });
return;
}
next();
};
}
// Se enchufa entre la ruta y su manejador:
app.post('/empleados', requiereCampos('nombre', 'cargo', 'salario'), (req, res) => {
// aquí ya es seguro asumir que los tres campos llegaron
});Verificado:
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"X"}'
{"error":"Faltan campos: cargo, salario"} [400]
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"X","cargo":"Y","salario":1}'
{"ok":true,...} [201]💡
...campos: string[]es el rest parameter que ya viste en la Clase 4 — permite llamarrequiereCampos('a', 'b', 'c')con tantos nombres como quieras.
Ejercicio 14 — Middleware de manejo de errores centralizado
Si una ruta lanza una excepción, ahora mismo Express responde con un volcado feo. Escribe un middleware que atrape cualquier error de cualquier ruta y responda un JSON limpio con 500.
💡 ¿Sabías que…? — el middleware de errores se reconoce por tener CUATRO parámetros
Express distingue un middleware normal (req, res, next) de uno de errores (err, req, res, next) contando sus parámetros. Si le pones tres, nunca recibirá errores; si le pones cuatro, Express lo trata como manejador de errores. Y va siempre al final, después de todas las rutas.
Ver solución
typescript
import express, { Request, Response, NextFunction } from 'express';
// ... todas tus rutas aquí ...
// AL FINAL, después de las rutas: 4 parámetros = manejador de errores
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('[error]', err.message); // queda el detalle en el log del servidor
res.status(500).json({ error: 'Error interno del servidor' }); // el cliente ve poco
});Verificado con una ruta que lanza a propósito:
$ curl -w " [%{http_code}]" localhost:3000/boom
{"error":"Error interno del servidor"} [500]⚠️ Fíjate en el detalle de seguridad: el mensaje real del error va al log del servidor (
console.error), no a la respuesta. Devolverle al cliente el stack trace completo le regala pistas sobre tu infraestructura a cualquiera.
Ejercicio 15 — Modularizar las rutas con express.Router()
server.ts ya está creciendo demasiado. Mueve todas las rutas de /empleados a src/routes/empleados.ts usando express.Router(), y móntalas desde server.ts.
💡 ¿Sabías que…? — es el mismo principio de la Clase 11, aplicado a rutas
Un Router es una mini-aplicación de Express: agrupa rutas relacionadas en su propio archivo y se "enchufa" al servidor bajo un prefijo. Es exactamente la idea de no tener un monolito que ya viste en la Clase 11, pero aplicada a la capa HTTP.
Ver solución
typescript
// src/routes/empleados.ts
import { Router } from 'express';
import { db } from '../db';
export const empleadosRouter = Router();
// Ojo: las rutas son relativas al prefijo donde se monte el router
empleadosRouter.get('/', (req, res) => {
res.json(db.prepare('SELECT * FROM empleados').all());
});
empleadosRouter.get('/:id', (req, res) => {
const emp = db.prepare('SELECT * FROM empleados WHERE id = ?').get(req.params.id);
if (!emp) {
res.status(404).json({ error: 'No encontrado' });
return;
}
res.json(emp);
});typescript
// src/server.ts — queda mucho más corto
import { empleadosRouter } from './routes/empleados';
app.use('/empleados', empleadosRouter); // el prefijo se define AQUÍVerificado — mismas URLs de siempre, código repartido:
$ curl -w " [%{http_code}]" localhost:3000/empleados/1
{"id":1,"nombre":"Ana Torres","cargo":"Desarrolladora","salario":3200} [200]💡 Fíjate que dentro del router las rutas son
'/'y'/:id', sin repetir/empleados— el prefijo se aplica una sola vez, al montarlo. Si mañana quieres versionar tu API, cambiasapp.use('/empleados', ...)porapp.use('/api/v1/empleados', ...)y listo, sin tocar el router.
Ejercicio 16 — Separar la lógica de datos en una capa de servicio
Tus rutas todavía escriben SQL directamente. Extrae las consultas a src/services/empleados.service.ts, de modo que las rutas solo llamen funciones con nombre (listarEmpleados(), crearEmpleado(...)) y no sepan nada de SQL.
💡 ¿Sabías que…? — separar capas es lo que hace un proyecto mantenible
La ruta debería preocuparse de HTTP (leer req, elegir el código de estado); el servicio, de datos (qué consulta correr). Cuando están mezclados, cambiar de SQLite a PostgreSQL te obliga a tocar todas las rutas. Separados, solo reescribes el servicio — las rutas ni se enteran.
Ver solución
typescript
// src/services/empleados.service.ts — TODO el SQL vive aquí
import { db } from '../db';
export interface Empleado {
id: number;
nombre: string;
cargo: string;
salario: number;
}
export function listarEmpleados(): Empleado[] {
return db.prepare('SELECT * FROM empleados').all() as unknown as Empleado[];
}
export function buscarPorId(id: string): Empleado | undefined {
return db.prepare('SELECT * FROM empleados WHERE id = ?').get(id) as Empleado | undefined;
}
export function crearEmpleado(nombre: string, cargo: string, salario: number): Empleado {
const info = db
.prepare('INSERT INTO empleados (nombre, cargo, salario) VALUES (?, ?, ?)')
.run(nombre, cargo, salario);
return { id: Number(info.lastInsertRowid), nombre, cargo, salario };
}typescript
// src/routes/empleados.ts — ni una línea de SQL, solo HTTP
import { Router } from 'express';
import { listarEmpleados, buscarPorId, crearEmpleado } from '../services/empleados.service';
export const empleadosRouter = Router();
empleadosRouter.get('/', (req, res) => {
res.json(listarEmpleados());
});
empleadosRouter.get('/:id', (req, res) => {
const emp = buscarPorId(req.params.id);
if (!emp) {
res.status(404).json({ error: 'No encontrado' });
return;
}
res.json(emp);
});
empleadosRouter.post('/', (req, res) => {
const { nombre, cargo, salario } = req.body;
res.status(201).json(crearEmpleado(nombre, cargo, salario));
});💡 Lee la ruta ahora: se entiende de un vistazo qué hace, sin distraerte con cómo consulta la base de datos. Eso es exactamente lo que buscas cuando el proyecto crece.
Ejercicio 17 — Integrador: segunda tabla, JOIN y transacciones
Monta el modelo de datos real: una tabla departamentos relacionada con empleados. Luego (a) devuelve cada empleado con el nombre de su departamento usando JOIN, y (b) escribe una operación que inserte en dos tablas de forma atómica — si algo falla a mitad, nada debe quedar guardado.
💡 ¿Sabías que…? — una transacción es "todo o nada"
BEGIN abre una transacción, COMMIT la confirma, ROLLBACK la deshace por completo. Es la garantía que necesitas cuando una operación toca varias tablas: sin transacción, un fallo a mitad de camino te deja la base de datos en un estado inconsistente (por ejemplo, un empleado apuntando a un departamento que nunca llegó a crearse).
Ver solución
(a) El modelo relacionado y el JOIN:
typescript
db.exec(`
CREATE TABLE departamentos (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL
);
CREATE TABLE empleados (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
salario REAL NOT NULL,
departamento_id INTEGER,
FOREIGN KEY (departamento_id) REFERENCES departamentos(id)
);
`);
// Cada empleado con el NOMBRE de su departamento, no solo el id
db.prepare(`
SELECT e.nombre, e.salario, d.nombre AS departamento
FROM empleados e
JOIN departamentos d ON e.departamento_id = d.id
`).all();{ nombre: 'Ana', salario: 3200, departamento: 'Tecnología' }
{ nombre: 'Luis', salario: 2800, departamento: 'Diseño' }
-- agrupando por departamento --
{ departamento: 'Tecnología', total: 1, promedio: 3200 }(b) La transacción atómica:
typescript
try {
db.exec('BEGIN');
const dep = db.prepare('INSERT INTO departamentos (nombre) VALUES (?)').run('Ventas');
db.prepare('INSERT INTO empleados (nombre, salario, departamento_id) VALUES (?,?,?)')
.run('Nuevo', 2500, Number(dep.lastInsertRowid));
db.exec('COMMIT'); // ambas inserciones quedan firmes
} catch (e) {
db.exec('ROLLBACK'); // ninguna queda: la base vuelve a como estaba
throw e;
}Verificado provocando un fallo deliberado a mitad de la transacción:
empleados antes: 2
rollback hecho: fallo a mitad de camino
empleados tras rollback: 2 ← la fila insertada desaparecióLa estructura completa a la que llegaste:
API/
├── src/
│ ├── server.ts ← solo arranca la app y monta routers
│ ├── db.ts ← conexión + esquema
│ ├── routes/
│ │ └── empleados.ts ← HTTP: rutas y códigos de estado
│ ├── services/
│ │ └── empleados.service.ts ← datos: todo el SQL
│ └── middlewares/
│ ├── validacion.ts ← requiereCampos(...)
│ └── errores.ts ← manejador central de errores
├── data/nexus.db
├── dist/
├── tsconfig.json
└── package.json💡 Compara esto con el
server.tsúnico del inicio de la clase. Cada carpeta tiene una responsabilidad, y eso es lo que hace que un proyecto siga siendo manejable cuando pasa de 3 rutas a 30.
Ejercicio 18 — Proteger un endpoint peligroso con una API key
DELETE /empleados/:id está abierto a cualquiera ahora mismo. Agrega un middleware que exija un header x-api-key — sin la clave correcta, la petición no debe llegar a borrar nada.
💡 ¿Sabías que…? — una API key protege el "qué", no el "quién"
Una API key es un secreto compartido: cualquiera que la conozca puede usarla, y el servidor no tiene forma de distinguir entre dos personas que la usan. Sirve para un primer nivel rápido de protección (bloquear tráfico anónimo), pero no identifica quién hizo qué — eso es lo que resuelve el JWT del Ejercicio 20.
Ver solución
typescript
// src/middlewares/apiKey.ts
import { Request, Response, NextFunction } from 'express';
export function requiereApiKey(req: Request, res: Response, next: NextFunction) {
const clave = req.header('x-api-key');
if (!clave || clave !== process.env.API_KEY) {
res.status(401).json({ error: 'API key inválida o ausente' });
return;
}
next();
}typescript
// enchufado en la ruta más peligrosa:
empleadosRouter.delete('/:id', requiereApiKey, (req, res) => { /* ... */ });Con API_KEY=clave-secreta-123 en .env, verificado:
$ curl -w " [%{http_code}]" -X DELETE localhost:3000/empleados/2
{"error":"API key inválida o ausente"} [401]
$ curl -w " [%{http_code}]" -X DELETE localhost:3000/empleados/2 -H "x-api-key: clave-secreta-123"
[204]⚠️ Esta protección es temporal a propósito — el Ejercicio 21 la reemplaza por JWT en las rutas de escritura de
/empleados. El archivoapiKey.tsqueda en el proyecto como referencia del patrón, aunque deja de estar enchufado en las rutas reales.
Ejercicio 19 — Registrar usuarios con contraseña hasheada (bcrypt)
Antes de poder emitir tokens, necesitas usuarios de verdad. Crea una tabla usuarios (email, password_hash) y un endpoint POST /usuarios que reciba email/password y guarde el hash, nunca el texto plano.
💡 ¿Sabías que…? — hashear NO es lo mismo que encriptar
Encriptar es reversible (con la clave correcta, recuperas el original). Hashear con bcrypt es de un solo sentido — no existe un "desbcrypt". Para verificar un login no se "deshashea" la contraseña guardada: se hashea la que llega y se comparan los hashes (bcrypt.compareSync). bcrypt además agrega una sal aleatoria distinta en cada hash, así que la misma contraseña produce un hash diferente cada vez que se registra un usuario — evita que dos usuarios con la misma clave sean detectables comparando la tabla.
Ver solución
sql
-- añadida a db.ts, junto a empleados y departamentos
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
)typescript
// src/services/usuarios.service.ts
import bcrypt from 'bcryptjs';
import { db } from '../db';
const RONDAS_SAL = 10;
export function crearUsuario(email: string, password: string) {
const hash = bcrypt.hashSync(password, RONDAS_SAL);
const info = db
.prepare('INSERT INTO usuarios (email, password_hash) VALUES (?, ?)')
.run(email, hash);
return { id: Number(info.lastInsertRowid), email };
}typescript
// src/routes/auth.ts
authRouter.post('/usuarios', requiereCampos('email', 'password'), (req, res) => {
const { email, password } = req.body;
try {
res.status(201).json(crearUsuario(email, password));
} catch (e) {
res.status(409).json({ error: 'Ese email ya está registrado' });
}
});Verificado — y confirmando en la base de datos que nunca se guarda texto plano:
$ curl -w " [%{http_code}]" -X POST localhost:3000/usuarios -H "Content-Type: application/json" -d '{"email":"carla@nexus.com","password":"clave123"}'
{"id":3,"email":"carla@nexus.com"} [201]
$ curl -w " [%{http_code}]" -X POST localhost:3000/usuarios -H "Content-Type: application/json" -d '{"email":"carla@nexus.com","password":"otra"}'
{"error":"Ese email ya está registrado"} [409]
$ node -e "const {DatabaseSync}=require('node:sqlite'); console.log(new DatabaseSync('./data/nexus.db').prepare('SELECT * FROM usuarios').all())"
{ id: 3, email: 'carla@nexus.com', password_hash: '$2b$10$BH4DivbBpnX/k4UnQUvtbemwyS8...' }💡 El
UNIQUEen la columnaSqliteError: UNIQUE constraint failed) — eltry/catchde la ruta lo traduce a un409 Conflict, el código de estado HTTP correcto para "ya existe un recurso con esos datos".
Ejercicio 20 — Login que emite un JWT
Con usuarios ya registrados, implementa POST /login: valida email + password contra la tabla usuarios, y si son correctos, devuelve un JSON Web Token.
💡 ¿Sabías que…? — un JWT no está encriptado, está FIRMADO
Un JWT tiene tres partes separadas por puntos: header.payload.signature. El payload (donde va {id, email}) está solo codificado en Base64 — cualquiera puede decodificarlo y leerlo, no es secreto. Lo que protege el token es la firma: se genera con el JWT_SECRET del servidor, y cualquier alteración al payload invalida la firma. Por eso nunca metas datos sensibles (contraseñas, tarjetas) dentro de un JWT — se puede leer, solo no se puede falsificar sin el secreto.
Ver solución
typescript
// src/routes/auth.ts
authRouter.post('/login', requiereCampos('email', 'password'), (req, res) => {
const { email, password } = req.body;
const usuario = buscarPorEmail(email);
if (!usuario || !verificarPassword(usuario, password)) {
res.status(401).json({ error: 'Credenciales inválidas' });
return;
}
const token = jwt.sign(
{ id: usuario.id, email: usuario.email },
process.env.JWT_SECRET as string,
{ expiresIn: '1h' }
);
res.json({ token });
});Verificado:
$ curl -w " [%{http_code}]" -X POST localhost:3000/login -H "Content-Type: application/json" -d '{"email":"carla@nexus.com","password":"mala"}'
{"error":"Credenciales inválidas"} [401]
$ curl -X POST localhost:3000/login -H "Content-Type: application/json" -d '{"email":"carla@nexus.com","password":"clave123"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MywiZW1haWwiOiJjYXJsYUBuZXh1cy5jb20i..."}🐛 Armando este ejercicio me topé con un bug real (
JWT_SECRETllegandoundefined) por el orden deimportvsdotenv.config()— es el Incidente 4 de la sección 12. Vale la pena leerlo antes de copiar este patrón a otro proyecto.
🧪 Entrevista: ¿Qué diferencia hay entre una sesión con cookies y un JWT? La sesión es stateful — el servidor guarda quién está logueado (en memoria o una base de datos). El JWT es stateless — toda la información va firmada dentro del propio token, el servidor no guarda nada; solo verifica la firma en cada petición. Por eso escala mejor entre varios servidores, a costa de que no puedes "invalidar" un JWT ya emitido sin infraestructura extra.
Ejercicio 21 — Exigir el JWT en toda escritura de /empleados
Reemplaza la API key del Ejercicio 18 por el JWT del Ejercicio 20: todas las rutas que escriben (POST, PUT, PATCH, DELETE de /empleados) deben exigir un token válido.
💡 ¿Sabías que…? — autenticación no es lo mismo que autorización
verificarToken responde quién eres (autenticación) — no responde qué puedes hacer (autorización). Este ejercicio solo resuelve la primera parte: cualquier usuario logueado puede crear, editar o borrar cualquier empleado. Un sistema real añadiría una capa más (roles, permisos) para la segunda — fuera del alcance de esta clase, pero es la pregunta natural que sigue.
Ver solución
typescript
// src/middlewares/jwt.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface RequestConUsuario extends Request {
usuario?: { id: number; email: string };
}
export function verificarToken(req: RequestConUsuario, res: Response, next: NextFunction) {
const encabezado = req.header('authorization');
if (!encabezado?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Falta el token (header Authorization: Bearer <token>)' });
return;
}
const token = encabezado.slice('Bearer '.length);
try {
req.usuario = jwt.verify(token, process.env.JWT_SECRET as string) as { id: number; email: string };
next();
} catch (e) {
res.status(401).json({ error: 'Token inválido o expirado' });
}
}typescript
// src/routes/empleados.ts — verificarToken PRIMERO en cada ruta de escritura
empleadosRouter.post('/', verificarToken, /* ...validación..., */ (req, res) => { /* ... */ });
empleadosRouter.put('/:id', verificarToken, /* ... */);
empleadosRouter.patch('/:id', verificarToken, (req, res) => { /* ... */ });
empleadosRouter.delete('/:id', verificarToken, (req, res) => { /* ... */ });Verificado:
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -d '{"nombre":"Sin Auth","cargo":"Test","salario":1000}'
{"error":"Falta el token (header Authorization: Bearer <token>)"} [401]
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"nombre":"Con Auth","cargo":"QA","salario":1500}'
{"id":7,"nombre":"Con Auth","cargo":"QA","salario":1500,"departamento_id":null,"foto":null} [201]Ejercicio 22 — Rate limiting contra fuerza bruta en /login
Sin límite, alguien puede probar miles de contraseñas por minuto contra /login. Agrega un límite de 5 intentos por minuto, por IP.
💡 ¿Sabías que…? — por qué limitar justo /login y no toda la API
Un límite muy estricto en TODA la API perjudica a usuarios legítimos con uso normal. /login es distinto: es el único endpoint donde "muchos intentos seguidos" casi siempre significa un ataque (nadie escribe mal su contraseña 50 veces en un minuto). Limitar ahí, específicamente, protege lo que más importa sin afectar el resto de la API.
Ver solución
typescript
// src/routes/auth.ts
import rateLimit from 'express-rate-limit';
const limitadorLogin = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Demasiados intentos de login — espera un minuto' },
});
authRouter.post('/login', limitadorLogin, requiereCampos('email', 'password'), (req, res) => {
/* ... */
});Verificado — los primeros 5 pasan la validación (y fallan por credenciales, 401), el sexto ni siquiera llega a validar credenciales:
intento 1: [401]
intento 2: [401]
intento 3: [401]
intento 4: [401]
intento 5: [401]
intento 6: [429]💡
429 Too Many Requestses el código de estado HTTP dedicado exactamente a esto — no es un400ni un403, es su propio código, y por eso los clientes HTTP (navegadores, librerías) lo tratan distinto (reintentar más tarde, en vez de fallar como un error normal).
Ejercicio 23 — Reemplazar el logging manual por morgan
El middleware casero del Ejercicio 2 (console.log con método y ruta) funciona, pero le falta contexto. Reemplázalo por morgan, que además reporta el código de estado y el tiempo de respuesta.
💡 ¿Sabías que…? — `morgan` trae varios formatos listos
'dev' (colores en consola, ideal para desarrollo) y 'combined' (formato Apache estándar, con IP, user-agent, fecha — el que usan la mayoría de herramientas de análisis de logs) son los dos más usados. Elegir uno según el entorno (Ejercicio 27) es el patrón real: 'dev' mientras programas, 'combined' en producción.
Ver solución
typescript
// src/app.ts
import morgan from 'morgan';
// en vez del middleware manual del Ejercicio 2:
app.use(morgan(process.env.LOG_LEVEL || 'dev'));Verificado — cada línea trae método, ruta, status (coloreado), tiempo de respuesta y tamaño de la respuesta, algo que el logger casero no traía:
GET /hello 200 1.764 ms - 32
POST /login 401 4.780 ms - 35
POST /login 429 0.259 ms - 61💡 En tests (
NODE_ENV=test, Ejercicio 26) el logging se apaga por completo — nadie necesita leer cientos de líneas de log mientras corren los asserts dejest.
Ejercicio 24 — Validación declarativa con zod
La validación manual (Ejercicios 5 y 13) funciona, pero reporta un campo a la vez y repite lógica entre rutas. Reemplázala con un único schema zod para el body de empleados.
💡 ¿Sabías que…? — un schema es una sola fuente de verdad
Con requiereCampos + validarSalario tenías dos funciones separadas, y cada una debía llamarse explícitamente en cada ruta. Con zod, un solo empleadoSchema describe la forma COMPLETA del objeto — tipos, campos opcionales, rangos — y el mismo schema sirve para POST y PUT sin repetir nada.
Ver solución
typescript
// src/middlewares/validacionZod.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodType } from 'zod';
export function validarBody<T extends ZodType>(schema: T) {
return (req: Request, res: Response, next: NextFunction) => {
const resultado = schema.safeParse(req.body);
if (!resultado.success) {
res.status(400).json({ error: 'Datos inválidos', detalles: z.treeifyError(resultado.error) });
return;
}
req.body = resultado.data;
next();
};
}
export const empleadoSchema = z.object({
nombre: z.string().min(1, 'nombre es obligatorio'),
cargo: z.string().min(1, 'cargo es obligatorio'),
salario: z.number().positive('salario debe ser positivo'),
departamento_id: z.number().int().positive().optional(),
});typescript
// src/routes/empleados.ts
empleadosRouter.post('/', verificarToken, validarBody(empleadoSchema), (req, res) => { /* ... */ });
empleadosRouter.put('/:id', verificarToken, validarBody(empleadoSchema), (req, res) => { /* ... */ });Verificado — el error ahora detalla CADA campo que falló, no solo el primero:
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"nombre":"X","cargo":"Y","salario":-50}'
{"error":"Datos inválidos","detalles":{"properties":{"salario":{"errors":["salario debe ser positivo"]}}}} [400]
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" -d '{"nombre":"X","salario":100}'
{"error":"Datos inválidos","detalles":{"properties":{"cargo":{"errors":["Invalid input: expected string, received undefined"]}}}} [400]Ejercicio 25 — Documentar la API con Swagger/OpenAPI
Cualquiera que quiera usar esta API tiene que leer el código fuente para saber qué rutas existen. Documéntala con una especificación OpenAPI servida en /docs.
💡 ¿Sabías que…? — una spec OpenAPI sirve incluso si nadie más usa tu API
Un README con ejemplos de curl se desactualiza en silencio: nada avisa cuando el código cambia y el texto se queda atrás. /docs interactivo (con swagger-ui-express) deja probar cada ruta desde el navegador — y si la spec no coincide con el comportamiento real, se nota de inmediato al probar, no meses después.
Ver solución
typescript
// src/swagger.ts (fragmento) — objeto OpenAPI escrito a mano, 100% tipado
export const openApiSpec = {
openapi: '3.0.0',
info: { title: 'API Nexus — Empleados', version: '1.0.0' },
paths: {
'/empleados': {
get: { summary: 'Listar empleados', responses: { '200': { description: 'Lista de empleados' } } },
post: {
summary: 'Crear un empleado (requiere JWT)',
security: [{ bearerAuth: [] }],
responses: { '201': { description: 'Creado' }, '401': { description: 'Sin token' } },
},
},
// ...
},
};typescript
// src/app.ts
import swaggerUi from 'swagger-ui-express';
import { openApiSpec } from './swagger';
app.use('/docs', swaggerUi.serve, swaggerUi.setup(openApiSpec));Verificado:
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3000/docs/
200
$ curl -s localhost:3000/docs/ | grep -o "<title>.*</title>"
<title>Swagger UI</title>Ejercicio 26 — Testing automatizado con jest + supertest
Hasta ahora cada verificación fue un curl manual. Escribe tests automatizados que corran el CRUD completo y el flujo de auth sin necesitar un servidor real corriendo.
💡 ¿Sabías que…? — para testear sin un puerto real, hay que separar `app` de `listen`
supertest sabe hablarle directamente a una instancia de Express sin que esté escuchando en un puerto — pero para eso necesita importar el app solo, sin que ese import dispare app.listen(...). Por eso este ejercicio parte server.ts en dos: app.ts (toda la configuración, exporta app) y server.ts (solo app.listen(...), para producción).
Ver solución
typescript
// src/app.ts — exporta la app SIN escuchar ningún puerto
export const app = express();
// ...toda la configuración de middlewares y rutas...typescript
// src/server.ts — reducido a "arrancar la app en un puerto"
import { app } from './app';
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Servidor corriendo en http://localhost:${PORT}`));typescript
// tests/empleados.test.ts (fragmento)
import request from 'supertest';
import { app } from '../src/app';
describe('Flujo completo: registro → login → crear empleado protegido', () => {
const email = `test-${Date.now()}@nexus.com`;
let token: string;
it('registra un usuario nuevo', async () => {
const res = await request(app).post('/usuarios').send({ email, password: 'clave-de-prueba' });
expect(res.status).toBe(201);
});
it('hace login y recibe un JWT', async () => {
const res = await request(app).post('/login').send({ email, password: 'clave-de-prueba' });
expect(res.body.token).toBeDefined();
token = res.body.token;
});
it('rechaza crear un empleado sin token', async () => {
const res = await request(app).post('/empleados').send({ nombre: 'X', cargo: 'Y', salario: 1 });
expect(res.status).toBe(401);
});
it('crea un empleado con el token válido', async () => {
const res = await request(app)
.post('/empleados')
.set('Authorization', `Bearer ${token}`)
.send({ nombre: 'Test Jest', cargo: 'QA', salario: 1234 });
expect(res.status).toBe(201);
});
});db.ts usa un archivo SQLite separado cuando corren los tests, para no mezclar datos de prueba con los de desarrollo:
typescript
// src/db.ts
const NOMBRE_DB = process.env.NODE_ENV === 'test' ? 'nexus.test.db' : 'nexus.db';
export const db = new DatabaseSync(path.join(DATA_DIR, NOMBRE_DB));Verificado — 9 tests, cubriendo listar, filtrar, estadísticas, y el flujo completo de auth:
$ npm test
Test Suites: 1 passed, 1 total
Tests: 9 passed, 9 total
Time: 1.253 s💡
package.jsontiene un scriptpretest(rm -f data/nexus.test.db) — npm lo corre automáticamente antes detest, así cada corrida empieza con la base de datos de prueba limpia, sin usuarios de una corrida anterior estorbando.
Ejercicio 27 — Configuración por entorno con NODE_ENV
Implementa en código lo que viste en la teoría de esta clase: un .env con secretos compartidos, y un .env.development / .env.production con lo que cambia por entorno (puerto, nivel de log de morgan).
💡 ¿Sabías que…? — dos llamadas a `dotenv.config()`, con `override`
dotenv.config() no pisa una variable que ya existe en process.env por defecto. Por eso el orden importa: cargar primero el .env compartido, y después el específico del entorno con { override: true } — así el archivo por entorno gana si define la misma clave.
Ver solución
bash
# .env.development
PORT=3000
LOG_LEVEL=devbash
# .env.production
PORT=8080
LOG_LEVEL=combinedtypescript
// src/app.ts
dotenv.config(); // secretos compartidos
dotenv.config({ path: `.env.${process.env.NODE_ENV || 'development'}`, override: true }); // por entorno
app.use(morgan(process.env.LOG_LEVEL || 'dev'));Verificado — el mismo build, dos comportamientos distintos según NODE_ENV:
$ NODE_ENV=production node dist/server.js
Servidor corriendo en http://localhost:8080
$ curl localhost:8080/hello
Hola desde la API con TypeScript
-- el log ahora usa formato 'combined', no 'dev' --
::1 - - [07/Aug/2026:13:50:30 +0000] "GET /hello HTTP/1.1" 200 32 "-" "curl/8.7.1"💡 Sin pasar
NODE_ENV, el valor por defecto ('development') hace quenpm startsiga usando el puerto 3000 de siempre — este ejercicio no rompe nada de lo que ya tenías, solo agrega la capacidad de cambiar de entorno explícitamente.
Ejercicio 28 — Subir una foto de empleado con multer
Agrega POST /empleados/:id/foto que reciba un archivo (multipart/form-data, campo foto), lo guarde en disco, y asocie el nombre guardado al empleado.
💡 ¿Sabías que…? — el nombre original de un archivo NUNCA es seguro
Si guardaras el archivo con el nombre que manda el cliente (file.originalname), dos usuarios subiendo foto.png se pisarían el archivo, y un nombre como ../../../etc/passwd podría escribir fuera de la carpeta esperada. Por eso multer genera un nombre nuevo (timestamp + número aleatorio), y el original solo se usa para copiar la extensión.
Ver solución
typescript
// src/middlewares/upload.ts (fragmento)
const almacenamiento = multer.diskStorage({
destination: CARPETA_UPLOADS, // project-root/uploads — NUNCA dentro de dist/
filename: (req, file, cb) => {
const sufijo = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${sufijo}${path.extname(file.originalname)}`);
},
});
const soloImagenes = (req, file, cb) => {
if (!file.mimetype.startsWith('image/')) { cb(new Error('SOLO_IMAGENES')); return; }
cb(null, true);
};typescript
// src/routes/empleados.ts
empleadosRouter.post('/:id/foto', verificarToken, uploadFoto, (req, res) => {
if (!req.file) { res.status(400).json({ error: 'Falta el archivo (campo "foto")' }); return; }
const actualizado = asignarFoto(req.params.id, req.file.filename);
if (!actualizado) { res.status(404).json({ error: 'Empleado no encontrado' }); return; }
res.json(buscarPorId(req.params.id));
});Verificado:
$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados/1/foto -H "Authorization: Bearer $TOKEN" -F "foto=@foto.png"
{"id":1,"nombre":"Ana Torres",...,"foto":"1786111481056-23844747.png"} [200]🐛 Gotcha real: en el primer intento, el error de
fileFilter(archivo que no es imagen) caía en el manejador de errores genérico del Ejercicio 14 y devolvía500— un error del CLIENTE (subir un.txt) disfrazado de error del SERVIDOR. El fix: atrapar el error demulterdentro del propio middleware (con su forma de callback,uploadFotoInterno.single('foto')(req, res, callback)), en vez de pasarlo directo como middleware — así se traduce a un400limpio, no un500:$ curl -w " [%{http_code}]" -X POST localhost:3000/empleados/1/foto -H "Authorization: Bearer $TOKEN" -F "foto=@no-imagen.txt" {"error":"Solo se aceptan imágenes"} [400]También descubrí, en el mismo ejercicio, que
path.join(__dirname, '..', 'uploads')desdesrc/middlewares/upload.tsapunta adist/uploadsuna vez compilado — un directorio querm -rf distborra por completo en cada rebuild. El fix fue subir un nivel más (__dirname, '..', '..', 'uploads') para aterrizar enuploads/en la raíz del proyecto, al mismo nivel quedata/.
Ejercicio 29 — Cache en memoria con invalidación
GET /empleados/estadisticas recalcula COUNT/AVG/MAX en cada petición. Agrega un cache en memoria — pero que se invalide automáticamente cada vez que algo escribe en empleados.
💡 ¿Sabías que…? — un cache sin invalidación es peor que no tener cache
Cachear sin invalidar significa servir datos viejos para siempre — el error más común con cachés improvisados. La parte difícil de un cache nunca es guardarlo; es saber cuándo tirarlo. Este ejercicio invalida explícitamente en cada función que modifica empleados (crear, actualizar, eliminar).
Ver solución
typescript
// src/cache.ts — un Map simple, vive mientras el proceso esté arriba
const almacen = new Map<string, unknown>();
export function obtenerCache<T>(clave: string): T | undefined {
return almacen.get(clave) as T | undefined;
}
export function guardarCache(clave: string, valor: unknown): void {
almacen.set(clave, valor);
}
export function invalidarCache(clave: string): void {
almacen.delete(clave);
}typescript
// src/services/empleados.service.ts
export function obtenerEstadisticas() {
const enCache = obtenerCache(CLAVE_CACHE_STATS);
if (enCache) return { ...enCache, desdeCache: true };
const stats = db.prepare('SELECT COUNT(*) AS total, AVG(salario) AS promedio, MAX(salario) AS maximo FROM empleados').get();
guardarCache(CLAVE_CACHE_STATS, stats);
return { ...stats, desdeCache: false };
}
export function crearEmpleado(/* ... */) {
const info = db.prepare(/* INSERT */).run(/* ... */);
invalidarCache(CLAVE_CACHE_STATS); // ← la clave del ejercicio
return buscarPorId(String(info.lastInsertRowid));
}
// el mismo invalidarCache() se repite en actualizarEmpleado, actualizarParcial y eliminarEmpleadoVerificado — el campo desdeCache deja ver el mecanismo en vivo:
$ curl localhost:3000/empleados/estadisticas
{"total":3,"promedio":3366.67,"maximo":4100,"desdeCache":false}
$ curl localhost:3000/empleados/estadisticas
{"total":3,"promedio":3366.67,"maximo":4100,"desdeCache":true}
$ curl -X POST localhost:3000/empleados -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"nombre":"Cache Demo","cargo":"QA","salario":5000}'
{"id":4,"nombre":"Cache Demo",...} [201]
$ curl localhost:3000/empleados/estadisticas
{"total":4,"promedio":3775,"maximo":5000,"desdeCache":false}💡 Fíjate en la última línea: el
POSTinvalidó el cache, así que la siguiente consulta a/estadisticasvolvió a calcular desde SQLite (desdeCache:false) — con los números YA actualizados (total:4,maximo:5000), no los viejos.
Ejercicio 30 — Healthcheck + apagado ordenado (graceful shutdown)
Cierra el ciclo de vida del servidor: un endpoint GET /health para que un orquestador sepa si el proceso está sano, y un manejo de SIGTERM/SIGINT que cierre la base de datos antes de salir.
💡 ¿Sabías que…? — un `docker stop` manda SIGTERM, no mata el proceso de golpe
Cuando Docker o Kubernetes detienen un contenedor, primero mandan SIGTERM y esperan un tiempo prudente antes de forzar con SIGKILL. Un proceso que ignora SIGTERM pierde esa ventana — y si estaba a mitad de una escritura en SQLite cuando lo mataron a la fuerza, el archivo puede quedar en un estado inconsistente. Atender SIGTERM explícitamente es lo que separa un contenedor que se apaga limpio de uno que se apaga a la mala.
Ver solución
typescript
// src/app.ts
app.get('/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});typescript
// src/server.ts
import { app } from './app';
import { db } from './db';
const PORT = process.env.PORT || 3000;
const servidor = app.listen(PORT, () => console.log(`Servidor corriendo en http://localhost:${PORT}`));
function apagarOrdenadamente(señal: string) {
console.log(`\n${señal} recibida — cerrando servidor...`);
servidor.close(() => {
db.close();
console.log('Servidor y base de datos cerrados. Adiós.');
process.exit(0);
});
}
process.on('SIGTERM', () => apagarOrdenadamente('SIGTERM'));
process.on('SIGINT', () => apagarOrdenadamente('SIGINT'));Verificado — el healthcheck, y el apagado ordenado al mandar la misma señal que envía Docker al detener un contenedor:
$ curl localhost:3000/health
{"status":"ok","uptime":19.298925834}
$ kill -TERM $(lsof -t -i:3000)
SIGTERM recibida — cerrando servidor...
Servidor y base de datos cerrados. Adiós.La estructura final del proyecto, después de los 30 ejercicios:
API/
├── src/
│ ├── app.ts ← toda la config de Express (Ejercicio 26)
│ ├── server.ts ← solo arranca la app + apagado ordenado (Ejercicio 30)
│ ├── db.ts ← conexión + esquema (empleados, departamentos, usuarios)
│ ├── cache.ts ← cache en memoria (Ejercicio 29)
│ ├── swagger.ts ← spec OpenAPI (Ejercicio 25)
│ ├── routes/
│ │ ├── empleados.ts ← HTTP: rutas y códigos de estado
│ │ └── auth.ts ← registro + login (Ejercicios 19-20)
│ ├── services/
│ │ ├── empleados.service.ts ← todo el SQL de empleados
│ │ └── usuarios.service.ts ← hash de contraseñas (Ejercicio 19)
│ └── middlewares/
│ ├── validacion.ts ← requiereCampos(...) manual
│ ├── validacionZod.ts ← validación declarativa (Ejercicio 24)
│ ├── jwt.ts ← verificarToken (Ejercicio 21)
│ ├── apiKey.ts ← requiereApiKey (Ejercicio 18, ya no montado)
│ ├── upload.ts ← multer + manejo de errores (Ejercicio 28)
│ └── errores.ts ← manejador central de errores
├── tests/
│ └── empleados.test.ts ← jest + supertest (Ejercicio 26)
├── uploads/ ← fotos subidas (Ejercicio 28, fuera de dist/)
├── data/nexus.db ← + nexus.test.db en tests
├── .env ← secretos compartidos
├── .env.development / .env.production ← config por entorno (Ejercicio 27)
├── jest.config.js / tsconfig.jest.json
├── tsconfig.json
└── package.json💡 Compara esta estructura con la del final del Ejercicio 17. Cada bloque nuevo (auth, validación, testing, observability) se sumó sin romper lo que ya funcionaba — y sin crear un solo proyecto nuevo. Esa es la diferencia entre un ejercicio de clase y algo que se empieza a parecer a software real.
❓ Preguntas y respuestas (autoevaluación)
1. ¿Es Node.js un framework?
No — es un runtime: un entorno que ejecuta JavaScript fuera del navegador, construido sobre el motor V8. Express es el framework que corre sobre Node.js.
2. ¿Por qué usar TypeScript para construir una API, y no JavaScript puro?
Porque tipar los datos que fluyen por la API (peticiones, respuestas, parámetros) detecta errores de forma en tiempo de compilación, en vez de descubrirlos cuando ya rompieron algo en producción.
3. ¿Qué comando crea un package.json sin hacer preguntas?
npm init -y— acepta todos los valores por defecto automáticamente.
4. ¿Qué es un middleware en Express?
Una función que se ejecuta entre la petición y la respuesta, con acceso a
req,resynext()— se usa para tareas transversales como CORS, parseo del body o logging.
5. ¿Para qué sirve express.json() específicamente?
Es el middleware que parsea el cuerpo JSON de una petición y lo deja disponible en
req.body. Sin registrarlo,req.bodyesundefined.
6. ¿Qué problema resuelve esModuleInterop: true?
Permite usar
import express from 'express'(sintaxis moderna) con paquetes escritos en CommonJS — sin esta opción, tocaría usarimport * as express from 'express', la sintaxis antigua.
7. ¿Qué código de estado devuelve un POST que creó algo, y cuál un DELETE que funcionó?
201(Created) para elPOST— se creó un recurso nuevo.204(No Content) para elDELETE— la operación salió bien pero no hay nada que devolver en el cuerpo. Un404va cuando el recurso pedido no existe.
8. ¿Qué hay que instalar para usar SQLite desde Node.js con node:sqlite?
Nada — Node.js 22.5+ trae el módulo integrado, sin paquetes externos ni banderas experimentales (verificado en Node 24). Lo único que necesitas es
@types/nodeactualizado para que TypeScript lo reconozca.
9. ¿Qué es un prepared statement y de qué te protege?
Una consulta con "huecos" (
?) donde los valores se pasan por separado (db.prepare('... WHERE id = ?').get(id)). El motor nunca confunde datos con código SQL, así que previene inyección SQL: un texto malicioso se guarda como texto, no se ejecuta.
10. Si tengo app.get('/empleados/:id', ...) registrada antes que app.get('/empleados/estadisticas', ...), ¿qué pasa al pedir /empleados/estadisticas?
Responde la ruta dinámica, tratando
"estadisticas"como si fuera unid. Express prueba las rutas en el orden en que las registraste, no por especificidad — por eso las rutas fijas van siempre antes que las dinámicas.
11. ¿Cuál es la diferencia principal entre type e interface?
interfacese puede extender (extends) y se combina automáticamente si la declaras varias veces;typeno permite ninguna de las dos, pero sí puede representar uniones (type A = string | number), algo queinterfaceno puede.
12. De los cuatro incidentes de la sección 12, ¿cuál es la lección común?
Que el entorno (y el ORDEN de ejecución) importa tanto como la lógica del código: un typo de un carácter aborta un
npm installcompleto (es todo-o-nada), instalar sin fijar versión puede darte paquetes distintos entre dos corridas del mismo comando, una regla nueva del compilador (rootDirobligatorio desde TS 6.0) rompe proyectos que antes funcionaban, y leer una variable de entorno en una constante de módulo puede capturarundefinedsidotenv.config()no corrió todavía. Ninguno era un error de lógica de negocio.
13. ¿Qué diferencia real hay entre una API key y un JWT como mecanismo de autenticación?
Una API key es un secreto compartido y anónimo — protege el "qué" (¿conoce el secreto?) pero no el "quién". Un JWT identifica al usuario: lleva su
id/
14. ¿Por qué nunca se guarda una contraseña en texto plano, ni siquiera "por ahora, para probar"?
Porque si la base de datos se filtra (una brecha, un backup mal configurado), todas las contraseñas quedan expuestas de inmediato. Hasheando con
bcrypt, un atacante con la base de datos todavía tendría que romper cada hash por separado — computacionalmente caro a propósito.
15. ¿Qué código de estado HTTP corresponde a "demasiadas peticiones en poco tiempo", y por qué no reusar simplemente un 400?
429 Too Many Requests. Reusar400mezclaría dos causas muy distintas (datos mal formados vs. límite de tasa excedido) bajo el mismo código —429además trae semántica extra que los clientes HTTP entienden (reintentar más tarde, no fallar de inmediato).
16. ¿Qué ventaja concreta da zod sobre validar manualmente campo por campo?
Un solo schema declarativo describe la forma completa del dato (tipos, opcionalidad, rangos) en un solo lugar, reutilizable en varias rutas — y el error que devuelve detalla TODOS los campos que fallaron a la vez, no solo el primero que se topó la validación manual.
17. ¿Por qué separar app.ts (la configuración de Express) de server.ts (que llama a .listen())?
Para poder testear con
supertestsin levantar un servidor real en un puerto —supertesthabla directamente con la instancia deapp. Siapp.listen()estuviera mezclado en el mismo archivo, importar la app para testear también abriría un puerto de verdad.
18. En un cache en memoria, ¿cuál es la parte realmente difícil: guardar el valor o invalidarlo?
Invalidarlo. Guardar es trivial (
Map.set); saber exactamente CUÁNDO ese valor cacheado dejó de reflejar la realidad — y limpiarlo en ese momento — es lo que distingue un cache útil de uno que sirve datos viejos para siempre.
19. ¿Qué señal manda Docker (o Kubernetes) al detener un contenedor, y qué pasa si el proceso la ignora?
SIGTERM, con una ventana de tiempo para cerrar limpio antes de forzar conSIGKILL. Si el proceso la ignora, pierde esa ventana — y si estaba a mitad de una escritura (por ejemplo en SQLite) cuando lo mataron a la fuerza, el archivo puede quedar en un estado inconsistente.
20. ¿Por qué un archivo .env.production normalmente NO llega a existir en un servidor de producción real?
Porque en un despliegue real (Docker, un PaaS, Kubernetes) las variables de entorno las inyecta la propia plataforma — un secret manager, variables del contenedor — no un archivo en disco. El archivo
.enves sobre todo una herramienta de desarrollo local; en producción cambia el mecanismo, pero el principio (config fuera del código) es el mismo.
📎 Apuntes relacionados
- Clase 5: interfaces
- Clase 13:
outDir,tsc -p, cadena detsconfig - Clase 14:
strict/strictNullChecks - Clase 16: el mismo error
TS5011derootDir - Conceptos
- Comandos
➡️ Siguiente
Utility types — Partial, Pick, Omit, Record… transformar tipos existentes sin reescribirlos.