Saltar al contenido
Curso de Node.js y APIs
Todos los cursos Ir al ejercicio →

Proyecto: API de tareas

  • ⏱ 180 minutos
  • 📶 Nivel: intermedio
  • 🧩 Incluye ejercicio

💡 🎯 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 APILo que aplicasLección
Arrancar el proyecto, scripts y dependenciasnpm, package.json, .gitignore3
Dividir el código en módulosES Modules, import y export2
Guardar y leer tareas en un archivofs/promises, path, JSON2
Rutas, Router y middlewareExpress4
CRUD completo con códigos de estadoVerbos HTTP, REST5
Configuración por variables de entornoprocess.env, .env6
Validación, manejo de errores y CORSErrores y seguridad7
Ruta de salud y script startDespliegue8

💡 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 rutaQué haceÉxito
GET /saludComprueba que la API vive200
GET /tareas?hecha=&q=&prioridad=&pagina=&limite=Lista con filtros y paginación200
GET /tareas/:idUna tarea200
POST /tareasCrea una tarea201
PATCH /tareas/:idModifica campos de una tarea200
DELETE /tareas/:idBorra una tarea204

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:

Estructura del proyecto
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:

RutaExpress
→validar
Lógica puravalidar, filtrar, paginar
→almacén
Archivo JSONdatos/tareas.json

3.Paso 1: crear el proyecto

Terminal
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:

package.json (fragmento)
"type": "module",
"scripts": {
  "start": "node src/index.js",
  "dev": "node --watch --env-file=.env src/index.js"
},
"engines": { "node": ">=20" }
.gitignore
node_modules
.env
datos/
.env.example
PORT=3000
ORIGENES=http://localhost:5173
NODE_ENV=development

Copia .env.example como .env (ya está en .gitignore). Después crea src/config.js:

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.

src/validar.js
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;
}
src/consultas.js
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:

src/almacen.js
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

src/errores.js
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:

src/rutas/tareas.js
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

src/app.js
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;
}
src/index.js
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}`);
});
Terminal
npm run dev
Salida esperada
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

Terminal (macOS, Linux, Git Bash)
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
Salida esperada
{"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

12.Qué sigue en tu camino

Con este proyecto ya dominas los cimientos del backend con Node. Para seguir:

13.Errores frecuentes

📒 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 con app.use.
  • Rutas async que 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.example y .gitignore.

14.Glosario

TérminoSignificado
RouterMini aplicación de Express que agrupa las rutas de un recurso.
Función puraFunción que solo depende de sus argumentos y no modifica nada fuera de ella.
Inyección de dependenciasPasar a una función las piezas que necesita (como leer y escribir) en lugar de crearlas dentro.
PaginaciónDividir una lista larga en páginas de tamaño fijo.
PersistenciaCapacidad de conservar los datos tras apagar el programa.
CapasSeparación del código por responsabilidad: rutas, lógica y almacenamiento.
EndpointCombinación de método y ruta que ofrece la API.
BackendParte 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»?

¿Para qué sirve express.Router()?

¿Qué ventaja tiene que el almacén reciba leer y escribir como argumentos?

¿Dónde deben registrarse el 404 y el manejador de errores?

¿Qué problema tiene un archivo JSON como almacén en producción?

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.

Ir al ejercicio