Saltar a contenido

APIs

De un vistazo

Materia: Capa 2 · Dar forma al sistema · Tiempo de lectura: ~32 min · Requisitos previos: Capa 1 y Bases de datos.

En una frase: vas a entender que una API no es "el código que devuelve JSON", sino un contrato entre dos programas que no se conocen —y que casi todo lo bueno o malo de una API (que sea usable, que aguante reintentos, que se pueda cambiar sin romper a nadie) sale de tomarse ese contrato en serio.

Por qué esto importa

Escribes un endpoint, devuelve JSON, el frontend lo pinta. Funciona. Entonces, ¿por qué hay lecciones enteras sobre esto?

Porque en cuanto hay más de un programa hablando, aparecen preguntas que no puedes responder desde "el código funciona":

  • El móvil pierde cobertura justo después de mandar un pago. Reintenta. ¿Acabo de cobrar dos veces al cliente?
  • El equipo de iOS lleva seis meses con una versión vieja de la app. Necesito cambiar la respuesta de un endpoint. ¿Cómo lo hago sin romperles la app a medio millón de usuarios?
  • Un cliente me llama: "vuestra API devuelve error". ¿Qué error? "Un 500." ¿Y ahora cómo sé si es culpa suya (mandó mal los datos) o mía (mi servidor petó)?
  • Tengo un endpoint que devuelve pedidos. Un cliente pide "dame todos". Son 4 millones. ¿Se los mando de golpe?
  • Quiero que solo el dueño de una cuenta vea sus datos. ¿Cómo sé, en cada petición suelta, quién la manda si HTTP no recuerda nada de la anterior?

Ninguna se resuelve escribiendo "mejor código". Se resuelven diseñando bien el contrato: qué prometes, con qué forma, con qué garantías, qué pasa cuando algo va mal. Eso es diseñar una API. El objetivo de esta lección es que dejes de ver una API como "endpoints que devuelven datos" y la veas como lo que es: una promesa pública sobre cómo se habla con tu sistema.

Intuición primero: el enchufe de la pared

Enchufas cualquier aparato —una lámpara, un cargador, una tele— en cualquier enchufe de tu casa y funciona. No sabes ni te importa qué central eléctrica hay detrás, ni si es carbón o solar, ni cómo llega la corriente. Solo confías en un contrato: "por estos dos agujeros salen 230 voltios a 50 hercios". La lámpara se diseñó contra ese contrato; la central se diseñó para cumplirlo. Ninguna de las dos sabe nada de la otra, y aun así encajan.

Una API (Application Programming Interface, interfaz de programación) es exactamente ese enchufe, pero entre programas. Es el punto de acoplamiento donde un programa ofrece capacidades y otro las consume, sin que ninguno vea las tripas del otro.

graph LR
  C["Cliente<br/>(app móvil, otro servicio,<br/>navegador...)"] -->|"pide algo<br/>según el contrato"| API["API<br/>(el enchufe)"]
  API -->|"responde según<br/>el contrato"| C
  API -.->|"por detrás,<br/>oculto"| S["Tu sistema<br/>(lógica, BD, colas...)"]

Y aquí está la idea que sostiene toda la lección:

Lo que hace útil a una API no es su implementación, es su contrato: qué operaciones ofrece, con qué entradas, qué devuelve, y qué garantías da. La implementación la puedes reescribir entera un domingo; el contrato no lo puedes cambiar sin avisar a todos los que dependen de él.

Igual que el enchufe: puedes cambiar la central eléctrica entera, pero como toques los 230V/50Hz, revientas todos los aparatos del país. El contrato es sagrado precisamente porque otros construyen encima sin preguntarte.

Guarda esta imagen. Todo lo demás —REST, códigos de estado, versionado, idempotencia— son formas concretas de escribir y honrar ese contrato.

El detalle

1. API "a secas" vs API web/HTTP

El término API es más amplio de lo que parece. La clase ArrayList de Java tiene una API (sus métodos públicos: add, get, remove). La biblioteca estándar de PHP tiene una API. Cualquier conjunto de funciones públicas que ofreces para que otros las usen es una API. La palabra clave es pública: lo que expones para que otro se acople.

