Atlas

API Styles: REST · HATEOAS · gRPC · GraphQL · JSON-RPC

El estilo de API es el contrato de comunicación entre servicios/clientes; elegirlo es un trade-off deliberado (acoplamiento, latencia, cacheabilidad, evolución del schema, quién consume), no una moda.

Teoría

Qué es un API style y por qué importa

Un API style define cuatro decisiones a la vez: cómo se modela el dominio (recursos vs procedimientos vs grafo), cómo se serializa (JSON texto vs binario protobuf), cómo se transporta (HTTP/1.1, HTTP/2, WebSocket) y cómo se evoluciona el contrato sin romper consumidores. Cambiar de estilo cambia las cuatro a la vez, por eso la decisión es arquitectónica y difícil de revertir.

Resumen operativo de los cuatro estilos:

La API es el contrato entre tu backend y el mundo. Un contrato mal diseñado es difícil de versionar sin romper clientes, genera N+1 por mal modelado de recursos, no escala sin paginación ni rate limiting, y confunde a los consumidores por inconsistencia. El nivel senior no es “sé escribir endpoints”, es “justifico cada decisión de contrato y sé qué cuesta cada una”.

Richardson Maturity Model y HATEOAS

El Richardson Maturity Model (RMM) gradúa qué tan RESTful es una API en cuatro niveles. No es una escala de correctitud que haya que escalar hasta arriba: es un mapa de cuánto acoplamiento y descubribilidad quieres.

NivelNombreQué haceEjemplo
L0The Swamp of POXUn solo URI, todo por POST, RPC-sobre-HTTPPOST /api con {"action":"getOrder"}
L1ResourcesMúltiples URIs, uno por recurso, pero un solo verboPOST /orders/199 para todo
L2HTTP VerbsVerbos correctos (GET/POST/PUT/DELETE) + status codes semánticosGET /orders/199200, DELETE204
L3Hypermedia (HATEOAS)Las respuestas embeben links que dicen las acciones disponibles a continuaciónver abajo

L2 es el sweet spot práctico. Stripe, GitHub y la mayoría de APIs públicas exitosas viven en L2 y están perfectamente bien. La ceremonia de L3 rara vez se justifica.

HATEOAS (Hypermedia As The Engine Of Application State) es L3: el cliente no hardcodea URIs ni conoce la máquina de estados; sigue links que el server provee según el estado actual del recurso.

GET /v1/orders/199200 OK
{
  "id": "199",
  "status": "pending",
  "total": 4200,
  "_links": {
    "self":    { "href": "/v1/orders/199" },
    "cancel":  { "href": "/v1/orders/199", "method": "DELETE" },
    "pay":     { "href": "/v1/orders/199/payment", "method": "POST" }
  }
}

Cuando la orden pasa a paid, el server deja de emitir el link pay y emite refund. El cliente no necesita saber las reglas de transición: solo renderiza las acciones que llegaron. Formatos estándar: HAL (_links), JSON:API, Siren.

Cuándo HATEOAS paga: APIs long-lived con muchos clientes que no controlas, donde quieres reestructurar endpoints o gatear acciones por estado sin desplegar cambios en los clientes. Cuándo es overkill: pocos clientes, tightly-coupled, generados desde un spec (OpenAPI/protobuf) — el beneficio de descubribilidad no se usa y el impuesto de payload/complejidad sí es real. Framing senior: el nivel de madurez es una decisión de costo/beneficio sobre acoplamiento y evolvability, no un ladder de correctitud.

REST best practices — idempotencia

Idempotente = ejecutar la operación N veces produce el mismo estado del servidor que ejecutarla una vez. Distinto de safe (sin efectos secundarios: GET/HEAD/OPTIONS).

MétodoSafeIdempotentePor qué
GET/HEADLeer no cambia estado
OPTIONSMetadata, sin efectos
PUTNoReemplaza el recurso completo → mismo resultado
DELETENoBorrar algo ya borrado = no-op
POSTNoNoCrear 2 veces = 2 recursos (doble cobro)
PATCHNoDependeset name=X sí; increment counter no

En sistemas distribuidos los requests se duplican (retry del cliente, timeout de red que reintenta, at-least-once de una cola). Si tu POST no es idempotente, un retry cobra dos veces. Solución: Idempotency-Key.

from fastapi import FastAPI, Header, HTTPException
from uuid import UUID

app = FastAPI()

