Patrones de Diseño de API
Convenciones y buenas prácticas para diseñar APIs REST consistentes y amigables para desarrolladores.
Cuándo Activar
- Diseñar nuevos endpoints de API
- Revisar contratos de API existentes
- Agregar paginación, filtrado u ordenamiento
- Implementar manejo de errores para APIs
- Planificar la estrategia de versionado de API
- Construir APIs públicas o para partners
Diseño de Recursos
Estructura de URL
# Los recursos son sustantivos, plural, minúsculas, kebab-case GET /api/v1/users GET /api/v1/users/:id POST /api/v1/users PUT /api/v1/users/:id PATCH /api/v1/users/:id DELETE /api/v1/users/:id # Sub-recursos para relaciones GET /api/v1/users/:id/orders POST /api/v1/users/:id/orders # Acciones que no mapean a CRUD (usar verbos con moderación) POST /api/v1/orders/:id/cancel POST /api/v1/auth/login POST /api/v1/auth/refresh
Reglas de Nomenclatura
# BIEN /api/v1/team-members # kebab-case para recursos de varias palabras /api/v1/orders?status=active # query params para filtrado /api/v1/users/123/orders # recursos anidados para pertenencia # MAL /api/v1/getUsers # verbo en la URL /api/v1/user # singular (usar plural) /api/v1/team_members # snake_case en URLs /api/v1/users/123/getOrders # verbo en recurso anidado
Métodos HTTP y Códigos de Estado
Semántica de Métodos
| Método | Idempotente | Seguro | Usar Para |
|---|---|---|---|
| GET | Sí | Sí | Recuperar recursos |
| POST | No | No | Crear recursos, disparar acciones |
| PUT | Sí | No | Reemplazo completo de un recurso |
| PATCH | No* | No | Actualización parcial de un recurso |
| DELETE | Sí | No | Eliminar un recurso |
*PATCH puede hacerse idempotente con la implementación adecuada
Referencia de Códigos de Estado
# Éxito 200 OK — GET, PUT, PATCH (con cuerpo de respuesta) 201 Created — POST (incluir header Location) 204 No Content — DELETE, PUT (sin cuerpo de respuesta) # Errores de Cliente 400 Bad Request — Fallo de validación, JSON malformado 401 Unauthorized — Autenticación ausente o inválida 403 Forbidden — Autenticado pero no autorizado 404 Not Found — El recurso no existe 409 Conflict — Entrada duplicada, conflicto de estado 422 Unprocessable Entity — Semánticamente inválido (JSON válido, datos incorrectos) 429 Too Many Requests — Límite de rate excedido # Errores de Servidor 500 Internal Server Error — Fallo inesperado (nunca exponer detalles) 502 Bad Gateway — Falló el servicio upstream 503 Service Unavailable — Sobrecarga temporal, incluir Retry-After
Errores Comunes
# MAL: 200 para todo { "status": 200, "success": false, "error": "Not found" } # BIEN: Usar códigos de estado HTTP semánticamente HTTP/1.1 404 Not Found { "error": { "code": "not_found", "message": "User not found" } } # MAL: 500 para errores de validación # BIEN: 400 o 422 con detalles por campo # MAL: 200 para recursos creados # BIEN: 201 con header Location HTTP/1.1 201 Created Location: /api/v1/users/abc-123
Formato de Respuesta
Respuesta Exitosa
json{ "data": { "id": "abc-123", "email": "alice@example.com", "name": "Alice", "created_at": "2025-01-15T10:30:00Z" } }
Respuesta de Colección (con Paginación)
json{ "data": [ { "id": "abc-123", "name": "Alice" }, { "id": "def-456", "name": "Bob" } ], "meta": { "total": 142, "page": 1, "per_page": 20, "total_pages": 8 }, "links": { "self": "/api/v1/users?page=1&per_page=20", "next": "/api/v1/users?page=2&per_page=20", "last": "/api/v1/users?page=8&per_page=20" } }
Respuesta de Error
json{ "error": { "code": "validation_error", "message": "Request validation failed", "details": [ { "field": "email", "message": "Must be a valid email address", "code": "invalid_format" }, { "field": "age", "message": "Must be between 0 and 150", "code": "out_of_range" } ] } }
Variantes de Envelope de Respuesta
typescript// Opción A: Envelope con wrapper data (recomendado para APIs públicas) interface ApiResponse<T> { data: T; meta?: PaginationMeta; links?: PaginationLinks; } interface ApiError { error: { code: string; message: string; details?: FieldError[]; }; } // Opción B: Respuesta plana (más simple, común para APIs internas) // Éxito: retornar el recurso directamente // Error: retornar objeto de error // Distinguir por código de estado HTTP
Paginación
Basada en Offset (Simple)
GET /api/v1/users?page=2&per_page=20 # Implementación SELECT * FROM users ORDER BY created_at DESC LIMIT 20 OFFSET 20;
Pros: Fácil de implementar, soporta "saltar a página N" Contras: Lento en offsets grandes (OFFSET 100000), inconsistente con inserciones concurrentes
Basada en Cursor (Escalable)
GET /api/v1/users?cursor=eyJpZCI6MTIzfQ&limit=20 # Implementación SELECT * FROM users WHERE id > :cursor_id ORDER BY id ASC LIMIT 21; -- obtener uno extra para determinar has_next
json{ "data": [...], "meta": { "has_next": true, "next_cursor": "eyJpZCI6MTQzfQ" } }
Pros: Rendimiento consistente independientemente de la posición, estable con inserciones concurrentes Contras: No se puede saltar a una página arbitraria, el cursor es opaco
Cuándo Usar Cuál
| Caso de Uso | Tipo de Paginación |
|---|---|
| Dashboards administrativos, datasets pequeños (<10K) | Offset |
| Scroll infinito, feeds, datasets grandes | Cursor |
| APIs públicas | Cursor (por defecto) con offset (opcional) |
| Resultados de búsqueda | Offset (los usuarios esperan números de página) |
Filtrado, Ordenamiento y Búsqueda
Filtrado
# Igualdad simple GET /api/v1/orders?status=active&customer_id=abc-123 # Operadores de comparación (usar notación de corchetes) GET /api/v1/products?price[gte]=10&price[lte]=100 GET /api/v1/orders?created_at[after]=2025-01-01 # Múltiples valores (separados por coma) GET /api/v1/products?category=electronics,clothing # Campos anidados (notación de punto) GET /api/v1/orders?customer.country=US
Ordenamiento
# Campo único (prefijo - para descendente) GET /api/v1/products?sort=-created_at # Múltiples campos (separados por coma) GET /api/v1/products?sort=-featured,price,-created_at
Búsqueda de Texto Completo
# Parámetro de consulta de búsqueda GET /api/v1/products?q=wireless+headphones # Búsqueda específica de campo GET /api/v1/users?email=alice
Conjuntos de Campos Reducidos (Sparse Fieldsets)
# Retornar solo los campos especificados (reduce el payload) GET /api/v1/users?fields=id,name,email GET /api/v1/orders?fields=id,total,status&include=customer.name
Autenticación y Autorización
Autenticación Basada en Token
# Bearer token en el header Authorization GET /api/v1/users Authorization: Bearer eyJhbGciOiJIUzI1NiIs... # API key (para servidor a servidor) GET /api/v1/data X-API-Key: sk_live_abc123
Patrones de Autorización
typescript// A nivel de recurso: verificar propiedad app.get("/api/v1/orders/:id", async (req, res) => { const order = await Order.findById(req.params.id); if (!order) return res.status(404).json({ error: { code: "not_found" } }); if (order.userId !== req.user.id) return res.status(403).json({ error: { code: "forbidden" } }); return res.json({ data: order }); }); // Basada en roles: verificar permisos app.delete("/api/v1/users/:id", requireRole("admin"), async (req, res) => { await User.delete(req.params.id); return res.status(204).send(); });
Rate Limiting
Headers
HTTP/1.1 200 OK X-RateLimit-Limit: 100 X-RateLimit-Remaining: 95 X-RateLimit-Reset: 1640000000 # Cuando se excede HTTP/1.1 429 Too Many Requests Retry-After: 60 { "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded. Try again in 60 seconds." } }
Niveles de Rate Limit
| Nivel | Límite | Ventana | Caso de Uso |
|---|---|---|---|
| Anónimo | 30/min | Por IP | Endpoints públicos |
| Autenticado | 100/min | Por usuario | Acceso API estándar |
| Premium | 1000/min | Por API key | Planes de API de pago |
| Interno | 10000/min | Por servicio | Servicio a servicio |
Versionado
Versionado en Ruta de URL (Recomendado)
/api/v1/users /api/v2/users
Pros: Explícito, fácil de enrutar, cacheable Contras: La URL cambia entre versiones
Versionado por Header
GET /api/users Accept: application/vnd.myapp.v2+json
Pros: URLs limpias Contras: Más difícil de probar, fácil de olvidar
Estrategia de Versionado
1. Empezar con /api/v1/ — no versionar hasta que sea necesario 2. Mantener como máximo 2 versiones activas (actual + anterior) 3. Línea de tiempo de deprecación: - Anunciar la deprecación (6 meses de aviso para APIs públicas) - Agregar header Sunset: Sunset: Sat, 01 Jan 2026 00:00:00 GMT - Retornar 410 Gone después de la fecha de sunset 4. Los cambios no disruptivos no necesitan una nueva versión: - Agregar nuevos campos a las respuestas - Agregar nuevos parámetros de consulta opcionales - Agregar nuevos endpoints 5. Los cambios disruptivos requieren una nueva versión: - Eliminar o renombrar campos - Cambiar tipos de campo - Cambiar la estructura de URL - Cambiar el método de autenticación
Patrones de Implementación
TypeScript (Next.js API Route)
typescriptimport { z } from "zod"; import { NextRequest, NextResponse } from "next/server"; const createUserSchema = z.object({ email: z.string().email(), name: z.string().min(1).max(100), }); export async function POST(req: NextRequest) { const body = await req.json(); const parsed = createUserSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: { code: "validation_error", message: "Request validation failed", details: parsed.error.issues.map(i => ({ field: i.path.join("."), message: i.message, code: i.code, })), }, }, { status: 422 }); } const user = await createUser(parsed.data); return NextResponse.json( { data: user }, { status: 201, headers: { Location: `/api/v1/users/${user.id}` }, }, ); }
Python (Django REST Framework)
pythonfrom rest_framework import serializers, viewsets, status from rest_framework.response import Response class CreateUserSerializer(serializers.Serializer): email = serializers.EmailField() name = serializers.CharField(max_length=100) class UserSerializer(serializers.ModelSerializer): class Meta: model = User fields = ["id", "email", "name", "created_at"] class UserViewSet(viewsets.ModelViewSet): serializer_class = UserSerializer permission_classes = [IsAuthenticated] def get_serializer_class(self): if self.action == "create": return CreateUserSerializer return UserSerializer def create(self, request): serializer = CreateUserSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = UserService.create(**serializer.validated_data) return Response( {"data": UserSerializer(user).data}, status=status.HTTP_201_CREATED, headers={"Location": f"/api/v1/users/{user.id}"}, )
Go (net/http)
gofunc (h *UserHandler) CreateUser(w http.ResponseWriter, r *http.Request) { var req CreateUserRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_json", "Invalid request body") return } if err := req.Validate(); err != nil { writeError(w, http.StatusUnprocessableEntity, "validation_error", err.Error()) return } user, err := h.service.Create(r.Context(), req) if err != nil { switch { case errors.Is(err, domain.ErrEmailTaken): writeError(w, http.StatusConflict, "email_taken", "Email already registered") default: writeError(w, http.StatusInternalServerError, "internal_error", "Internal error") } return } w.Header().Set("Location", fmt.Sprintf("/api/v1/users/%s", user.ID)) writeJSON(w, http.StatusCreated, map[string]any{"data": user}) }
Lista de Verificación de Diseño de API
Antes de publicar un nuevo endpoint:
- La URL del recurso sigue las convenciones de nomenclatura (plural, kebab-case, sin verbos)
- Se usa el método HTTP correcto (GET para lecturas, POST para creaciones, etc.)
- Se retornan códigos de estado apropiados (no 200 para todo)
- La entrada se valida con esquema (Zod, Pydantic, Bean Validation)
- Las respuestas de error siguen el formato estándar con códigos y mensajes
- Se implementa paginación para endpoints de lista (cursor u offset)
- Autenticación requerida (o marcado explícitamente como público)
- Autorización verificada (el usuario solo puede acceder a sus propios recursos)
- Rate limiting configurado
- La respuesta no filtra detalles internos (stack traces, errores SQL)
- Nomenclatura consistente con los endpoints existentes (camelCase vs snake_case)
- Documentado (especificación OpenAPI/Swagger actualizada)