En esta lección nos ocupa un tipo concreto: la API web o API HTTP, donde los dos programas no comparten memoria ni proceso —están en máquinas distintas— y se hablan por la red, usando HTTP (el protocolo de la web, que ya viste por encima en Capa 1). Esto cambia todo respecto a llamar a una función local:

  • La llamada puede fallar por mil sitios que no controlas: se cae la red, hay latencia, se pierde el paquete. Una llamada a función local no "se pierde".
  • Es lenta: cruzar la red cuesta milisegundos, no nanosegundos. No puedes llamar a una API remota en un bucle apretado como llamarías a una función.
  • Cliente y servidor pueden estar escritos en lenguajes distintos, por equipos distintos, y evolucionar por separado. Por eso el contrato tiene que ser explícito y estable: no hay un compilador común que verifique que encajan.

API no es lo mismo que HTTP, ni que REST, ni que JSON

Se usan como sinónimos y no lo son. HTTP es el protocolo de transporte (cómo viajan los mensajes). REST es un estilo de diseñar la API sobre HTTP (lo vemos ya). JSON es un formato de datos para las representaciones. Puedes tener una API HTTP que no sea REST (RPC), una API REST que devuelva XML en vez de JSON, o una API que ni use HTTP (gRPC sobre HTTP/2, WebSockets...). Distinguir las capas te evita discusiones tontas.

2. HTTP, lo justo para entender lo demás

Una API HTTP es una conversación de peticiones y respuestas. El cliente manda una petición (request) y el servidor devuelve una respuesta (response). Cada petición tiene esta forma:

POST /api/pedidos HTTP/1.1
Host: api.mitienda.com
Authorization: Bearer eyJhbGc...
Content-Type: application/json

{"producto_id": 42, "cantidad": 2}

Desglose de lo que importa:

  • Método (POST): el verbo, qué quieres hacer. Enseguida su semántica.
  • Ruta (/api/pedidos): sobre qué recurso actúas.
  • Cabeceras (headers): metadatos. Authorization (quién eres), Content-Type (en qué formato va el cuerpo), Accept (en qué formato lo quieres de vuelta), etc.
  • Cuerpo (body): los datos, cuando los hay (en un POST/PUT, típicamente JSON).

Y la respuesta:

HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/pedidos/1001

{"id": 1001, "producto_id": 42, "cantidad": 2, "estado": "pendiente"}
  • Código de estado (201): cómo fue la cosa. La sección 4 va entera sobre esto.
  • Cabeceras y cuerpo: metadatos y datos de la respuesta.
sequenceDiagram
    participant C as Cliente
    participant S as Servidor (API)
    C->>S: POST /api/pedidos  { producto_id: 42 }
    Note over S: valida, crea el pedido en BD
    S-->>C: 201 Created  { id: 1001, ... }
    C->>S: GET /api/pedidos/1001
    S-->>C: 200 OK  { id: 1001, estado: "pendiente" }

Con esto ya podemos hablar de cómo se diseña encima. Y el estilo dominante es REST.

3. REST de verdad (no "URLs bonitas")

REST (Representational State Transfer) se explica mal el 90% de las veces como "usar URLs limpias y verbos HTTP". Eso es la cáscara. La idea de fondo es más potente y merece entenderse, porque explica por qué las decisiones concretas.

REST se apoya en cuatro ideas que encajan entre sí:

a) Todo es un recurso. En REST no piensas en "funciones a las que llamar" (eso es RPC, sección 10), sino en cosas (recursos) sobre las que actúas: un pedido, un cliente, una factura. Cada recurso tiene una URL que lo identifica: /pedidos/1001 es el pedido 1001. La URL es un sustantivo, no un verbo. Esto está mal:

GET /crearPedido        ❌ verbo en la URL
GET /getPedido?id=1001  ❌ verbo en la URL

Y esto está bien:

POST /pedidos           ✅ el verbo lo pone el método HTTP
GET  /pedidos/1001      ✅ el sustantivo (recurso) va en la URL

b) El verbo lo pone HTTP, no la URL. Los métodos HTTP tienen una semántica definida y REST se apoya en ella. Esta es la tabla que hay que interiorizar:

Método Qué significa Sobre qué Cuerpo Idempotente Seguro
GET Léeme este recurso Colección o elemento No Sí (no modifica)
POST Crea uno nuevo aquí Colección No No
PUT Reemplaza entero este recurso Elemento No
PATCH Modifica en parte este recurso Elemento Depende No
DELETE Borra este recurso Elemento No No

