Cómo diseñar una API REST desde cero: buenas prácticas

D
DanisCh
• 14 min de lectura
Cómo diseñar una API REST desde cero: buenas prácticas
Java JavaScript Programación

Una API REST bien diseñada es predecible, consistente y fácil de usar. Los desarrolladores que la consumen pueden intuir cómo funciona sin leer toda la documentación, los errores son claros y accionables, y la API puede crecer sin romper a los clientes existentes. Una API mal diseñada, en cambio, obliga a leer la documentación para cada endpoint, produce errores crípticos y acumula deuda técnica con cada nueva funcionalidad.

En esta guía aprenderás los principios y decisiones concretas que hacen que una API REST sea buena: cómo nombrar los recursos, qué códigos de estado usar, cómo estructurar las respuestas, cómo manejar errores y cómo versionar la API.

Los principios REST en la práctica

REST (Representational State Transfer) no es un estándar con reglas obligatorias, sino un estilo arquitectónico con principios que guían el diseño. En la práctica, una API REST bien diseñada sigue estas convenciones:

  • Los recursos son sustantivos, no verbos. La URL identifica un recurso (cosa), y el método HTTP expresa la acción (qué hacer con esa cosa).
  • Sin estado entre peticiones. Cada petición contiene toda la información necesaria para procesarla. El servidor no recuerda el estado de sesiones anteriores.
  • Interfaz uniforme. Los mismos patrones se aplican a todos los recursos: si sabes cómo usar el endpoint de usuarios, ya sabes cómo usar el de productos.

Nombrar los recursos correctamente

La regla más importante y más incumplida: las URLs son sustantivos en plural, los métodos HTTP son los verbos.

# ❌ Verbos en las URLs (estilo RPC, no REST)
GET  /getUsuarios
POST /crearUsuario
PUT  /actualizarUsuario/123
POST /eliminarUsuario/123
GET  /buscarUsuariosPorEmail?email=ana@mail.com

# ✅ Sustantivos en plural, métodos HTTP como verbos
GET    /usuarios           → listar todos los usuarios
POST   /usuarios           → crear un usuario nuevo
GET    /usuarios/123       → obtener el usuario 123
PUT    /usuarios/123       → reemplazar el usuario 123 completo
PATCH  /usuarios/123       → actualizar campos del usuario 123
DELETE /usuarios/123       → eliminar el usuario 123
# Recursos anidados: para relaciones entre recursos
GET  /usuarios/123/pedidos          → pedidos del usuario 123
GET  /usuarios/123/pedidos/456      → pedido 456 del usuario 123
POST /usuarios/123/pedidos          → crear un pedido para el usuario 123

# Cuándo anidar y cuándo no:
# Anidar cuando el recurso hijo no tiene sentido sin el padre
# No anidar más de dos niveles: /usuarios/123/pedidos/456/lineas es límite razonable
# /usuarios/123/pedidos/456/lineas/789/impuestos/1 → demasiado profundo

# Alternativa para rutas muy profundas: exponer el recurso directamente
GET /pedidos/456          → en lugar de /usuarios/123/pedidos/456
GET /lineas-pedido/789    → en lugar de /pedidos/456/lineas/789
# Convenciones de formato en las URLs
# ✅ kebab-case para URLs con varias palabras
GET /productos-destacados
GET /categorias-de-producto
GET /usuarios/123/metodos-de-pago

# ❌ Evitar
GET /productosDestacados   # camelCase en URLs
GET /productos_destacados  # snake_case (funciona pero es menos habitual en REST)
GET /PRODUCTOS              # mayúsculas

# Las URLs son case-sensitive por defecto en la mayoría de servidores
# /Usuarios y /usuarios son diferentes → usa siempre minúsculas

Métodos HTTP: cuándo usar cada uno

# GET: leer datos sin modificar nada
# Debe ser idempotente: varias llamadas iguales dan el mismo resultado
GET /productos          → lista de productos
GET /productos/42       → producto 42

# POST: crear un recurso nuevo o ejecutar una acción
# El servidor asigna el id del nuevo recurso
# No es idempotente: dos llamadas iguales crean dos recursos
POST /productos         → crea un producto, devuelve 201 con el recurso creado
POST /pedidos/456/pagar → acción que no encaja en CRUD (ver sección de acciones)

