Tema
APIs REST completas
Ya sabes crear rutas con Express. Ahora construirás una API REST de calidad profesional: convenciones de URLs, códigos de estado correctos, validación con zod, errores centralizados, paginación y filtros. Es lo que espera cualquier frontend (React, Vue, móvil) que consuma tu API.
Concepto
REST es un estilo para diseñar APIs basado en recursos (sustantivos) y métodos HTTP (verbos).
| Acción | Método | URL | Respuesta exitosa |
|---|---|---|---|
| Listar | GET | /api/productos | 200 + array |
| Ver uno | GET | /api/productos/15 | 200 + objeto |
| Crear | POST | /api/productos | 201 + objeto creado |
| Reemplazar | PUT | /api/productos/15 | 200 + objeto |
| Actualizar parcial | PATCH | /api/productos/15 | 200 + objeto |
| Eliminar | DELETE | /api/productos/15 | 204 sin cuerpo |
Reglas de oro:
- URLs con sustantivos en plural:
/api/productos, no/api/obtenerProductos. - Relaciones anidadas:
/api/clientes/7/pedidos. - Filtros, orden y paginación en la query:
/api/productos?categoria=ropa&orden=-precio&pagina=2. - Siempre JSON, y errores con un formato consistente.
Códigos de estado que debes dominar
| Código | Nombre | Cuándo |
|---|---|---|
200 | OK | Lectura o actualización exitosa |
201 | Created | Recurso creado |
204 | No Content | Eliminado, sin cuerpo |
400 | Bad Request | Datos mal formados o inválidos |
401 | Unauthorized | No autenticado (falta o es inválido el token) |
403 | Forbidden | Autenticado, pero sin permiso |
404 | Not Found | El recurso no existe |
409 | Conflict | Duplicado (ej. email ya registrado) |
422 | Unprocessable Entity | Validación fallida (alternativa a 400) |
429 | Too Many Requests | Límite de peticiones excedido |
500 | Internal Server Error | Error inesperado del servidor |
Validación con zod
Nunca confíes en los datos del cliente. zod te permite declarar la forma de los datos y validarlos con mensajes claros.
bash
npm i express zodjs
// ejemplo-zod.js
import { z } from 'zod'
const ProductoSchema = z.object({
nombre: z.string().trim().min(2, 'Mínimo 2 caracteres').max(100),
precio: z.number().positive('Debe ser mayor a 0'),
stock: z.number().int().min(0).default(0),
categoria: z.enum(['ropa', 'electronica', 'hogar']),
email: z.email('Email inválido').optional()
})
// parse: lanza error si no es válido
const ok = ProductoSchema.parse({ nombre: ' Playera ', precio: 199, categoria: 'ropa' })
console.log(ok) // { nombre: 'Playera', precio: 199, stock: 0, categoria: 'ropa' }
// safeParse: no lanza, devuelve { success, data | error }
const resultado = ProductoSchema.safeParse({ nombre: 'A', precio: -5, categoria: 'otro' })
if (!resultado.success) {
for (const issue of resultado.error.issues) {
console.log(issue.path.join('.'), '→', issue.message)
}
}
// nombre → Mínimo 2 caracteres
// precio → Debe ser mayor a 0
// categoria → Invalid option: expected one of "ropa"|"electronica"|"hogar"Trucos útiles:
js
// Query strings llegan como texto: coerce los convierte
const QuerySchema = z.object({
pagina: z.coerce.number().int().min(1).default(1),
limite: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().optional()
})
// Para PATCH: todos los campos opcionales
const ProductoParcial = ProductoSchema.partial()
// Inferir el tipo en TypeScript
// type Producto = z.infer<typeof ProductoSchema>Proyecto: API de productos
txt
api-productos/
├── src/
│ ├── app.js
│ ├── index.js
│ ├── errors/HttpError.js
│ ├── middlewares/
│ │ ├── validar.js
│ │ └── errores.js
│ ├── schemas/producto.schema.js
│ ├── services/productos.service.js
│ ├── controllers/productos.controller.js
│ └── routes/productos.routes.js
└── package.jsonjs
// Error con código HTTP: lo lanzamos desde cualquier capa
export class HttpError extends Error {
constructor(status, mensaje, detalles) {
super(mensaje)
this.status = status
this.detalles = detalles
}
static badRequest(msg = 'Petición inválida', detalles) { return new HttpError(400, msg, detalles) }
static notFound(msg = 'Recurso no encontrado') { return new HttpError(404, msg) }
static conflict(msg = 'Conflicto') { return new HttpError(409, msg) }
}js
import { z } from 'zod'
export const crearProductoSchema = z.object({
nombre: z.string().trim().min(2).max(100),
descripcion: z.string().max(1000).optional(),
precio: z.number().positive(),
stock: z.number().int().min(0).default(0),
categoria: z.enum(['ropa', 'electronica', 'hogar'])
})
export const actualizarProductoSchema = crearProductoSchema.partial()
export const idParamSchema = z.object({
id: z.coerce.number().int().positive()
})
export const listarQuerySchema = z.object({
pagina: z.coerce.number().int().min(1).default(1),
limite: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().optional(),
categoria: z.enum(['ropa', 'electronica', 'hogar']).optional(),
// orden: "precio" ascendente, "-precio" descendente
orden: z.enum(['precio', '-precio', 'nombre', '-nombre']).optional()
})js
import { HttpError } from '../errors/HttpError.js'
// Valida body, params o query con un schema de zod.
// Guarda los datos limpios en req.validado para que el controlador los use.
export const validar = (schemas) => (req, res, next) => {
req.validado = {}
for (const parte of ['body', 'params', 'query']) {
if (!schemas[parte]) continue
const resultado = schemas[parte].safeParse(req[parte] ?? {})
if (!resultado.success) {
const detalles = resultado.error.issues.map((i) => ({
campo: i.path.join('.'),
mensaje: i.message
}))
return next(HttpError.badRequest('Datos inválidos', detalles))
}
req.validado[parte] = resultado.data
}
next()
}js
import { HttpError } from '../errors/HttpError.js'
export function noEncontrado(req, res, next) {
next(HttpError.notFound(`Ruta no encontrada: ${req.method} ${req.originalUrl}`))
}
// eslint-disable-next-line no-unused-vars
export function manejadorErrores(err, req, res, next) {
// JSON mal formado en el body
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'JSON inválido' })
}
if (err instanceof HttpError) {
return res.status(err.status).json({
error: err.message,
...(err.detalles && { detalles: err.detalles })
})
}
// Error inesperado: lo registramos completo, pero no lo mostramos al cliente
console.error(err)
res.status(500).json({ error: 'Error interno del servidor' })
}js
import { HttpError } from '../errors/HttpError.js'
// Datos en memoria (en la lección 12 los pasamos a una base de datos)
const productos = [
{ id: 1, nombre: 'Playera básica', precio: 199, stock: 50, categoria: 'ropa' },
{ id: 2, nombre: 'Audífonos BT', precio: 899, stock: 12, categoria: 'electronica' },
{ id: 3, nombre: 'Lámpara de mesa', precio: 450, stock: 8, categoria: 'hogar' }
]
let siguienteId = 4
export function listar({ pagina, limite, q, categoria, orden }) {
let resultado = [...productos]
if (q) {
const texto = q.toLowerCase()
resultado = resultado.filter((p) => p.nombre.toLowerCase().includes(texto))
}
if (categoria) resultado = resultado.filter((p) => p.categoria === categoria)
if (orden) {
const desc = orden.startsWith('-')
const campo = desc ? orden.slice(1) : orden
resultado.sort((a, b) => {
const r = a[campo] > b[campo] ? 1 : a[campo] < b[campo] ? -1 : 0
return desc ? -r : r
})
}
const total = resultado.length
const inicio = (pagina - 1) * limite
return {
datos: resultado.slice(inicio, inicio + limite),
paginacion: { pagina, limite, total, paginas: Math.ceil(total / limite) }
}
}
export function obtener(id) {
const producto = productos.find((p) => p.id === id)
if (!producto) throw HttpError.notFound('Producto no encontrado')
return producto
}
export function crear(datos) {
const existe = productos.some((p) => p.nombre.toLowerCase() === datos.nombre.toLowerCase())
if (existe) throw HttpError.conflict('Ya existe un producto con ese nombre')
const nuevo = { id: siguienteId++, ...datos }
productos.push(nuevo)
return nuevo
}
export function actualizar(id, cambios) {
const producto = obtener(id) // lanza 404 si no existe
Object.assign(producto, cambios)
return producto
}
export function eliminar(id) {
const indice = productos.findIndex((p) => p.id === id)
if (indice === -1) throw HttpError.notFound('Producto no encontrado')
productos.splice(indice, 1)
}js
import * as servicio from '../services/productos.service.js'
// Los controladores quedan cortos: los datos ya vienen validados
export const listar = (req, res) => {
res.json(servicio.listar(req.validado.query))
}
export const obtener = (req, res) => {
res.json(servicio.obtener(req.validado.params.id))
}
export const crear = (req, res) => {
const producto = servicio.crear(req.validado.body)
res.status(201).location(`/api/productos/${producto.id}`).json(producto)
}
export const actualizar = (req, res) => {
res.json(servicio.actualizar(req.validado.params.id, req.validado.body))
}
export const eliminar = (req, res) => {
servicio.eliminar(req.validado.params.id)
res.sendStatus(204)
}js
import { Router } from 'express'
import * as ctrl from '../controllers/productos.controller.js'
import { validar } from '../middlewares/validar.js'
import {
crearProductoSchema,
actualizarProductoSchema,
idParamSchema,
listarQuerySchema
} from '../schemas/producto.schema.js'
const router = Router()
router.get('/', validar({ query: listarQuerySchema }), ctrl.listar)
router.get('/:id', validar({ params: idParamSchema }), ctrl.obtener)
router.post('/', validar({ body: crearProductoSchema }), ctrl.crear)
router.patch('/:id', validar({ params: idParamSchema, body: actualizarProductoSchema }), ctrl.actualizar)
router.delete('/:id', validar({ params: idParamSchema }), ctrl.eliminar)
export default routerjs
import express from 'express'
import productosRouter from './routes/productos.routes.js'
import { noEncontrado, manejadorErrores } from './middlewares/errores.js'
const app = express()
app.use(express.json({ limit: '100kb' }))
app.get('/api/salud', (req, res) => res.json({ ok: true, hora: new Date().toISOString() }))
app.use('/api/productos', productosRouter)
app.use(noEncontrado)
app.use(manejadorErrores)
export default appjs
import app from './app.js'
const PUERTO = process.env.PORT ?? 3000
app.listen(PUERTO, () => console.log(`API en http://localhost:${PUERTO}/api`))¿Por qué req.validado y no reemplazar req.query?
En Express 5, req.query es de sólo lectura (un getter). Guardar los datos limpios en otra propiedad evita errores y deja claro que ya pasaron por validación.
Probar la API
bash
# Listar con filtros, orden y paginación
curl "http://localhost:3000/api/productos?categoria=ropa&orden=-precio&pagina=1&limite=5"
# Crear (válido)
curl -X POST http://localhost:3000/api/productos -H "Content-Type: application/json" \
-d '{"nombre":"Taza","precio":120,"categoria":"hogar"}'
# Crear (inválido) → 400 con detalles
curl -X POST http://localhost:3000/api/productos -H "Content-Type: application/json" \
-d '{"nombre":"T","precio":-1}'Respuesta de error:
json
{
"error": "Datos inválidos",
"detalles": [
{ "campo": "nombre", "mensaje": "Too small: expected string to have >=2 characters" },
{ "campo": "precio", "mensaje": "Too small: expected number to be >0" },
{ "campo": "categoria", "mensaje": "Invalid option: expected one of \"ropa\"|\"electronica\"|\"hogar\"" }
]
}Mensajes en español
Pasa el mensaje en cada regla: z.string().min(2, 'Mínimo 2 caracteres'), o configura un mapa de errores global con z.config({ customError: ... }).
Herramientas para probar APIs
- Thunder Client o REST Client (extensiones de VS Code).
- Postman, Insomnia o Bruno (aplicaciones de escritorio).
Con REST Client puedes guardar tus pruebas en un archivo api.http:
txt
### Listar
GET http://localhost:3000/api/productos?orden=precio
### Crear
POST http://localhost:3000/api/productos
Content-Type: application/json
{ "nombre": "Cojín", "precio": 250, "categoria": "hogar" }Buena práctica
- Responde siempre con el mismo formato de error:
{ error, detalles? }. El frontend lo agradecerá. - Nunca envíes el stack trace o mensajes internos de la base de datos al cliente en producción.
- Versiona la API si tendrás clientes externos:
/api/v1/productos.
Error común
Devolver 200 para todo, incluso errores ({ ok: false }). Usa los códigos HTTP: los clientes, proxies y herramientas de monitoreo dependen de ellos.
Ejercicios
- Agrega
PUT /api/productos/:idque reemplace el producto completo (requiere todos los campos). - Agrega a la query los filtros
minymaxde precio, validando quemin <= max(pista:.refine()).
Ver solución
js
// routes: PUT usa el schema completo, no el parcial
router.put('/:id', validar({ params: idParamSchema, body: crearProductoSchema }), ctrl.reemplazar)
// controller
export const reemplazar = (req, res) => {
const { id } = req.validado.params
servicio.obtener(id) // 404 si no existe
res.json(servicio.actualizar(id, req.validado.body))
}js
// schema
export const listarQuerySchema = z.object({
pagina: z.coerce.number().int().min(1).default(1),
limite: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().optional(),
categoria: z.enum(['ropa', 'electronica', 'hogar']).optional(),
orden: z.enum(['precio', '-precio', 'nombre', '-nombre']).optional(),
min: z.coerce.number().min(0).optional(),
max: z.coerce.number().min(0).optional()
}).refine(
(q) => q.min === undefined || q.max === undefined || q.min <= q.max,
{ message: 'min no puede ser mayor que max', path: ['min'] }
)
// service, dentro de listar():
if (min !== undefined) resultado = resultado.filter((p) => p.precio >= min)
if (max !== undefined) resultado = resultado.filter((p) => p.precio <= max)Resumen
- REST: recursos en plural + métodos HTTP + códigos de estado correctos.
- Valida todo lo que entra (body, params, query) con zod en un middleware reutilizable.
- Centraliza errores con una clase
HttpErrory un manejador único. - Ofrece paginación, filtros y orden por query string, con respuesta
{ datos, paginacion }.