"Seguro" (safe) significa que no cambia nada en el servidor: un GET solo lee. Un buscador puede rastrear todos tus GET sin miedo; jamás debe rastrear un DELETE. "Idempotente" lo vemos a fondo en la sección 5, porque es la propiedad más importante y la peor entendida.

c) Statelessness (sin estado): cada petición se basta a sí misma. Esta es la restricción de REST que más gente ignora y más consecuencias tiene. El servidor no guarda memoria de la conversación entre una petición y la siguiente. Cada petición debe traer todo lo necesario para entenderla —incluida la identidad de quién la manda (por eso la autenticación viaja en cada petición, sección 8, no "se inicia sesión y ya").

graph TB
  subgraph SS["✅ Sin estado (REST)"]
    P1["Petición 1<br/>(trae todo: auth + datos)"] --> R1["Servidor responde<br/>y OLVIDA"]
    P2["Petición 2<br/>(trae todo otra vez)"] --> R2["Servidor responde<br/>y OLVIDA"]
  end

¿Por qué esta restricción, que parece incómoda? Porque te permite escalar horizontalmente sin dolor. Si el servidor no recuerda nada, da igual qué máquina atienda cada petición: puedes poner diez servidores detrás de un balanceador y repartir peticiones a lo loco, porque ninguna depende de "lo que pasó en la petición anterior en esta máquina". El estado vive en la base de datos (compartida), no en la memoria de un servidor concreto. Esto conecta con escalabilidad y balanceo de carga, que verás más adelante. El precio: mandas la identidad y el contexto una y otra vez. Lo pagas de sobra.

d) Representaciones. El recurso (el pedido 1001) es una cosa abstracta que vive en tu sistema. Lo que viaja por la red es una representación de él: normalmente JSON, pero podría ser XML, CSV o HTML. El cliente pide el formato con Accept: application/json y el servidor sirve esa representación. La misma cosa, distintas fotos de ella. Por eso se llama Representational State Transfer: transfieres representaciones del estado de los recursos.

El test rápido de si algo es REST

Mira las URLs. Si ves verbos (/crearPedido, /actualizarStock), es RPC disfrazado. Si ves sustantivos y el verbo está en el método HTTP (POST /pedidos, PATCH /stock/42), vas por REST. No es dogma —a veces RPC es lo correcto (sección 10)— pero saber en qué modelo estás evita diseños incoherentes.

4. Códigos de estado: el resultado, en un número

El código de estado es lo primero que mira el cliente: le dice cómo fue sin tener que parsear el cuerpo. Van en familias, y lo importante es usar la familia correcta, no memorizar los 60 códigos:

  • 2xx — Fue bien. 200 OK (aquí tienes lo que pediste), 201 Created (creé el recurso; suele venir con cabecera Location apuntando al nuevo), 204 No Content (fue bien, no hay nada que devolver; típico de un DELETE).
  • 3xx — Redirección. 301 (movido permanentemente), 304 Not Modified (no ha cambiado desde tu última copia, úsala; base del caching).
  • 4xx — Error del cliente. Culpa tuya, cliente. El más importante de entender como familia: el servidor está bien, la petición está mal. 400 Bad Request (JSON malformado, falta un campo), 401 Unauthorized (no sé quién eres, autentícate), 403 Forbidden (sé quién eres pero no puedes), 404 Not Found (ese recurso no existe), 409 Conflict (choca con el estado actual, p.ej. email ya registrado), 422 Unprocessable Entity (la sintaxis está bien pero los datos no validan), 429 Too Many Requests (frena, estás pidiendo demasiado).
  • 5xx — Error del servidor. Culpa mía. 500 Internal Server Error (petó algo y no lo he gestionado —el cajón de sastre de "se me rompió"), 503 Service Unavailable (estoy caído o saturado, reintenta luego).

La distinción 4xx vs 5xx es la más rentable de toda la sección, porque separa de quién es el marrón:

El error de devolver 200 con un error dentro

Un antipatrón clásico y venenoso: devolver 200 OK con un cuerpo {"error": "no encontrado"}. Ahora mientes en el código de estado. Todo lo que hay entre tu servidor y el cliente —proxies, cachés, monitorización, reintentos automáticos— mira el código, no tu cuerpo JSON, y ve un 200: creerá que fue bien. Cachearán el error, no lo reintentarán, tus gráficas dirán "0% de errores" mientras los clientes se quejan. El código de estado es parte del contrato: si algo falló, dilo con un 4xx o 5xx de verdad.