# PUT: reemplazar un recurso completo
# Idempotente: varias llamadas iguales producen el mismo resultado
# El cliente envía TODOS los campos, los omitidos se borran o quedan como nulos
PUT /productos/42       → reemplaza el producto 42 completamente

# PATCH: actualización parcial
# Solo se envían los campos que cambian
# No necesariamente idempotente
PATCH /productos/42     → actualiza solo los campos enviados del producto 42

# DELETE: eliminar un recurso
# Idempotente: eliminar algo ya eliminado devuelve 204 (no error)
DELETE /productos/42    → elimina el producto 42

Acciones que no encajan en CRUD

# Algunos casos no encajan limpiamente en GET/POST/PUT/PATCH/DELETE
# Soluciones habituales:

# Opción 1: modelar la acción como un recurso
POST /pedidos/456/cancelaciones    # crear una cancelación del pedido
POST /sesiones                     # iniciar sesión (crear una sesión)
DELETE /sesiones/actual            # cerrar sesión (eliminar la sesión)
POST /usuarios/123/verificaciones  # enviar email de verificación

# Opción 2: sub-recurso de estado
PATCH /pedidos/456/estado          # actualizar el estado del pedido
# body: { "estado": "cancelado" }

# Opción 3: verbo como último recurso (solo si las otras opciones no encajan)
POST /pedidos/456/pagar
POST /usuarios/123/restablecer-contrasena
POST /reportes/ventas/generar

Códigos de estado HTTP: usarlos correctamente

Los códigos de estado son parte del lenguaje de HTTP. Usarlos correctamente permite a los clientes entender qué ocurrió sin leer el cuerpo de la respuesta.

# ─── 2xx: Éxito ───────────────────────────────────────────────────────────────

200 OK
# La petición tuvo éxito. El cuerpo contiene el resultado.
# Úsalo para: GET, PUT, PATCH exitosos

201 Created
# Se creó un recurso nuevo.
# Incluye la cabecera Location con la URL del recurso creado.
# Úsalo para: POST que crea un recurso

204 No Content
# Éxito, pero sin cuerpo en la respuesta.
# Úsalo para: DELETE exitoso, PATCH que no devuelve el recurso actualizado


# ─── 3xx: Redirección ─────────────────────────────────────────────────────────

301 Moved Permanently
# El recurso se ha movido a una nueva URL permanentemente.
# El cliente debe actualizar sus referencias.

304 Not Modified
# El recurso no ha cambiado desde la última vez.
# Útil con caché: el cliente puede usar su versión guardada.


# ─── 4xx: Error del cliente ───────────────────────────────────────────────────

400 Bad Request
# La petición está mal formada: JSON inválido, campo que falta, tipo incorrecto.
# Úsalo para: validación de datos de entrada

401 Unauthorized
# No autenticado: el cliente no ha proporcionado credenciales
# o las credenciales son inválidas. (El nombre es confuso: debería llamarse Unauthenticated)

403 Forbidden
# Autenticado pero sin permisos para este recurso.
# El usuario existe y está identificado, pero no tiene acceso.

404 Not Found
# El recurso no existe.
# También úsalo para 403 cuando no quieres revelar si el recurso existe.

409 Conflict
# Conflicto con el estado actual: email duplicado, edición concurrente.

422 Unprocessable Entity
# La sintaxis es correcta pero los datos no pasan la validación semántica.
# Muchas APIs lo usan en lugar de 400 para errores de validación.

429 Too Many Requests
# Rate limiting: el cliente ha hecho demasiadas peticiones.
# Incluir cabecera Retry-After con cuándo puede volver a intentarlo.


# ─── 5xx: Error del servidor ──────────────────────────────────────────────────

500 Internal Server Error
# Error inesperado en el servidor. Nunca devuelvas el stack trace al cliente.

502 Bad Gateway
# El servidor está actuando como proxy y el servicio upstream falló.

503 Service Unavailable
# El servidor no puede atender peticiones ahora: sobrecarga, mantenimiento.
# Incluir cabecera Retry-After.
# ❌ Errores habituales con los códigos de estado
GET /usuarios/999
→ 200 OK con body: { "error": "Usuario no encontrado" }
# Nunca devuelvas 200 con un error en el body

