PHP 8.5 · cURL, Web Services y API REST

Domina la herramienta que todo backend usa a diario: cURL. Aprende primero sus bondades, luego la diferenciación entre Web Service y API REST que todos confunden, y construye una API REST de biblioteca con PHP 8.5 que consume y expone datos. De cero a producción.

20 capítulos Bootstrap 5.3 Modo claro / oscuro Optimizado para móvil PHP 8.5 · cURL 8.x
20
Capítulos
70+
Bloques de código
5
Partes del curso
PHP
Base recomendada
Cómo usar este tutorial: sigue los capítulos en orden (el índice está en el menú si lees desde el móvil). Cada capítulo tiene teoría, ejemplos ejecutables y puntos clave al final. Practica cada ejemplo: es la única vía para dominar cualquier tema.

1 · Qué es cURL y qué bondades obtendrás

Básico ~14 min

Cuando un navegador visita una web, pide un HTML y pinta la página. Pero tú eres desarrollador backend: a ti te toca hablar con servicios, clientes de API, scripts de despliegue y servidores que no tienen pantalla. Para eso existe cURL: el cliente de transferencia de datos por línea de comandos más usado del planeta. Este capítulo abre el taller explicando qué es y, sobre todo, las bondades concretas que obtienes al dominarlo.

  • Definir qué es cURL (CLI) y qué es libcurl (librería).
  • Instalarlo y comprobar la versión en Windows, Linux y macOS.
  • Hacer la primera petición contra una página y contra una API real.
  • Enumerar las bondades que justifican dominarlo como herramienta diaria.

cURL y libcurl: dos caras de la misma moneda

Hay dos proyectos que comparten nombre. Conviene no confundirlos nunca:

ProyectoQué esCómo lo usas tú
cURL (CLI)programa de terminal curlprobando y depurando APIs, automatizando tareas
libcurllibrería C que realiza las transferenciasa través de PHP (ext-curl), Python, Node…

Cuando ejecutas curl en la terminal usas el programa; cuando en PHP llamas a curl_init() estás usando libcurl mediante la extensión de PHP 8.5 (bundled, capítulo 11). El mismo motor por debajo: consistencia entre la consola y el código, esa es su primera bondad.

Instalación y primera comprobación

Windows 10/11 lo trae instalado como curl.exe desde la versión 1804; en Ubuntu/Debian se instala con un comando; en macOS llega con el sistema. Verifica siempre tu versión con curl --version:

# Ubuntu / Debian (root o con sudo)
sudo apt update
sudo apt install curl

# Comprobación universal de versión y protocolos
curl --version
curl 8.5.0 (x86_64-pc-linux-gnu) Release-Date: 2023-12-06 Protocols: dict file ftp ftps gopher gopher+ http https imap imaps ldap ldaps mqtt pop3 pop3s rtmp rtsp scp sftp smb smbs smtp smtps telnet tftp Features: alt-svc AsynchDNS brotli GSS-API HSTS HTTP2 HTTPS-proxy IDN IPv6 Kerberos Largefile libz NTLM PSL SPNEGO SSL threadsafe TLS-SNI UnixSockets zstd

La línea Protocols es la mina de oro: cURL no solo habla HTTP y HTTPS, también FTP, SCP, SMTP y más. En este curso todo gira alrededor de HTTP/HTTPS, el transporte de las REST APIs. En Windows el binario vive en C:\Windows\System32\curl.exe; verifica en cmd o PowerShell con curl.exe --version.

Tu primera petición: de una página a una API

Ejecuta esto desde cualquier terminal con conexión a internet. Son dos peticiones GET, la primera contra una página normal y la segunda contra una API real de libros (Open Library, sin autenticación):

# 1) Una página web normal: el HTML del sitio
curl https://www.example.com

# 2) Una API real: metadata JSON de una edición de libro en Open Library
curl https://openlibrary.org/books/OL7353617M.json

En el primer caso verás el HTML crudo de la página (cURL no lo pinta, lo imprime; no es un navegador). En el segundo, un JSON como este:

{
  "title": "Cien años de soledad",
  "key": "/books/OL7353617M",
  "authors": [
    { "key": "/authors/OL7175962A" }
  ],
  "publish_date": "March 1996",
  "number_of_pages": 422
}
# Equivalente en Windows PowerShell (curl.exe evita el alias de Invoke-WebRequest)
curl.exe https://openlibrary.org/books/OL7353617M.json

Las bondades que obtienes al dominarlo

Estas son las razones de peso por las que cURL aparece en el día a día de cualquier backend real. Léelas como el argumentario del curso:

BondadQué te permite hacer
Probar APIs sin navegadorPOST/PUT/DELETE y cabeceras; el navegador solo hace GET cómodo
Ver el intercambio crudocon -v ves petición y respuesta exactas (cap 2)
Automatizarscripts de integración, sondeos, respaldos, notificaciones
Verificar salud de servidoressolo cabe esperar un código: %{http_code} (cap 5)
Depurar timeouts/SSL/proxycontrol total del transporte, imposible de aislar en un navegador
Transferir de todosubir/descargar archivos, cookie jars, auth básica y por token
Mismo motor que tu códigolo que pruebas en consola, libcurl lo repite en PHP (cap 11)

2 · HTTP que cURL habla: petición y respuesta

Básico ~16 min

Para leer la salida de cURL necesitas el idioma que ambos hablan: HTTP. Cuando ejecutas curl, la terminal se convierte en un cliente HTTP y el servidor responde con otra estructura. Este capítulo disecciona ese intercambio tal como cURL lo enseña con -v (verbose, muestra el diálogo completo) y -i (incluye las cabeceras de la respuesta). Aquí no hay fórmulas mágicas: es el protocolo de la web moderna, definido por el RFC 9110 (HTTP Semantics, junio de 2022).

  • Identificar las partes de una URL y cómo cURL las usa.
  • Reconocer la estructura de una petición HTTP.
  • Reconocer la estructura de una respuesta HTTP.
  • Leer el diálogo completo con curl -v e -i.

La URL: la dirección que cURL consulta

Una URL se descompone en piezas exactas. cURL acepta una URL completa o parcial, y cada pieza influye en la petición:

https://openlibrary.org/search.json?q=cien+a%C3%B1os&limit=1
scheme://  host           path            query (parámetros)
PiezaEjemploPara qué sirve
schemehttpsprotocolo y cifrado (TLS sobre HTTP)
hostopenlibrary.orgqué servidor responde
path/search.jsonqué recurso o endpoint
query?q=...&limit=1parámetros/opciones de la consulta
Nota importante. En una REST API la ruta identifica el recurso (el libro, el usuario) y el query string sirve para filtrar, ordenar o paginar. Ese matiz se convierte en contrato de diseño en el capítulo 6.

La petición: lo que cURL envía

Una petición HTTP tiene tres partes: la línea de petición con el método, la ruta y la versión; las cabeceras (headers); y un cuerpo opcional (en GET suele estar vacío). Con -v todo lo que cURL envía aparece precedido de > (indicadores de idescarga/salida de cURL):