La contraparte también es cara: devolver 500 cuando el cliente mandó mal los datos. Ahora tú (o tu sistema de guardias) recibes una alerta de "el servidor petó" a las 3 de la mañana por un cliente que se equivocó de campo. Era un 400. Elegir bien la familia es, en la práctica, elegir a quién despiertas de madrugada.

5. Idempotencia: la propiedad que te salva ante los reintentos

Esta es, probablemente, la idea más importante de la lección y la que menos gente sabe explicar.

Una operación es idempotente si ejecutarla una vez o muchas veces seguidas produce el mismo resultado en el servidor. No dice que devuelva lo mismo; dice que el efecto sobre el sistema es el mismo.

Por qué importa, en una frase: la red no es fiable, así que los reintentos son inevitables, y solo puedes reintentar sin miedo lo que es idempotente.

Piensa en el escenario real: el cliente manda una petición, el servidor la procesa correctamente... y la respuesta se pierde por el camino (se cayó la wifi justo entonces). El cliente no sabe si funcionó o no —solo sabe que no recibió respuesta. ¿Qué hace? Reintentar. Y ahora todo depende de si la operación era idempotente:

sequenceDiagram
    participant C as Cliente
    participant S as Servidor
    C->>S: PUT /clientes/7  { email: "a@b.com" }
    S-->>C: (procesado OK, pero la respuesta SE PIERDE)
    Note over C: no llegó respuesta... reintento
    C->>S: PUT /clientes/7  { email: "a@b.com" }
    Note over S: vuelve a poner el mismo email<br/>estado final IDÉNTICO ✅
    S-->>C: 200 OK

Ahora repasa la tabla de métodos con esta lente:

  • GET es idempotente (y seguro): leer diez veces no cambia nada. Reintenta tranquilo.
  • PUT es idempotente: "pon el email a a@b.com" da igual ejecutarlo una o cinco veces; el estado final es el mismo. Por eso PUT es un reemplazo completo, no un incremento.
  • DELETE es idempotente: borrar el pedido 1001 una vez lo borra; borrarlo otra vez... sigue borrado. El efecto final (no existe) es el mismo. (El segundo puede devolver 404, pero el estado es idéntico.)
  • POST NO es idempotente: "crea un pedido nuevo" ejecutado dos veces crea dos pedidos. Aquí está el peligro. Si el cliente reintenta un POST /pedidos tras un timeout, puede duplicar el pedido (o el cobro).

Esto no es teoría: es la raíz del "me han cobrado dos veces". Por eso los POST peligrosos (pagos, pedidos) se protegen con una clave de idempotencia (idempotency key): el cliente genera un identificador único para esa intención y lo manda en una cabecera; el servidor recuerda que ya procesó esa clave y, si llega repetida, devuelve el resultado anterior en vez de volver a ejecutar.

POST /api/pagos HTTP/1.1
Idempotency-Key: 7f3a9c1e-... (único por intención de pago)
Content-Type: application/json

{"importe": 4999, "moneda": "EUR"}

Así es como Stripe (y cualquier pasarela seria) evita el doble cobro. Diseñar tus verbos respetando la idempotencia natural de cada uno, y proteger los POST críticos con clave, es lo que hace que tu API sobreviva a una red real.

6. Diseño de recursos y URLs

Reglas prácticas que caen solas de "todo es un recurso, el verbo va en el método":

  • Sustantivos, en plural, jerárquicos. /pedidos, /pedidos/1001, /pedidos/1001/lineas, /pedidos/1001/lineas/3. La jerarquía expresa la relación "las líneas pertenecen a un pedido".
  • La colección y el elemento son URLs distintas. /pedidos (la colección: creas con POST, listas con GET) vs /pedidos/1001 (el elemento: lees, reemplazas con PUT, borras con DELETE).
  • Nada de verbos ni de extensiones. No /pedidos/1001/cancelar... aunque aquí hay matiz: las acciones que no encajan en CRUD son el punto débil de REST puro. Cancelar un pedido no es "borrarlo" ni "reemplazarlo". Opciones pragmáticas: modelar la cancelación como un cambio de estado (PATCH /pedidos/1001 {"estado": "cancelado"}) o, si es una operación de peso, aceptar un sub-recurso de acción (POST /pedidos/1001/cancelaciones). No te retuerzas por dogma: la claridad gana al purismo.
  • Minúsculas, guiones, sin jerga interna. /ordenes-de-compra, no /OrdenesDeCompra ni /tbl_oc. La URL es parte del contrato público: exponer tu nombre de tabla es acoplar al cliente a tu esquema.

