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.
1 · Qué es cURL y qué bondades obtendrás
Básico ~14 minCuando 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:
| Proyecto | Qué es | Cómo lo usas tú |
|---|---|---|
| cURL (CLI) | programa de terminal curl | probando y depurando APIs, automatizando tareas |
| libcurl | librería C que realiza las transferencias | a 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 --versionLa 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.jsonEn 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.jsonLas 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:
| Bondad | Qué te permite hacer |
|---|---|
| Probar APIs sin navegador | POST/PUT/DELETE y cabeceras; el navegador solo hace GET cómodo |
| Ver el intercambio crudo | con -v ves petición y respuesta exactas (cap 2) |
| Automatizar | scripts de integración, sondeos, respaldos, notificaciones |
| Verificar salud de servidores | solo cabe esperar un código: %{http_code} (cap 5) |
| Depurar timeouts/SSL/proxy | control total del transporte, imposible de aislar en un navegador |
| Transferir de todo | subir/descargar archivos, cookie jars, auth básica y por token |
| Mismo motor que tu código | lo que pruebas en consola, libcurl lo repite en PHP (cap 11) |
-L
para seguir redirecciones, pero entiende que eso lo decides tú, no la
herramienta.curl --version. Una petición GET a una API devuelve JSON,
no HTML. Bondad central del curso: lo que pruebas en consola se repite
idéntico dentro de tu código PHP.2 · HTTP que cURL habla: petición y respuesta
Básico ~16 minPara 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 -ve-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)| Pieza | Ejemplo | Para qué sirve |
|---|---|---|
scheme | https | protocolo y cifrado (TLS sobre HTTP) |
host | openlibrary.org | qué servidor responde |
path | /search.json | qué recurso o endpoint |
query | ?q=...&limit=1 | parámetros/opciones de la consulta |
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.jsonLee 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| Elemento de la respuesta | Qué significa |
|---|---|
HTTP/1.1 200 OK | status-line: 200 = la petición se cumplió |
Server: nginx | qué servidor web respondió |
Content-Type: application/json | el cuerpo es JSON, no HTML |
Transfer-Encoding: chunked | el cuerpo llega por fragmentos dinámicos |
| el JSON final | cuerpo: 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.
401 Unauthorized con cabecera
WWW-Authenticate dice más de un token que diez párrafos de
documentación. Depurar HTTP es leer el diálogo, no solo la respuesta.-v
muestra todo (envío >, recepción <);
-i muestra cabeceras de respuesta + cuerpo. HTTP carece de
estado: cada petición decide por sí sola.3 · Métodos, códigos y JSON con cURL
Básico ~16 minHTTP 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 (
-Hy-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étodo | Intención | Seguro | Idempotente |
|---|---|---|---|
GET | leer un recurso | sí | sí |
POST | crear un recurso (o acción compleja) | no | no |
PUT | reemplazar un recurso completo | no | sí |
PATCH | modificar parcialmente un recurso | no | no* |
DELETE | eliminar un recurso | no | sí |
*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ódigo | Significado | Cuándo lo ves con cURL |
|---|---|---|
200 OK | éxito general (GET, PUT, PATCH) | lectura o modificación correcta |
201 Created | recurso creado | POST exitoso |
204 No Content | éxito sin cuerpo | DELETE exitoso |
400 Bad Request | petición mal formada | JSON inválido, faltan campos |
401 Unauthorized | sin autenticación válida | falta/falla el token |
403 Forbidden | autenticado pero sin permiso | rol insuficiente |
404 Not Found | recurso inexistente | ruta o id erróneo |
409 Conflict | estado impide la operación | prestamo de un libro ya prestado |
422 Unprocessable | válido en forma, inválido en reglas de negocio | email mal formado en el negocio |
500 Internal | error del servidor | excepción sin manejar |
429 Too Many | rate limit excedido | demasiadas peticiones |
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.jsonLa 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/1La ú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.
201 sabe que debe
consultar la Location; un 409 le dice que pregunte
de nuevo al usuario; un 422 que corrija la forma. Si tu API
siempre devuelve 200, estás regalando la mitad del protocolo.-H "Content-Type: application/json" +
-d '{...}' (o --data @archivo). El cuerpo largo se
guarda en archivo. Método + código + JSON = el triángulo de toda REST API.4 · WebService vs API REST: la diferenciación
Intermedio ~18 minSi 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ón | Qué exige |
|---|---|
| Recursos | los datos son recursos con URL única: /api/socios/42 |
| Métodos HTTP | GET/POST/PUT/PATCH/DELETE aplican la intención (cap 3) |
| Representaciones | el recurso se entrega en un formato negociable (JSON hoy) |
| Sin estado | cada 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/0140328721En 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:
| Criterio | Web Service (SOAP clásico) | API REST |
|---|---|---|
| Naturaleza | tecnología/estándares concretos (SOAP, WSDL, XML) | estilo de arquitectura sobre HTTP (sin estándar rígido) |
| Contrato | WSDL: contrato formal y rígido, cliente generado | recurso + método + formato; contrato ligero (a veces OpenAPI) |
| Formato | XML obligatorio | negociable; JSON por defecto |
| Transporte | HTTP/SMTP/otro mediante SOAP | HTTP (y HTTPS) sobre todo |
| Flexibilidad | baja: cambio en WSDL = regenerar clientes | alta: evoluciona con versionado y media types |
| Madurez actual | empresarial, banca, legacy | web, móvil, SaaS, todo lo nuevo |
Y la síntesis que se repite en la conversación técnica:
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 + JSON5 · cURL CLI a fondo: opciones, scripting y códigos de salida
Esencial ~20 minYa 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
-wy 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:
| Variable | Qué 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ódigo | Significado |
|---|---|
0 | la transferencia se completó con éxito |
6 | no se pudo resolver el host (URL mal escrita, sin DNS) |
7 | fallo al conectarse al host |
22 | el servidor devolvió un código HTTP de error (4xx/5xx) y usaste -f |
23 | error de escritura (p. ej. disco lleno con -o) |
28 | se 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=$?" # 22Timeouts: 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/librosRedirecciones, 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--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.-sS + -f + --max-time, y
comprueba $? antes de seguir. Y si ves -k
en una petición a producción, es una alerta: nada de saltar la
verificación TLS fuera de laboratorios.-w con
%{http_code}, %{time_total} y compañía
modela la salida. Los exit codes son el idioma de los scripts:
0 éxito, 6 DNS, 7 conexión, 22 error HTTP (con -f),
23 escritura, 28 timeout. --max-time y
--connect-timeout evitan scripts colgados.
-b/-c cookies, -u auth básica,
-k solo pruebas, -L/-I/-G
completan la caja de herramientas.6 · Diseñando la API de la biblioteca: recursos, convenciones y contrato
Esencial ~22 minEl 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:
| Tabla | Recurso | Rol en la API |
|---|---|---|
socios | /v1/socios | recurso principal (personas registradas) |
libros | /v1/libros | obra: isbn, título, género, autor |
ejemplares | /v1/libros/{id}/ejemplares | copias físicas; subrecurso anidado |
prestamos | /v1/prestamos | negocio central: estados en capítulo 7 |
reservas | /v1/reservas | apartado de un ejemplar aún no entregado |
multas | /v1/multas | deuda de un socio (nace de un vencido) |
usuarios_app | /v1/usuarios | interno: autenticación (no se expone a la calle) |
auditoria | — | nunca 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/librosInformaticaY 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étodo | Endpoint | Qué hace | Éxito |
|---|---|---|---|
GET | /v1/libros | lista con filtros ?q=&genero=&page= | 200 |
GET | /v1/libros/{id} | detalle de una obra | 200 |
POST | /v1/libros | registra una nueva obra | 201 |
GET | /v1/libros/{id}/ejemplares | copias físicas del libro | 200 |
GET | /v1/socios | listado con ?activo= | 200 |
GET | /v1/socios/{id}/prestamos | préstamos de un socio | 200 |
POST | /v1/prestamos | crea un préstamo (reservado) | 201 |
PATCH | /v1/prestamos/{id} | cambia estado (devolver, cancelar) | 200 |
GET | /v1/multas | multas 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/0140328721El 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.
/v1) y se comparte con el equipo frontend ANTES de
escribir el primer handler. Este taller cumple esa regla:
los capítulos 9 a 12 implementan exactamente esta tabla de
endpoints.usuarios_app y auditoria son
internos (no se exponen). Jerarquía de 2 niveles máximo, filtros en
query string, kebab-case en URLs y camelCase en JSON. Versionado
/v1 en la URL. HATEOAS ligero (bloque
links). La API cornflake es RPC disfrazado: URL y
método fijos + acción en el cuerpo.7 · Estados del préstamo y códigos HTTP correctos
Esencial ~20 minEl 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:
| Estado | Significado | Transiciones válidas |
|---|---|---|
RESERVADO | apartado, aún no entregado | → ACTIVO, CANCELADO |
ACTIVO | entregado, fecha límite futura | → DEVUELTO, VENCIDO |
VENCIDO | fecha límite pasada (genera multa) | → DEVUELTO |
DEVUELTO | cerrado con devolución | — |
CANCELADO | apartado 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 | Éxito | Errores típicos |
|---|---|---|
GET lista | 200 con arreglo | 400 (página inválida), 500 |
GET detalle | 200 con el recurso | 404 (no existe), 400 |
POST crear | 201 + cabecera Location | 400, 404, 409, 422 |
PATCH cambiar estado | 200 con el nuevo estado | 400, 404, 409, 422 |
DELETE logico | 204 sin cuerpo | 404, 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" }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.201 solo cuando nace
un recurso (y con Location), 204 solo en
operaciones que no devuelven cuerpo, 409 para conflicto
de estado y 422 para validación semántica. Si dudas,
este cuadro es tu brújula.8 · Validación, errores y buenas prácticas REST
Esencial ~22 minYa 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 reglaLa 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áctica | Qué aporta | Cómo se ve |
|---|---|---|
| Idempotencia | GET/PUT/DELETE seguros de repetir (RFC 9110) | mismo resultado sin efectos dobles |
| Paginación | nunca listas infinitas | ?page=2&limit=25 + bloque meta |
| Rate limiting | protege el servicio de abuso | 429 + cabecera Retry-After |
| Caché | respuestas rápidas y menos carga | ETag / If-None-Match → 304 |
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: 60Fí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 422type, title, status,
detail, instance, con
application/problem+json. Paginación con
meta, rate limiting con 429 +
Retry-After, caché con ETag →
304. Nunca detalles internos en el 500. Con esto,
el diseño de la API está cerrado: en el capítulo 9 empezamos a
construirla en PHP 8.5.9 · Front controller y router artesanal
Esencial ~22 minEl 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)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.$_SERVER). Desde que el router entrega el handler,
todo el camino habla en lenguaje de dominio: recursos, IDs y
excepciones. Esa frontera es tu cordura cuando la API crezca.index.php (mod_rewrite en Apache, try_files
en nginx). El router lee método + ruta de $_SERVER y
el registro de rutas es un arreglo del contrato. Los comodines
{id} se convierten en regex con números. El 404
uniforme ya es contrato verificable antes que el negocio: en el
capítulo 10 los handlers empiezan a responder con datos.10 · Handlers y capa de dominio: el negocio dentro de la API
Esencial ~24 minEl 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 dominioNada 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:
| Endpoint | Consultas SQL | Requests del cliente |
|---|---|---|
GET /v1/libros (con JOIN) | 1 | 1 |
GET /v1/libros/{id} | 1 | 1 |
GET /v1/libros/{id}/ejemplares | 2 | 1 |
| Anti-patrón N+1 (listar con subuso) | 1 + N ❌ | 1 |
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.
ServicioPrestamos, sin HTTP), handler decide
(mapea excepciones a códigos), respuesta pinta (JSON con tildes,
problem details). Listados con LEFT JOIN +
GROUP BY = 1 query fija, sin N+1 nunca.
Excepciones de dominio con nombres del negocio: cada una tiene
UNA traducción HTTP (409, 422, 404). El contrato del capítulo 7
ahora pasa con PHP real.11 · cURL dentro de PHP: la extensión
Esencial ~24 minEsto 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
ClienteHttpreutilizable.
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.1El 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 sí 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 cURL | Constante PHP |
|---|---|
-s (silencioso) | CURLOPT_RETURNTRANSFER => true |
--connect-timeout 2.5 | CURLOPT_CONNECTTIMEOUT => 2.5 |
--max-time 10 | CURLOPT_TIMEOUT => 10 |
-w "%{http_code}" | curl_getinfo($ch, CURLINFO_HTTP_CODE) |
-H "Content-Type: ..." | CURLOPT_HTTPHEADER => [...] |
-d '{}' | CURLOPT_POSTFIELDS => '{}' |
-u user:pass | CURLOPT_USERPWD => 'user:pass' |
-b / -c cookie | CURLOPT_COOKIE / CURLOPT_COOKIEJAR |
-k | CURLOPT_SSL_VERIFYPEER => false (solo laboratorio) |
-L | CURLOPT_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'];CURLOPT_RETURNTRANSFER en true y un
CURLOPT_TIMEOUT explícito — el valor por defecto es 0,
que significa "esperar para siempre" (documentado en php.net).
Y el curl_exec() === false habla de red, no de HTTP:
distinguir ambos es tu primera línea de defensa en producción.POSTFIELDS + Content-Type
correcto. exec() === false = error de red; el 500 llega
como string. ClienteHttp envuelve todo y se reutilizará
en los caps 14-19. Timeouts SIEMPRE explícitos (default 0 = colgado
para siempre).12 · Ejercicio de parte: «Préstamos del mes»
Proyecto ~25 minCierra 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
ClienteHttpy 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étodo | Endpoint | Éxito | Error |
|---|---|---|---|
GET | /v1/reportes/prestamos-del-mes?mes=2026-07 | 200 con arreglo por día | 400 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 400El 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-07Fí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":
| Lado | Qué gasta | Conteo |
|---|---|---|
| Cliente PHP | 1 petición cURL al reporte | 1 request HTTP |
| API (handler) | 1 SELECT agregado con GROUP BY | 1 query SQL |
| Si hubiera N+1 | listar días y re-consultar por cada día | 1 + 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.
GROUP BY y rango = en el WHERE (1 query).
Validación del parámetro mes en el servidor
→ 400. Cliente de consola con ClienteHttp +
json_decode + printf.
Presupuesto: 1 request + 1 query, sin N+1. Así cerró la Parte III;
la Parte IV sale a Internet real (Open Library) con el mismo
cliente.13 · Open Library como laboratorio
Aplicación ~24 minHasta 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.jsony 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):
| Endpoint | Qué devuelve | Parámetros clave |
|---|---|---|
GET /search.json | obras que coinciden (trabajo + ediciones) | q, fields, limit, page, sort |
GET /works/{OLID}.json | una obra por su identificador | — |
GET /isbn/{isbn}.json | la edición de un ISBN | — |
GET /search/authors.json | autores | q |
GET /covers.../b/{id}-M.jpg | portada (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.
curl_init contra una API ajena, haz el laboratorio:
prueba los endpoints por CLI, lee la documentación oficial y
toma nota de límites y campos. Eso es regla 6 en acción: nada se
afirma sin la fuente delante. Open Library no cobra ni pide key,
pero sí exige respeto (User-Agent + caché).search.json (busca obras, respuesta
start/num_found/docs), /works/x.json y
/isbn/x.json (detalle por identificador), Covers API
(portadas S/M/L por isbn o cover_i). Límites verificados: 1 req/s
anónimo, 3 req/s identificado. Cachea, usa User-Agent + email, y
prefiere una sola búsqueda a cientos de peticiones. Con el mapa
hecho, el capítulo 14 lo envuelve en PHP.14 · Consumir Open Library real desde PHP
Aplicación ~25 minEl 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.jsony 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 Library | Campo interno (data) |
|---|---|
title | titulo |
author_name[0] | autor |
first_publish_year | anio |
cover_i | portada (URL ya armada) |
key | olid (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.
timeout
+ normalización con fallbacks. Y jamás dejes que un error de la API
ajena te tumbe la página: captura, registra (cap 16) y responde con
un estado vacío o un mensaje amable. La calle no perdona.ClienteOpenLibrary
por encima de ClienteHttp: URLs verificadas, User-Agent
fijo, rawurlencode en la query. Normalización con
?? por cada campo (traducción OL → tu contrato).
Búsqueda por título (/search.json) + detalle por ISBN
(/isbn/x.json) + portadas. Fallos tipificados: 429 →
LimiteExcedido, 5xx → ServicioNoDisponible, 404 → NoEncontrado;
el catch general registra y sigue. Ahora sí: el próximo capítulo
blinda tu propia API (seguridad).15 · Seguridad en la API: tokens, claves y CORS
Crítico ~26 minUna 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 conpassword_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_claveLa 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 401Rate 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: 86400Si 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;
}password_hash/password_verify
(bcrypt, contra timing attacks) para claves, hash_equals
para tokens, rate limiting con 429 + Retry-After, CORS con lista
blanca y preflight respondido en el router. Y una regla de la casa
de oro: el error_log jamás registra claves ni tokens
(solo hashes y IDs).password_hash (hash autocontenido: algoritmo+sal+costo,
columna 255 bytes) y password_verify en la capa de
aplicación, jamás en SQL. Tokens: random_bytes para
emitir, hash SHA-256 para guardar, hash_equals para
comparar, Bearer en cabecera, nunca en URL. Rate limiting: ventana
+ 429 + Retry-After. Sanitización: prepared statements + validación
de tipos. CORS: lista blanca + preflight OPTIONS 204. Nada de
secretos en logs.16 · Producción: logs, métricas y pruebas de humo
Aplicación ~25 minYa 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/certificadomicrotime en servidor y
curl_getinfo (total_time, connect_time, TTFB) en
cliente. Suite de humo: -sS + -f +
-o /dev/null + -w, con set -e.
Puzzle resuelto: 28=timeout, 60=SSL, 7=proxy/red, 22=error HTTP.
Con esto, la API está lista para el proyecto integrador de la
Parte V (caps 17-20).17 · Integrador I: catálogo y autenticación
Aplicación ~24 minLa 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/librosAlta 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()];
}
}password_verify
+ respuesta idéntica anti-enumeración + token
random_bytes guardado como hash SHA-256 (una sola
muestra al cliente). Catálogo: buscar en Open Library con una sola
petición y normalizar; alta solo con el detalle de ISBN elegido.
Socios: validación servidor + bcrypt. Ya tienes el arranque del
sistema: siguiente parada, préstamos, devoluciones y multas del
capítulo 18.18 · Integrador II: préstamos, devoluciones y multas
Aplicación ~25 minEl 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:
| Desde | Acción válida | Resultado | Si no… |
|---|---|---|---|
DISPONIBLE | prestar | PRESTADO | 409 |
PRESTADO | devolver | DEVUELTO (+ 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);
}beginTransaction, la condición de seguridad
como UPDATE condicional o FOR UPDATE,
decidir con rowCount(), y en el catch
hacer rollBack y relanzar. Nada de "primero
verifico, luego actualizo": la validación y la escritura viven en
una sola instrucción atómica. Eso es lo que los
errores de concurrencia no perdonan.estado = "PRESTADO" WHERE ... "DISPONIBLE", 0 filas →
409, 1 fila → adelante. Devolver: FOR UPDATE + recalcular
y liberar + multa dentro de la misma transacción. Multa: días de
retraso deterministas × tarifa. Máquina de estados en código:
tabla de transiciones única y guardia 409 antes de
escribir. El catálogo y el préstamo ya dialogan: falta auditar y
medir (cap 19).19 · Integrador III: auditoría y reportes
Aplicación ~24 minEl 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 FKEXPLAIN antes de estrenar. La
auditoría dual se delega al trigger (capa de base), jamás al
practicante con memoria corta.current_setting('app.usuario') / @app_usuario
+ CURRENT_USER, disparado en AFTER de prestamos.
Reportes: préstamos del mes (DATE_TRUNC + COUNT + GROUP BY,
rango >=/<), top libros, socios deudores
(HAVING sobre agregado). Desempeño: LIMIT/OFFSET + EXPLAIN.
Con esto «Biblioteca online» es funcional, auditable y medible —
graduación en el capítulo 20.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:
| # | Criterio | Dónde lo viste |
|---|---|---|
| 1 | Recursos como sustantivos plurales, no verbos en la URL | cap 6 |
| 2 | Métodos semánticos unicamente: GET/POST/PATCH/DELETE | cap 3 |
| 3 | Códigos de estado correctos: 200/201/204/400/404/409/422 | cap 3 y 7 |
| 4 | Errores con formato uniforme (Problem Details) | cap 8 |
| 5 | Sin estado en la aplicación; el estado lo guarda el recurso | cap 2 |
| 6 | Contrato documentado y probado antes de programar | cap 6 y 12 |
| 7 | Autenticación por token Bearer; jamás en la URL | cap 15 |
| 8 | Validación doble (cliente + servidor) y prepared statements | cap 8 y 10 |
| 9 | Rate limiting con 429 + Retry-After | cap 15 |
| 10 | Presupuesto de queries y respuestas paginadas | cap 12 y 19 |
| 11 | Auditoría dual de operaciones sensibles | cap 16 y 19 |
| 12 | Ningún secreto en logs, respuestas ni repositorios | cap 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/librosEl 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 middlewareLos 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 19El 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-20Route::apiResource, Sanctum (hash SHA-256 de tokens,
abilities, revocación), API Resources y middleware
auth:sanctum — la misma lógica que tu API artesanal.
Mapa de 5 partes, 20 capítulos. Has construido, consumido y
asegurado una API REST real en PHP 8.5. ¡Egresado del taller!
La serie continúa: PHP · Laravel 12/13 a fondo te espera.