@app.post("/v1/orders", status_code=201)
async def create_order(
    body: OrderCreate,
    idempotency_key: UUID = Header(...),
):
    key = str(idempotency_key)

    # Atomic "insert-if-absent": el UNIQUE index sobre idempotency_key
    # es lo que evita la race de dos retries concurrentes ejecutando ambos.
    cached = await store.get(key)
    if cached is not None:
        if cached.request_fingerprint != fingerprint(body):
            # Mismo key, body distinto: rechazar, no devolver lo viejo.
            raise HTTPException(422, "Idempotency-Key reused with different body")
        if cached.status == "in_flight":
            raise HTTPException(409, "Request already in progress")
        return cached.response  # replay: misma respuesta, sin re-ejecutar

    await store.put(key, status="in_flight", fingerprint=fingerprint(body))  # TTL 24h
    order = await order_service.create(body)
    await store.put(key, status="done", response=order)
    return order

Claves senior que separan un if key in dict de una solución real:

REST best practices — paginación (cursor vs offset)

Offset (LIMIT/OFFSET) es simple y permite saltar a “página 7”, pero tiene dos defectos que lo descalifican a escala:

SELECT * FROM orders ORDER BY created_at DESC OFFSET 999980 LIMIT 20;
-- La DB LEE y DESCARTA 999980 filas para darte 20 → costo O(n).
-- Y si se inserta/borra mientras paginas → filas saltadas o duplicadas entre páginas.

Cursor (keyset) pagina por el último valor visto sobre un índice; costo O(page size) y consistente bajo escrituras:

SELECT * FROM orders
WHERE (created_at, id) < (:c_ts, :c_id)   -- tiebreaker id: created_at rara vez es único
ORDER BY created_at DESC, id DESC
LIMIT 20;                                  -- siempre rápido, usa el índice (created_at, id)
from base64 import urlsafe_b64encode, urlsafe_b64decode
import json

def encode_cursor(ts, id_) -> str:
    raw = json.dumps({"ts": ts.isoformat(), "id": str(id_)}).encode()
    return urlsafe_b64encode(raw).decode()  # opaco: el cliente no lo parsea

@app.get("/v1/orders")
async def list_orders(cursor: str | None = None, size: int = Query(20, ge=1, le=100)):
    tuple_ = _decode(cursor) if cursor else None
    rows = await order_service.page(after=tuple_, size=size)
    next_cursor = encode_cursor(rows[-1].created_at, rows[-1].id) if len(rows) == size else None
    return {"data": rows, "next_cursor": next_cursor, "size": size}  # end ⇒ next_cursor null
EscenarioEstrategiaPor qué
Admin panel, datos pequeños/estáticosOffsetUX “ir a página 5” importa; O(n) irrelevante
Feed infinito, timeline, catálogoCursorDatos cambian; O(page) y sin saltos
API públicaCursorNo controlas cuántas páginas piden
Exportación de datasets grandesCursorOffset se vuelve imposible

Senior: el cursor debe ser opaco (base64, idealmente firmado) para poder cambiar el sort sin romper clientes, debe incluir tiebreaker (id) porque el sort primario rara vez es único, y debe apoyarse en un índice que cubra (sort_key, id) (index-only scan). Señaliza fin con next_cursor: null, nunca dependas de count.

REST best practices — versioning

EstrategiaEjemploProContra
URL path/v2/ordersVisible, cacheable, curl-able, trivial de documentarDuplica rutas
Header / media-typeAccept: application/vnd.api.v2+jsonURIs estables, “REST puro”Invisible, difícil de testear/cachear
Query param/orders?version=2SimpleFeo, ensucia cache keys

Regla senior: versiona en el borde, evoluciona aditivamente dentro de una versión. La mayoría de cambios NO deben crear una versión nueva:

from fastapi import APIRouter
v1 = APIRouter(prefix="/v1")
v2 = APIRouter(prefix="/v2")

@v1.get("/orders")   # formato legacy
async def list_v1(): ...

@v2.get("/orders")   # formato nuevo (cursor pagination, envelope distinto)
async def list_v2(): ...

Para APIs públicas, URL path gana por visibilidad y cacheabilidad. gRPC obtiene versioning barato vía field numbers de protobuf (solo agregas, nunca reusas un número).

REST best practices — error model (RFC 9457 / 7807)

No inventes un formato de error por endpoint. Usa Problem Details for HTTP APIs: RFC 7807, actualizado y obsoletado por RFC 9457 (2023), mismo media type application/problem+json.

from fastapi.responses import JSONResponse

def problem(status, title, detail, type_="about:blank", **ext):
    return JSONResponse(
        status_code=status,
        media_type="application/problem+json",
        content={"type": type_, "title": title, "status": status,
                 "detail": detail, "instance": ext.pop("instance", None), **ext},
    )