7. Versionado: cambiar sin romper a nadie

Aquí es donde "el contrato es sagrado" deja de ser filosofía. Tienes clientes en producción acoplados a la forma actual de tus respuestas. Necesitas evolucionar. La regla de oro:

Distingue cambios compatibles (no rompen a nadie) de cambios incompatibles (breaking changes). Los primeros los haces libremente; los segundos exigen una versión nueva.

  • Compatible (adelante): añadir un campo nuevo a la respuesta, añadir un endpoint nuevo, añadir un parámetro opcional. Un cliente que no lo conoce lo ignora y sigue funcionando.
  • Incompatible (necesita versión): quitar o renombrar un campo, cambiar su tipo (de string a número), cambiar la semántica de un endpoint, hacer obligatorio un parámetro antes opcional. Cualquiera de estos rompe a quien dependía de lo anterior.

La estrategia más común y honesta es versión en la URL: GET /v1/pedidos, GET /v2/pedidos. Es fea pero explícita e imposible de malinterpretar: la versión se ve en cada petición, en cada log, en cada ejemplo. Alternativas (versión por cabecera Accept: application/vnd.miapi.v2+json) son más "puristas" pero más fáciles de romper por accidente y más difíciles de probar a mano.

La mejor versión es la que no tienes que crear

Versionar es caro: mantienes dos (o tres) formas de tu API vivas a la vez. Antes de romper, pregúntate si puedes hacerlo compatible. Nunca quites un campo, márcalo como obsoleto (deprecated), avisa, da plazo, y quítalo en la siguiente versión mayor. Diseñar las respuestas con margen desde el principio (envolver en un objeto en vez de devolver un array pelado, por ejemplo) te ahorra versiones futuras.

8. Autenticación y autorización

Dos cosas distintas que se confunden:

  • Autenticación (authN): ¿quién eres? Demostrar tu identidad.
  • Autorización (authZ): ¿qué puedes hacer? Una vez sé quién eres, ¿tienes permiso para esto?

En HTTP, 401 es "no sé quién eres" (falla authN) y 403 es "sé quién eres pero no puedes" (falla authZ). Como REST es sin estado (sección 3c), la identidad viaja en cada petición, típicamente en la cabecera Authorization. Panorama de mecanismos, de menos a más:

  • API key: una cadena secreta larga que identifica a la aplicación cliente. Simple, buena para servicio-a-servicio. Su límite: identifica a la app, no a un usuario, y si se filtra da acceso total hasta que la revoques.
  • Token (opaco): tras un login, el servidor te da un token; lo mandas en cada petición (Authorization: Bearer <token>) y el servidor lo busca en su almacén para saber quién eres. Revocable fácil (lo borras del almacén), pero obliga a consultar el almacén en cada petición.
  • JWT (JSON Web Token): un token autocontenido y firmado. En vez de guardar la sesión en el servidor, el token lleva dentro los datos (quién eres, qué roles, cuándo caduca) y una firma que el servidor verifica con su clave. Ventaja enorme para el modelo sin estado: el servidor valida la firma sin consultar ninguna base de datos. El precio: como no hay estado en el servidor, es difícil de revocar antes de que caduque (por eso los JWT suelen ser de vida corta + un refresh token para renovarlos).
  • OAuth 2.0 (por intuición): no es "otro token", es un protocolo de delegación. Resuelve "quiero que esta app acceda a parte de mis datos en otro servicio sin darle mi contraseña". Es el "Iniciar sesión con Google": tú te autenticas ante Google, Google le da a la app un token con permisos acotados (scopes), y la app actúa en tu nombre sin ver nunca tu clave. Por dentro suele emitir JWTs. Quédate con la idea: delegar acceso acotado sin compartir credenciales.
graph LR
  L["Login<br/>(usuario+clave, una vez)"] --> T["Servidor emite<br/>un token / JWT"]
  T --> U["Cliente lo guarda"]
  U --> P["En CADA petición:<br/>Authorization: Bearer ..."]
  P --> V["Servidor verifica<br/>(firma JWT o consulta almacén)"]

