🔌 Diseño de APIs REST
Cómo diseñar APIs REST profesionales: recursos, métodos, códigos de estado, paginación, filtrado, versionado, idempotencia, rate limiting y documentación con OpenAPI.
Diseño de APIs REST
Una API REST bien diseñada se entiende sin documentación: por la URL, por el verbo y por el código de estado devuelto. Este artículo cubre las decisiones de diseño que separan una API que da trabajo de una que se disfruta consumir.
Recursos, no acciones
REST modela recursos (sustantivos) y expresa lo que se hace con ellos mediante métodos HTTP (verbos). Si tu API tiene URLs con verbos, la estás haciendo mal:
| Mal diseño | Bien diseñado |
|---|---|
GET /getUsuarios |
GET /usuarios |
POST /crearUsuario |
POST /usuarios |
POST /borrarLibro/5 |
DELETE /libros/5 |
POST /actualizarPrecio |
PATCH /libros/5 |
Reglas de nomenclatura:
- Plural para colecciones:
/libros, no/libro. - Minúsculas y con guiones entre palabras:
/historial-compras. - Un recurso concreto se identifica por su id en la ruta:
/libros/123. - Las relaciones se anidan solo cuando son parte natural del recurso:
/autores/7/libros(libros del autor 7). - Los verbos quedan reservados para acciones que no son CRUD puro:
POST /libros/123/prestar.
💡 Si tienes que cambiar el verbo, cambia la ruta:
DELETE /libros/123elimina el recurso;POST /libros/123/prestarcambia su estado sin borrarlo.
Métodos correctos por operación
| Operación | Método | Ruta | Idempotente | Significado |
|---|---|---|---|---|
| Listar | GET |
/libros |
✅ | Colección, normalmente paginada |
| Obtener uno | GET |
/libros/{id} |
✅ | Recurso concreto |
| Crear | POST |
/libros |
❌ | Crea; devuelve 201 |
| Reemplazar | PUT |
/libros/{id} |
✅ | Sustituye TODO el recurso |
| Modificar parcial | PATCH |
/libros/{id} |
❌ | Cambia solo los campos enviados |
| Eliminar | DELETE |
/libros/{id} |
✅ | Borra (o marca como borrado) |
La idempotencia importa porque permite reintentos: si el cliente repite la misma petición N veces y el resultado es el mismo que una vez, el cliente puede reintentar de forma segura cuando la red falla. POST no es idempotente porque cada llamada crea un recurso nuevo.
POST /libros HTTP/1.1
Host: api.libreria.com
Content-Type: application/json
{ "titulo": "Cien años de soledad", "autor_id": 3, "precio": 19.99 }
HTTP/1.1 201 Created
Content-Type: application/json
Location: /libros/42
{ "id": 42, "titulo": "Cien años de soledad", "precio": 19.99 }
⚠️
PUTreemplaza el recurso completo: si omites un campo en el body, ese campo se pierde. Para cambios parciales usaPATCH, noPUTcon todos los campos “por si acaso”.
Códigos de estado que debes dominar
| Código | Cuándo usarlo |
|---|---|
200 OK |
Éxito de un GET, PUT, PATCH |
201 Created |
Creación con POST (cuerpo + cabecera Location) |
204 No Content |
DELETE o PATCH que no devuelven cuerpo |
304 Not Modified |
Caché validada con If-None-Match / ETag |
400 Bad Request |
Body malformado o parámetros inválidos |
401 Unauthorized |
Sin autenticación o credenciales inválidas |
403 Forbidden |
Autenticado pero sin permiso |
404 Not Found |
Recurso inexistente o endpoint privado |
409 Conflict |
Estado que impide la operación (ej: borrar autor con libros) |
422 Unprocessable Entity |
Sintaxis válida, pero semántica inválida (email malo) |
429 Too Many Requests |
Rate limit superado |
500 Internal Server Error |
Error no controlado del servidor |
503 Service Unavailable |
Mantenimiento o sobrecarga |
💡 No devuelvas
500por errores que sabes cómo clasificar. Un422con un body de error claro es mucho más útil para el cliente que un500genérico.
Formato de error consistente, en JSON:
{
"error": {
"code": "LIBRO_NO_ENCONTRADO",
"message": "No existe un libro con id 42",
"details": { "id": 42 }
}
}
API de ejemplo: la librería
Recursos y operaciones completas de una API de libros:
| Recurso | Operaciones |
|---|---|
/libros |
GET (listar, paginar, filtrar), POST (crear) |
/libros/{id} |
GET, PUT, PATCH, DELETE |
/autores |
GET, POST |
/libros/{id}/prestar |
POST (acción), POST /libros/{id}/devolver |
/pedidos |
GET (solo del cliente autenticado), POST |
Paginación: page/limit vs cursor
Para colecciones grandes, nunca devuelvas todo. Dos estrategias:
Paginación por offset (page/limit)
Simple y suficiente para tablas pequeñas, pero inestable: si se insertan filas entre peticiones, puedes saltarte o repetir registros.
GET /libros?page=3&limit=20
{
"data": [ { "id": 41, "titulo": "Rayuela" } ],
"pagination": {
"page": 3,
"limit": 20,
"total": 847,
"total_pages": 43
}
}
Paginación por cursor
Estable para datos cambiantes y más rápida en tablas enormes: se piden los elementos después de un marcador opaco (no indices saltados).
GET /libros?limit=20&cursor=eyJpZCI6NDB9
{
"data": [ { "id": 41, "titulo": "Rayuela" } ],
"next_cursor": "eyJpZCI6NjB9"
}
El cliente pide la siguiente página con next_cursor; cuando next_cursor es null, no hay más.
💡 Para feeds y datos que cambian (timelines, notificaciones) usa cursor. Para tablas de administración estables usa page/limit por simplicidad.
Filtrado y ordenación
Define nombres de parámetros explícitos, no repitas los campos de la tabla:
GET /libros?autor_id=3&precio_min=10&precio_max=25&orden=precio&direccion=desc
| Parámetro | Efecto |
|---|---|
autor_id=3 |
Filtra por autor |
precio_min / precio_max |
Rango de precio |
q=marquez |
Búsqueda por texto libre |
orden=precio |
Campo de ordenación |
direccion=desc |
Orden ascendente/descendente |
⚠️ Nunca interpoles parámetros de ordenación en SQL directamente sin validarlos:
orden=id; DROP TABLE libroses inyección. Usa un allowlist de columnas.
Versionado de la API
Las APIs evolucionan y romper a tus clientes cuesta dinero. Estrategias, en orden de preferencia:
- Versionado por ruta (más común y explícito):
/v1/libros,/v2/libros. - Versionado por cabecera:
Accept: application/vnd.libreria.v2+json. - Versionado por parámetro:
?version=2(menos recomendado, contamina URLs).
GET /v2/libros/42 HTTP/1.1
Accept: application/vnd.libreria.v2+json
Política práctica:
- Añadir un campo nunca requiere nueva versión (los clientes ignoran lo desconocido).
- Cambiar un campo o su tipo requiere nueva versión.
- Mantén la versión antigua con un periodo de deprecación documentado y cabecera
Deprecation.
Idempotencia en POST
Como POST no es idempotente, un reintento tras un timeout puede duplicar pedidos. Solución: cabecera Idempotency-Key. El servidor guarda la clave con el resultado y, ante una repetición, devuelve el mismo resultado sin re-ejecutar.
POST /pedidos HTTP/1.1
Idempotency-Key: c07b3f1a-7f5e-4b2a-9c3d-1e2f3a4b5c6d
{ "libro_ids": [42, 7], "total": 35.99 }
Reglas:
- El cliente genera una clave única (UUID) por operación.
- El servidor guarda
(clave → resultado)durante unos minutos/días. - Si llega la misma clave, devuelve el resultado guardado (normalmente
200, no201). - Un cambio de body con la misma clave debe ser rechazado con
422.
💡 Stripe, PayPal y todas las pasarelas de pago lo usan. Si tu API crea recursos con
POST, implementaIdempotency-Key: te ahorra duplicados y te da la excusa perfecta para reintentos seguros.
Rate limiting
Protege tu API de abusos y de consumidores con bugs. Cabeceras estándar:
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 83
RateLimit-Reset: 3600
Cuando se supera el límite:
HTTP/1.1 429 Too Many Requests
Retry-After: 120
{ "error": { "code": "RATE_LIMITED", "message": "Límite superado. Reintenta en 120 segundos." } }
💡 Ante un
429, el cliente debe respetarRetry-Aftercon backoff exponencial (1s, 2s, 4s, 8s…) y añadir jitter para evitar tormentas de reintentos sincronizadas.
Documentación con OpenAPI
OpenAPI (antes Swagger) describe tu API en un archivo YAML/JSON versionable. Sirve para generar clientes, validar contratos y montar una UI interactiva (Swagger UI, Redoc).
openapi: 3.1.0
info:
title: API de la Librería
version: 1.0.0
paths:
/libros:
get:
summary: Lista libros paginados
parameters:
- name: limit
in: query
schema: { type: integer, default: 20 }
- name: cursor
in: query
schema: { type: string }
responses:
"200":
description: Lista de libros
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { $ref: "#/components/schemas/Libro" } }
next_cursor: { type: string, nullable: true }
post:
summary: Crea un libro
requestBody:
required: true
content:
application/json:
schema: { $ref: "#/components/schemas/LibroInput" }
responses:
"201":
description: Libro creado
headers:
Location: { schema: { type: string } }
components:
schemas:
Libro:
type: object
properties:
id: { type: integer }
titulo: { type: string }
precio: { type: number }
LibroInput:
type: object
required: [titulo, autor_id]
properties:
titulo: { type: string, minLength: 1 }
autor_id: { type: integer }
💡 Genera la especificación desde el código (con anotaciones o
schemars/pydantic), nunca a mano: así el contrato documentado siempre coincide con la implementación. El editor de Swagger valida la especificación al vuelo.
Checklist final de diseño
- URLs con sustantivos en plural, sin verbos.
- Cada verbo HTTP con su significado y código de estado correcto.
- Errores con formato JSON consistente y código propio.
- Paginación en todas las colecciones (
page/limito cursor). - Filtrado y ordenación por parámetros de query explícitos y validados.
- Versionado por ruta con política de deprecación.
-
Idempotency-Keyen operaciones de creación. - Rate limiting con
Retry-Aftery backoff en el cliente. - Contrato documentado en OpenAPI.
Para profundizar
- restfulapi.net: la guía práctica de diseño REST más citada.
- MDN — Códigos de estado HTTP: referencia de cada código en español.
- OpenAPI Specification: la especificación oficial del estándar.
- Stripe API reference: ejemplo de API bien diseñada con idempotencia y paginación.
- Architectural Styles and the Design of Network-based Software Architectures: la tesis de Roy Fielding que define REST.