@app.exception_handler(ValidationError)
async def on_validation(request, exc):
    return problem(
        422, "Validation Error", "One or more fields failed validation",
        type_="https://api.example.com/errors/validation",
        instance=str(request.url),
        errors=[{"field": e["loc"][-1], "message": e["msg"]} for e in exc.errors()],
    )

Campos estándar: type (URI que identifica el tipo de error), title (frase corta estable), status (repite el código HTTP), detail (explicación humana de esta ocurrencia), instance (URI de la ocurrencia). Puedes agregar campos de extensión (errors, code, balance).

Anti-patrón crítico: devolver 200 OK con el error en el body. Rompe caching HTTP, load balancers, retries automáticos y todo el tooling estándar. Usa el status code correcto:

2xx  200 OK · 201 Created (POST) · 204 No Content (DELETE)
4xx  400 Bad Request · 401 Unauthorized (no autenticado) · 403 Forbidden (sin permiso)
     404 Not Found · 409 Conflict (email ya existe) · 422 Unprocessable (bien formado, semánticamente inválido)
     429 Too Many Requests (rate limit)
5xx  500 Internal · 502 Bad Gateway · 503 Unavailable · 504 Gateway Timeout

Detalle relacionado: rate limiting (429 + Retry-After + X-RateLimit-*). Token Bucket permite bursts hasta el límite (default para APIs generales); Leaky Bucket fuerza velocidad constante (procesamiento de pagos). En producción, el contador vive en Redis para ser distribuido, no en memoria del worker.

REST best practices — OpenAPI como contrato

OpenAPI (antes Swagger) es la descripción machine-readable del contrato REST. No es documentación decorativa: es el artefacto del que se generan clients tipados, mocks, validación de requests y contract tests. En FastAPI el schema se deriva de los type hints y modelos Pydantic — el código es la fuente de verdad:

class OrderCreate(BaseModel):
    customer_id: UUID
    items: list[OrderItemCreate] = Field(min_length=1)

class OrderResponse(BaseModel):
    id: UUID
    status: str
    total: float
    created_at: datetime

@app.post("/v1/orders", status_code=201, response_model=OrderResponse,
          responses={422: {"description": "Validation error (problem+json)"}})
async def create_order(body: OrderCreate, idempotency_key: UUID = Header(...)):
    ...

FastAPI expone el OpenAPI JSON en /openapi.json y UIs en /docs (Swagger UI) y /redoc. Contrato senior: spec-first o code-first, pero un solo source of truth; generar el spec a mano y a la vez el código diverge y miente. Con OpenAPI generas SDKs de cliente, validas contra el spec en CI, y publicas un contrato que los consumidores pueden codegenerar.

gRPC vs REST

gRPC = RPC sobre HTTP/2 con contrato protobuf (IDL) y codegen de clientes/servidores en múltiples lenguajes.

syntax = "proto3";
service Orders {
  rpc Get(GetReq)   returns (Order);              // unary
  rpc Watch(WatchReq) returns (stream OrderEvent); // server-streaming
  rpc Import(stream Row) returns (ImportSummary);  // client-streaming
  rpc Chat(stream Msg)   returns (stream Msg);     // bidi
}
message GetReq { string id = 1; }   // los field numbers (=1) SON el contrato de wire
DimensiónRESTgRPC
WireJSON textoprotobuf binario (compacto)
TransporteHTTP/1.1+HTTP/2 (multiplexado, sin head-of-line a nivel request)
ContratoOpenAPI (opcional)protobuf (obligatorio, codegen)
StreamingSSE/WebSocket apartenativo (server/client/bidi)
Cache HTTPSí (intermediarios)No
BrowserNativoRequiere gRPC-Web + proxy (Envoy)
Debug/opscurl, legibleopaco, necesita tooling (grpcurl)

gRPC gana en comunicación interna service-to-service, alto throughput, baja latencia, entornos polyglot (Go ↔ Python ↔ Node) donde el codegen tipado elimina una clase de bugs de integración, y cuando necesitas streaming. Costos: sin cache HTTP intermediario, wire opaco, load-balancing necesita infra L7-aware (las conexiones HTTP/2 long-lived derrotan a un LB L4 naive), y el browser necesita gRPC-Web.

Backward compat en protobuf es disciplina dura: nunca reuses ni renumeres un field number; solo append; reserve los números/nombres eliminados. Respuesta senior común: ambos — gRPC interno, gateway REST/GraphQL en el borde (via transcoding gRPC-JSON).

GraphQL

Un endpoint (POST /graphql), un lenguaje de query donde el cliente pide exactamente los campos, colapsando múltiples round-trips en uno:

query { order(id: "199") { id total items { sku qty } } }   # sin over/under-fetching
mutation { cancelOrder(id: "199") { id status } }

Elimina over-fetching (traer campos que no usas) y under-fetching (N requests para armar una vista). Ideal para front-ends con necesidades de datos heterogéneas (mobile + web + widgets) sobre un dominio graph-shaped. Pero traslada toda la complejidad al server, y ese es el tema de entrevista:

Framing senior: no es “REST vs GraphQL org-wide”. A menudo GraphQL es un BFF (Backend for Frontend) que agrega servicios REST/gRPC internos. Elige GraphQL cuando tienes muchos clientes diversos y dominio graph-shaped; quédate en REST cuando el dominio es resource-shaped, cache-heavy o público con consumidores no confiables.

JSON-RPC

Protocolo mínimo: un endpoint, dispatch por method, sin la ceremonia de recursos REST.

POST /rpc
{ "jsonrpc": "2.0", "method": "orders.cancel", "params": { "id": "199" }, "id": 7 }

→ { "jsonrpc": "2.0", "result": { "id": "199", "status": "cancelled" }, "id": 7 }
→ { "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 7 }

id correlaciona request/response (permite batching y pipelining); omitirlo = notification (fire-and-forget). Los error codes están estandarizados (-32700 parse error, -32600 invalid request, -32601 method not found, -32602 invalid params, -32603 internal).

Cuándo: acciones que no modelan bien como recursos — RPC puro tipo estimateGas, subscribe, eth_call. Común en blockchain (Ethereum JSON-RPC), Language Server Protocol (LSP), y tools internos donde no quieres la semántica REST. Es transport-agnóstico (HTTP, WebSocket, stdio). Costo vs REST: pierdes la cacheabilidad HTTP, los status codes semánticos y la descubribilidad de recursos.

Cómo seleccionar el enfoque según requisitos

El estilo lo dictan el consumidor y la topología, no la moda. Árbol de decisión senior:

  1. ¿El consumidor es un browser público / terceros que no controlas? → REST (ubicuidad, cache HTTP, curl-ability, tooling). Default seguro para un SaaS externo.
  2. ¿Comunicación interna service-to-service, polyglot, baja latencia, alto volumen o streaming? → gRPC (contrato fuerte, codegen, HTTP/2).
  3. ¿Muchos front-ends con formas de datos distintas y dominio graph-shaped, y el over/under-fetching de REST duele? → GraphQL (probablemente como BFF).
  4. ¿Acciones que no son recursos (RPC puro), o un protocolo interno simple? → JSON-RPC.
  5. ¿API long-lived, muchos clientes que no controlas, con máquina de estados que evoluciona y quieres desacoplarlos de URIs/workflow? → REST + HATEOAS (L3). Si no, quédate en L2.

Trade-off central en una frase: REST maximiza descubribilidad, cacheabilidad y bajo acoplamiento a costa de over/under-fetching y payloads verbosos; gRPC maximiza eficiencia y contrato fuerte a costa de opacidad y hostilidad al browser; GraphQL mueve el poder de decisión al cliente a costa de trasladar toda la complejidad de performance, caching y authz al server; JSON-RPC minimiza ceremonia a costa de perder la riqueza semántica de HTTP. No es una respuesta única: en un sistema real conviven — gRPC en el mesh interno, un gateway REST/GraphQL en el borde.

Ejercicios

  1. Idempotencia con race de retries. Tienes POST /v1/payments. Dos retries del mismo Idempotency-Key llegan simultáneamente a dos workers. Diseña el mecanismo de store que garantice que solo se cobra una vez, y describe qué pasa con el segundo request mientras el primero está in-flight.
Solución

Store durable (Postgres/Redis) con UNIQUE index sobre idempotency_key. Cada worker intenta un insert atómico INSERT ... ON CONFLICT DO NOTHING con estado in_flight antes de ejecutar el cobro. Solo uno gana el insert; el otro recibe conflicto y consulta el registro existente. Si el registro está in_flight, el segundo request responde 409 Conflict con Retry-After (el cliente reintenta y eventualmente encuentra el registro done y recibe la respuesta cacheada). Guardar además un fingerprint del body: si el mismo key llega con un body distinto → 422 (reuso incorrecto del key, no devolver el resultado viejo). TTL de 24h. La clave es que la atomicidad la da el UNIQUE index, no la lógica de aplicación — un if key in dict tiene una race window entre el check y el insert.

  1. Diseña la paginación de un feed público de alto volumen. El endpoint GET /v1/events sirve un timeline que recibe inserts constantes. Elige estrategia, escribe la query SQL con su índice, y explica cómo evitas que el cliente pueda romperte cambiando el sort.
Solución

Cursor (keyset) pagination, no offset — el volumen y los inserts constantes descalifican offset (O(n) + saltos/duplicados). Índice: CREATE INDEX ON events (created_at DESC, id DESC). Query:

SELECT * FROM events
WHERE (created_at, id) < (:c_ts, :c_id)
ORDER BY created_at DESC, id DESC
LIMIT :size;

El id es tiebreaker obligatorio porque created_at no es único. El cursor se emite opaco (base64 de {ts, id}, idealmente firmado con HMAC): el cliente no puede fabricar un cursor arbitrario ni depende del formato interno, así que puedes cambiar el sort key en el futuro sin romper clientes. next_cursor: null señaliza el fin. El sort del cursor debe coincidir con el índice para lograr un index-only scan.

  1. Un consumidor te pide “convertir la API a gRPC porque es más rápido”. El consumidor es una SPA en el browser. Responde con la decisión correcta y la justificación.
Solución

“Más rápido” sin contexto es un red flag. gRPC no es browser-nativo: la SPA necesitaría gRPC-Web + un proxy (Envoy) que traduzca, y perderías cache HTTP intermediario y payloads legibles para debug. Para un consumidor browser, REST (o GraphQL si el fetching es el problema real) es la elección correcta. gRPC brilla en el mesh interno service-to-service polyglot, no en el borde hacia un browser. Respuesta senior: si internamente ya hay gRPC entre servicios, expón un gateway REST/GraphQL en el borde (transcoding gRPC-JSON) — obtienes lo mejor de ambos sin forzar gRPC-Web en el cliente. La ganancia de latencia de gRPC además es marginal frente al costo de red pública; el cuello de botella de una SPA rara vez es el framing binario.

  1. Evoluciona un contrato sin romper clientes. La respuesta actual tiene "amount": 4200 (centavos, integer). Negocio pide soportar múltiples monedas con decimales. Describe cómo lo haces sin versionar y qué harías si tuvieras que versionar.
Solución

Aditivo primero: no cambies el tipo ni el significado de amount. Agrega campos nuevos opcionales: "currency": "COP" y, si necesitas decimales, un "amount_decimal": "42.00" como string (evita floats en dinero) o un objeto "money": {"value": "42.00", "currency": "COP"}. Los clientes viejos ignoran los campos nuevos (deben tolerar campos desconocidos — Postel). Depreca amount con headers Deprecation + Sunset y changelog. Solo si el cambio es genuinamente breaking (ej. debes eliminar amount porque su semántica en centavos es incompatible) creas /v2, corres v1 y v2 en paralelo durante una ventana, y usas contract tests (Pact) para confirmar qué consumidores dependen del campo viejo. Regla: versiona en el borde, evoluciona aditivamente dentro de una versión.

  1. Diseña el error model. Un PUT /v1/orders/{id}/status puede fallar por: orden inexistente, transición de estado inválida (delivered → pending), y payload malformado. Define el status code y el cuerpo problem+json de cada caso.
Solución

Todos con media type application/problem+json (RFC 9457):

  • Orden inexistente → 404 Not Found: {"type": ".../errors/not-found", "title": "Order not found", "status": 404, "detail": "No order with id 199", "instance": "/v1/orders/199/status"}.
  • Transición inválida → 409 Conflict (el recurso existe pero su estado no permite la operación): {"type": ".../errors/invalid-transition", "title": "Invalid status transition", "status": 409, "detail": "Cannot move from delivered to pending", "current_state": "delivered", "requested_state": "pending"}. (422 también es defendible; 409 comunica mejor “conflicto con el estado actual del recurso”.)
  • Payload malformado (falta status o tipo inválido) → 422 Unprocessable Entity con array errors de campo/mensaje.

Nunca devolver 200 con el error en el body. Los campos de extensión (current_state, errors) son legítimos en problem+json.

Preguntas tipo entrevista (EN)

Q1: Which HTTP methods are idempotent, and how do you make a non-idempotent POST safe to retry?

Q2: Offset vs cursor pagination — when does offset break, and what are cursor’s downsides?

Q3: How do you version a REST API and evolve it without breaking existing clients?

Q4: When would you choose gRPC over REST, and what do you give up?

Q5: GraphQL trades REST’s rigidity for flexibility — what does that cost you on the server?

Q6: What is HATEOAS / the Richardson Maturity Model, and is level 3 worth it?

Q7: Why is returning 200 OK with an error object in the body an anti-pattern, and how should errors be modeled?

Q8: When is JSON-RPC the right choice over REST, and what do you lose?

Referencias