Nunca metas secretos en el token pensando que están ocultos

Un JWT va firmado, no cifrado: cualquiera puede decodificar su contenido (es Base64, no un candado) y leer lo que hay dentro. La firma garantiza que no ha sido alterado, no que sea secreto. No metas datos sensibles en el payload. Y jamás aceptes un JWT sin verificar la firma —"decodificarlo" y "verificarlo" son cosas distintas; el bug de seguridad clásico es confiar en el primero.

9. Paginación, filtrado y errores: el contrato en los detalles

Paginación. Nunca devuelvas "todos". GET /pedidos con 4 millones de filas mata tu servidor y al cliente. Dos estrategias:

  • Por offset: GET /pedidos?page=3&size=50. Simple, permite saltar a cualquier página. Se rompe con datos que cambian mientras paginas (si se inserta una fila, se te duplican o saltan elementos entre páginas) y es lento en páginas altas.
  • Por cursor: GET /pedidos?after=eyJpZCI6MTAwMX0&size=50. Devuelves un puntero opaco al "último visto". Estable ante inserciones y eficiente; el precio es que no puedes saltar a "la página 500" directamente. Es lo que usan Twitter, Stripe, GitHub para feeds grandes.

Filtrado y orden van en la query string, que no cambia el recurso, solo lo acota: GET /pedidos?estado=pendiente&orden=-fecha. No inventes endpoints por combinación (/pedidosPendientesOrdenados ❌).

Errores con contrato. Un error también es una respuesta que el cliente tiene que programar. Devuélvelo con estructura, no con una frase suelta:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json

{
  "error": "validacion",
  "mensaje": "Faltan campos obligatorios",
  "detalles": [
    {"campo": "email", "problema": "requerido"},
    {"campo": "edad", "problema": "debe ser un entero positivo"}
  ]
}

Así el cliente puede reaccionar por código (pintar el error junto al campo email) en vez de parsear texto. Un formato de error consistente en toda la API es parte del contrato tanto como los datos de éxito.

10. REST vs RPC vs GraphQL: cuándo cada uno

REST no es la única forma, y venderlo como "la correcta" es un error. Los tres estilos resuelven cosas distintas:

  • REST — piensas en recursos y operaciones CRUD sobre ellos. Brilla cuando tu dominio son cosas (pedidos, usuarios, productos) y quieres algo estándar, cacheable y fácil de entender por cualquiera. Sacrificas: las operaciones que no son CRUD se modelan a la fuerza, y sufres de over-fetching (traes campos que no querías) y under-fetching (necesitas 3 llamadas para pintar una pantalla).

  • RPC (Remote Procedure Call, p.ej. gRPC) — piensas en acciones/funciones que ejecutas remotamente: transferir(A, B, 100), reproducir(cancion). Brilla cuando tu dominio son verbos, no cosas —operaciones, cálculos, comandos— y cuando quieres máximo rendimiento entre tus propios servicios (gRPC usa HTTP/2 y formato binario, mucho más rápido que JSON). Sacrificas: menos estándar, más acoplado, no cacheable con la infraestructura HTTP normal, menos legible "a ojo".

  • GraphQL — el cliente pide exactamente los campos que quiere en una sola consulta, y el servidor los compone. Mata el over/under-fetching: una pantalla compleja se resuelve en una petición pidiendo justo lo necesario. Brilla con clientes muy variados (móvil, web, TV) que necesitan datos distintos del mismo backend. Sacrificas: complejidad grande en el servidor, caching mucho más difícil (todo va por POST a un único endpoint), y es fácil que un cliente lance una consulta que hunda tu base de datos.

graph TB
  Q["¿Tu dominio son<br/>COSAS o ACCIONES?"]
  Q -->|"cosas, CRUD, estándar,<br/>cacheable"| REST["REST"]
  Q -->|"acciones, rendimiento,<br/>servicio-a-servicio interno"| RPC["RPC / gRPC"]
  Q -->|"clientes variados que<br/>piden datos muy distintos"| GQL["GraphQL"]

La respuesta honesta para la mayoría de proyectos: empieza con REST. Es lo más entendido, lo mejor soportado por la infraestructura (cachés, proxies, herramientas) y lo que menos te sorprenderá. Cambia a RPC o GraphQL cuando tengas un dolor concreto que REST no cubra, no por moda.

