🔎 Buscar

🔌 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.

Wiki / Apuntes📖 Contenido

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/123 elimina el recurso; POST /libros/123/prestar cambia 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 }

⚠️ PUT reemplaza el recurso completo: si omites un campo en el body, ese campo se pierde. Para cambios parciales usa PATCH, no PUT con 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 500 por errores que sabes cómo clasificar. Un 422 con un body de error claro es mucho más útil para el cliente que un 500 gené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 libros es 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:

  1. Versionado por ruta (más común y explícito): /v1/libros, /v2/libros.
  2. Versionado por cabecera: Accept: application/vnd.libreria.v2+json.
  3. 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, no 201).
  • 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, implementa Idempotency-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 respetar Retry-After con 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/limit o cursor).
  • Filtrado y ordenación por parámetros de query explícitos y validados.
  • Versionado por ruta con política de deprecación.
  • Idempotency-Key en operaciones de creación.
  • Rate limiting con Retry-After y backoff en el cliente.
  • Contrato documentado en OpenAPI.

Para profundizar

Estudio · Recursos de todo el mundo (inglés, chino, japonés, español, francés, ruso…) curados y traducidos al español.