Escribir REST API con Express en Node.js

1. Descripción general

Este artículo te guía en la construcción de una REST API para gestionar Order con Express — desde la inicialización del proyecto, definición de routes, middleware, validación de datos, hasta manejo centralizado de errores. Al terminar, podrás construir por tu cuenta una API CRUD completa, con tests automáticos usando Jest y Supertest, y conocerás los errores más comunes al trabajar con Express.

El ejemplo a lo largo del artículo: una API para gestionar pedidos (Order) de un Customer, con soporte para crear, ver, editar y eliminar.

2. Preparación del proyecto

Crea un nuevo proyecto Node.js e instala Express:

mkdir order-api && cd order-api
npm init -y
npm install express
npm install --save-dev jest supertest

Añade type: module para usar la sintaxis import/export (ES Module), y declara el script de test en package.json:

{
  "name": "order-api",
  "type": "module",
  "scripts": {
    "start": "node src/server.js",
    "test": "node --experimental-vm-modules node_modules/.bin/jest"
  }
}

Nota: Jest con ES Module necesita la flag --experimental-vm-modules. Si no quieres complicaciones, puedes omitir "type": "module" y usar require/module.exports (CommonJS) — ambos son válidos, depende de la preferencia del equipo.

3. Crear un servidor Express básico

// src/app.js
import express from 'express';

const app = express();
app.use(express.json()); // parsea el cuerpo JSON en req.body

app.get('/health', (req, res) => {
  res.json({ status: 'UP' });
});