Cómo se ve en la práctica

Un mismo recurso —pedidos— en un controlador Symfony, con las decisiones de esta lección aplicadas:

#[Route('/api/v1/pedidos')]
class PedidoController extends AbstractController
{
    // GET colección: paginado y filtrable. Seguro e idempotente.
    #[Route('', methods: ['GET'])]
    public function listar(Request $req, PedidoRepository $repo): JsonResponse
    {
        $pagina = max(1, $req->query->getInt('page', 1));
        $tam    = min(100, $req->query->getInt('size', 50)); // techo: nunca "todos"
        $estado = $req->query->get('estado');                // filtro opcional

        $pedidos = $repo->buscarPaginado($estado, $pagina, $tam);
        return $this->json($pedidos); // 200 OK por defecto
    }

    // GET elemento: 404 honesto si no existe.
    #[Route('/{id}', methods: ['GET'])]
    public function ver(?Pedido $pedido): JsonResponse
    {
        if (!$pedido) {
            return $this->json(['error' => 'no_encontrado'], 404);
        }
        return $this->json($pedido);
    }

    // POST: crea. NO idempotente -> protegido con clave de idempotencia.
    #[Route('', methods: ['POST'])]
    public function crear(Request $req, PedidoService $servicio): JsonResponse
    {
        $clave = $req->headers->get('Idempotency-Key');
        if ($previo = $servicio->resultadoPrevio($clave)) {
            return $this->json($previo, 200); // ya se procesó: devuelvo, no repito
        }

        $datos = json_decode($req->getContent(), true);
        if (!isset($datos['producto_id'])) {
            // 422: la sintaxis va bien, los datos no validan. Culpa del cliente.
            return $this->json([
                'error' => 'validacion',
                'detalles' => [['campo' => 'producto_id', 'problema' => 'requerido']],
            ], 422);
        }

        $pedido = $servicio->crear($datos, $clave);
        // 201 + Location apuntando al recurso nuevo: contrato REST bien hecho.
        return $this->json($pedido, 201, ['Location' => '/api/v1/pedidos/'.$pedido->getId()]);
    }

    // PUT: reemplaza entero. Idempotente -> reintentar es seguro.
    #[Route('/{id}', methods: ['PUT'])]
    public function reemplazar(?Pedido $pedido, Request $req, PedidoService $s): JsonResponse
    {
        if (!$pedido) {
            return $this->json(['error' => 'no_encontrado'], 404);
        }
        $s->reemplazar($pedido, json_decode($req->getContent(), true));
        return $this->json($pedido, 200);
    }

    // DELETE: idempotente. 204 sin cuerpo.
    #[Route('/{id}', methods: ['DELETE'])]
    public function borrar(?Pedido $pedido, PedidoService $s): JsonResponse
    {
        if ($pedido) {
            $s->borrar($pedido);
        }
        return new JsonResponse(null, 204); // borrado o ya no estaba: mismo estado final
    }
}

Fíjate en que casi ninguna decisión es sobre "cómo hacer la operación" (eso está delegado en el servicio). Todas son sobre el contrato: qué método para qué, qué código de estado, cómo se pagina, cómo se protege el POST, qué versión. Ese es el trabajo de diseñar una API.

Lo que sacrificas / errores comunes

  • Verbos en la URL (/crearPedido): RPC disfrazado de REST. No es pecado hacer RPC, pero decide a conciencia; el diseño incoherente (mitad recursos, mitad acciones) es lo peor de ambos mundos.
  • 200 con un error dentro: mentir en el código de estado rompe todo lo automático (cachés, reintentos, monitorización). El código es el contrato.
  • 500 para errores del cliente: confundir 4xx con 5xx te despierta de madrugada por culpas ajenas y esconde tus fallos reales entre ruido.
  • Reintentar un POST no idempotente sin clave: el doble cobro nace aquí. Si una operación crea o cobra, o la haces idempotente con clave, o asume que la reejecutarán.
  • Devolver "todos": sin paginación, el primer cliente que pida una colección grande te tumba el servicio.
  • Romper el contrato sin versionar: quitar un campo "que nadie usa" siempre lo usa alguien. Compatible = libre; incompatible = versión nueva. Sin excepciones silenciosas.
  • Confundir firmado con cifrado en JWT: el payload es legible por cualquiera. Firma protege integridad, no confidencialidad.
  • Elegir GraphQL/gRPC "porque mola": heredas su complejidad sin tener su problema. Empieza por REST y muévete cuando duela de verdad.