POST /usuarios con email duplicado
→ 500 Internal Server Error
# Un email duplicado es un error del cliente (409), no del servidor

DELETE /usuarios/999 (no existe)
→ 404 Not Found
# Podrías devolver 204: DELETE es idempotente, eliminar algo ya eliminado es éxito

# ✅ Los correctos
GET /usuarios/999                → 404 Not Found
POST /usuarios (email duplicado) → 409 Conflict
DELETE /usuarios/999             → 204 o 404 (ambos son aceptables)

Estructura de las respuestas

La consistencia en la estructura de las respuestas es tan importante como la consistencia en las URLs. Los clientes deben poder anticipar dónde está cada dato.

# Respuesta de un recurso individual
GET /usuarios/123

{
  "id": 123,
  "nombre": "Ana García",
  "email": "ana@mail.com",
  "activo": true,
  "creado_en": "2026-01-15T10:30:00Z",
  "actualizado_en": "2026-06-01T09:00:00Z"
}

# Respuesta de una colección con paginación
GET /usuarios?pagina=2&limite=10

{
  "datos": [
    { "id": 11, "nombre": "Carlos López", ... },
    { "id": 12, "nombre": "María Torres", ... }
  ],
  "paginacion": {
    "pagina_actual": 2,
    "por_pagina": 10,
    "total": 47,
    "total_paginas": 5,
    "siguiente": "/usuarios?pagina=3&limite=10",
    "anterior": "/usuarios?pagina=1&limite=10"
  }
}
# Respuesta al crear un recurso
POST /usuarios
# body: { "nombre": "Jorge", "email": "jorge@mail.com" }

# HTTP/1.1 201 Created
# Location: /usuarios/124

{
  "id": 124,
  "nombre": "Jorge Ramírez",
  "email": "jorge@mail.com",
  "activo": true,
  "creado_en": "2026-09-05T12:00:00Z",
  "actualizado_en": "2026-09-05T12:00:00Z"
}

# El 201 incluye el recurso creado completo para que el cliente
# no tenga que hacer un GET adicional para obtener el id y los campos calculados

 

Manejo de errores consistente

Los errores deben tener una estructura consistente en toda la API. Un error bien diseñado le dice al cliente qué salió mal, por qué, y qué puede hacer para corregirlo.

# Estructura de error recomendada
{
  "error": {
    "codigo": "VALIDACION_FALLIDA",    # código legible por máquina (para manejar en código)
    "mensaje": "Los datos enviados no son válidos",  # descripción legible por humanos
    "detalles": [                      # lista de errores específicos (útil en validaciones)
      {
        "campo": "email",
        "mensaje": "El email ya está registrado en el sistema"
      },
      {
        "campo": "edad",
        "mensaje": "La edad debe ser un número entre 0 y 150"
      }
    ],
    "referencia": "https://docs.mi-api.com/errores/VALIDACION_FALLIDA"  # opcional
  }
}

 

# Ejemplos de errores para distintas situaciones

# 400 Bad Request: JSON malformado
{
  "error": {
    "codigo": "JSON_INVALIDO",
    "mensaje": "El cuerpo de la petición no es JSON válido"
  }
}

# 401 Unauthorized: sin token
{
  "error": {
    "codigo": "NO_AUTENTICADO",
    "mensaje": "Esta operación requiere autenticación. Proporciona un token válido."
  }
}

# 403 Forbidden: sin permisos
{
  "error": {
    "codigo": "SIN_PERMISOS",
    "mensaje": "No tienes permisos para eliminar este recurso"
  }
}

# 404 Not Found
{
  "error": {
    "codigo": "RECURSO_NO_ENCONTRADO",
    "mensaje": "No se encontró ningún usuario con id 999"
  }
}

# 429 Too Many Requests
{
  "error": {
    "codigo": "DEMASIADAS_PETICIONES",
    "mensaje": "Has superado el límite de 100 peticiones por minuto",
    "reintentar_en": 45   # segundos hasta poder volver a intentarlo
  }
}

 

# Lo que NO debes hacer con los errores:

