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 usarrequire/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áticamenteContent-Type: application/jsonen un objetoreq.body. Sin esta línea,req.bodyseráundefined.- Separa
app.jsdeserver.jspara 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', ...)—:ides un route param, se obtiene a través dereq.params.id(siempre es string, necesitasNumber()para comparar).- Llamar a
next({ status: 404, message: ... })pasa el error al middleware de manejo de errores (sección 7), en lugar de escribirres.status(404)...repetidamente en cada ruta. 204 No Contentpara 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 envalidationResult(req). - El último middleware en el array verifica
errors.isEmpty()— si hay errores, devuelve400con detalles, no llama anext()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 todoapp.use(router)— Express procesa los middlewares en orden de declaración. - Cualquier
next(err)o error throw en una ruta (si usasexpress-async-errorso 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 anext(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.toMatchObjectsolo verifica los campos especificados, ignorando otros (comoidautogenerado) — conveniente para probar respuestas con valores dinámicos.
Nota: como
orderses 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 enbeforeEacho 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.