Skip to content

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ónMétodoURLRespuesta exitosa
ListarGET/api/productos200 + array
Ver unoGET/api/productos/15200 + objeto
CrearPOST/api/productos201 + objeto creado
ReemplazarPUT/api/productos/15200 + objeto
Actualizar parcialPATCH/api/productos/15200 + objeto
EliminarDELETE/api/productos/15204 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ódigoNombreCuándo
200OKLectura o actualización exitosa
201CreatedRecurso creado
204No ContentEliminado, sin cuerpo
400Bad RequestDatos mal formados o inválidos
401UnauthorizedNo autenticado (falta o es inválido el token)
403ForbiddenAutenticado, pero sin permiso
404Not FoundEl recurso no existe
409ConflictDuplicado (ej. email ya registrado)
422Unprocessable EntityValidación fallida (alternativa a 400)
429Too Many RequestsLímite de peticiones excedido
500Internal Server ErrorError 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 zod
js
// 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.json
js
// 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 router
js
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 app
js
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 ​

  1. Agrega PUT /api/productos/:id que reemplace el producto completo (requiere todos los campos).
  2. Agrega a la query los filtros min y max de precio, validando que min <= 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 HttpError y un manejador único.
  • Ofrece paginación, filtros y orden por query string, con respuesta { datos, paginacion }.

Academia Web — aprende, copia y construye.