export default app;
// src/server.js
import app from './app.js';

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Order API corriendo en el puerto ${PORT}`);
});
  • express.json() es un middleware integrado que ayuda a Express a parsear automáticamente Content-Type: application/json en un objeto req.body. Sin esta línea, req.body será undefined.
  • Separa app.js de server.js para que los tests no necesiten abrir un puerto real — esta es una práctica importante para las REST API de Node.js.

Prueba ejecutando:

node src/server.js
curl http://localhost:3000/health
{"status":"UP"}

4. Definir el recurso Order

Usa una “base de datos” en memoria con un array para enfocarse en la API, sin depender de una DB real.

// src/orders/orderRepository.js
let orders = [];
let nextId = 1;

export function findAll() {
  return orders;
}

export function findById(id) {
  return orders.find((o) => o.id === id);
}

export function create(data) {
  const order = { id: nextId++, ...data, status: 'PENDING' };
  orders.push(order);
  return order;
}

export function update(id, data) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return null;
  orders[index] = { ...orders[index], ...data };
  return orders[index];
}

export function remove(id) {
  const index = orders.findIndex((o) => o.id === id);
  if (index === -1) return false;
  orders.splice(index, 1);
  return true;
}

Este repositorio simula las operaciones CRUD que verás en cualquier ORM (Sequelize, Prisma…), solo que guardando en RAM en lugar de en una DB.

5. Enrutamiento con Express Router

En lugar de adjuntar todas las rutas a app, usa express.Router() para modularizar por recurso:

// src/orders/orderRouter.js
import { Router } from 'express';
import * as repo from './orderRepository.js';

const router = Router();

router.get('/', (req, res) => {
  res.json(repo.findAll());
});

router.get('/:id', (req, res, next) => {
  const order = repo.findById(Number(req.params.id));
  if (!order) return next({ status: 404, message: 'Orden no existe' });
  res.json(order);
});

router.post('/', (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});

router.put('/:id', (req, res, next) => {
  const order = repo.update(Number(req.params.id), req.body);
  if (!order) return next({ status: 404, message: 'Orden no existe' });
  res.json(order);
});

router.delete('/:id', (req, res, next) => {
  const deleted = repo.remove(Number(req.params.id));
  if (!deleted) return next({ status: 404, message: 'Orden no existe' });
  res.status(204).end();
});

export default router;

Adjunta el router a la app con el prefijo /orders:

// src/app.js (actualizado)
import orderRouter from './orders/orderRouter.js';

app.use('/orders', orderRouter);
  • router.get('/:id', ...) — :id es un route param, se obtiene a través de req.params.id (siempre es string, necesitas Number() para comparar).
  • Llamar a next({ status: 404, message: ... }) pasa el error al middleware de manejo de errores (sección 7), en lugar de escribir res.status(404)... repetidamente en cada ruta.
  • 204 No Content para DELETE exitoso — sin devolver body.

6. Validar datos de entrada

Validar manualmente con if/else es fácil de perder casos. Usa express-validator para hacerlo conciso y claro:

npm install express-validator
// src/orders/orderValidator.js
import { body, validationResult } from 'express-validator';

export const validateCreateOrder = [
  body('customerName').isString().notEmpty().withMessage('customerName es obligatorio'),
  body('amount').isFloat({ gt: 0 }).withMessage('amount debe ser mayor a 0'),
  (req, res, next) => {
    const errors = validationResult(req);
    if (!errors.isEmpty()) {
      return res.status(400).json({ errors: errors.array() });
    }
    next();
  },
];

Adjunta el validador a la ruta POST:

// src/orders/orderRouter.js (actualizado)
import { validateCreateOrder } from './orderValidator.js';

router.post('/', validateCreateOrder, (req, res) => {
  const order = repo.create(req.body);
  res.status(201).json(order);
});
  • Cada body(...) es una regla que verifica un campo; si falla, el error se recopila en validationResult(req).
  • El último middleware en el array verifica errors.isEmpty() — si hay errores, devuelve 400 con detalles, no llama a next() para bloquear el request y evitar que vaya al handler real.

Envía un request sin amount:

curl -X POST http://localhost:3000/orders \
  -H "Content-Type: application/json" \
  -d '{"customerName": "Nam"}'
{"errors":[{"type":"field","msg":"amount debe ser mayor a 0","path":"amount",...}]}

7. Manejo centralizado de errores

Express permite definir un middleware de manejo de errores — es un middleware con 4 parámetros (err, req, res, next), colocado después de todas las rutas:

// src/errorHandler.js
export function errorHandler(err, req, res, next) {
  const status = err.status || 500;
  const message = err.message || 'Error del sistema';

  if (status === 500) {
    console.error(err); // registra el error real para debug, no envía el stack trace al cliente
  }

  res.status(status).json({ message });
}
// src/app.js (actualizado, añade al FINAL del archivo)
import { errorHandler } from './errorHandler.js';

app.use(errorHandler);
  • Express reconoce este middleware como error handler por el número de parámetros (4), no por el nombre de la función.
  • Debes colocar app.use(errorHandler) al final, después de todo app.use(router) — Express procesa los middlewares en orden de declaración.
  • Cualquier next(err) o error throw en una ruta (si usas express-async-errors o try/catch) caerá aquí.

Error común: olvidar llamar a next(err) en código async (por ejemplo, await db.query(...) throw) hace que Express no capture el error, el servidor falla o el request se congela. Con Express 5 (en versiones nuevas), los async handlers que lanzan errores son capturados automáticamente — con Express 4 necesitas envolver con try/catch y llamar a next(err) manualmente.

8. Añadir una capa Service para separar la lógica de negocio

Cuando la lógica es más compleja (por ejemplo: no permitir crear una orden si amount excede el límite del cliente), no lo metas todo en el router. Extrae un orderService:

// src/orders/orderService.js
import * as repo from './orderRepository.js';

const MAX_AMOUNT = 100_000_000;

export function createOrder(data) {
  if (data.amount > MAX_AMOUNT) {
    const err = new Error('Excede el límite permitido');
    err.status = 422;
    throw err;
  }
  return repo.create(data);
}
// src/orders/orderRouter.js (actualizado ruta POST)
import * as orderService from './orderService.js';

router.post('/', validateCreateOrder, (req, res, next) => {
  try {
    const order = orderService.createOrder(req.body);
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

El router ahora solo se encarga de HTTP (código de status, parsear params), mientras que orderService maneja la lógica de negocio. Este es un patrón similar a la capa Controller–Service en otros frameworks, que permite probar la lógica de negocio independientemente de HTTP.

9. Escribir tests con Jest y Supertest

Supertest permite llamar a la API directamente en el objeto app (instancia de Express) sin necesidad de abrir un puerto real — esta es la razón por la que la sección 3 separa app.js de server.js.

// tests/order.test.js
import request from 'supertest';
import app from '../src/app.js';

describe('Order API', () => {
  it('crear order exitosamente devuelve 201 e id', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam', amount: 500000 });

    expect(response.status).toBe(201);
    expect(response.body).toMatchObject({
      customerName: 'Nam',
      amount: 500000,
      status: 'PENDING',
    });
    expect(response.body.id).toBeDefined();
  });

  it('sin amount devuelve 400', async () => {
    const response = await request(app)
      .post('/orders')
      .send({ customerName: 'Nam' });

    expect(response.status).toBe(400);
  });

  it('obtener order inexistente devuelve 404', async () => {
    const response = await request(app).get('/orders/9999');

    expect(response.status).toBe(404);
    expect(response.body.message).toBe('Orden no existe');
  });
});

Ejecuta los tests:

npm test
PASS  tests/order.test.js
  Order API
    ✓ crear order exitosamente devuelve 201 e id
    ✓ sin amount devuelve 400
    ✓ obtener order inexistente devuelve 404
  • request(app) inicializa un cliente HTTP virtual vinculado directamente a la app Express — más rápido que probar a través de la red real.
  • toMatchObject solo verifica los campos especificados, ignorando otros (como id autogenerado) — conveniente para probar respuestas con valores dinámicos.

Nota: como orders es un array en memoria compartido entre tests, los tests que se ejecutan en orden pueden influirse mutuamente (por ejemplo, la order creada en test 1 aún existe cuando test 2 se ejecuta). Con una API real conectada a una DB, resetea los datos en beforeEach o usa transaction rollback.

10. Cuándo no usar Express puro

  • API grande, múltiples dominios complejos: Express no tiene convenciones obligatorias sobre la estructura, es fácil generar “código espagueti” si el equipo no se auto-regula. Considera NestJS (con DI, módulos, decoradores como Spring) si tu equipo está familiarizado con arquitecturas en capas claras.
  • Type-safety fuerte necesario: Express + JavaScript puro no detecta errores de tipo en tiempo de compilación. Si el proyecto es grande, usa TypeScript desde el inicio, no esperes a “convertir después” — el costo de conversión posterior es mucho mayor.
  • Alto rendimiento, streaming/WebSocket complejos necesarios: Express sigue funcionando, pero Fastify u otros frameworks más optimizados en rendimiento (benchmark de serialización JSON más rápido) pueden ser más adecuados para sistemas con tráfico extremadamente grande.

Para APIs pequeñas a medianas, CRUD claro como en el ejemplo anterior, Express sigue siendo una opción rápida y concisa, con comunidad grande y fácil de encontrar middlewares existentes (auth, rate-limit, CORS…).

11. Conclusión

A través de este artículo, has construido una REST API Order completa con Express: routing con Router, validación de entrada con express-validator, manejo centralizado de errores con middleware de 4 parámetros, y separación de la lógica de negocio en una capa de servicio para facilitar las pruebas. La sección de testing con Jest + Supertest te permite refactorizar con confianza sin miedo a romper el comportamiento de la API. Desde esta base, puedes añadir conexión a base de datos real (Prisma, Mongoose), autenticación (JWT), o migrar a TypeScript conforme el proyecto crece.

nodejsexpressrest-apijavascripttesting