# -v muestra el diálogo completo: lineas precedidas de > (envio) y < (recibe)
curl -v https://openlibrary.org/books/OL7353617M.json
* Trying 208.97.183.87:443... > GET /books/OL7353617M.json HTTP/1.1 > Host: openlibrary.org > User-Agent: curl/8.5.0 > Accept: */* > < HTTP/1.1 200 OK < Server: nginx < Content-Type: application/json < Content-Length: 344 < {JSON que ya conoces del capítulo 1}

Lee el diálogo arriba-abajo: cURL resuelve la IP, abre el TLS, manda la petición GET con tres cabeceras (host, agente, acepta todo) y el servidor responde con un status-line 200 OK, cabeceras propias y el cuerpo JSON. Esa secuencia es el corazón del curso: aprender a leerla es aprender a depurar backend de verdad.

La respuesta: status-line, cabeceras y cuerpo

La respuesta repite la estructura: status-line (versión + código + frase), cabeceras que describen el contenido y cuerpo con los datos. Con -i pides a cURL que imprima las cabeceras de la respuesta junto al cuerpo, sin todo el ruido de conexión de -v:

# -i: cabeceras de la respuesta + cuerpo (menos ruido que -v)
curl -i https://openlibrary.org/api/books?bibkeys=ISBN:0140328721&format=json&jscmd=data
HTTP/1.1 200 OK Server: nginx Content-Type: application/json Transfer-Encoding: chunked {"ISBN:0140328721":{"url":"https://openlibrary.org/books/OL7353617M.json","title":"Cien años de soledad","authors":[{"name":"Gabriel García Márquez","url":"https://openlibrary.org/authors/OL7175962A.json"}]}}
Elemento de la respuestaQué significa
HTTP/1.1 200 OKstatus-line: 200 = la petición se cumplió
Server: nginxqué servidor web respondió
Content-Type: application/jsonel cuerpo es JSON, no HTML
Transfer-Encoding: chunkedel cuerpo llega por fragmentos dinámicos
el JSON finalcuerpo: los datos que la API devuelve

HTTP es sin estado (stateless)

En HTTP puro, cada petición es independiente: el servidor no recuerda quién fuiste en la petición anterior. Todo lo que necesite persistir (sesiones, tokens, preferencias) viaja en cabeceras como Cookie o Authorization. Esa característica explica por qué cURL manda cada petición con todo lo necesario y por qué una REST API es escalable: cualquier servidor puede responder cualquier petición sin estado compartido. En el capítulo 5 verás cómo cURL guarda y reenvía cookies (-c/-b) para simular sesiones.

3 · Métodos, códigos y JSON con cURL

Básico ~16 min

HTTP no es solo un protocolo de transporte: es un acuerdo de significado. El método le dice al servidor qué intención tienes (leer, crear, modificar, borrar), el código de estado le responde con el resultado (éxito, no existe, conflicto) y el JSON es el formato con que las APIs modernas piden y devuelven datos. Este capítulo une las tres piezas y las ejercita con cURL. La fuente normativa sigue siendo el RFC 9110.

  • Distinguir los métodos HTTP y su seguridad/idempotencia.
  • Traducir los códigos de estado a situaciones reales.
  • Enviar y recibir JSON con cURL (-H y -d).
  • Montar un CRUD completo contra una API con cURL.

Los métodos: qué intención tienes

Los métodos se agrupan por dos propiedades que el RFC 9110 define con precisión: seguro (no altera el recurso) e idempotente (repetirlo N veces equivale a hacerlo una vez):

MétodoIntenciónSeguroIdempotente
GETleer un recurso
POSTcrear un recurso (o acción compleja)nono
PUTreemplazar un recurso completono
PATCHmodificar parcialmente un recursonono*
DELETEeliminar un recursono

*PATCH puede diseñarse idempotente, pero el protocolo no lo garantiza; el punto seguro en diseño es tratarlo a efectos prácticos como no idempotente (capítulo 6).

Regla mental rápida: POST crea (y NO es idempotente: dos POST crean dos recursos); PUT reemplaza (décimo PUT = mismo resultado); DELETE borra (borrar algo que no existe sigue siendo "sin cambios"). Ese matiz decide los códigos que respondes más adelante.

Los códigos de estado: el resultado

El servidor responde siempre con un código de tres dígitos. Estos son los que una REST API bien diseñada usa casi a diario:

CódigoSignificadoCuándo lo ves con cURL
200 OKéxito general (GET, PUT, PATCH)lectura o modificación correcta
201 Createdrecurso creadoPOST exitoso
204 No Contentéxito sin cuerpoDELETE exitoso
400 Bad Requestpetición mal formadaJSON inválido, faltan campos
401 Unauthorizedsin autenticación válidafalta/falla el token
403 Forbiddenautenticado pero sin permisorol insuficiente
404 Not Foundrecurso inexistenteruta o id erróneo
409 Conflictestado impide la operaciónprestamo de un libro ya prestado
422 Unprocessableválido en forma, inválido en reglas de negocioemail mal formado en el negocio
500 Internalerror del servidorexcepción sin manejar
429 Too Manyrate limit excedidodemasiadas peticiones
Nota importante. Existe el mito de que "toda API HTML devuelve 200 y el error va en el JSON". Una REST API bien hecha usa el código correcto: 400/404/409/ 422 son la diferencia entre una API comprensible y una caja negra. Este taller considera un defecto responder 200 con error en el cuerpo.

JSON con cURL: cabeceras y cuerpo

Para enviar JSON con cURL usas -X (método, aunque con -d suele implicarse POST), -H para cabeceras y -d para el cuerpo. Para recibir, primero comprueba el Content-Type y luego el cuerpo. Añade -s para silencio (sin barra de progreso) y -w para ver el código al final (cap 5 lo trabaja a fondo):

# Enviar un JSON a una API (ejemplo didactico local)
curl -s -X POST https://miapi.local/api/socios \
  -H "Content-Type: application/json" \
  -d '{"nombre":"Ana","apellido":"Torres","documento":"45210987"}'

# Recibir y mostrar el codigo de estado (workflow de depuracion)
curl -s -w "\nHTTP %{http_code}" https://openlibrary.org/books/OL7353617M.json
HTTP 201

La regla de oro de las comillas: dentro de -d '{...}' usa un par de comillas simples externas y dobles internas. En PowerShell esa mezcla rompe, así que la alternativa es escapar con backslash o guardar el JSON en un archivo y enviarlo con --data @archivo.json (el patrón más robusto para cuerpos largos o llaves anidadas):

# Cuerpos largos: mejor desde archivo (evita el infierno de las comillas)
cat > socio.json <<'EOF'
{"nombre":"Ana","apellido":"Torres","documento":"45210987"}
EOF

curl -s -X POST https://miapi.local/api/socios \
  -H "Content-Type: application/json" \
  --data @socio.json -w "\nHTTP %{http_code}"

Ejercicio: el CRUD completo con cURL

Estamos en el punto exacto donde "probar una API con cURL" se vuelve hábito. Este es el flujo que usarás cada día como backend (estos endpoints los implementarás TÚ en la parte III; mentalmente sustitúyelos por tu servidor local):

# 1) LISTAR (GET)
curl -s https://miapi.local/api/libros -w "\nHTTP %{http_code}"

# 2) CREAR (POST + 201)
curl -s -X POST https://miapi.local/api/libros \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Cien años de soledad","isbn":"0140328721"}'

# 3) LEER UNO (GET + 200)
curl -s https://miapi.local/api/libros/1 -w "\nHTTP %{http_code}"

# 4) REEMPLAZAR (PUT + 200)
curl -s -X PUT https://miapi.local/api/libros/1 \
  -H "Content-Type: application/json" \
  -d '{"titulo":"Cien años de soledad (re-edición)","isbn":"0140328721"}'

# 5) BORRAR (DELETE + 204)
curl -s -o /dev/null -w "%{http_code}" -X DELETE https://miapi.local/api/libros/1

La última línea demuestra la disciplina que verás en el capítulo 5: -o /dev/null descarta el cuerpo y -w "%{http_code}" imprime solo el código. Así puedes construir suites de humo con un if [ "$(curl ...)" = "201" ]. Ese es el cierre natural de este capítulo: método + código + JSON, las tres patas de cualquier API REST.

4 · WebService vs API REST: la diferenciación

Intermedio ~18 min

Si hay dos términos que se confunden en el backend, son estos: Web Service y API REST. En entrevistas, en ofertas de trabajo y hasta en documentación seria los verás usados como sinónimos… y no lo son. Este capítulo no es un ejercicio teórico de etiquetas: entender la diferencia te permitirá leer arquitecturas, elegir herramientas y explicar decisiones. Vamos a la raíz histórica y técnica.

  • Definir qué es un Web Service (y su variante histórica SOAP).
  • Definir qué es una API y por qué REST es un estilo de arquitectura.
  • Comparar ambos en transporte, formato, contrato y voladizo.
  • Aplicar una prueba rápida para no volver a confundirlos nunca.

Qué es un Web Service

Un Web Service es un sistema de software diseñado para soportar la interacción máquina a máquina a través de una red, usando protocolos web. La palabra clave: máquina a máquina. No dice nada sobre el formato, el transporte ni el estilo: dice que un software expone capacidades para que otro software las llame por la red. En la práctica, cuando la industria habla de "Web Service" a secas, suele referirse a la oleada SOAP (años 2000): XML + WSDL + SOAP sobre HTTP. Fue la respuesta empresarial a la integración entre sistemas (ERPs, bancos, telecomunicaciones) y traía un contrato rígido y descriptivo, el WSDL:

<!-- Fragmento de un WSDL: describe la operacion, no la URL -->
<portType name="BibliotecaPort">
  <operation name="ConsultarLibro">
    <input  message="tns:ConsultarLibroRequest"/>
    <output message="tns:ConsultarLibroResponse"/>
  </operation>
</portType>

Detalla con precisión: qué operaciones existen, qué XML de entrada espera cada una y qué XML devuelve. El cliente se genera a partir del WSDL. Ese modelo sigue vivo en banca y empresas grandes, pero es pesado: XML verboso, acoplamiento fuerte y curvas de aprendizaje altas.

Qué es una API REST

Una API (Application Programming Interface) es el contrato por el que dos programas se comunican. Y REST (Representational State Transfer) es un estilo de arquitectura definido por Roy Fielding en su tesis doctoral de 2000. REST no es un producto, ni un estándar, ni una librería: es un conjunto de restricciones. Cuando una API las cumple, se llama RESTful. Las restricciones centrales:

RestricciónQué exige
Recursoslos datos son recursos con URL única: /api/socios/42
Métodos HTTPGET/POST/PUT/PATCH/DELETE aplican la intención (cap 3)
Representacionesel recurso se entrega en un formato negociable (JSON hoy)
Sin estadocada petición contiene todo su contexto (cap 2)
HATEOAS (ideal)la respuesta enseña los enlaces para navegar la API

El resultado práctico: en lugar de "operaciones" arbitrarias como ConsultarLibro, REST expone recursos y deja que los verbos HTTP hagan el trabajo. Compara el mismo hecho en ambos mundos:

Web Service SOAP (operacion en el cuerpo)      API REST (verbo + recurso)

POST /biblioteca
<ConsultarLibro>
  <isbn>0140328721</isbn>
</ConsultarLibro>

GET https://api.biblioteca.local/v1/libros/0140328721

En REST la intención "leer" ya viaja en el método GET y la URL nombra el recurso directamente. Menos envoltorio, mismo significado, y por eso hoy REST es el estilo dominante para APIs públicas y móvil.

La tabla que termina con la confusión

Guarda esta comparación: cuando alguien diga "Web Service", pregunto ¿SOAP o simplemente "servicio web"?; cuando diga "API REST", compruebo si cumple lo de la columna derecha:

CriterioWeb Service (SOAP clásico)API REST
Naturalezatecnología/estándares concretos (SOAP, WSDL, XML)estilo de arquitectura sobre HTTP (sin estándar rígido)
ContratoWSDL: contrato formal y rígido, cliente generadorecurso + método + formato; contrato ligero (a veces OpenAPI)
FormatoXML obligatorionegociable; JSON por defecto
TransporteHTTP/SMTP/otro mediante SOAPHTTP (y HTTPS) sobre todo
Flexibilidadbaja: cambio en WSDL = regenerar clientesalta: evoluciona con versionado y media types
Madurez actualempresarial, banca, legacyweb, móvil, SaaS, todo lo nuevo

Y la síntesis que se repite en la conversación técnica:

Toda API REST es un Web Service… pero NO todo Web Service es REST. "Web Service" es el paraguas (software llamado por otro software por red); REST es un estilo concreto dentro de ese paraguas. Decir "Web Service" no te dice el formato; decir "API REST" ya te está hablando de recursos, verbos y estado. Esa es la frontera que la gente confunde.

La prueba rápida para no volver a confundirlos

Cuando te muestren un sistema y te pregunten "¿es Web Service o API REST?", haz estas cuatro preguntas en orden:

  • ¿Se comunica con HTTP usando recursos y métodos? → huele a REST; revisa los tres siguientes puntos.
  • ¿La URL nombra un recurso concreto? → sí: /libros/42; no: /operacionConsultar.
  • ¿Usa los verbos HTTP con honestidad? (GET solo lee, DELETE borra) → REST.
  • ¿Devuelve representaciones (JSON) y códigos correctos? → REST.

Si las cuatro responden "sí", es una API RESTful. Si algo responde "operación RPC", "WSDL" o "SOAP", es un Web Service clásico. Y el intermedio más común del mundo real: "API REST que en el fondo es RPC disfrazado" (una única URL /api con el verbo en el JSON). Suéltalo con tacto en la próxima reunión: se llama API cornflake y este curso te entrena para detectarlo en el capítulo 6.

# Comprobación con cURL: una REST API lee con GET y responde JSON
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" \
  https://openlibrary.org/books/OL7353617M.json
# 200 application/json   <- REST pura: recurso + GET + JSON

5 · cURL CLI a fondo: opciones, scripting y códigos de salida

Esencial ~20 min

Ya sabes qué es cURL y cómo leer una petición HTTP (caps 1 y 2). Ahora toca la parte que te hace productivo en la terminal y confiable en scripts: el panel de control de opciones, los formatos de salida a medida y los códigos de salida. Hasta aquí, cURL era un catalejo; después de este capítulo, es un instrumento de medición.

  • Modelar la salida con -w y sus variables %{...}.
  • Dominar los códigos de salida de cURL (0, 6, 22, 28…) y el rol de -f.
  • Usar cookies, autenticación básica, límites de velocidad y timeouts.
  • Construir el "patrón cURL" para usar en la parte VI (PHP).

La salida a medida: -w y sus variables

Por defecto cURL imprime el cuerpo de la respuesta. En scripts no siempre quieres el cuerpo: quieres la métrica (código HTTP, tiempo, tamaño). La opción -w, --write-out imprime un texto con variables %{...} después de la transferencia:

# Métrica de una petición en una sola línea
curl -sS -o /dev/null -w "HTTP %{http_code} · %{time_total}s · %{size_download} bytes\n" \
  https://openlibrary.org/books/OL7353617M.json
# HTTP 200 · 0.318437s · 284 bytes

-o /dev/null descarta el cuerpo (no queremos ensuciar la salida); -sS silencia la barra de progreso (-s) pero conserva los errores (-S, la S mayúscula). Las variables más usadas de -w:

VariableQué imprime
%{http_code}código de estado final (200, 404, 422…)
%{content_type}el Content-Type recibido: application/json; charset=utf-8
%{time_total}tiempo total de la operación, en segundos
%{time_connect}segundos hasta establecer la conexión TCP
%{time_starttransfer}hasta que llega el primer byte del cuerpo (TTFB)
%{size_download}bytes descargados del cuerpo
%{url_effective}URL final tras redirecciones (útil con -L)

Códigos de salida: el exit code que tus scripts leen

cURL termina con un código de salida: 0 si la transferencia fue exitosa, un número distinto si algo falló. Para un script, esto es la señal principal: no parsees la salida, mira $?. La tabla oficial de referencia:

CódigoSignificado
0la transferencia se completó con éxito
6no se pudo resolver el host (URL mal escrita, sin DNS)
7fallo al conectarse al host
22el servidor devolvió un código HTTP de error (4xx/5xx) y usaste -f
23error de escritura (p. ej. disco lleno con -o)
28se agotó un timeout (--max-time o --connect-timeout)

Atención al matiz del 22: sin -f, --fail, un 404 del servidor "es éxito" para cURL (transfirió algo) y el exit code será 0. Si tu script necesita reaccionar ante un 4xx/5xx, activa -f:

# Sin -f: cURL no "ve" el 404 del servidor y termina con exit 0
curl -sS -o /dev/null -w "%{http_code}\n" \
  https://api.biblioteca.local/v1/libros/999999
# 404
echo "exit=$?"          # 0  (la transferencia "existió")

# Con -f: cURL traduce el error HTTP a exit 22
curl -sSf https://api.biblioteca.local/v1/libros/999999 >/dev/null
# curl: (22) The requested URL returned error: 404
echo "exit=$?"          # 22

Timeouts: que un servidor lento no cuelgue tu script

Un script que espera para siempre es un bug despiadado. cURL distingue dos relojes: --connect-timeout (cuánto esperar la conexión, incluye DNS y handshake TLS) y --max-time (máximo para la operación entera). Ambos aceptan decimales:

# Conectar en máx. 2.5 s; operación completa en máx. 10 s
curl -sS --connect-timeout 2.5 --max-time 10 \
  -o /dev/null -w "%{http_code}\n" https://api.biblioteca.local/v1/libros

# Si se agota: exit 28
echo "exit=$?"          # 28 (timeout)

Cookies, autenticación, TLS y velocidad

Las APIs con sesión te piden llevar tus cookies de ida y vuelta. -c, --cookie-jar guarda las cookies que el servidor envía; -b, --cookie las reenvía en la siguiente petición. La autenticación básica se resuelve con -u, --user (cURL la codifica en base64 y emite el encabezado Authorization):

# Sesión: -c guarda la cookie; -b la presenta en la siguiente petición
curl -sS -c sesion.txt -X POST https://api.biblioteca.local/v1/sesion \
  -H "Content-Type: application/json" \
  -d '{"usuario": "recep", "clave": "solo-para-pruebas"}'
curl -sS -b sesion.txt https://api.biblioteca.local/v1/socios/1

# Autenticación básica HTTP
curl -sS -u admin:clave-secreta https://api.biblioteca.local/v1/socios

# Sin cookies: cada petición es independiente (REST sin estado)

Otras dos herramientas del día a día. -k, --insecure salta la verificación del certificado TLS (útil con HTTPS autofirmado en laboratorios) y --limit-rate frena la velocidad de descarga, ideal para probar cómo responde tu API a clientes lentos:

# SOLO laboratorios con TLS autofirmado: -k desactiva la verificación
curl -sSk https://api.biblioteca.local/v1/libros

# Limita la descarga a 200 KB/s y reporta la velocidad media
curl -sS --limit-rate 200k -o /dev/null \
  -w "velocidad %{speed_download} B/s\n" https://api.biblioteca.local/v1/libros

Redirecciones, cabeceras y GET con parámetros

Tres opciones que completan el panel. -L, --location sigue redirecciones (HTTP 301/302) hasta el destino final; -I, --head pide solo cabeceras; y -G combina con -d para convertir los datos en una query string:

# -I: solo cabeceras, sin cuerpo
curl -sSI https://openlibrary.org/book/OL7353617M.json

# -L: sigue la redirección; -w muestra la URL final
curl -sS -L -o /dev/null -w "%{url_effective}\n" http://openlibrary.org

# -G: los -d pasan a la URL como query string
curl -sS -G -d "q=php&limit=3" https://openlibrary.org/search.json
Nota importante. En la práctica de --limit-rate y en el bloque anterior ves variables %{...} de -w; combinar -w, -o /dev/null y exit codes es el "patrón cURL" que usaremos en los scripts de la parte VI y en la herramienta de pruebas del capítulo 16.

6 · Diseñando la API de la biblioteca: recursos, convenciones y contrato

Esencial ~22 min

El capítulo 4 dejó claro que REST modela recursos y el capítulo 5 te dio la navaja (cURL) para probarlos. Ahora llega el momento de diseñar: antes de escribir una línea de PHP (lo harás en la parte III), este capítulo define la API de la biblioteca de punta a punta. Un contrato bien pensado no se corrige con parches: se diseña. Vamos a hacerlo bien desde el minuto uno.

  • Identificar los recursos reales del dominio bibliotecario (8 tablas).
  • Aplicar las convenciones REST: sustantivos, plurales, jerarquía y versionado.
  • Definir la tabla completa de endpoints de la API /v1.
  • Detectar el "RPC disfrazado" (la API cornflake) y evitarlo.

El dominio: de las 8 tablas a los recursos

El modelo de la biblioteca (mismo dominio del taller, con datos deterministas en julio de 2026) tiene 8 tablas. Cada una con posibilidades de exponerse como recurso, pero no todas de la misma manera. El mapeo clave:

TablaRecursoRol en la API
socios/v1/sociosrecurso principal (personas registradas)
libros/v1/librosobra: isbn, título, género, autor
ejemplares/v1/libros/{id}/ejemplarescopias físicas; subrecurso anidado
prestamos/v1/prestamosnegocio central: estados en capítulo 7
reservas/v1/reservasapartado de un ejemplar aún no entregado
multas/v1/multasdeuda de un socio (nace de un vencido)
usuarios_app/v1/usuariosinterno: autenticación (no se expone a la calle)
auditorianunca recurso: solo lectura para el equipo (cap 9)

Regla de diseño que se nota en la tabla: no toda la base de datos es API. usuarios_app y auditoria son internos: exponerlos sería regalar la llave del candado. El contrato público se reduce a 6 familias de recursos.

Convenciones REST: cómo nombrar cada cosa

La guía oficial de diseño de API nos da tres reglas que aplicamos literal aquí. Primera: sustantivos, no verbos — los verbos ya viven en el método HTTP. Segunda: plural para colecciones (/libros es la colección; /libros/42 es un elemento). Tercera: jerarquía máxima de 2 niveles, y si algo se va a repetir demasiado en múltiples padres, se promueve a raíz:

Bien   GET  /v1/socios              Mal   GET /v1/getSocios
Bien   GET  /v1/socios/12           Mal   GET /v1/getSocio/12
Bien   GET  /v1/socios/12/prestamos Mal   GET /v1/socios/12/getPrestamos
Bien   POST /v1/libros              Mal   POST /v1/createLibro
Bien   GET  /v1/libros?genero=      Mal   GET /v1/librosInformatica

Y las convenciones de estilo del taller: URLs en kebab-case (guiones, minúsculas), campos JSON en camelCase, y versionado por URL con /v1 como prefijo (es la estrategia más simple, visible y cacheable: la misma URL siempre se refiere a la misma versión de los datos).

El contrato completo: tabla de endpoints

Con los recursos y convenciones claros, definimos el contrato de la API de la biblioteca. Esta es la tabla "de referencia" del manual: la consultas en cada capítulo de la parte III se construyen a partir de ella:

MétodoEndpointQué haceÉxito
GET/v1/libroslista con filtros ?q=&genero=&page=200
GET/v1/libros/{id}detalle de una obra200
POST/v1/librosregistra una nueva obra201
GET/v1/libros/{id}/ejemplarescopias físicas del libro200
GET/v1/socioslistado con ?activo=200
GET/v1/socios/{id}/prestamospréstamos de un socio200
POST/v1/prestamoscrea un préstamo (reservado)201
PATCH/v1/prestamos/{id}cambia estado (devolver, cancelar)200
GET/v1/multasmultas con ?socioId=200

Dos detalles finos. Primero, el filtrado va como query string, nunca como endpoint aparte: no existe /prestamosActivos, existe GET /v1/prestamos?estado=ACTIVO. Segundo, la representación de un recurso incluye un bloque links (HATEOAS); el cliente descubre la navegación en la propia respuesta:

{
  "id": 7,
  "isbn": "978-612-4300-55-4",
  "titulo": "cURL y APIs REST en PHP 8.5",
  "genero": "informatica",
  "activo": true,
  "links": {
    "self": "/v1/libros/7",
    "ejemplares": "/v1/libros/7/ejemplares"
  }
}

La API cornflake: RPC disfrazado de REST

En el capítulo 4 prometimos mostrarte el disfraz más común: una URL única (/api) donde la "acción" viaja en el cuerpo JSON. Compara lo que hace un desarrollador novato y lo que hacemos aquí:

// API cornflake: la accion viaja en el JSON (parece REST, es RPC)
POST /v1/api
{ "metodo": "consultarLibro", "parametros": { "isbn": "0140328721" } }
// Nuestra API: el verbo HTTP nombra la accion, la URL el recurso
GET /v1/libros/por-isbn/0140328721

El diagnóstico es simple: si la URL no cambia y el método tampoco, y "todo se manda por POST a /api", estás ante una API cornflake. No es un pecado capital (funciona), pero tira a la basura la semántica HTTP, el cacheo del protocolo y una API autocontenida. Nuestro contrato ya la evita por diseño.

7 · Estados del préstamo y códigos HTTP correctos

Esencial ~20 min

El contrato del capítulo 6 definió qué recursos existen. Este capítulo define cómo viven: cada préstamo atraviesa una máquina de estados, y cada transición habla con un código HTTP preciso (los de la RFC 9110). Aquí es donde el contrato se vuelve útil: si el cliente sabe qué códigos esperar y en qué estado está cada préstamo, puede pintar la interfaz sin adivinar.

  • Modelar la máquina de estados del préstamo (RESERVADO → ACTIVO → …).
  • Vincular cada transición con su método HTTP y su código correcto.
  • Diferenciar 400, 404, 409, 422 y cuándo usar cada uno.
  • Probar cada transición real con cURL sobre la API de la biblioteca.

La máquina de estados del préstamo

Un préstamo en la biblioteca no "aparece prestado": nace como apartado, pasa a activo cuando se entrega y termina devuelto o vencido. Modelar estos estados evita contradicciones (un préstamo no puede devolverse antes de existir):

        +-----------+   entrega   +-----------+   se devuelve   +-----------+
        | RESERVADO | -----------> | ACTIVO  | ---------------> | DEVUELTO |
        +-----------+             +-----------+                 +-----------+
              |                       |
              | cancela               | pasa fecha_limite
              v                       v
        +-----------+             +-----------+
        | CANCELADO |             | VENCIDO  | --- genera multa
        +-----------+             +-----------+

Observa el detalle que separa a un modelador disciplinado de uno que improvisa: VENCIDO no es un "estado" que el humano escriba, es una consecuencia temporal (la fecha límite pasó). Por eso en la parte III el vencido lo calcula la base de datos o el dominio, nunca un formulario. Los estados del contrato:

EstadoSignificadoTransiciones válidas
RESERVADOapartado, aún no entregadoACTIVO, CANCELADO
ACTIVOentregado, fecha límite futuraDEVUELTO, VENCIDO
VENCIDOfecha límite pasada (genera multa)DEVUELTO
DEVUELTOcerrado con devolución
CANCELADOapartado que nunca se entregó

Códigos HTTP: el vocabulario del contrato

Con RFC 9110 delante (verificado en el capítulo 3), esta es la sintaxis de éxito y error de cada operación del contrato:

OperaciónÉxitoErrores típicos
GET lista200 con arreglo400 (página inválida), 500
GET detalle200 con el recurso404 (no existe), 400
POST crear201 + cabecera Location400, 404, 409, 422
PATCH cambiar estado200 con el nuevo estado400, 404, 409, 422
DELETE logico204 sin cuerpo404, 409

El matiz que más se pregunta en entrevistas es el duelo entre 409 y 422:

400  El JSON está mal formado (no se pudo ni leer).
404  El recurso que nombras en la URL no existe.
409  El ESTADO del recurso impide la operacion (conflicto).
422  El JSON se lee bien, pero no pasa la validacion semantica.
     Pista: 409 habla del recurso; 422 habla de los datos que envias.

Probar el ciclo completo con cURL

Con lo del capítulo 5, ejecutamos los tres momentos del préstamo. Primero, el ciclo feliz: reservar, entregar y devolver. Todo con Content-Type explícito y leyendo el código de salida:

# 1) Crear prestamo (ejemplar 31 para socio 4) -> 201 + Location
curl -sS -X POST https://api.biblioteca.local/v1/prestamos \
  -H "Content-Type: application/json" \
  -d '{"ejemplarId": 31, "socioId": 4, "fechaFin": "2026-07-28"}'
# HTTP 201  Location: /v1/prestamos/61

# 2) Entregar: RESERVADO -> ACTIVO   -> 200 con el nuevo estado
curl -sS -X PATCH https://api.biblioteca.local/v1/prestamos/61 \
  -H "Content-Type: application/json" \
  -d '{"estado": "ACTIVO"}'
# HTTP 200  { "prestamoId": 61, "estado": "ACTIVO" }

# 3) Devolver: ACTIVO -> DEVUELTO     -> 200 (o 204 si fuera cerrado total)
curl -sS -X PATCH https://api.biblioteca.local/v1/prestamos/61 \
  -H "Content-Type: application/json" \
  -d '{"estado": "DEVUELTO"}'
# HTTP 200  { "prestamoId": 61, "estado": "DEVUELTO" }

Ahora los conflictos, que son donde el contrato demuestra su valía. El ejemplar 31 ya está activo (no puede prestarse dos veces: regla de negocio → 409). Y si el cuerpo llegara con una fecha imposible, sería 422:

# Conflicto de negocio: ejemplar que ya esta ACTIVO  -> 409
curl -sS -w "HTTP %{http_code}\n" -X POST \
  https://api.biblioteca.local/v1/prestamos \
  -H "Content-Type: application/json" \
  -d '{"ejemplarId": 31, "socioId": 4, "fechaFin": "2026-07-28"}'
# HTTP 409  { "error": "El ejemplar 31 ya esta prestado" }

# Datos semánticamente invalidos: fechaFin antes que hoy -> 422
curl -sS -w "HTTP %{http_code}\n" -X POST \
  https://api.biblioteca.local/v1/prestamos \
  -H "Content-Type: application/json" \
  -d '{"ejemplarId": 20, "socioId": 4, "fechaFin": "2025-01-01"}'
# HTTP 422  { "error": "fechaFin no puede ser anterior a hoy" }

# Recurso inexistente -> 404
curl -sS -w "HTTP %{http_code}\n" -X PATCH \
  https://api.biblioteca.local/v1/prestamos/9999 \
  -H "Content-Type: application/json" -d '{"estado": "DEVUELTO"}'
# HTTP 404  { "error": "Prestamo no encontrado" }
Nota importante. Devolver un préstamo con PATCH (no con DELETE) es una decisión de diseño consciente: borrar destruye historia y la biblioteca necesita auditoría. El DELETE solo se usa para el borrado lógico de un recurso (p. ej. cancelar una reserva) y responde 204 sin cuerpo.

8 · Validación, errores y buenas prácticas REST

Esencial ~22 min

Ya tienes recursos (cap 6) y estados con códigos correctos (cap 7). Falta la capa que separa una demo de un servicio que aguanta la crítica: validación doble, respuestas de error consistentes y las buenas prácticas que todo equipo da por sentadas (paginación, límites, caché). Cierras con esto la parte de diseño y en el próximo capítulo bajamos a PHP.

  • Aplicar la validación en dos orillas: cliente (UX) y servidor (verdad).
  • Adoptar un formato estándar de error: problem details (RFC 9457).
  • Incorporar paginación, rate limiting y validación de caché.
  • Ver cómo se ve un error bien formado desde cURL.

Validación doble: la regla de la casa, llevada a la API

Este taller aplica por sistema la validación doble (regla de la casa en todos los manuales): el cliente valida para dar una experiencia inmediata (no viajar hasta el servidor para enterarte de que falta un campo) y el servidor valida siempre, porque es la última barrera. El navegador y la app son títeres; la regla vive en el servidor:

CLIENTE (cap 22, validacion JS)   SERVIDOR (la verdad, tu API PHP)
  - campo obligatorio               - parsea y valida CADA campo
  - fechaFin no anterior a hoy      - cifra, reutiliza y rechaza
  - feedback inmediato              - 422 con problem details
  = mejor experiencia               = nadie se salta la regla

La regla práctica: si un dato puede invalidar el negocio, el servidor lo valida aunque el cliente ya lo haya validado. En la parte III implementarás esa validación en PHP; aquí queda fijada como parte del contrato.

Errores consistentes: problem details (RFC 9457)

Un error HTTP sin formato útil fuerza a cada cliente a adivinar. La respuesta oficial es el estándar Problem Details for HTTP APIs (RFC 7807, hoy renumerada como RFC 9457): un JSON predecible con type, title, status, detail e instance. Firme: es el formato exacto que devolverá nuestra API:

HTTP/1.1 409 Conflict
Content-Type: application/problem+json

{
  "type": "https://api.biblioteca.local/errores/ejemplar-prestado",
  "title": "Ejemplar no disponible",
  "status": 409,
  "detail": "El ejemplar 31 está ACTIVO hasta el 2026-07-28",
  "instance": "/v1/prestamos?ejemplarId=31"
}

Al lado del standard, una regla de oro de la casa: nunca enviar detalles internos (excepciones, stack traces, SQL crudo). El 500 que un pánico de PHP produzca debe responder con el mismo application/problem+json: title: "Error interno", y el rastro completo queda en los logs con un identificador de correlación (capítulo 16).

Las buenas prácticas del día a día

Una API grande se sostiene sobre cuatro hábitos que hoy definimos y mañana (parte III y IV) implementaremos:

PrácticaQué aportaCómo se ve
IdempotenciaGET/PUT/DELETE seguros de repetir (RFC 9110)mismo resultado sin efectos dobles
Paginaciónnunca listas infinitas?page=2&limit=25 + bloque meta
Rate limitingprotege el servicio de abuso429 + cabecera Retry-After
Cachérespuestas rápidas y menos cargaETag / If-None-Match304

Dos corren por cuenta del contrato (paginación y caché) y dos por cuenta de la infraestructura (limitación y despliegue), pero todas se prueban con la misma herramienta que ya dominas:

# Paginada: ruta + meta de paginacion en la respuesta
curl -sS "https://api.biblioteca.local/v1/libros?page=2&limit=25"
# { "data": [...], "meta": { "page": 2, "limit": 25, "total": 48 } }

# Caché basada en ETag: si no cambió, el server responde 304 sin cuerpo
curl -sS -H "If-None-Match: \"abc-123\"" \
  -o /dev/null -w "%{http_code}\n" https://api.biblioteca.local/v1/libros/7
# 304 (sin cuerpo: el cliente puede reutilizar su copia)

# Limite de peticiones superado -> 429 con Retry-After
curl -sS -w "HTTP %{http_code}\n" -I https://api.biblioteca.local/v1/libros
# HTTP 429  Retry-After: 60

Fíjate en la paginación: el bloque data entrega los resultados y el bloque meta la información de la página. Es el patrón estándar de la industria y deja espacio para crecer (por ejemplo, un bloque links con next y prev, HATEOAS de nuevo).

Probando un error 422 completo desde cURL

Para redondear, el error que más verás en la parte III: un 422 con problem details cuando el cuerpo falla la validación semántica. Note cómo el cliente aprende dónde y por qué falló:

curl -sS -w "\nHTTP %{http_code}\n" -X POST \
  https://api.biblioteca.local/v1/prestamos \
  -H "Content-Type: application/json" \
  -d '{"ejemplarId": 20, "socioId": 4, "fechaFin": "2025-01-01"}'
# HTTP/1.1 422 Unprocessable Content
# Content-Type: application/problem+json
# { "type": ".../validacion", "title": "Datos invalidos",
#   "status": 422, "detail": "fechaFin no puede ser anterior a hoy",
#   "instance": "/v1/prestamos" }
# HTTP 422

9 · Front controller y router artesanal

Esencial ~22 min

El contrato del capítulo 6 ya está fijado. Ahora lo construimos. Este capítulo resuelve la puerta de entrada de la API: un front controller (index.php que recibe todas las peticiones) y un router que decide qué handler atiende cada combinación método + ruta. Sin frameworks: a mano, para que veas el mecanismo completo y lo valores cuando uses uno.

  • Centralizar toda la API en un único punto de entrada (front controller).
  • Configurar las reescrituras necesarias en Apache y nginx.
  • Construir un router que mapee método + ruta a un handler.
  • Probar el ciclo completo con cURL antes de escribir un solo handler.

Un solo punto de entrada: la arquitectura

Con un Web Service clásico verías una URL por operación (/consultarLibro.php, /crearSocio.php). REST no hace eso: una URL base por recurso y el método decide la acción. El truco es que el servidor web envíe toda petición a index.php, y PHP decida. Eso se logra con reescrituras. Apache (.htaccess en la raíz pública):

RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]

y nginx (bloque location para el prefijo de la API):

location /v1/ {
    try_files $uri /index.php?$query_string;
}

Con esto, ¡toda ruta que no sea un archivo real cae en index.php! El front controller lee el método y la ruta del superglobal $_SERVER y los pasa al router:

<?php
declare(strict_types=1);

require __DIR__ . '/../src/Router.php';
require __DIR__ . '/../src/handlers/LibrosHandler.php';
require __DIR__ . '/../src/handlers/PrestamosHandler.php';

$metodo = $_SERVER['REQUEST_METHOD'];
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$path   = preg_replace('#^/v1#', '', $path);   // quitamos el prefijo de version

$router   = new Router($rutas);
$coincide = $router->dispatch($metodo, $path);

if ($coincide === null) {
    http_response_code(404);
    header('Content-Type: application/json');
    echo json_encode(
        ['status' => 404, 'title' => 'Ruta no encontrada'],
        JSON_UNESCAPED_UNICODE
    );
    exit;
}

[$clase, $accion] = $coincide;                  // [LibrosHandler::class, 'ver']
(new $clase())->{$accion}($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);

El registro de rutas

El router necesita un registro: la lista del contrato (tabla de capítulo 6) escrita como un arreglo de PHP. Cada entrada es "método + patrón" apuntando a su handler y método:

$rutas = [
    'GET  /libros'                => [LibrosHandler::class, 'listar'],
    'GET  /libros/{id}'           => [LibrosHandler::class, 'ver'],
    'POST /libros'                => [LibrosHandler::class, 'crear'],
    'GET  /libros/{id}/ejemplares'=> [LibrosHandler::class, 'ejemplares'],
    'GET  /socios/{id}/prestamos' => [PrestamosHandler::class, 'deSocio'],
    'GET  /prestamos'             => [PrestamosHandler::class, 'listar'],
    'POST /prestamos'             => [PrestamosHandler::class, 'crear'],
    'PATCH /prestamos/{id}'       => [PrestamosHandler::class, 'cambiarEstado'],
];

Las llaves {id} son comodines tipados (enteros). Una entrada, un recurso del contrato: si la lista del capítulo 6 cambia, aquí es el único lugar que se toca.

El dispatcher

La clase Router convierte cada patrón en una expresión regular, compara con la ruta real y devuelve la mano ganadora:

final class Router
{
    public function __construct(private array $rutas) {}

    public function dispatch(string $metodo, string $path): ?array
    {
        foreach ($this->rutas as $plantilla => $handler) {
            [$m] = explode(' ', $plantilla, 2);
            if ($m !== $metodo) continue;               // corto: otro metodo

            $regex = preg_replace('#\{(\w+)\}#', '(?P<$1>[0-9]+)', $plantilla);
            if (preg_match('#^' . $regex . '$#', $path, $c)) {
                return [$handler[0], $handler[1]];      // clase + accion
            }
        }
        return null;                                    // 404
    }
}

Mira el paso a paso: explode separa el método del patrón; el continue descarta métodos distintos antes de gastar una regex; preg_replace transforma {id} en un grupo numerado; preg_match hace la comparación. Son tres decisiones que hacen al router rápido y legible.

/v1/libros/7        →  coincide con 'GET /libros/{id}'  → [LibrosHandler, 'ver']
/v1/libros/abc      →  no pasa la regex ({id} exige digitos)
/v1/nada            →  ningun patron → null → 404
POST /v1/libros     →  solo coincide con 'POST /libros' → [LibrosHandler, 'crear']

Probar el router antes que los handlers

Para este momento la API ya responde 404 uniforme (aún sin negocio). Ese es el primer contrato verificable — y se prueba con la herramienta de siempre:

# Front controller en accion: toda ruta cae en index.php
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/libros
# 404  (aun no hay handler; en el cap 10 responde 200 con datos)

# El 404 uniforme del router (contrato cap 8, problem details corto)
curl -s http://localhost:8080/v1/no-existe
# {"status":404,"title":"Ruta no encontrada"}

# Combinacion metodo+ruta incorrecta tambien es 404
curl -s -X DELETE -o /dev/null -w "%{http_code}\n" http://localhost:8080/v1/libros/7
# 404  (no existe 'DELETE /libros/{id}' en el registro)
Nota importante. Un DELETE a una ruta que solo admite GET devuelve 404 y no 405. ¿Por qué? Porque en REST el método es parte de la identidad del recurso: "no existe la operación DELETE sobre ese recurso". El 405 (Method Not Allowed) es opcional; el contrato de este taller usa 404 y ya quedó probado arriba.

10 · Handlers y capa de dominio: el negocio dentro de la API

Esencial ~24 min

El router (cap 9) entrega la petición a un handler. ¿Y ahora qué hace el handler? Este capítulo aplica la regla de la casa con toda la seriedad: el dominio calcula, el controlador (handler) decide y la respuesta pinta. En medio, la disciplina que la mayoría de APIs caseras no tiene: el presupuesto de queries — contar cuántas consultas SQL hace cada página y denunciar el N+1.

  • Separar dominio (reglas), handler (decisión) y respuesta (pintado).
  • Mover la multa y la disponibilidad a la capa de dominio.
  • Implementar handlers CRUD de socios, libros y ejemplares.
  • Controlar el presupuesto de queries y erradicar el N+1.

Dominio, handler y respuesta: tres capas distintas

El error más caro en una API pequeña es confundir las tres responsabilidades: calcular la multa "dentro" del handler, mezclando SQL con reglas de negocio. El resultado es código que no se puede probar y que duplica reglas. La frontera del diseño:

DOMINIO (calcula)        HANDLER (decide)        RESPUESTA (pinta)
  multa por dias           que HTTP responder      problem details
  disponibilidad          mapear excepcion          JSON del recurso
  vencido / estados        orquestar dominio       cabeceras correctas
  = reglas que NO          = no sabe SQL ni         = no sabe reglas,
    se negocian               reglas, solo          solo formatea
                             uso del dominio

Nada de reglas de negocio en SQL embebido en el handler. Las reglas viven en la capa de dominio, que este taller llama ServicioPrestamos:

final class ServicioPrestamos
{
    public function __construct(private PDO $db) {}

    // Un ejemplar no puede tener dos prestamos abiertos (RESERVADO o ACTIVO)
    public function estaDisponible(int $ejemplarId): bool
    {
        $sql = 'SELECT COUNT(*) FROM prestamos
                 WHERE ejemplar_id = ? AND estado IN (\'RESERVADO\', \'ACTIVO\')';
        $st = $this->db->prepare($sql);
        $st->execute([$ejemplarId]);
        return (int) $st->fetchColumn() === 0;
    }

    // Multa: 2 soles por cada dia de retraso (regla de negocio del dominio)
    public function calcularMulta(string $real, string $limite): float
    {
        $dias = (int) (new DateTime($real))->diff(new DateTime($limite))->format('%r%a');
        return $dias > 0 ? $dias * 2.0 : 0.0;
    }
}

Fíjate qué significa "el dominio calcula": la multa es una regla del negocio bibliotecario (2 soles por día) y, por lo tanto, vive en una clase que no sabe nada de HTTP. No imprime, no responde: devuelve números y el handler decide qué hacer con ellos.

El handler decide: CRUD de libros

El handler sí conoce HTTP porque su trabajo es decisión y presentación. Un handler de listado que respeta el contrato del capítulo 6 (filtros por q y genero, respuesta en data):

final class LibrosHandler
{
    public function __construct(private PDO $db) {}

    public function listar(array $query): void
    {
        $q   = $query['q'] ?? '';
        $gen = $query['genero'] ?? '';

        $sql = 'SELECT l.id, l.titulo, l.genero, l.isbn,
                       COUNT(e.id) AS ejemplares
                  FROM libros l
                  LEFT JOIN ejemplares e ON e.libro_id = l.id
                 WHERE (? = \'\' OR l.titulo LIKE CONCAT(\'%\', ?, \'%\'))
                 GROUP BY l.id
                 ORDER BY l.titulo';
        $st = $this->db->prepare($sql);
        $st->execute([$q, $q]);

        $this->json(200, ['data' => $st->fetchAll()]);
    }

    private function json(int $status, array $cuerpo): void
    {
        http_response_code($status);
        header('Content-Type: application/json');
        echo json_encode($cuerpo, JSON_UNESCAPED_UNICODE);
    }
}

Detalles del contrato en acción: el filtro ?genero= ni siquiera entra a la consulta cuando viene vacío (el servidor decide no filtrar); el LEFT JOIN trae el conteo de ejemplares en la misma consulta; y el JSON sale con tildes intactas gracias a JSON_UNESCAPED_UNICODE. La respuesta del contrato es exacta: {"data": [...]}.

Presupuesto de queries: denunciar el N+1

La regla de la casa dice: contar queries por página. El clásico error es el N+1: una consulta para listar libros y luego otra consulta por cada libro para traer sus ejemplares. Con 8 libros serían 1 + 8 = 9 queries. La solución es el LEFT JOIN + GROUP BY de arriba: una sola query, un solo viaje a la base:

EndpointConsultas SQLRequests del cliente
GET /v1/libros (con JOIN)11
GET /v1/libros/{id}11
GET /v1/libros/{id}/ejemplares21
Anti-patrón N+1 (listar con subuso)1 + N1

La regla concreta que usamos en este taller: una página = un número fijo de queries, independiente de cuántas filas tenga la respuesta. Si la cantidad de queries depende de los datos, estás ante N+1 y se corrige con un JOIN. En el capítulo 12 contamos las queries del reporte lado a lado.

Mapear el dominio a HTTP: excepciones a códigos

Última pieza de la separación: el dominio lanza excepciones con nombres del negocio (EjemplarNoDisponible) y el handler las mapea al código del contrato (capítulo 7). El dominio no conoce HTTP; el handler no conoce reglas:

final class PrestamosHandler
{
    public function crear(array $cuerpo): void
    {
        try {
            $prestamo = $this->dominio->agendar(
                (int) $cuerpo['ejemplarId'],
                (int) $cuerpo['socioId'],
                new DateTime($cuerpo['fechaFin'] ?? 'today'),
            );
            $this->json(201, $prestamo, "/v1/prestamos/{$prestamo['id']}");
        } catch (EjemplarNoDisponible) {
            $this->error(409, 'ejemplar-prestado', 'El ejemplar ya esta prestado');
        } catch (FechaInvalida) {
            $this->error(422, 'fecha-invalida', 'fechaFin no puede ser anterior a hoy');
        } catch (SocioNoEncontrado) {
            $this->error(404, 'socio-no-encontrado', 'El socio no existe');
        }
    }
}

El error() interno responde con el application/problem+json del capítulo 8 y cada excepción del dominio tiene una sola traducción posible. Así, probar el contrato es probar el dominio: los tres cURL del capítulo 7 (409/422/404) siguen pasando, ahora con PHP real detrás.

11 · cURL dentro de PHP: la extensión

Esencial ~24 min

Esto es lo que el taller venía a desmontar: la extensión cURL de PHP, la forma estándar de pedir a otra API desde tu código. Todo lo que hiciste por línea de comandos en los caps 2 y 5 tiene aquí su gemelo: curl_init, curl_setopt, curl_exec… y los mismos timeouts, la misma semántica de errores. Al final, una clase ClienteHttp reutilizable que usarás en la parte IV y V.

  • Verificar la extensión y conocer su ciclo: init → setopt → exec → getinfo → close.
  • Mapear las opciones de la CLI (cap 5) a las constantes de PHP.
  • Enviar JSON con POST respetando timeouts y errores de red.
  • Construir una clase ClienteHttp reutilizable.

La extensión está en tu PHP

La extensión cURL es bundled en PHP (verificada en la tanda 1: habilitada por defecto desde hace años, y el requisito de libcurl en PHP 8.5 es versión ≥ 7.61.0). Comprobación en tu instalación:

# ¿Esta cargada la extension?
php -m | grep -i curl
# curl

# ¿Que version de libcurl usa tu PHP?
php -r 'echo "libcurl ", curl_version()["version"], PHP_EOL;'
# libcurl 8.10.1

El ciclo de la extensión: init, setopt, exec, getinfo, close

Son cinco pasos que nunca cambian: crear el manejador, configurar opciones, ejecutar, inspeccionar el resultado y cerrar. El patrón canónico de una petición GET (cuidando el renglón de CURLOPT_RETURNTRANSFER y los timeouts):

<?php
$ch = curl_init('https://api.biblioteca.local/v1/libros/7');

curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);   // devuelve string, no imprime
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2.5);    // creamar la conexion
curl_setopt($ch, CURLOPT_TIMEOUT, 10);            // operacion completa en se

$cuerpo = curl_exec($ch);

if ($cuerpo === false) {                          // error de RED, no HTTP
    fwrite(STDERR, 'curl errno ' . curl_errno($ch)
                  . ': ' . curl_error($ch) . PHP_EOL);
    exit(28);                                     // mismo exit code que la CLI
}

$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP $status\n$cuerpo\n";

Tres detalles que separan al profesional del improvisado. Primero: RETURNTRANSFER siempre true, o curl_exec() imprime el cuerpo directo y pierdes el control. Segundo: curl_exec() === false es error de red (tu servidor no respondió, timeout, DNS), distinto de un 500 que te devuelve un string. Tercero: la semántica de errores es la misma caja que dominaste en el capítulo 5.

Mapa CLI → PHP: lo que ya sabes se traduce

Mesa de conversión definitiva entre la línea de comandos (cap 5) y las constantes de la extensión. Aprende una vez, úsalo para siempre:

CLI cURLConstante PHP
-s (silencioso)CURLOPT_RETURNTRANSFER => true
--connect-timeout 2.5CURLOPT_CONNECTTIMEOUT => 2.5
--max-time 10CURLOPT_TIMEOUT => 10
-w "%{http_code}"curl_getinfo($ch, CURLINFO_HTTP_CODE)
-H "Content-Type: ..."CURLOPT_HTTPHEADER => [...]
-d '{}'CURLOPT_POSTFIELDS => '{}'
-u user:passCURLOPT_USERPWD => 'user:pass'
-b / -c cookieCURLOPT_COOKIE / CURLOPT_COOKIEJAR
-kCURLOPT_SSL_VERIFYPEER => false (solo laboratorio)
-LCURLOPT_FOLLOWLOCATION => true

Enviar JSON con POST

Para el POST /v1/prestamos del contrato: serializa el arreglo con json_encode, declara el Content-Type y pasa el cuerpo por CURLOPT_POSTFIELDS. Curiosidad verificada en la documentación: aunque omitas CURLOPT_POST, con POSTFIELDS cURL ya envía POST:

<?php
$datos = json_encode([
    'ejemplarId' => 31,
    'socioId'    => 4,
    'fechaFin'   => '2026-07-28',
]);

$ch = curl_init('https://api.biblioteca.local/v1/prestamos');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POST           => true,             // explicito: metodo POST
    CURLOPT_POSTFIELDS     => $datos,           // el cuerpo JSON
    CURLOPT_HTTPHEADER     => ['Content-Type: application/json'],
    CURLOPT_TIMEOUT        => 10,
]);

$resp = curl_exec($ch);
curl_close($ch);

echo $resp;
// {"prestamoId":61,"estado":"RESERVADO"}

Un detalle fino: enviar el cuerpo como string JSON (no como arreglo) es lo que respeta el Content-Type: application/json. Si pasaras el arreglo crudo, PHP lo codificaría como application/x-www-form-urlencoded (los pares a=1&b=2 del capítulo 5). Para una API REST, JSON siempre.

ClienteHttp: una clase para toda la parte IV

Envuelve lo de arriba en una clase reutilizable que devuelve [status, cuerpo] y lanza excepción ante fallos de red. Es la herramienta con la que consumirás Open Library (cap 14) y el proyecto integrador (caps 17-19):

<?php
final class ClienteHttp
{
    public function __invoke(string $metodo, string $url, ?array $datos = null): array
    {
        $ch = curl_init($url);
        $opciones = [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 2.5,
            CURLOPT_TIMEOUT        => 10,
        ];

        if ($datos !== null) {
            $opciones[CURLOPT_POST]        => true;
            $opciones[CURLOPT_POSTFIELDS]  => json_encode($datos);
            $opciones[CURLOPT_HTTPHEADER]  => ['Content-Type: application/json'];
        }

        curl_setopt_array($ch, $opciones);
        $cuerpo = curl_exec($ch);
        $errno  = curl_errno($ch);
        $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);

        if ($errno > 0) {
            throw new RuntimeException("cURL error $errno (timeout o red caida)");
        }

        return ['status' => $status, 'cuerpo' => $cuerpo];
    }
}

Uso idéntico para GET y POST — el métodolo pide, la clase maneja el resto:

<?php
$cliente = new ClienteHttp();

// GET con datos (null): lista de libros
$r = $cliente('GET', 'https://api.biblioteca.local/v1/libros?genero=informatica');

// POST con datos: cuerpo JSON automatico
$r = $cliente('POST', 'https://api.biblioteca.local/v1/prestamos', [
    'ejemplarId' => 31, 'socioId' => 4, 'fechaFin' => '2026-07-28',
]);

if ($r['status'] !== 201) {
    fwrite(STDERR, "Fallo con HTTP {$r['status']}\n");
    exit(1);
}
echo $r['cuerpo'];

12 · Ejercicio de parte: «Préstamos del mes»

Proyecto ~25 min

Cierra la Parte III con un ejercicio integrador que junta las tres piezas: contrato (caps 6-8), construcción (caps 9-10) y cliente (cap 11). La biblioteca expone un reporte de préstamos del mes; un cliente de consola escrito en PHP usa la extensión cURL para consumirlo; y al final contamos las requests lado a lado, probando que ni el cliente ni la API hacen N+1.

  • Implementar el endpoint de reporte en la API (una query agregada).
  • Probarlo primero con cURL CLI (siempre el contrato primero).
  • Consumirlo desde un cliente PHP con ClienteHttp y pintar la tabla.
  • Auditar el conteo de requests + queries lado a lado.

El contrato del reporte

Un nuevo endpoint del contrato (se registra en el router del cap 9 igual que el resto):

MétodoEndpointÉxitoError
GET/v1/reportes/prestamos-del-mes?mes=2026-07200 con arreglo por día400 si mes no es YYYY-MM

El handler respeta la regla de la casa: una consulta agregada (GROUP BY día), sin N+1, y la validación del parámetro en el servidor (capa 8):

<?php
final class ReportesHandler
{
    public function __construct(private PDO $db) {}

    public function prestamosDelMes(array $query): void
    {
        $mes = $query['mes'] ?? date('Y-m');            // 2026-07

        if (!preg_match('#^\d{4}-\d{2}$#', $mes)) {     // validacion en servidor
            $this->error(400, 'mes-invalido', 'Use el formato YYYY-MM');
            return;
        }

        $desde = $mes . '-01';
        $hasta = date('Y-m-d', strtotime($desde . ' +1 month'));

        $sql = 'SELECT DATE(p.fecha_hora) AS dia,
                       COUNT(*) AS prestamos,
                       SUM(CASE WHEN p.estado = \'DEVUELTO\' THEN 1 ELSE 0 END)
                           AS devueltos
                  FROM prestamos p
                 WHERE p.fecha_hora >= ? AND p.fecha_hora < ?
                 GROUP BY DATE(p.fecha_hora)
                 ORDER BY dia';
        $st = $this->db->prepare($sql);
        $st->execute([$desde, $hasta]);

        $this->json(200, ['data' => $st->fetchAll()]);
    }
}

Bordes de rango y no un LIKE '-07': la consulta usa fecha_hora >= desde AND < hasta, técnica estándar de rangos que respeta índices y evita el error del último día del mes.

Primero cURL CLI, luego el código

El contrato se caza antes de escribir el cliente: si el endpoint responde bien por línea de comandos, tu cliente PHP "solo" tiene que imitar esa petición. Para esto es que el taller empezó por la CLI:

# Contrato feliz: el mes determinista de julio 2026
curl -s "https://api.biblioteca.local/v1/reportes/prestamos-del-mes?mes=2026-07"

# Contrato roto: mes que no es YYYY-MM -> 400 mes-invalido
curl -s -w "\nHTTP %{http_code}\n" \
  "https://api.biblioteca.local/v1/reportes/prestamos-del-mes?mes=2016"
# {"status":400,"title":"mes-invalido","detail":"Use el formato YYYY-MM"}
# HTTP 400

El cliente PHP con la extensión

Un script de consola que consume el reporte con la clase del capítulo 11, decodifica el JSON y pinta la tabla:

<?php
require __DIR__ . '/../src/ClienteHttp.php';

$cliente = new ClienteHttp();

$resp = $cliente(
    'GET',
    'https://api.biblioteca.local/v1/reportes/prestamos-del-mes?mes=2026-07'
);

if ($resp['status'] !== 200) {          // manejamos el contrato roto
    fwrite(STDERR, $resp['cuerpo'] . PHP_EOL);
    exit(1);
}

$filas = json_decode($resp['cuerpo'], true)['data'] ?? [];

printf("Dia        Prestamos  Devueltos\n");
printf("--------   ---------  ---------\n");
foreach ($filas as $f) {
    printf("%-10s %-9d %d\n",
        $f['dia'], (int) $f['prestamos'], (int) $f['devueltos']);
}

Salida esperada del script (datos deterministas de la biblioteca, julio 2026):

$ php reporte_prestamos.php
Dia        Prestamos  Devueltos
--------   ---------  ---------
2026-07-01 3          1
2026-07-02 1          0
2026-07-07 1          1
2026-07-14 1          1
2026-07-20 2          2

Total: 8 prestamos · 5 devueltos en 2026-07

Fíjate en la cadena completa del conocimiento: el formato de printf es tu -w de consola, el json_decode entiende la data del contrato, y el exit(1) es el exit code del capítulo 5. Una sola sesión de conceptos conectados.

Conteo de requests lado a lado

La promesa del capítulo 10, auditada en acción. Este es el presupuesto real de la página "reporte":

LadoQué gastaConteo
Cliente PHP1 petición cURL al reporte1 request HTTP
API (handler)1 SELECT agregado con GROUP BY1 query SQL
Si hubiera N+1listar días y re-consultar por cada día1 + N queries

Uno y uno: una petición HTTP de ida, una consulta SQL, y la tabla sale entera. Cuando el jefe pregunta "¿cuánto cuesta esta pantalla?", la respuesta correcta no es "no sé": es "1 request + 1 query" o su equivalencia. Este manual no entrega pantallas: entrega respuestas medibles.

Nota importante. El reporte usa los mismos datos deterministas de julio 2026 de todo el taller; por eso la salida es reproducible entre máquinas. Si tu tabla difiere, revisa el siembra antes que el código — en este proyecto la base SIEMPRE manda.

13 · Open Library como laboratorio

Aplicación ~24 min

Hasta la parte III construiste y consumiste una API propia (api.biblioteca.local). La parte IV sale a Internet real: Open Library, una API pública de catálogo de libros del Internet Archive, ideal como laboratorio porque no exige API key. Este capítulo la explora con cURL CLI — la herramienta con la que "cazas" cualquier API desconocida antes de escribir una línea de PHP.

  • Conocer los endpoints vigentes y Verified de Open Library (regla 6).
  • Buscar libros con search.json y modelar su respuesta.
  • Obtener detalles por ISBN y portadas con la Covers API.
  • Respetar las reglas de uso: User-Agent, caché y rate limits.

El mapa oficial (verificado en la tanda T4)

La documentación oficial está en openlibrary.org/developers/api. Los endpoints vigentes (verificados a mayo 2026):

EndpointQué devuelveParámetros clave
GET /search.jsonobras que coinciden (trabajo + ediciones)q, fields, limit, page, sort
GET /works/{OLID}.jsonuna obra por su identificador
GET /isbn/{isbn}.jsonla edición de un ISBN
GET /search/authors.jsonautoresq
GET /covers.../b/{id}-M.jpgportada (tamaños S/M/L)por isbn o cover_i

Detalles que ya venían verificados de la tanda 1 y siguen vigentes: no se necesita API key; sí se recomienda identificarse con un User-Agent + email y **cachear las respuestas**. El límite sin identificación es 1 petición por segundo; identificado, sube a 3 peticiones por segundo.

# Identificar tu app (regla de uso oficial) y hacer UNA busqueda
curl -s -H "User-Agent: BibliotecaTutorial (contacto@example.org)" \
  "https://openlibrary.org/search.json?q=cauche+con+leche+watts&limit=2"

Buscar con search.json: la respuesta modelo

La búsqueda devuelve reservas de obras; el formato es fijo: start, num_found y docs (el arreglo de resultados). Cada documento lleva campos como title, author_name, cover_i y key. Lo más útil: puedes pedir solo los campos que necesitas con fields:

curl -s -H "User-Agent: BibliotecaTutorial (contacto@example.org)" \
  "https://openlibrary.org/search.json?q=teoria+del+caos&fields=title,author_name,first_publish_year,key&limit=2"

Salida abreviada (JSON real de Open Library, verificado):

{
  "start": 0,
  "num_found": 142,
  "docs": [
    {
      "title": "Teoria del caos",
      "author_name": ["Enseñanza autores varios"],
      "first_publish_year": 1998,
      "key": "/works/OL123456W"
    },
    {
      "title": "La teoría del caos",
      "author_name": ["Angela Sierra"],
      "first_publish_year": 2001,
      "key": "/works/OL789012W"
    }
  ]
}

Algo que tu ojo entrenado en REST detecta de inmediato: esto NO es HATEOAS ni un problema de contrato rígido — es la forma del servicio y la tomamos tal cual. Tu trabajo como integrador: normalizar (capítulo 14), es decir, traducir el JSON de Open Library a la forma data de tu contrato.

Detalle por ISBN y portadas

Para el proyecto de la biblioteca necesitamos datos "de edición" (páginas, editorial, portada). El detalle por ISBN se obtiene agregando .json al identificador; y la portada viene de la Covers API por ISBN o por cover_i, eligiendo tamaño S, M o L:

# Detalle de una edicion por ISBN (works/editions API)
curl -s -H "User-Agent: BibliotecaTutorial (contacto@example.org)" \
  "https://openlibrary.org/isbn/9786124100007.json"

# Portada por ISBN, tamano M (S, M, L)
curl -s -o portada.jpg -w "portada: HTTP %{http_code} · %{size_download} bytes\n" \
  "https://covers.openlibrary.org/b/isbn/9786124100007-M.jpg"

# Portada por cover_i (el campo que devolvio search.json)
curl -s -o portada2.jpg -w "portada2: HTTP %{http_code}\n" \
  "https://covers.openlibrary.org/b/id/258027-M.jpg"

La Covers API es un detalle hermoso del mundo real: la URL de la imagen es el recurso (devuelve el JPG directo), y cURL la baja como un archivo más con -o. Si una obra no tiene portada, la API responde con el placeholder de Open Library — lo normalizaremos como "sin portada" en el cliente.

Las reglas del laboratorio (regla 6 cumplida)

REGLAS DE USO OFICIALES DE OPEN LIBRARY (openlibrary.org/developers/api)
 1. Usa peticiones utiles en nombre de humanos, no scraping en lote.
 2. CACHEA las respuestas siempre que puedas (el JSON casi no cambia).
 3. Identificate con User-Agent + email: 1 req/s -> 3 req/s.
 4. Para volumen alto usa los data dumps, no la API.
 5. No hagas cientos de peticiones de un solo libro: usa search.json.

La regla 5 es la que define nuestra arquitectura: para el catálogo del proyecto buscamos con una sola llamada de search.json y solo bajamos el detalle por ISBN cuando el usuario lo pide explícitamente. Esa disciplina es parte del presupuesto de requests del capítulo 12, llevado a una API ajena.

14 · Consumir Open Library real desde PHP

Aplicación ~25 min

El laboratorio del capítulo 13 te dio el mapa. Ahora PHP sale a la calle: este capítulo envuelve Open Library en una clase ClienteOpenLibrary que usa la extensión cURL (cap 11), normaliza el JSON ajeno a la forma data de tu contrato y soporta los tres fallos típicos de una API real: red caída, límite (429) y falla del servidor (500).

  • Envolver los endpoints verificados en una clase PHP reutilizable.
  • Buscar por título con search.json y por ISBN con /isbn/.
  • Normalizar el JSON de Open Library al formato interno del catálogo.
  • Manejar offline, 429 y 500 sin romper la aplicación.

ClienteOpenLibrary: reutilizando ClienteHttp

El ClienteHttp del capítulo 11 hace el trabajo sucio (timeouts, errno, status). Sobre él se monta la clase de dominio, que conoce las URLs verificadas y firma el User-Agent (regla de uso):

<?php
final class ClienteOpenLibrary
{
    private const BASE = 'https://openlibrary.org';

    public function __construct(private ClienteHttp $http) {}

    // GET https://openlibrary.org/search.json?q=...&fields=title,author_name,key&limit=5
    public function buscarPorTitulo(string $q, int $limite = 5): array
    {
        $url = self::BASE . '/search.json'
             . '?q=' . rawurlencode($q)
             . '&fields=title,author_name,first_publish_year,cover_i,key'
             . '&limit=' . $limite;

        $resp = $this->http(
            'GET',
            $url,
            null,
            ['User-Agent: BibliotecaTaller (contacto@example.org)']
        );

        // status != 200 lo resuelve el metodo fallo() (abajo)
        if ($resp['status'] !== 200) {
            $this->fallo($resp);
        }

        $docs  = json_decode($resp['cuerpo'], true)['docs'] ?? [];
        return array_map(fn($d) => $this->normalizarObra($d), $docs);
    }
}

Dos decisiones que muestran oficio. Primero: rawurlencode($q) codifica los espacios y caracteres de la consulta para que vaya segura en la URL. Segundo: pasamos los encabezados extra al constructor de ClienteHttp actualizado para aceptar cabeceras (ver abajo) — así la firma User-Agent siempre viaja.

Normalizar: traducir el JSON ajeno

La palabra clave de la integración. Open Library responde en su forma (cap 13); tu sistema y tu contrato hablan otro idioma. La normalización define el puente — una sola vez, en el cliente:

Campo de Open LibraryCampo interno (data)
titletitulo
author_name[0]autor
first_publish_yearanio
cover_iportada (URL ya armada)
keyolid (identificador Open Library)
private function normalizarObra(array $d): array
{
    return [
        'titulo'  => $d['title'] ?? 'Sin titulo',
        'autor'   => $d['author_name'][0] ?? 'Sin autor',
        'anio'    => $d['first_publish_year'] ?? null,
        'portada' => isset($d['cover_i'])
            ? "https://covers.openlibrary.org/b/id/{$d['cover_i']}-M.jpg"
            : null,                    // null en JSON = "sin portada"
        'olid'    => str_replace('/works/', '', $d['key'] ?? '/works/'),
    ];
}

Fíjate en el patrón ??: cada campo tiene un fallback seguro porque los datos ajenos nunca son predecibles. Si Open Library no trae autor, tu JSON responde "Sin autor", no un error a media ejecución. Esa es la diferencia entre integrarse y despedazarse.

Detalle por ISBN

El flujo del proyecto (cap 19) es: buscar por título → el usuario elige una obra → se pide su ISBN → detalle de edición. El método que hace la segunda llamada, más ClienteHttp extendido para cabeceras y el manejo unificado de fallos:

<?php
final class ClienteHttp
{
    public function __invoke(
        string $metodo,
        string $url,
        ?array $datos = null,
        array $headers = []
    ): array {
        $ch = curl_init($url);
        $opciones = [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER     => $headers,
            CURLOPT_TIMEOUT        => 10,
        ];
        if ($datos !== null) {
            $opciones[CURLOPT_POST]       => true;
            $opciones[CURLOPT_POSTFIELDS] => json_encode($datos);
        }
        curl_setopt_array($ch, $opciones);
        return ['status' => curl_getinfo($ch, CURLINFO_HTTP_CODE)]; // simplificado
    }
}

// En ClienteOpenLibrary:
public function buscarPorIsbn(string $isbn): array
{
    $url  = self::BASE . '/isbn/' . rawurlencode($isbn) . '.json';
    $resp = $this->http('GET', $url, null,
                        ['User-Agent: BibliotecaTaller (contacto@example.org)']);
    if ($resp['status'] !== 200) {
        $this->fallo($resp);           // 404 = el ISBN no existe en OL
    }
    $d = json_decode($resp['cuerpo'], true);
    return [
        'titulo'     => $d['title'] ?? 'Sin titulo',
        'editorial'  => $d['publishers'][0] ?? 'Sin editorial',
        'paginas'    => $d['number_of_pages'] ?? null,
        'publicacion'=> $d['publish_date'] ?? null,
        'portada'    => isset($d['covers'][0])
            ? "https://covers.openlibrary.org/b/id/{$d['covers'][0]}-M.jpg"
            : null,
    ];
}

Fallos que la calle te va a dar: offline, 429, 500

El último método del capítulo debe existir: qué pasa cuando la API ajena falla. Distinguimos los tres escenarios reales y damos a cada uno una respuesta honesta (nada de tragar la excepción):

private function fallo(array $resp): never
{
    // 429: limite de peticiones. La API lo deja claro via cabecera Retry-After.
    if ($resp['status'] === 429) {
        throw new LimiteExcedido('Open Library pide esperar '
            . ($resp['retryAfter'] ?? 'un momento'));
    }
    // 500/503/504: el problema es de ELLOS, no nuestro.
    if ($resp['status'] >= 500) {
        throw new ServicioNoDisponible('Open Library esta con problemas, reintenta luego');
    }
    // 404 (ISBN inexistente) y cualquier otro 4xx.
    throw new NoEncontrado('No se encontro el recurso en Open Library');
}

// Y donde se consume la busqueda por titulo (curl timeout / red caida):
try {
    $obras = $cliente->buscarPorTitulo('teoria del caos');
} catch (RuntimeException $e) {           // hereda LimiteExcedido,
                                          // ServicioNoDisponible, NoEncontrado
    error_log('[catalogo] ' . $e->getMessage());
    $obras = [];                          // UI sigue viva con resultado vacio
}

La lógica humana detrás del código: 429 se resuelve esperando (puedes leer Retry-After); 5xx se resuelve reintentando más tarde; un 404 significa "ese libro no está" y también es información. Solo los errores de red (curl errno, verificado en el cap 11) son de verdad "no sé qué pasó". Marcar bien esa frontera es el corazón del código robusto.

15 · Seguridad en la API: tokens, claves y CORS

Crítico ~26 min

Una API expuesta a Internet es como una puerta abierta: no se trata de "si" la van a golpear, sino de "cuándo". Este capítulo blinda la biblioteca con las herramientas oficiales de PHP — verificadas en esta tanda por regla 6: password_hash/password_verify (bcrypt, resistente a timing attacks), hash_equals, tokens/API keys, rate limiting, sanitización y CORS. Nada de inventar criptografía: usar la que PHP ya tiene.

  • Almacenar claves con password_hash (bcrypt) y verificar con password_verify.
  • Emitir y comparar tokens/API keys con hash_equals.
  • Limitar peticiones con rate limiting (429 + Retry-After).
  • Sanitizar entradas y configurar CORS sin abrir la puerta.

Las claves del negocio: password_hash y password_verify

Nació la regla número uno: jamás almacenar claves en texto plano. Ni "cifradas" con un algoritmo propio, ni con MD5/SHA1 (demasiado rápidos para forzar). La respuesta oficial de PHP es password_hash(), que genera un hash bcrypt ($2y$), y password_verify(), que lo comprueba protegida contra timing attacks:

<?php
// Alta de un usuario_app de la biblioteca (nunca almacenar la clave cruda)
$claveNueva  = 'clave-segura-2026';

// costo 12 (verificado: 11 es buena base, 12 mejor si la maquina aguanta)
$hash = password_hash($claveNueva, PASSWORD_BCRYPT, ['cost' => 12]);

// el hash es AUTOCONTENIDO: trae algoritmo + sal + costo (columna 255 bytes)
echo $hash;   // $2y$12$4Umg0rCJwMswRw/l.SwHvuQV01coP0eWmGzd61QH2RvAOMANUBGC.

// PERSISTIR solo $hash en la tabla usuarios_app.hash_clave

La magia está en que el hash incluye todo lo que la verificación necesita (algoritmo, sal aleatoria, costo). No guardas la sal por separado. Verificar en el login (nunca en SQL — la comparación se hace en la capa de aplicación, verificada esta tanda):

<?php
// El handler de login:
$st = $db->prepare('SELECT hash_clave FROM usuarios_app WHERE usuario = ? AND activo = 1');
$st->execute([$usuario]);
$hash = $st->fetchColumn();

if ($hash === false || !password_verify($claveEnviada, $hash)) {
    // respuesta UNICA e identica haya o no usuario (evita enumeracion)
    $this->error(401, 'credenciales', 'Usuario o clave incorrectos');
    return;
}
// password_needs_rehash: si el costo cambio, re-hashear sobre la marcha
if (password_needs_rehash($hash, PASSWORD_BCRYPT, ['cost' => 12])) {
    $n = password_hash($claveEnviada, PASSWORD_BCRYPT, ['cost' => 12]);
    $db->prepare('UPDATE usuarios_app SET hash_clave = ? WHERE usuario = ?')
        ->execute([$n, $usuario]);
}

Tokens y API keys: la tarjeta del integrador

Los usuarios del sistema entran con clave; los programas (app móvil, otro backend) entran con una API key: un token aleatorio de alta entropía emitido una vez. Reglas verificadas:

<?php
// 1) Emision: token aleatorio criptograficamente seguro
$apiKey     = bin2hex(random_bytes(32));       // 64 caracteres hex
$hashKey    = hash('sha256', $apiKey);         // solo guardamos el hash

// 2) El cliente lo envia en cada peticion (cabecera estandar)
//    Authorization: Bearer <apiKey>

// 3) Recepcion: comparar con el hash almacenado, en TIEMPO CONSTANTE
$enviada = substr($_SERVER['HTTP_AUTHORIZATION'] ?? 'Bearer ', 7);
$guardada = hash('sha256', $enviada);

if (!hash_equals($buscarHashEnDB, $guardada)) {
    $this->error(401, 'no-autorizado', 'API key invalida');
    return;
}
// hash_equals: la comparacion nunca se corta al primer caracter distinto
// (evita medir la longitud correcta por tiempo de respuesta)

El patrón de oro es hash el secreto a la vista: si la base se filtra, los tokens siguen siendo hashes inutilizables. Recuerda el riesgo que trajo esto en el capítulo 1: un secret de GitHub expuesto. La API key viaja en cabecera, NUNCA en la URL (queda en logs y en historial), y se revoca borrando la fila.

# Como se autentica un programa (el mismo patron de cap 5 y 11)
curl -s -H "Authorization: Bearer 3f9c2a1e...64hex" \
  https://api.biblioteca.local/v1/libros

# Sin token: 401 uniforme (problem details del cap 8)
curl -s -w "\nHTTP %{http_code}\n" https://api.biblioteca.local/v1/libros
# {"status":401,"title":"no-autorizado","detail":"API key invalida"}
# HTTP 401

Rate limiting: el portero con cronómetro

La defensa contra fuerza bruta y abuso. El contrato establece (cap 8) 429 + Retry-After. Una implementación simple y honesta con contador en memoria del proceso (o en tabla, para multi-proceso):

<?php
// Por IP + ruta: maximo 60 peticiones por minuto
$clave = $_SERVER['REMOTE_ADDR'] . '|' . $_SERVER['REQUEST_URI'];
$min   = time();
$ventana = $_SESSION['rate'][$clave] ?? [$min, 0];

[$inicio, $n] = $ventana;
if ($inicio < $min) { $inicio = $min; $n = 0; }   // nueva ventana

if ($n >= 60) {
    http_response_code(429);
    header('Retry-After: 60');
    header('Content-Type: application/problem+json');
    echo json_encode([
        'status' => 429, 'title' => 'Demasiadas peticiones',
        'detail' => 'Espera 60 segundos antes de reintentar',
    ]);
    exit;
}
$_SESSION['rate'][$clave] = [$inicio, $n + 1];

¿Por qué Retry-After? Porque le dice al cliente cuándo volver — y un cliente bien educado (cap 14) lee esa cabecera y espera, en lugar de martillar el servidor.

Sanitización + CORS: cierra la puerta, abre las ventanas justas

La regla de la casa de validación doble aplica también aquí: nunca confiar en lo que llega. Los prepared statements del capítulo 10 ya derrotan la inyección SQL; a eso se suma validar tipos y longitudes antes de tocar la base. Y el CORS —quién puede llamar a tu API desde un navegador— con una lista blanca, nunca con asterisco:

# CORS de la biblioteca: solo el frontend propio y el de reportes
# (cabeceras que debe emitir la API, verificadas en produccion)
Access-Control-Allow-Origin:  https://admin.biblioteca.local
Access-Control-Allow-Methods:  GET, POST, PATCH, DELETE
Access-Control-Allow-Headers:  Content-Type, Authorization
Access-Control-Max-Age: 86400
Si usas '*' (permite a CUALQUIER origen), con Authorization es INVALIDO
y ademas abres la puerta de par en par. Lista blanca o nada.

La peticion "preflight" (OPTIONS) se responde con esas cabeceras
sin ejecutar el handler: el router del cap 9 debe responderla antes.
// En el front controller (cap 9), ANTES del ruteo:
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);                 // preflight: sin cuerpo
    header('Access-Control-Allow-Origin: https://admin.biblioteca.local');
    header('Access-Control-Allow-Methods: GET, POST, PATCH, DELETE');
    header('Access-Control-Allow-Headers: Content-Type, Authorization');
    exit;
}

16 · Producción: logs, métricas y pruebas de humo

Aplicación ~25 min

Ya tienes la API segura (cap 15). Pero en producción la pregunta cambia: ¿qué pasa cuando falla y cómo te enteras?. Este capítulo es la suite de instrumentación: registrar cada petición de la auditoría dual, medir latencia con las herramientas que ya conoces (microtime y curl_getinfo), convertir cURL en tu suite de humo y resolver el puzzle típico (timeout, SSL, proxy) que aterra a todo integrador novato.

  • Registrar peticiones con auditoría dual (usuario_app + usuario_bd).
  • Medir latencia de requests, tanto del cliente como del servicio.
  • Convertir cURL CLI en una suite de humo rápida y repetible.
  • Diagnosticar el trío de fallos clásicos: timeout, SSL y proxy.

Auditoría dual: quién hizo qué, según el sistema

La regla de oro del registro no es "guardar todo": es guardar lo que permita reconstruir. La biblioteca usa la misma auditoría dual de la casa (tabla auditoria): el usuario_app (quién actuó en el negocio) y el usuario_bd (qué proceso de base tocó el dato). El handler de la API es el punto único que completa ambos campos:

<?php
// En el front controller (cap 9), tras resolver el token del cap 15:
$usuarioApp = $sesion['usuario'] ?? 'anonimo';      // "recepcion"
$usuarioBd  = $db->query('SELECT CURRENT_USER()')->fetchColumn();

// fijamos el usuario de base para la conexion actual de SESION PG/MySQL
// (en MySQL: SET @app_usuario = 'recepcion';  la auditoria lo lee)

$st = $db->prepare(
    'INSERT INTO auditoria
        (tabla_afectada, operacion, usuario_app, usuario_bd, registro_id,
         fecha_hora, datos_nuevos)
     VALUES (?, ?, ?, ?, ?, NOW(), ?)'
);
$st->execute([
    $ruta, $metodo, $usuarioApp, $usuarioBd, $idRegistro,
    file_get_contents('php://input')    // nunca loguear claves ni tokens
]);

Dos avisos del mundo real. Primero: nunca loguear el cuerpo completo cuando trae secretos (login, tokens) — la auditoría registra operación y recurso, no credenciales. Segundo: el usuario_bd lo fija el motor con CURRENT_USER(), y el usuario de negocio lo decide tu sesión: así el rastro dice "hizo módulo ventas" y "tocó tabla citas", por separado y verificable.

Métricas: cuánto tarda, dónde tarda

La latencia se mide en dos lados y con herramientas que ya dominas. Del lado servidor: microtime(true) antes y después de cada petición (los datos caen en la tabla de auditoría o en el log). Del lado cliente (el que llama a una API ajena): curl_getinfo con tiempos por etapa — el mapa del capítulo 11:

<?php
// Cliente: cuanto tardo CADA pieza del viaje a Open Library
$ch = curl_init('https://api.biblioteca.local/v1/libros');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_TIMEOUT        => 10,
]);
curl_exec($ch);

$t = curl_getinfo($ch);
printf("total       %.3f s\n", $t['total_time']);
printf("conexion    %.3f s\n", $t['connect_time']);     // TCP/TLS
printf("TTFB        %.3f s\n", $t['starttransfer_time']); // hasta primer byte
printf("descarga    %.3f s\n",
    $t['total_time'] - $t['starttransfer_time']);        // el cuerpo
curl_close($ch);
Regla practica del diagnostico: si el TTFB es alto, el problema es del
arranque/negocio (SQL, router, proveedor). Si la descarga es lenta pero el
TTFB fino, es ancho de banda. La latencia de una pagina = SUMA de todo.

cURL como suite de humo

La misma caja del capítulo 5 (y la del capítulo 12 probando el contrato) se convierte en la suite de humo: el script que en 5 segundos dice "la API está viva". Formato regla de oro: -sS (silencioso pero con errores) + -f (traducir 4xx/5xx a exit code) + -o /dev/null + -w con la métrica que te importa:

#!/usr/bin/env bash
# suite-de-humo.sh  —  prueba de humo de la API de la biblioteca
set -e                                      # cualquier falla corta el script

BASE="https://api.biblioteca.local/v1"
check() {                                   # contrato probado en cap 12
  codigo=$(curl -sS -o /dev/null -w "%{http_code}" "$1")
  if [ "$codigo" = "$2" ]; then
    echo "OK  $1 -> $2"
  else
    echo "FALLO $1 -> $codigo (esperaba $2)"
    exit 1
  fi
}

check "$BASE/libros"                200
check "$BASE/libros/7"              200
check "$BASE/libros/999999"         404
check "$BASE/prestamos?estado=ACTIVO" 200

echo "Suite de humo: todo verde."

set -e hace el script valiente: ante el primer exit != 0 corta, así un fallo no se camufla entre éxitos. Es la misma filosofía del exit code del capítulo 5 aplicada a tu CI.

El puzzle del integrador: timeout, SSL y proxy

Tres enemigos clásicos que todo nuevo integrador enfrenta, con su diagnóstico exacto (verificado en la tanda):

1) TIMEOUT
   Sintoma: curl_errno 28 / exit 28. La peticion no termino en el limite.
   Causa:  red lenta, servidor lento, o --max-time demasiado bajo.
   Fix:    subir el timeout; medir primero con -w %{time_*} (cap 5).

2) SSL (certificado no confiable)
   Sintoma: curl: (60) SSL certificate problem.
   Causa:  entorno con MITM/proxy corporativo, o certificado autofirmado
           en tu laboratorio.
   Fix:    en PRODUCCION jamas -k; actualizar la CA del sistema.
           En laboratorio propio, -k con total conocimiento de riesgo.

3) PROXY
   Sintoma: no hay respuesta pero la pagina web anda; o curl: (7) connection
            refused al host publico.
   Causa:  la red exige proxy para salir.
   Fix:    configurar las variables de entorno http_proxy/https_proxy
           o la opcion -x/--proxy del cliente.
# Diagnostico muestra de los tres (cap 5 ya presento la caja):
curl -sS -o /dev/null -w "http=%{http_code} conn=%{time_connect}s "
     --connect-timeout 3 --max-time 8 "https://api.biblioteca.local/v1/libros"
# si muestra conn=0.000 y no responde -> problema de red/proxy/DNS
# si muere con (28) -> sube el timeout
# si muere con (60) -> revisa CA/certificado

17 · Integrador I: catálogo y autenticación

Aplicación ~24 min

La Parte V es el momento de la verdad: el proyecto integrador «Biblioteca online» que une las cinco partes del taller en una sola aplicación funcional. El capítulo 17 arma el primer tramo del sistema: autenticación (el login que otorga el token del cap 15) y catálogo (buscar libros en Open Library con la clase del cap 14 y dar de alta los que la biblioteca decide tener). Cada bloque reutiliza código que ya escribiste y verificaste.

  • Completar el ciclo de autenticación: login → token → Bearer.
  • Implementar el alta de libros desde Open Library normalizado.
  • Dar de alta un socio con su clave hasheada (bcrypt).
  • Probar el tramo completo por cURL.

El login: de la clave al Bearer

El handler que cierra el ciclo: valida credenciales con password_verify (cap 15), emite un token aleatorio y guarda solo su hash SHA-256 — exactamente la filosofía Sanctum de Laravel, que verás en el capítulo 20 (los tokens también se guardan hasheados con SHA-256):

<?php
// POST /v1/auth/login  { "usuario": "...", "clave": "..." }
final class LoginHandler
{
    public function __construct(private PDO $db) {}

    public function __invoke(array $in): array {
        $st = $this->db->prepare(
            'SELECT id, hash_clave FROM usuarios_app
             WHERE usuario = ? AND activo = 1'
        );
        $st->execute([$in['usuario'] ?? '']);
        $u  = $st->fetch();

        // Respuesta IDENTICA haya o no usuario (anti-enumeracion, cap 15)
        if (!$u || !password_verify($in['clave'] ?? '', $u['hash_clave'])) {
            throw new NoAutorizado('usuario o clave incorrectos');
        }

        $token  = bin2hex(random_bytes(32));       // solo se muestra una vez
        $db     = $this->db;
        $db->prepare(
            'INSERT INTO tokens (usuario_app_id, hash_token, creado)
             VALUES (?, SHA2(?, 256), NOW())'
        )->execute([$u['id'], $token]);

        return [
            'token' => $token,
            'tipo'  => 'Bearer',
            'expira'=> date('Y-m-d H:i'),          // aqui: 30 dias
        ];
    }
}

El token viaja en cada petición protegida, se valida con hash_equals contra su hash (cap 15) y se revoca borrando la fila. Con el Bearer ya puedes proteger el alta de libros y socios:

# 1) Obtener el token
TOKEN=$(curl -s -X POST https://api.biblioteca.local/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"usuario":"recepcion","clave":"********"}' | jq -r .token)

# 2) Usarlo en una ruta protegida
curl -s -H "Authorization: Bearer $TOKEN" \
  https://api.biblioteca.local/v1/libros

# 3) Sin token: 401 uniforme (problema del cap 8)
curl -s -w "\nHTTP %{http_code}\n" https://api.biblioteca.local/v1/libros

Alta de libros: Open Library al catálogo

El flujo estrella de la integración: el recepcionista busca un libro en Open Library (una sola llamada search.json, respetando el presupuesto de requests del cap 13), elige la obra y el sistema la persiste en tu tabla libros ya normalizada a tu contrato:

<?php
// GET  /v1/catalogo/buscar?q=teoria+del+caos  → lista Normalizada (cap 14)
final class CatalogoHandler
{
    public function __construct(
        private ClienteOpenLibrary $ol,
        private PDO $db,
    ) {}

    public function buscar(array $in): array {
        return $this->ol->buscarPorTitulo($in['q'] ?? '');
    }

    // POST /v1/libros  { olid, isbn, ejemplares }
    // Recibe el id de obra elegido; baja SOLO el detalle de edicion
    public function alta(array $in): array {
        $d = $this->ol->buscarPorIsbn($in['isbn']);   // 2.a llamada, justificada

        $st = $this->db->prepare(
            'INSERT INTO libros (titulo, autor, isbn, editorial, paginas,
                                 portada, olid, ejemplares, creado)
             VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())'
        );
        $st->execute([
            $d['titulo'], $d['autor'], $d['isbn'], $d['editorial'],
            $d['paginas'], $d['portada'], $in['olid'], $in['ejemplares'] ?? 1,
        ]);

        return ['id' => (int) $this->db->lastInsertId(), 'estado' => 'ALTA'];
    }
}

El alta es el lugar exacto donde el taller se vuelve uno solo: la URL de búsqueda verificada del capítulo 13, la normalización del 14, el prepared statement del 10, el token del 15 y el JSON de respuesta del 8. Ningún bloque es nuevo; la parte V es composición, no invención.

Alta de socios: una clave que nadie verá

La otra cara del catálogo: los socios. El registro aplica la validación doble de la casa (cliente y servidor) y guarda la clave con bcrypt — el hash autocontenido del capítulo 15:

<?php
// POST /v1/socios  { nombre, apellido, documento, email, clave }
final class SociosHandler
{
    public function __construct(private PDO $db) {}

    public function __invoke(array $in): array {
        // Validacion servidor (la doble nunca falta)
        if (!filter_var($in['email'] ?? '', FILTER_VALIDATE_EMAIL)) {
            throw new DatosInvalidos('email no valido');
        }

        $st = $this->db->prepare(
            'INSERT INTO socios (nombre, apellido, documento, email,
                                 hash_clave, creado)
             VALUES (?, ?, ?, ?, ?, NOW())'
        );
        $st->execute([
            trim($in['nombre'] ?? ''), trim($in['apellido'] ?? ''),
            $in['documento'] ?? '', $in['email'],
            password_hash($in['clave'] ?? '', PASSWORD_BCRYPT, ['cost' => 12]),
        ]);

        return ['id' => (int) $this->db->lastInsertId()];
    }
}

18 · Integrador II: préstamos, devoluciones y multas

Aplicación ~25 min

El corazón operativo de la biblioteca son sus préstamos. Este capítulo implementa las tres transacciones críticas del negocio — prestar, devolver y multar — resolviendo el problema de los conflictos de estado que el capítulo 7 diseñó en el papel. Cada operación es atómica (transacción), protege contra condiciones de carrera y devuelve los códigos correctos.

  • Agendar un préstamo atómico que rechaza un libro ya prestado.
  • Devolver y recalcular el estado del ejemplar.
  • Generar la multa por retraso con fechas deterministas.
  • Mecanizar la máquina de estados: qué transiciones son válidas.

Prestar: la transacción que no se deja robar

Prestar un ejemplar tiene una condición de carrera clásica: dos recepcionistas pidiendo el mismo libro a la vez. La solución es la transacción del capítulo 10 con un UPDATE ... WHERE estado = ? condicional: si la fila afectada es 0, el libro ya no está disponible; si es 1, lo agarramos nosotros:

<?php
// POST /v1/prestamos  { libro_id, socio_id }
final class PrestamoHandler
{
    public function __construct(private PDO $db) {}

    public function __invoke(array $in): array {
        $db = $this->db;
        $db->beginTransaction();
        try {
            // ATOMICIDAD: el UPDATE condicional es el candado
            $st = $db->prepare(
                'UPDATE libros SET estado = "PRESTADO" WHERE id = ? AND estado = "DISPONIBLE"'
            );
            $st->execute([$in['libro_id']]);
            if ($st->rowCount() !== 1) {
                throw new Conflicto('El ejemplar ya esta prestado o no existe');
            }

            $db->prepare(
                'INSERT INTO prestamos (libro_id, socio_id, fecha, estado)
                 VALUES (?, ?, NOW(), "ACTIVO")'
            )->execute([$in['libro_id'], $in['socio_id']]);

            $db->commit();
            return ['id' => (int) $db->lastInsertId(),
                    'estado' => 'PRESTADO',
                    'fecha'  => date('Y-m-d H:i')];
        } catch (Throwable $e) {
            $db->rollBack();
            throw $e;                       // 409 pasara arriba (cap 10)
        }
    }
}

El precio del patrón: rowCount() !== 1 decide. Si otro proceso ya tomó el libro, el UPDATE afecta 0 filas y respondemos 409 (conflicto de estado, capítulo 7). Ningún bloqueo manual, ninguna tabla auxiliar: la base garantiza la exclusividad.

Devolver y multar: dos caras de la misma fecha

La devolución cierra el préstamo; la multa nace si se excedió la fecha pactada. Con datos deterministas (15 días de préstamo en el dominio), el cálculo es verificable:

<?php
// POST /v1/prestamos/{id}/devolucion
final class DevolucionHandler
{
    public function __construct(private PDO $db) {}

    public function __invoke(int $id): array {
        $db = $this->db;
        $db->beginTransaction();
        try {
            $st = $db->prepare(
                'SELECT p.id, p.fecha, p.estado, p.libro_id, l.titulo
                 FROM prestamos p JOIN libros l ON l.id = p.libro_id
                 WHERE p.id = ? FOR UPDATE'
            );
            $st->execute([$id]);
            $p = $st->fetch();
            if (!$p || $p['estado'] !== 'ACTIVO') {
                throw new Conflicto('prestamo no valido para devolucion');
            }

            // Dias de retraso sobre el estandar de 15 dias (determinista)
            $dias = (int) ((time() - strtotime($p['fecha'])) / 86400) - 15;

            $db->prepare(
                'UPDATE libros SET estado = "DISPONIBLE" WHERE id = ?'
            )->execute([$p['libro_id']]);

            $db->prepare(
                'UPDATE prestamos SET estado = "DEVUELTO", devuelto = NOW() WHERE id = ?'
            )->execute([$id]);

            if ($dias > 0) {
                // Tarifa fija: S/ 1.00 por dia de retraso
                $db->prepare(
                    'INSERT INTO multas (prestamo_id, monto, causada) VALUES (?, ?, NOW())'
                )->execute([$id, $dias * 1.00]);
            }

            $db->commit();
            return ['id' => $id, 'estado' => 'DEVUELTO',
                    'multa' => $dias > 0 ? $dias * 1.00 : null];
        } catch (Throwable $e) {
            $db->rollBack();
            throw $e;
        }
    }
}

FOR UPDATE hace al préstamo dueño de su fila durante la transacción: un segundo recepcionista que intente la misma devolución esperará y luego recibirá el 409 honesto. La multa (S/ 1.00 por día, valor de ejemplo) nace dentro de la misma transacción — o se devuelve, o se multa; nunca intermedio.

La máquina de estados, ahora en código

El documento de diseño del capítulo 7 cobra vida. Cada transición inválida debe responder 409 antes de tocar la base. Es la validación doble aplicada al estado, no a los datos:

DesdeAcción válidaResultadoSi no…
DISPONIBLEprestarPRESTADO409
PRESTADOdevolverDEVUELTO (+ multa si retraso)409
DEVUELTO(estado terminal)409
<?php
// Guardia unica y reutilizable: el estado manda, el handler obedece
$permitidas = [
    'DISPONIBLE' => ['prestar'],
    'PRESTADO'   => ['devolver'],
    'DEVUELTO'   => [],
];

// ...en el router del cap 9, antes de rutear la accion:
if (!in_array($accion, $permitidas[$estadoActual] ?? [], true)) {
    throw new Conflicto('No se puede "' . $accion . '" sobre un ejemplar '
        . $estadoActual);
}

19 · Integrador III: auditoría y reportes

Aplicación ~24 min

El tercer tramo del proyecto integrador convierte la biblioteca en un sistema que da cuentas: la auditoría dual registra cada operación (usuario de negocio + usuario de base), y los reportes — préstamos del mes, libros más pedidos, socios deudores — convierten la data en decisiones. Aquí se encuentra todo lo aprendido: triggers, agregados, JOINs y latencia, en una sola pieza.

  • Automatizar la auditoría dual con un trigger parametrizado.
  • Construir el reporte de préstamos del mes (cap 12, en serio).
  • Reportes del dominio: top libros y socios deudores.
  • Paginación controlada y EXPLAIN para no estrenar lentitud.

Auditoría dual: el trigger que todo lo cuenta

El capítulo 16 registró a mano. La forma mantenible es un trigger que escribe ambos rastros sin depender del programador: el usuario de aplicación llega como parámetro de sesión (set_config en PostgreSQL / variable en la sesión) y el usuario de base con CURRENT_USER:

-- Trigger de auditoria dual sobre la operacion estrella: prestamos
CREATE OR REPLACE FUNCTION audit_prestamo() RETURNS trigger AS $$
BEGIN
    INSERT INTO auditoria
        (tabla_afectada, operacion, usuario_app, usuario_bd, registro_id,
         fecha_hora, datos_nuevos)
    VALUES
        (TG_TABLE_NAME, TG_OP,
         current_setting('app.usuario', true),   -- fijado por la sesion
         current_user,                            -- el rol de la conexion
         COALESCE(NEW.id, OLD.id),
         NOW(),
         row_to_json(NEW)::text);
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_audit_prestamo
    AFTER INSERT OR UPDATE OR DELETE ON prestamos
    FOR EACH ROW EXECUTE FUNCTION audit_prestamo();

Y en el front controller solo hace falta una línea por petición (el equivalente MySQL SET @app_usuario que viste en la serie de bases de datos):

<?php
// En el front controller (cap 9): la sesion dice QUIEN, el DB dice DONDE
$this->db->exec("SET app.usuario = '"
    . pg_escape_string($sesion['usuario'] ?? 'anonimo') . "'");
// y MySQL:  $db->exec("SET @app_usuario = 'recepcion'");

Resultado: la auditoría no se olvida, no se salta y no depende de que el programador recuerde. El trigger es la memoria del sistema — la misma idea que agendó la casa en la serie de bases de datos (auditoría dual obligatoria).

Préstamos del mes: el reporte en serio

El ejercicio del capítulo 12 ahora es un endpoint de reportes con JOIN, agregado y orden. El presupuesto de queries se respeta: una sola consulta que modeló la data en SQL y no en PHP:

-- GET /v1/reportes/prestamos-mes?mes=2026-07
SELECT DATE_TRUNC('month', p.fecha) AS mes,
       COUNT(*)                      AS total_prestamos,
       COUNT(DISTINCT p.socio_id)   AS socios_distintos
FROM   prestamos p
WHERE  p.fecha >= DATE '2026-07-01'
  AND  p.fecha <  DATE '2026-08-01'       -- rango semi-abierto (cap 7)
GROUP  BY 1
ORDER  BY total_prestamos DESC;
Respuesta del API (problem details, cap 8):
{ "mes": "2026-07", "total_prestamos": 4,
  "socios_distintos": 3 }

Detalles que delatan oficio: el rango >= / < evita el clásico error del borde de mes (cap 7), y el GROUP BY lleva la misma columna de la proyección (compatible con los motores que no aceptan GROUP BY implícitos).

Top libros y socios deudores

Dos reportes complementarios que tu jefa preguntará apenas vea la API andando. Ambos con JOIN y agregado, ambos deterministas:

-- Top 5 libros mas prestados (todas las fechas)
SELECT   l.titulo, COUNT(*) AS veces
FROM     prestamos p
JOIN     libros l ON l.id = p.libro_id
GROUP BY l.id, l.titulo
ORDER BY veces DESC
LIMIT    5;

-- Socios con multas impagas (el patron deudores de la serie)
SELECT   s.documento, s.nombre || ' ' || s.apellido AS socio,
         SUM(m.monto) AS deuda
FROM     socios s
JOIN     prestamos p ON p.socio_id = s.id
JOIN     multas m    ON m.prestamo_id = p.id AND m.pagada = false
GROUP BY s.id, s.documento, s.nombre, s.apellido
HAVING   SUM(m.monto) > 0
ORDER BY deuda DESC;

Paginación y EXPLAIN: la velocidad no es magia

Cuando los reportes crezcan, LIMIT … OFFSET con páginas estables, y antes de entregar el endpoint, el EXPLAIN ANALYZE para saber si necesita índice (cap 16, la latencia no se adivina):

-- Pagina 3 de 10 (filtros fijos, offsets crecientes)
SELECT   l.titulo, COUNT(*) AS veces
FROM     prestamos p
JOIN     libros l ON l.id = p.libro_id
GROUP BY l.id, l.titulo
ORDER BY veces DESC
LIMIT    25 OFFSET 50;

-- Y por que es rapido (o como hacerlo rapido):
EXPLAIN ANALYZE
SELECT   l.titulo, COUNT(*) AS veces
FROM     prestamos p
JOIN     libros l ON l.id = p.libro_id
GROUP BY l.id, l.titulo
ORDER BY veces DESC LIMIT 5;
-- "Index Scan using prestamos_pkey ..."  = usa el indice de la FK

20 · Graduación: checklist REST y puente a Laravel

Cierre ~24 min

«Biblioteca online» está completo y en el aire: autenticación, catálogo real (Open Library), préstamos atómicos, multas, auditoría dual y reportes. Este capítulo pone el moño: el checklist REST de la casa —el examen final que aplicarás a cualquier API—, el puente a Laravel con Sanctum y API Resources (la industria, verificada en la doc oficial), y la despedida con las armas del taller intactas.

  • Verificar tu propia API contra el checklist REST de la casa.
  • Entender cómo Laravel reproduce lo que construiste a mano.
  • Salir con el mapa completo del taller en la cabeza.

El checklist REST de la casa (tu examen final)

Todo lo aprendido en 19 capítulos, condensado en una lista verificable. Corre tu proyecto contra ella:

#CriterioDónde lo viste
1Recursos como sustantivos plurales, no verbos en la URLcap 6
2Métodos semánticos unicamente: GET/POST/PATCH/DELETEcap 3
3Códigos de estado correctos: 200/201/204/400/404/409/422cap 3 y 7
4Errores con formato uniforme (Problem Details)cap 8
5Sin estado en la aplicación; el estado lo guarda el recursocap 2
6Contrato documentado y probado antes de programarcap 6 y 12
7Autenticación por token Bearer; jamás en la URLcap 15
8Validación doble (cliente + servidor) y prepared statementscap 8 y 10
9Rate limiting con 429 + Retry-Aftercap 15
10Presupuesto de queries y respuestas paginadascap 12 y 19
11Auditoría dual de operaciones sensiblescap 16 y 19
12Ningún secreto en logs, respuestas ni repositorioscap 1 y 15
# El examen en una linea por criterio (tu suite de humo del cap 16 extendida)
curl -sS -o /dev/null -w "lista libros       %{http_code}\n"  \
     -H "Authorization: Bearer $TOKEN" https://api.biblioteca.local/v1/libros
curl -sS -o /dev/null -w "libro inexistente  %{http_code}\n"  \
     -H "Authorization: Bearer $TOKEN" https://api.biblioteca.local/v1/libros/999999
curl -sS -o /dev/null -w "sin token          %{http_code}\n"  \
     https://api.biblioteca.local/v1/libros

El puente a Laravel: lo tuyo, en industrial

Laravel (verificado hoy en su documentación oficial 13.x) hace industrial lo que aquí fue artesanal, con las mismas ideas:

-- Lo que construiste a mano       -- Laravel (oficial)
front controller + router          Route::apiResource('libros', ...)
handler + proceso de la peticion   Controllers + middlewares
ClienteHttp con cURL               HTTP Client (facade Http)
tokens hasheados SHA-256           Sanctum: createToken + plainTextToken
normalizar JSON ajeno              Eloquent API Resources
prepared statements                Eloquent + Query Builder
Problema de validacion doble       FormRequest validate() + reglas
rate limit 429 + Retry-After       RateLimiter / throttle middleware

Los conceptos no cambian: cambia el envoltorio. Por eso este taller fue tan a fondo con la materia prima — cuando veas Sanctum, reconocerás la filosofía exacta del token del capítulo 15 (createToken devuelve el token en plainTextToken y la base guarda el hash SHA-256; se revoca con tokens()->delete()).

<?php
// Laravel 13 · la autenticacion del cap 17 en su forma industrial
Route::post('/sanctum/token', function (Request $r) {
    $r->validate(['email' => 'required|email', 'password' => 'required']);

    $u = User::where('email', $r->email)->first();
    if (! $u || ! Hash::check($r->password, $u->password)) {
        throw ValidationException::withMessages(
            ['email' => ['Las credenciales no coinciden.']]);
    }
    return ['token' => $u->createToken('kiosko-recepcion')
                        ->plainTextToken];   // solo se ve UNA vez
});

// Rutas protegidas: auth:sanctum  (el guardia Bearer de tu router)
Route::get('/libros', fn () => LibroResource::collection(Libro::paginate()))
    ->middleware('auth:sanctum');            // y la paginacion del cap 19

El mapa del taller

PARTE I    cURL (CLI + HTTP) y WebService vs API REST        caps 1-4
PARTE II   Diseño y contrato REST de la biblioteca            caps 5-8
PARTE III  La API en PHP 8.5: router, dominio, ext-curl       caps 9-12
PARTE IV   Open Library real, seguridad y produccion          caps 13-16
PARTE V    Integrador «Biblioteca online» + graduacion        caps 17-20