# ❌ Devolver el stack trace al cliente
{
  "error": "NullPointerException at UserService.java:142\n  at UserController..."
}
# Expone detalles internos, es una vulnerabilidad de seguridad

# ❌ Mensaje de error genérico e inútil
{ "error": "Something went wrong" }

# ❌ Error en inglés cuando la API es en español
{ "error": "User not found" }

# ❌ Sin distinguir qué campo falló en la validación
{ "error": "Validation failed" }

 

Filtrado, búsqueda, ordenación y paginación

# Filtrado: parámetros de query para filtrar colecciones
GET /productos?categoria=electronica&activo=true
GET /pedidos?estado=pendiente&usuario_id=123
GET /usuarios?activo=true&rol=admin

# Búsqueda de texto
GET /productos?buscar=teclado+mecanico
GET /usuarios?q=garcia

# Ordenación: campo y dirección
GET /productos?ordenar=precio&direccion=asc
GET /productos?ordenar=precio&direccion=desc
GET /pedidos?ordenar=creado_en&direccion=desc  # más recientes primero

# Ordenación múltiple (menos habitual pero útil)
GET /usuarios?ordenar=apellido,nombre&direccion=asc,asc

# Paginación por offset (la más habitual)
GET /productos?pagina=1&limite=20     # primera página de 20
GET /productos?offset=40&limite=20    # alternativa con offset directo

# Paginación por cursor (mejor para datos que cambian frecuentemente)
GET /productos?cursor=eyJpZCI6NDJ9&limite=20
# El cursor apunta a una posición específica en los datos
# Más eficiente en bases de datos grandes que el offset

# Seleccionar campos específicos (reduce el tamaño de la respuesta)
GET /usuarios?campos=id,nombre,email
GET /productos?campos=id,nombre,precio,stock

# Incluir relaciones (similar al include de Prisma)
GET /pedidos/123?incluir=usuario,lineas
GET /productos?incluir=categoria,imagenes

Cabeceras HTTP importantes

# Cabeceras de petición habituales

Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...   # autenticación JWT
Content-Type: application/json                    # formato del body enviado
Accept: application/json                          # formato que acepta el cliente
Accept-Language: es-ES,es;q=0.9                  # idioma preferido
X-Request-ID: uuid-único                          # id para trazar la petición en logs


# Cabeceras de respuesta habituales

Content-Type: application/json; charset=utf-8    # formato del body devuelto
Location: /usuarios/124                           # URL del recurso creado (201)
X-Request-ID: uuid-único                          # devolver el mismo id de la petición
X-RateLimit-Limit: 100                            # peticiones permitidas por ventana
X-RateLimit-Remaining: 73                         # peticiones restantes
X-RateLimit-Reset: 1725523200                     # timestamp del reset de la ventana
Retry-After: 45                                   # segundos hasta poder reintentar (429, 503)


# Cabeceras de caché
Cache-Control: no-store                           # no cachear (datos sensibles)
Cache-Control: max-age=3600                       # cachear 1 hora
Cache-Control: max-age=3600, must-revalidate
ETag: "33a64df551425fcc55e4d42a148795d9f25f89d"  # identificador de la versión del recurso
Last-Modified: Thu, 05 Sep 2026 12:00:00 GMT     # última modificación

Autenticación y seguridad

# El patrón más habitual: JWT Bearer Token

# 1. El cliente se autentica y recibe un token
POST /auth/login
# body: { "email": "ana@mail.com", "contraseña": "mi-contraseña" }

# Respuesta:
{
  "token":         "eyJhbGciOiJIUzI1NiJ9...",
  "tipo":          "Bearer",
  "expira_en":     3600,      # segundos hasta que expira
  "refresh_token": "eyJhbGci..."  # para renovar sin volver a pedir credenciales
}

# 2. El cliente incluye el token en cada petición
GET /usuarios/perfil
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

# 3. Cuando expira, usar el refresh token
POST /auth/refresh
# body: { "refresh_token": "eyJhbGci..." }


# Buenas prácticas de seguridad en APIs REST:

# ✅ Siempre HTTPS: nunca exponer una API en HTTP en producción
# ✅ Validar y sanitizar todos los inputs en el servidor
# ✅ No confiar en los datos del cliente aunque el frontend los valide
# ✅ Rate limiting: limitar peticiones por IP y por usuario
# ✅ No exponer IDs secuenciales (revelan volumen de datos): usar UUIDs
# ✅ Principio de mínimo privilegio: los tokens solo dan acceso a lo necesario
# ✅ Cabeceras de seguridad: CORS restrictivo, Content-Security-Policy
# ✅ Logs de auditoría: registrar quién hizo qué y cuándo

 

Versionado de la API

Cuando publicas una API, otras aplicaciones dependen de ella. Si cambias el contrato (quitas un campo, cambias el formato de una respuesta, renombras un endpoint), rompes esos clientes. El versionado permite hacer cambios sin romper clientes existentes.

# Opción 1: versión en la URL (la más habitual y más clara)
GET /v1/usuarios
GET /v2/usuarios

# Opción 2: versión en la cabecera Accept
GET /usuarios
Accept: application/json; version=2

# Opción 3: versión en cabecera personalizada
GET /usuarios
API-Version: 2

# La versión en la URL es la más explícita y la más fácil de probar en el navegador
# La mayoría de APIs públicas importantes (Stripe, GitHub, Twilio) usan versión en URL


# Estrategia de versionado:

# Cambios que NO requieren nueva versión (compatibles hacia atrás):
# - Añadir campos nuevos en las respuestas
# - Añadir endpoints nuevos
# - Añadir parámetros opcionales a endpoints existentes
# - Hacer obligatorio un campo que era opcional (solo si los clientes ya lo envían)

# Cambios que SÍ requieren nueva versión (rompen clientes existentes):
# - Eliminar campos de la respuesta
# - Renombrar campos
# - Cambiar el tipo de un campo (string → number)
# - Cambiar la estructura de una respuesta
# - Eliminar endpoints

# Ciclo de vida de versiones:
# v1 activa → se lanza v2 → v1 entra en deprecación → aviso a clientes
# → periodo de migración (mínimo 6-12 meses para APIs públicas)
# → v1 se da de baja

Documentación de la API

# OpenAPI / Swagger: el estándar para documentar APIs REST
# Define la API en YAML o JSON, genera documentación interactiva automáticamente

# Ejemplo de spec OpenAPI 3.0 para un endpoint:
openapi: 3.0.0
info:
  title: API de Mi Tienda
  version: 1.0.0

paths:
  /usuarios/{id}:
    get:
      summary: Obtener un usuario por ID
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Usuario encontrado
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Usuario'
        '404':
          description: Usuario no encontrado

components:
  schemas:
    Usuario:
      type: object
      properties:
        id:
          type: integer
          example: 123
        nombre:
          type: string
          example: Ana García
        email:
          type: string
          format: email
          example: ana@mail.com
        creado_en:
          type: string
          format: date-time

Resumen: checklist de diseño

  • Las URLs usan sustantivos en plural y son recursos, no acciones: /usuarios, no /getUsuarios.
  • Los métodos HTTP expresan la acción: GET lee, POST crea, PUT reemplaza, PATCH actualiza parcialmente, DELETE elimina.
  • Los códigos de estado son correctos y precisos: 201 para creación, 204 para éxito sin cuerpo, 400 para datos inválidos, 401 para no autenticado, 403 para sin permisos, 404 para no encontrado, 409 para conflicto.
  • Las respuestas de error tienen estructura consistente con un código legible por máquina, un mensaje legible por humanos y detalles de campo cuando aplica.
  • Las colecciones soportan paginación, filtrado, ordenación y selección de campos mediante parámetros de query.
  • La API está versionada desde el principio. Los cambios compatibles no requieren nueva versión; los cambios que rompen el contrato sí.
  • La autenticación usa Bearer Token (JWT), siempre sobre HTTPS, con rate limiting y validación de todos los inputs en el servidor.
  • La API está documentada con OpenAPI para que los consumidores puedan explorarla de forma interactiva.
Etiquetas: API API REST

¿Te ha gustado esta entrada?

Compártela con tus compañeros para que también sigan aprendiendo.

Comunidad y Comentarios

0 COMENTARIOS

No hay comentarios todavía. Sé el primero en compartir tu opinión.

Escribe tu opinión
Respondiendo a