Proyecto: API de tareas
💡 🎯 Al terminar esta lección podrás…
- Organizar una API en módulos con responsabilidades claras.
- Combinar Express,
Router, validación y un manejador de errores central. - Guardar y recuperar datos en un archivo JSON con
fs/promises. - Filtrar y paginar resultados a partir de la query.
- Dejar el proyecto listo para desplegar.
1.Qué vas a construir
Vas a crear una API REST de tareas completa y lista para publicar: se pueden crear, listar (con filtros y paginación), editar y borrar tareas; los datos se guardan en un archivo JSON y sobreviven a un reinicio; las entradas se validan; los errores se responden en JSON coherente; y está preparada para desplegarse. Reúne todo el curso en un solo proyecto.
| Función de la API | Lo que aplicas | Lección |
|---|---|---|
| Arrancar el proyecto, scripts y dependencias | npm, package.json, .gitignore | 3 |
| Dividir el código en módulos | ES Modules, import y export | 2 |
| Guardar y leer tareas en un archivo | fs/promises, path, JSON | 2 |
Rutas, Router y middleware | Express | 4 |
| CRUD completo con códigos de estado | Verbos HTTP, REST | 5 |
| Configuración por variables de entorno | process.env, .env | 6 |
| Validación, manejo de errores y CORS | Errores y seguridad | 7 |
Ruta de salud y script start | Despliegue | 8 |
💡 Consejo · Cómo trabajar
Construye paso a paso y prueba con curl (o Thunder Client) después de cada paso. Si algo falla, el error está en lo último que cambiaste.
Los endpoints finales serán:
| Método y ruta | Qué hace | Éxito |
|---|---|---|
GET /salud | Comprueba que la API vive | 200 |
GET /tareas?hecha=&q=&prioridad=&pagina=&limite= | Lista con filtros y paginación | 200 |
GET /tareas/:id | Una tarea | 200 |
POST /tareas | Crea una tarea | 201 |
PATCH /tareas/:id | Modifica campos de una tarea | 200 |
DELETE /tareas/:id | Borra una tarea | 204 |
2.Plan: la arquitectura
En vez de un solo archivo gigante, separa responsabilidades. Cada módulo hace una cosa y es fácil de probar:
api-tareas/
├── package.json
├── .gitignore ← node_modules, .env, datos/
├── .env.example
├── datos/
│ └── tareas.json ← se crea solo
└── src/
├── index.js ← arranque: lee la config y escucha en el puerto
├── app.js ← crea y configura la aplicación Express
├── config.js ← lee las variables de entorno
├── almacen.js ← guarda y lee tareas (archivo JSON)
├── validar.js ← validarTarea
├── consultas.js ← filtrarTareas y paginar
├── errores.js ← crearErrorHttp y manejarErrores
└── rutas/
└── tareas.js ← el Router de /tareas
El truco de diseño es que las funciones puras (validar, filtrar, paginar) no saben nada de Express ni del disco, y que el almacén recibe sus funciones de lectura y escritura como argumentos. Así puedes comprobarlas sin arrancar el servidor, igual que en los ejercicios del curso:
validaralmacén3.Paso 1: crear el proyecto
mkdir api-tareas
cd api-tareas
npm init -y
npm install express cors
mkdir src src/rutas datos
Edita package.json para usar ES Modules y añade los scripts:
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch --env-file=.env src/index.js"
},
"engines": { "node": ">=20" }
node_modules
.env
datos/
PORT=3000
ORIGENES=http://localhost:5173
NODE_ENV=development
Copia .env.example como .env (ya está en .gitignore). Después crea src/config.js:
export function leerConfig(env = process.env) {
return {
puerto: Number(env.PORT) || 3000,
esProduccion: env.NODE_ENV === "production",
origenes: (env.ORIGENES ?? "")
.split(",")
.map((o) => o.trim())
.filter(Boolean),
};
}
4.Paso 2: validar, filtrar y paginar
Estas tres piezas son funciones puras: reciben datos y devuelven datos. En el ejercicio de este proyecto las escribirás tú y las comprobarás en el navegador; aquí tienes su forma.
export function validarTarea(datos, parcial = false) {
const errores = [];
const t = datos?.titulo;
if (t === undefined && parcial) {
// en PATCH el título es opcional
} else if (typeof t !== "string" || t.trim() === "") {
errores.push("titulo obligatorio");
} else if (t.length > 100) {
errores.push("titulo demasiado largo");
}
if (datos?.hecha !== undefined && typeof datos.hecha !== "boolean") {
errores.push("hecha debe ser booleano");
}
if (datos?.prioridad !== undefined && !["baja", "media", "alta"].includes(datos.prioridad)) {
errores.push("prioridad invalida");
}
return errores;
}
export function filtrarTareas(tareas, query = {}) {
return tareas.filter((t) => {
if (query.hecha !== undefined && t.hecha !== (query.hecha === "true")) return false;
if (query.prioridad !== undefined && t.prioridad !== query.prioridad) return false;
if (query.q && !t.titulo.toLowerCase().includes(String(query.q).toLowerCase())) return false;
return true;
});
}
export function paginar(items, pagina, limite) {
let p = Math.floor(Number(pagina));
let l = Math.floor(Number(limite));
if (!(p >= 1)) p = 1;
if (!(l >= 1)) l = 10;
l = Math.min(l, 50);
const inicio = (p - 1) * l;
return {
datos: items.slice(inicio, inicio + l),
total: items.length,
pagina: p,
limite: l,
paginas: Math.max(1, Math.ceil(items.length / l)),
};
}
📌 Nota · Por qué se convierte cada valor
Los parámetros de la query llegan como texto ("2", "true"). Todas las conversiones y valores por defecto están en estas funciones, no repartidos por las rutas.
5.Paso 3: el almacén en un archivo JSON
El almacén guarda la lista de tareas como JSON. Recibe cómo leer y cómo escribir, así que no depende del disco directamente:
import { readFile, writeFile, mkdir } from "node:fs/promises";
import path from "node:path";
export function crearAlmacen(leer, escribir) {
async function cargar() {
try {
return JSON.parse(await leer());
} catch (error) {
if (error.code === "ENOENT") return []; // aún no existe el archivo
throw error;
}
}
async function guardar(tareas) {
await escribir(JSON.stringify(tareas, null, 2));
}
return {
listar: cargar,
async crear(datos) {
const tareas = await cargar();
const id = tareas.reduce((max, t) => Math.max(max, t.id), 0) + 1;
const nueva = { id, ...datos };
tareas.push(nueva);
await guardar(tareas);
return nueva;
},
async actualizar(id, cambios) {
const tareas = await cargar();
const i = tareas.findIndex((t) => t.id === id);
if (i === -1) return null;
tareas[i] = { ...tareas[i], ...cambios, id };
await guardar(tareas);
return tareas[i];
},
async borrar(id) {
const tareas = await cargar();
const i = tareas.findIndex((t) => t.id === id);
if (i === -1) return false;
tareas.splice(i, 1);
await guardar(tareas);
return true;
},
};
}
// El almacén real: usa un archivo en la carpeta datos/
export function crearAlmacenEnArchivo() {
const archivo = path.join(import.meta.dirname, "..", "datos", "tareas.json");
return crearAlmacen(
() => readFile(archivo, "utf8"),
async (texto) => {
await mkdir(path.dirname(archivo), { recursive: true });
await writeFile(archivo, texto);
}
);
}
Fíjate en el id nuevo: máximo actual + 1, no «cantidad + 1». Así, tras borrar tareas o reiniciar el servidor, nunca se repite un id existente.
⚠️ Importante · Límites de este diseño
Un archivo JSON sirve para aprender y para proyectos muy pequeños. Si dos peticiones escriben a la vez pueden pisarse, y en muchas plataformas de despliegue el disco se borra al reiniciar (lección 8). Una base de datos resuelve ambos problemas: es tu siguiente paso.
6.Paso 4: errores y rutas
export function crearErrorHttp(estado, mensaje) {
const error = new Error(mensaje);
error.status = estado;
return error;
}
export function manejarErrores(err, req, res, next) {
const estado = err.status ?? 500;
if (estado >= 500) console.error(err);
res.status(estado).json({
error: estado >= 500 ? "Error interno del servidor" : err.message,
...(err.detalles ? { detalles: err.detalles } : {}),
});
}
Ahora el Router. Recibe el almacén como argumento, por lo que las rutas no saben dónde se guardan los datos:
import express from "express";
import { validarTarea } from "../validar.js";
import { filtrarTareas, paginar } from "../consultas.js";
import { crearErrorHttp } from "../errores.js";
function leerId(req) {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1) {
throw crearErrorHttp(400, "El id debe ser un número entero positivo");
}
return id;
}
function lanzarSiInvalido(errores) {
if (errores.length > 0) {
const error = crearErrorHttp(400, "Datos inválidos");
error.detalles = errores;
throw error;
}
}
export function crearRutasTareas(almacen) {
const router = express.Router();
router.get("/", async (req, res) => {
const todas = await almacen.listar();
const filtradas = filtrarTareas(todas, req.query);
res.json(paginar(filtradas, req.query.pagina, req.query.limite));
});
router.get("/:id", async (req, res) => {
const id = leerId(req);
const tarea = (await almacen.listar()).find((t) => t.id === id);
if (!tarea) throw crearErrorHttp(404, "Tarea no encontrada");
res.json(tarea);
});
router.post("/", async (req, res) => {
lanzarSiInvalido(validarTarea(req.body));
const { titulo, hecha = false, prioridad = "media" } = req.body;
const nueva = await almacen.crear({ titulo: titulo.trim(), hecha, prioridad });
res.status(201).json(nueva);
});
router.patch("/:id", async (req, res) => {
const id = leerId(req);
lanzarSiInvalido(validarTarea(req.body, true));
const { titulo, hecha, prioridad } = req.body;
const cambios = {};
if (titulo !== undefined) cambios.titulo = titulo.trim();
if (hecha !== undefined) cambios.hecha = hecha;
if (prioridad !== undefined) cambios.prioridad = prioridad;
const actualizada = await almacen.actualizar(id, cambios);
if (!actualizada) throw crearErrorHttp(404, "Tarea no encontrada");
res.json(actualizada);
});
router.delete("/:id", async (req, res) => {
const borrada = await almacen.borrar(leerId(req));
if (!borrada) throw crearErrorHttp(404, "Tarea no encontrada");
res.sendStatus(204);
});
return router;
}
📌 Nota · Rutas limpias gracias a Express 5
Las rutas son async y simplemente hacen throw: Express 5 envía esos errores al manejador central. Si usas Express 4, tendrías que envolver cada ruta con try/catch y next(error).
Más información sobre Router en la guía de enrutamiento de Express.
7.Paso 5: ensamblar y arrancar
import express from "express";
import cors from "cors";
import { crearRutasTareas } from "./rutas/tareas.js";
import { manejarErrores } from "./errores.js";
export function crearApp(almacen, config) {
const app = express();
const inicio = Date.now();
app.use(cors({ origin: config.origenes.length > 0 ? config.origenes : false }));
app.use(express.json({ limit: "100kb" }));
app.get("/salud", (req, res) => {
res.json({ estado: "ok", uptimeSegundos: Math.floor((Date.now() - inicio) / 1000) });
});
app.use("/tareas", crearRutasTareas(almacen));
app.use((req, res) => res.status(404).json({ error: "Ruta no encontrada" }));
app.use(manejarErrores);
return app;
}
import { leerConfig } from "./config.js";
import { crearAlmacenEnArchivo } from "./almacen.js";
import { crearApp } from "./app.js";
const config = leerConfig();
const app = crearApp(crearAlmacenEnArchivo(), config);
app.listen(config.puerto, () => {
console.log(`API de tareas en http://localhost:${config.puerto}`);
});
npm run dev
API de tareas en http://localhost:3000
Una consecuencia de este diseño: crearApp recibe el almacén, así que en pruebas puedes darle uno falso en memoria sin tocar el disco.
8.Paso 6: probarla de punta a punta
curl http://localhost:3000/salud
curl -X POST http://localhost:3000/tareas -H "Content-Type: application/json" -d '{"titulo":"Estudiar Express","prioridad":"alta"}'
curl -X POST http://localhost:3000/tareas -H "Content-Type: application/json" -d '{"titulo":""}'
curl "http://localhost:3000/tareas?prioridad=alta&limite=5"
curl -X PATCH http://localhost:3000/tareas/1 -H "Content-Type: application/json" -d '{"hecha":true}'
curl -X DELETE http://localhost:3000/tareas/1 -i
curl http://localhost:3000/tareas/abc
{"estado":"ok","uptimeSegundos":12}
{"id":1,"titulo":"Estudiar Express","hecha":false,"prioridad":"alta"}
{"error":"Datos inválidos","detalles":["titulo obligatorio"]}
{"datos":[{"id":1,"titulo":"Estudiar Express","hecha":false,"prioridad":"alta"}],"total":1,"pagina":1,"limite":5,"paginas":1}
{"id":1,"titulo":"Estudiar Express","hecha":true,"prioridad":"alta"}
HTTP/1.1 204 No Content
{"error":"El id debe ser un número entero positivo"}
Después de crear una tarea, abre datos/tareas.json: verás tus datos. Detén el servidor, vuelve a arrancarlo y comprueba que siguen ahí. En PowerShell recuerda usar curl.exe y escapar las comillas, o prueba con Thunder Client.
9.Revisión de calidad
Antes de dar el proyecto por terminado, recórrelo con esta lista:
10.Publicarla
Sigue los pasos de la lección 8: sube el repositorio a GitHub, crea el servicio web en una plataforma, usa npm ci como instalación y npm start como arranque, y define NODE_ENV=production y ORIGENES en el panel. Prueba /salud con la URL pública.
⚠️ Importante · Recuerda el disco efímero
Si la plataforma borra el disco al reiniciar, tus tareas se perderán. Para el portafolio basta con que la API funcione y lo expliques; si quieres datos persistentes de verdad, el siguiente paso es una base de datos.
11.Retos para ir más lejos
- Ordenar:
GET /tareas?orden=tituloy?orden=-titulo(descendente). - PUT: añade el reemplazo completo de una tarea.
- Fecha límite: campo opcional
limitevalidado comoAAAA-MM-DD. - Pruebas automáticas: Node trae un ejecutor de pruebas integrado,
node:test. Escribe pruebas paravalidarTareaycrearAlmaceny ejecútalas connode --test. - Cuentas de usuario: que cada persona vea solo sus tareas (autenticación).
- Un frontend: una página con
fetchque use tu API (acuérdate de CORS).
12.Qué sigue en tu camino
Con este proyecto ya dominas los cimientos del backend con Node. Para seguir:
- Bases de datos: sustituir el archivo JSON por una base de datos (relacional como PostgreSQL o SQLite, o documental como MongoDB). Es el paso más natural.
- Autenticación: registro, inicio de sesión, contraseñas con hash y tokens.
- TypeScript: añade tipos a tu código para detectar errores antes de ejecutarlo.
- Documentar la API: describir tus rutas con un estándar como OpenAPI.
- Conectarla con React: usa el curso de React para construir la interfaz que consume tu API.
13.Errores frecuentes
- Todo en un solo archivo: pronto es imposible de mantener. Separa módulos.
- Calcular el id con
length + 1: se repiten ids tras borrar. Usa el máximo + 1. - Olvidar
awaiten el almacén: respondes antes de guardar y devuelves promesas en vez de datos. - Registrar el 404 o el manejador de errores antes de las rutas: capturan todo.
- Subir
datos/o.enva Git. - No convertir los parámetros de la query:
"2"no es2. - Fiarse del archivo JSON en producción: puede perderse o corromperse.
📒 Apuntes para llevar
- Separa la API en módulos: config, almacén, validación, consultas, errores, rutas y app.
- Las funciones puras (validar, filtrar, paginar) son fáciles de probar y de reutilizar.
- El almacén recibe sus funciones de lectura y escritura: no depende del disco.
- Id nuevo = máximo actual + 1.
express.Router()agrupa las rutas de un recurso y se monta conapp.use.- Rutas
asyncque lanzan errores: Express 5 los envía al manejador central. - Orden en
app.js: CORS,express.json, rutas, 404 y manejador de errores. - Listo para producción:
start,PORT,/salud,.env.exampley.gitignore.
14.Glosario
| Término | Significado |
|---|---|
| Router | Mini aplicación de Express que agrupa las rutas de un recurso. |
| Función pura | Función que solo depende de sus argumentos y no modifica nada fuera de ella. |
| Inyección de dependencias | Pasar a una función las piezas que necesita (como leer y escribir) en lugar de crearlas dentro. |
| Paginación | Dividir una lista larga en páginas de tamaño fijo. |
| Persistencia | Capacidad de conservar los datos tras apagar el programa. |
| Capas | Separación del código por responsabilidad: rutas, lógica y almacenamiento. |
| Endpoint | Combinación de método y ruta que ofrece la API. |
| Backend | Parte de una aplicación que corre en el servidor. |
🧠 Comprueba lo aprendido
Elige una respuesta en cada pregunta. Verás al instante si acertaste y por qué.
¿Por qué se calcula el id nuevo como «máximo + 1» y no como «cantidad + 1»?
Si hay ids 1 y 3 (se borró el 2), «cantidad + 1» daría 3 y chocaría; el máximo + 1 da 4.
¿Para qué sirve express.Router()?
Permite escribir las rutas de /tareas aparte y montarlas con app.use("/tareas", router).
¿Qué ventaja tiene que el almacén reciba leer y escribir como argumentos?
En las pruebas se le pasan funciones falsas en memoria.
¿Dónde deben registrarse el 404 y el manejador de errores?
Si se registran antes, capturarían todas las peticiones.
¿Qué problema tiene un archivo JSON como almacén en producción?
Para datos serios se usa una base de datos.
Tu progreso se guarda en este navegador y se refleja en el índice del curso.
💡 🚀 ¡Ahora te toca!
La práctica fija lo aprendido. Pasa al ejercicio de esta lección.