APIs REST
"REST es un conjunto de restricciones, no un protocolo. Lo que la mayoría llama 'REST' es realmente 'JSON sobre HTTP'." — Roy Fielding (creador de REST), continuamente exasperado.
Qué vas a aprender en este capítulo
REST es el estilo arquitectónico dominante para APIs en la web. Este capítulo cubre los principios, las mejores prácticas, y la implementación práctica con FastAPI — el framework Python moderno más recomendado.
1.1 Principios REST
📐 Fundamento
Restricciones REST (Roy Fielding, 2000):
- Cliente-servidor: separación clara de responsabilidades.
- Stateless: cada request contiene toda la información necesaria; el servidor no guarda estado de sesión.
- Cacheable: las respuestas deben indicar si pueden cachearse.
- Sistema en capas: el cliente no sabe si habla con el servidor original o un proxy.
- Uniform interface: los recursos se identifican con URIs, se manipulan con verbos HTTP estándar.
- HATEOAS (opcional pero parte del estándar): respuestas incluyen links a acciones siguientes.
Mapeo de operaciones a HTTP:
| Operación | Método HTTP | URL ejemplo | Idempotente | Seguro |
|---|---|---|---|---|
| Listar | GET |
/pedidos |
Sí | Sí |
| Obtener | GET |
/pedidos/123 |
Sí | Sí |
| Crear | POST |
/pedidos |
No | No |
| Reemplazar | PUT |
/pedidos/123 |
Sí | No |
| Actualizar parcialmente | PATCH |
/pedidos/123 |
No (típicamente) | No |
| Eliminar | DELETE |
/pedidos/123 |
Sí | No |
- Seguro: no modifica datos.
- Idempotente: ejecutar N veces = ejecutar 1 vez.
Códigos de respuesta HTTP:
| Código | Significado | Cuándo usar |
|---|---|---|
| 200 OK | Éxito con cuerpo | GET, PUT exitoso |
| 201 Created | Recurso creado | POST exitoso |
| 204 No Content | Éxito sin cuerpo | DELETE, algunas PUT |
| 400 Bad Request | Input inválido | Validación falló |
| 401 Unauthorized | No autenticado | Token faltante/inválido |
| 403 Forbidden | No autorizado | Token válido pero sin permisos |
| 404 Not Found | Recurso no existe | ID inválido |
| 409 Conflict | Conflicto de estado | Crear duplicado |
| 422 Unprocessable Entity | Inputs sintácticamente ok pero semánticamente inválidos | Negocio validó mal |
| 429 Too Many Requests | Rate limit excedido | Anti-abuse |
| 500 Internal Server Error | Bug del servidor | Sin manejar |
| 503 Service Unavailable | Servicio temporal abajo | Mantenimiento, sobrecarga |
Convenciones de URLs:
GOOD BAD
/pedidos /getPedidos ← verbos en URL
/pedidos/123 /pedidos?id=123 ← ID como query
/pedidos/123/items /pedidoItems/123 ← jerarquía perdida
/pedidos?estado=ABIERTO&page=2 /pedidosAbiertosPagina2 ← acciones en URL
Recursos en plural y minúscula con guiones:
/menu-de-platillos ✓
/menuDePlatillos ✗
/MenuDePlatillos ✗
1.2 FastAPI — implementación
📐 Fundamento
Setup mínimo:
pip install fastapi uvicorn[standard] sqlalchemy psycopg2 pydantic[email]
API completa de pedidos:
from fastapi import FastAPI, HTTPException, Depends, status
from pydantic import BaseModel, Field, EmailStr
from typing import Optional
from datetime import datetime
from enum import Enum
app = FastAPI(
title="La Esquina API",
version="1.0.0",
description="API de pedidos para La Esquina"
)
# === MODELOS PYDANTIC (validación automática) ===
class EstadoPedido(str, Enum):
ABIERTO = "ABIERTO"
LISTO = "LISTO"
ENTREGADO = "ENTREGADO"
CANCELADO = "CANCELADO"
class ItemPedido(BaseModel):
platillo_id: int = Field(..., gt=0, description="ID del platillo")
cantidad: int = Field(..., gt=0, le=20, description="1 a 20 unidades")
class PedidoCrear(BaseModel):
mesa_id: int = Field(..., gt=0)
items: list[ItemPedido] = Field(..., min_length=1)
notas: Optional[str] = Field(None, max_length=500)
class PedidoRespuesta(BaseModel):
id: int
mesa_id: int
estado: EstadoPedido
total: float
creado_en: datetime
items: list[ItemPedido]
class Config:
from_attributes = True # permite construir desde ORM
# === ENDPOINTS ===
@app.post("/pedidos", response_model=PedidoRespuesta, status_code=201)
def crear_pedido(pedido: PedidoCrear, user=Depends(get_current_user)):
"""Crear un nuevo pedido."""
nuevo = db.crear_pedido(
mesa_id=pedido.mesa_id,
items=pedido.items,
notas=pedido.notas,
mozo_id=user.id
)
return nuevo
@app.get("/pedidos", response_model=list[PedidoRespuesta])
def listar_pedidos(
estado: Optional[EstadoPedido] = None,
page: int = 1,
size: int = 20,
user=Depends(get_current_user)
):
"""Listar pedidos con filtros y paginación."""
if size > 100:
raise HTTPException(400, "Máximo 100 por página")
return db.listar_pedidos(estado=estado, offset=(page-1)*size, limit=size)
@app.get("/pedidos/{pedido_id}", response_model=PedidoRespuesta)
def obtener_pedido(pedido_id: int, user=Depends(get_current_user)):
pedido = db.get_pedido(pedido_id)
if not pedido:
raise HTTPException(404, "Pedido no encontrado")
if pedido.mozo_id != user.id and not user.is_admin:
raise HTTPException(403, "Sin permisos")
return pedido
@app.patch("/pedidos/{pedido_id}", response_model=PedidoRespuesta)
def actualizar_estado(
pedido_id: int,
nuevo_estado: EstadoPedido,
user=Depends(get_current_user)
):
pedido = db.get_pedido(pedido_id)
if not pedido:
raise HTTPException(404)
# Validar transición de estado
if not estado_transicion_valida(pedido.estado, nuevo_estado):
raise HTTPException(422, f"No se puede pasar de {pedido.estado} a {nuevo_estado}")
return db.actualizar_estado(pedido_id, nuevo_estado)
@app.delete("/pedidos/{pedido_id}", status_code=204)
def cancelar_pedido(pedido_id: int, user=Depends(get_current_user)):
db.cancelar_pedido(pedido_id, user.id)
return None
# Iniciar el servidor:
# uvicorn main:app --reload --host 0.0.0.0 --port 8000
Lo que FastAPI te da gratis:
- Validación automática con Pydantic —
pedido.itemssiempre será una lista válida. - Documentación automática en
/docs(Swagger UI) y/redoc. - Schema OpenAPI en
/openapi.json— usable para generar clientes en cualquier lenguaje. - Async nativo — soporta
async defpara máxima concurrencia. - Type hints — el editor te ayuda con autocomplete y detección de errores.
1.3 Autenticación con JWT
📐 Fundamento
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import jwt, JWTError
from passlib.context import CryptContext
from datetime import datetime, timedelta
pwd_context = CryptContext(schemes=["bcrypt"])
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
SECRET_KEY = os.environ["SECRET_KEY"]
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verificar_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def crear_access_token(data: dict, expires_minutes: int = 15) -> str:
payload = {**data, "exp": datetime.utcnow() + timedelta(minutes=expires_minutes)}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
@app.post("/token")
def login(form: OAuth2PasswordRequestForm = Depends()):
user = db.get_user_by_email(form.username)
if not user or not verificar_password(form.password, user.hashed_password):
raise HTTPException(401, "Credenciales inválidas",
headers={"WWW-Authenticate": "Bearer"})
access_token = crear_access_token({"sub": str(user.id), "role": user.role})
return {"access_token": access_token, "token_type": "bearer"}
def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(401, "Token inválido")
except JWTError:
raise HTTPException(401, "Token inválido")
user = db.get_user(int(user_id))
if not user:
raise HTTPException(401, "Usuario no encontrado")
return user
# Cualquier endpoint con `Depends(get_current_user)` requiere token válido
1.4 Versionado y deprecación
📐 Fundamento
Estrategias de versionado:
| Estrategia | URL | Header | Pros | Cons |
|---|---|---|---|---|
| Path | /v1/pedidos |
— | Visible, fácil de cachear | Acopla URL a versión |
| Header | /pedidos |
Accept: application/vnd.la-esquina.v1+json |
URL limpia | Menos visible, harder de testear |
| Query param | /pedidos?v=1 |
— | Simple | Mezcla versión con datos |
Recomendado: path versioning.
from fastapi import APIRouter
v1_router = APIRouter(prefix="/v1")
v2_router = APIRouter(prefix="/v2")
@v1_router.get("/pedidos")
def listar_v1(): ...
@v2_router.get("/pedidos")
def listar_v2(): # cambios breaking respecto a v1
...
app.include_router(v1_router)
app.include_router(v2_router)
Política de deprecación:
- Anunciar la nueva versión y la fecha de end-of-life de la vieja (mínimo 6 meses).
- Header de deprecación en respuestas v1:
Deprecation: true+Sunset: Wed, 11 Nov 2026 23:59:59 GMT. - Logs de uso de v1: ¿quién la sigue usando?
- Comunicación directa a usuarios activos de v1.
- Apagado en la fecha anunciada.
Cambios breaking vs no-breaking:
| Tipo | ¿Breaking? |
|---|---|
| Agregar campo nuevo opcional en respuesta | No |
| Agregar nuevo endpoint | No |
| Eliminar campo de respuesta | Sí |
| Cambiar tipo de un campo (int → string) | Sí |
| Hacer un campo obligatorio cuando era opcional | Sí |
| Cambiar el código de respuesta | Sí |
1.5 Mejores prácticas adicionales
📐 Fundamento
Paginación:
@app.get("/pedidos")
def listar(page: int = 1, size: int = 20):
items = db.get_pedidos(offset=(page-1)*size, limit=size)
total = db.count_pedidos()
return {
"items": items,
"page": page,
"size": size,
"total": total,
"pages": (total + size - 1) // size
}
Cursor-based pagination (mejor para datasets grandes):
@app.get("/pedidos")
def listar(cursor: Optional[str] = None, size: int = 20):
items = db.get_pedidos_after(cursor=cursor, limit=size+1)
next_cursor = items[-1].id if len(items) > size else None
return {"items": items[:size], "next_cursor": next_cursor}
Rate limiting:
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app.state.limiter = limiter
@app.post("/pedidos")
@limiter.limit("10/minute")
def crear_pedido(...): ...
Manejo consistente de errores:
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
@app.exception_handler(RequestValidationError)
async def validation_handler(request, exc):
return JSONResponse(
status_code=422,
content={
"error": "validation_error",
"message": "Inputs inválidos",
"details": exc.errors(),
"request_id": request.headers.get("X-Request-ID")
}
)
CORS (Cross-Origin Resource Sharing) — para que el frontend en otro dominio pueda llamar:
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://la-esquina.com"], # NO usar "*" en producción
allow_credentials=True,
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"]
)
1.6 Ejercicios
✏️ Ejercicio 1.1 — Diseño de endpoints
Diseñá los endpoints REST para un sistema de reservas de mesas de La Esquina. Operaciones necesarias:
- Ver disponibilidad de mesas para una fecha y hora.
- Crear una reserva.
- Cancelar una reserva.
- Ver mis reservas.
Para cada uno: método HTTP, URL, qué retorna, qué códigos de respuesta usás.
Solución
| Operación | Método | URL | Respuesta | Códigos |
|---|---|---|---|---|
| Ver disponibilidad | GET |
/mesas/disponibles?fecha=2026-05-10&hora=20:00&personas=4 |
Lista de mesas con id, capacidad |
200, 400 |
| Crear reserva | POST |
/reservas |
Reserva creada con id |
201, 400, 409 |
| Cancelar reserva | DELETE |
/reservas/{id} |
(sin cuerpo) | 204, 403, 404 |
| Mis reservas | GET |
/reservas?mias=true o /usuarios/me/reservas |
Lista | 200, 401 |
Detalles:
- 409 (Conflict) si la mesa fue reservada justo antes (race condition).
- 403 si intenta cancelar reserva de otro usuario.
- 404 si la reserva no existe.
- 400 si los inputs (fecha en pasado, personas <= 0) son inválidos.
class ReservaCrear(BaseModel):
mesa_id: int = Field(..., gt=0)
fecha_hora: datetime
personas: int = Field(..., ge=1, le=20)
nombre_contacto: str = Field(..., min_length=1, max_length=100)
@field_validator('fecha_hora')
def fecha_no_pasada(cls, v):
if v < datetime.now():
raise ValueError('La fecha no puede ser en el pasado')
return v
1.7 Para profundizar
- REST API Design Rulebook — Mark Massé.
- FastAPI docs — fastapi.tiangolo.com (excelente).
- Stripe API documentation — referencia de cómo se ve una API de clase mundial.
- Siguiente: GraphQL.
1.X Errores comunes
⚠️ Trampa común
Usar GET para mutar estado. Endpoints como GET /api/users/123/delete o GET /pagar?monto=100 son tentadores porque "se pueden poner en un link". Pero los browsers, proxies y crawlers asumen que GET es seguro e idempotente: pueden cachearlo, reintentarlo o dispararlo solo (Googlebot, prefetching del browser). Resultado: usuarios borrados sin que nadie haga clic.
Tip: seguí los verbos: GET para leer, POST para crear, PUT/PATCH para modificar, DELETE para borrar. Sin excepciones.
⚠️ Trampa común
Devolver mensajes de error que filtran información. {"error": "Usuario admin@empresa.com no encontrado"} revela que admin@empresa.com no es usuario válido. En login es peor: si el mensaje cambia entre "usuario inexistente" y "contraseña incorrecta", un atacante puede enumerar usuarios sin tener contraseñas.
Tip: errores genéricos hacia afuera (401 Credenciales inválidas), errores detallados solo en logs internos. Usá un código de error estable (AUTH_INVALID) para que el cliente lo maneje sin parsear el texto.
Definiciones nuevas: REST, recurso, URI, HATEOAS, idempotencia, FastAPI, Pydantic, OpenAPI, Swagger, JWT, OAuth2, versionado de APIs, paginación cursor-based, rate limiting, CORS.