FullStackJS Camp
Módulo 6·proyecto·4h
Objetivos de aprendizaje
  • Diseñar e implementar un CRUD completo con Express
  • Persistir datos en un archivo JSON usando fs/promises
  • Usar variables de entorno con dotenv
  • Implementar validaciones de entrada
  • Servir un frontend estático junto con la API

REST API Profesional con Express

En este proyecto construiremos una API de gestión de tareas con CRUD completo y persistencia en un archivo JSON.

Estructura del proyecto

code
api-tareas/
├── server/
│   ├── server.js          ← Servidor Express principal
│   ├── .env               ← Variables de entorno
│   ├── package.json
│   └── db/
│       └── tareas.json    ← "Base de datos" JSON
└── public/
    └── index.html         ← Frontend estático (opcional)

Configuración inicial

bash
mkdir api-tareas && cd api-tareas
mkdir -p server/db public
cd server
npm init -y
npm install express dotenv
npm install --save-dev nodemon
json
// server/package.json
{
  "name": "api-tareas",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js",
    "dev:native": "node --watch server.js"
  }
}
bash
# server/.env
PORT=3000
NODE_ENV=development
json
// server/db/tareas.json — archivo inicial vacío
[]

El servidor completo

javascript
// server/server.js
import express from "express";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import "dotenv/config";

const app = express();
const PORT = process.env.PORT ?? 3000;

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_FILE = path.join(__dirname, "db", "tareas.json");
const PUBLIC_DIR = path.join(__dirname, "..", "public");

// ── Middlewares ──────────────────────────────────────────────
app.use(express.json());
app.use(express.static(PUBLIC_DIR));

// Logger simple
app.use((req, res, next) => {
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
  next();
});

// ── Helpers de persistencia ──────────────────────────────────
async function leerTareas() {
  const raw = await fs.readFile(DB_FILE, "utf-8");
  return raw.trim() ? JSON.parse(raw) : [];
}

async function guardarTareas(tareas) {
  await fs.writeFile(DB_FILE, JSON.stringify(tareas, null, 2), "utf-8");
}

// ── Rutas ────────────────────────────────────────────────────

// GET /tareas — Listar todas (con filtro opcional por estado)
app.get("/tareas", async (req, res) => {
  try {
    let tareas = await leerTareas();

    if (req.query.estado) {
      tareas = tareas.filter((t) => t.estado === req.query.estado);
    }

    res.json({ ok: true, total: tareas.length, datos: tareas });
  } catch (err) {
    res.status(500).json({ ok: false, error: "Error al leer las tareas" });
  }
});

// GET /tareas/:id — Obtener una tarea
app.get("/tareas/:id", async (req, res) => {
  try {
    const tareas = await leerTareas();
    const tarea = tareas.find((t) => t.id === req.params.id);

    if (!tarea) {
      return res.status(404).json({ ok: false, error: "Tarea no encontrada" });
    }

    res.json({ ok: true, datos: tarea });
  } catch (err) {
    res.status(500).json({ ok: false, error: "Error del servidor" });
  }
});

// POST /tareas — Crear una nueva tarea
app.post("/tareas", async (req, res) => {
  try {
    const { titulo, descripcion, prioridad = "media" } = req.body;

    // Validación
    if (!titulo || titulo.trim().length < 3) {
      return res.status(400).json({
        ok: false,
        error: "El título es requerido y debe tener al menos 3 caracteres",
      });
    }
    const prioridadesValidas = ["alta", "media", "baja"];
    if (!prioridadesValidas.includes(prioridad)) {
      return res.status(400).json({
        ok: false,
        error: `Prioridad inválida. Valores permitidos: ${prioridadesValidas.join(", ")}`,
      });
    }

    const nuevaTarea = {
      id: crypto.randomUUID(),
      titulo: titulo.trim(),
      descripcion: descripcion?.trim() ?? "",
      estado: "pendiente",
      prioridad,
      creadaEn: new Date().toISOString(),
      actualizadaEn: new Date().toISOString(),
    };

    const tareas = await leerTareas();
    tareas.push(nuevaTarea);
    await guardarTareas(tareas);

    res.status(201).json({ ok: true, datos: nuevaTarea });
  } catch (err) {
    res.status(500).json({ ok: false, error: "Error al crear la tarea" });
  }
});

// PUT /tareas/:id — Actualizar una tarea
app.put("/tareas/:id", async (req, res) => {
  try {
    const tareas = await leerTareas();
    const idx = tareas.findIndex((t) => t.id === req.params.id);

    if (idx === -1) {
      return res.status(404).json({ ok: false, error: "Tarea no encontrada" });
    }

    const { titulo, descripcion, estado, prioridad } = req.body;
    const estadosValidos = ["pendiente", "en-progreso", "completada"];

    if (estado && !estadosValidos.includes(estado)) {
      return res.status(400).json({
        ok: false,
        error: `Estado inválido. Valores: ${estadosValidos.join(", ")}`,
      });
    }

    // Merge — solo actualiza los campos enviados
    tareas[idx] = {
      ...tareas[idx],
      ...(titulo && { titulo: titulo.trim() }),
      ...(descripcion !== undefined && { descripcion: descripcion.trim() }),
      ...(estado && { estado }),
      ...(prioridad && { prioridad }),
      actualizadaEn: new Date().toISOString(),
    };

    await guardarTareas(tareas);
    res.json({ ok: true, datos: tareas[idx] });
  } catch (err) {
    res.status(500).json({ ok: false, error: "Error al actualizar la tarea" });
  }
});

// DELETE /tareas/:id — Eliminar una tarea
app.delete("/tareas/:id", async (req, res) => {
  try {
    const tareas = await leerTareas();
    const idx = tareas.findIndex((t) => t.id === req.params.id);

    if (idx === -1) {
      return res.status(404).json({ ok: false, error: "Tarea no encontrada" });
    }

    tareas.splice(idx, 1);
    await guardarTareas(tareas);
    res.status(204).send();
  } catch (err) {
    res.status(500).json({ ok: false, error: "Error al eliminar la tarea" });
  }
});

// Ruta 404 genérica
app.use((req, res) => {
  res.status(404).json({ ok: false, error: `Ruta ${req.url} no encontrada` });
});

// ── Start ─────────────────────────────────────────────────────
app.listen(PORT, () => {
  console.log(`🚀 API corriendo en http://localhost:${PORT}`);
  console.log(`📁 DB: ${DB_FILE}`);
});

Probando la API con curl

bash
# Crear tarea
curl -X POST http://localhost:3000/tareas \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Estudiar Node.js","prioridad":"alta"}'

# Listar todas
curl http://localhost:3000/tareas

# Filtrar por estado
curl "http://localhost:3000/tareas?estado=pendiente"

# Obtener una (reemplaza el ID)
curl http://localhost:3000/tareas/abc-123

# Actualizar estado
curl -X PUT http://localhost:3000/tareas/abc-123 \
  -H "Content-Type: application/json" \
  -d '{"estado":"completada"}'

# Eliminar
curl -X DELETE http://localhost:3000/tareas/abc-123

Endpoints de la API

MétodoRutaDescripción
GET/tareasListar todas (filtro: ?estado=)
GET/tareas/:idObtener una por ID
POST/tareasCrear nueva tarea
PUT/tareas/:idActualizar campos
DELETE/tareas/:idEliminar

Próximos pasos

Esta API usa un archivo JSON como "base de datos". En el módulo VII migraremos este mismo patrón a PostgreSQL con la librería pg.