Resumen

  1. Una API es un contrato, no una implementación. Lo que expones (operaciones, forma, garantías) es sagrado porque otros construyen encima; las tripas las reescribes cuando quieras.
  2. REST = recursos (sustantivos en la URL) + verbos HTTP con semántica + sin estado (cada petición se basta) + representaciones. El sin-estado es lo que te deja escalar.
  3. Los códigos de estado dicen cómo fue y de quién es la culpa: 4xx cliente, 5xx servidor. Usar la familia correcta es parte del contrato.
  4. Idempotencia: GET/PUT/DELETE deben poder repetirse sin cambiar el efecto; POST no. Como la red obliga a reintentar, esto es lo que evita el doble cobro (clave de idempotencia en los POST críticos).
  5. Versiona cuando rompas (cambio incompatible), no cuando amplíes (compatible). Y elige el estilo por el problema: REST para cosas, RPC para acciones/rendimiento, GraphQL para clientes variados —empezando por REST.

Ejercicios socráticos

No busques la respuesta fuera. Razónala con lo de esta lección. Si te atascas, la pista está en el texto.

  1. Un cliente móvil manda POST /api/pagos, el servidor cobra correctamente, pero la respuesta se pierde por falta de cobertura. El móvil reintenta automáticamente. Sin ningún mecanismo extra, ¿qué pasa? Ahora explica con qué mecanismo concreto lo evitarías y por qué un PUT no tendría este problema de entrada.
  2. Tu compañero propone devolver siempre 200 OK y meter el resultado real en un campo "status" del JSON ("así el cliente solo mira un sitio"). Dale la objeción más fuerte que se te ocurra: ¿qué cosas del mundo entre cliente y servidor se rompen con eso?
  3. Te piden "cancelar un pedido" y REST puro no tiene un verbo para eso. Propón dos formas de modelarlo respetando el estilo de recursos, y di cuál eligirías según si cancelar es un simple cambio de estado o una operación con efectos de peso (reembolsos, avisos).
  4. Vas a quitar el campo nombre_completo de la respuesta de /usuarios porque ahora tienes nombre y apellidos por separado. ¿Es un cambio compatible o incompatible? ¿Qué haces exactamente para no romper a los clientes actuales, paso a paso?
  5. Un endpoint GET /pedidos empieza a ir lentísimo cuando un cliente pide la "página 9000". ¿Por qué la paginación por offset degrada en páginas altas, y qué gana y qué pierde el cliente si migras a paginación por cursor?

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Por qué se dice que en una API "el contrato es sagrado" pero "la implementación es desechable"? Da un ejemplo de cada.
  • [ ] Define idempotente (ojo: no es "devuelve lo mismo") y di por qué POST no lo es y PUT sí. ¿Qué desastre concreto evita respetar esto?
  • [ ] Un cliente recibe un 403 y otro un 401. ¿Qué le pasa a cada uno y qué debe hacer distinto cada uno para arreglarlo?
  • [ ] ¿Qué significa que REST sea sin estado y qué te permite hacer en producción que un diseño con estado dificultaría?
  • [ ] REST vs RPC vs GraphQL: para cada uno, un caso donde es la elección correcta y una cosa que sacrificas al elegirlo.

Para seguir tirando del hilo

  • RESTful Web APIs (Richardson & Amundsen) — el libro que explica REST de verdad, incluido el nivel de madurez y el hipermedia que aquí solo he rozado.
  • La documentación de la API de Stripe — el ejemplo canónico de API bien diseñada: mira cómo usan claves de idempotencia, paginación por cursor, versionado por fecha y errores estructurados. Se aprende más leyéndola que muchos libros.
  • HTTP: The Definitive Guide (Gourley & Totty) — para bajar al detalle del protocolo sobre el que todo esto se apoya: métodos, cabeceras, caching, códigos.
  • Experimento: coge una API pública (GitHub, por ejemplo) y hazle peticiones a mano con curl mirando los códigos de estado y las cabeceras de respuesta. Provoca un 404, un 401, mira cómo pagina. Ver el contrato con tus ojos asienta esto más que releerlo.