Módulo 17: APIs REST y JSON
Objetivos del módulo
- Entender qué es una API y los principios de REST: recursos, verbos, URIs y ausencia de estado
- Conocer los códigos de estado HTTP más usados y cuándo devolver cada uno
- Trabajar con JSON en PHP con
json_encode()yjson_decode()de forma segura - Construir una API REST con PHP puro: front controller, enrutado, lectura del cuerpo y respuestas JSON
- Implementar el CRUD de un recurso
/api/v1/tareascontra una base de datos con PDO - Devolver errores con un formato consistente basado en RFC 9457 (Problem Details)
- Configurar CORS y proteger la API con un token Bearer
- Probar la API con curl, Postman/Thunder Client y ficheros
.http - Consumir APIs desde PHP con
file_get_contents(), cURL y Guzzle - Consumir tu API desde el navegador con
fetchen JavaScript - Aplicar buenas prácticas y versionado
1. ¿Qué es una API?
📘 Concepto: Una API (Application Programming Interface) es un “contrato” que define cómo un programa puede pedirle cosas a otro. Una API web es un conjunto de URLs a las que otro programa (una app móvil, un frontend en JavaScript, otro servidor) hace peticiones HTTP, y que responden con datos (normalmente en JSON) en lugar de con páginas HTML.
Analogía: en un restaurante, tú (el cliente) no entras en la cocina. Le pides al camarero (la API) un plato de la carta (los recursos disponibles) siguiendo unas normas (el formato del pedido). El camarero te trae el plato (la respuesta) o te dice que no queda (un código de error). No necesitas saber cómo se cocina.
| Web tradicional | API | |
|---|---|---|
| Responde con | HTML para que lo vea una persona | Datos (JSON) para que los use un programa |
| Quién la consume | El navegador directamente | Apps móviles, frontends (React, Vue), otros servidores |
| Errores | Una página “Ups, algo ha fallado” | Un código HTTP y un JSON describiendo el problema |
| Ejemplo | GET /productos.php → página con tabla | GET /api/productos → [{"id":1,"nombre":"Teclado"}] |
Gracias a las APIs, un mismo backend en PHP puede dar servicio a la web, a la app de Android, a la de iOS y a las integraciones con otras empresas.
2. Principios de REST
📘 Concepto: REST (Representational State Transfer) es un estilo de arquitectura para diseñar APIs sobre HTTP. No es un protocolo ni una librería, sino un conjunto de convenciones que hacen que las APIs sean predecibles.
Recursos y URIs
En REST todo gira en torno a recursos: las “cosas” de tu aplicación (tareas, usuarios, pedidos). Cada recurso se identifica con una URI formada por sustantivos en plural, nunca verbos:
| ✅ Bien | ❌ Mal |
|---|---|
/api/v1/tareas | /api/v1/obtenerTareas |
/api/v1/tareas/42 | /api/v1/tarea.php?id=42&accion=ver |
/api/v1/usuarios/7/tareas | /api/v1/tareasDelUsuario?u=7 |
Los verbos HTTP
La acción no va en la URL, sino en el método HTTP:
| Método | Acción | Ejemplo | Idempotente |
|---|---|---|---|
GET | Leer | GET /tareas (lista), GET /tareas/42 (una) | Sí |
POST | Crear | POST /tareas con el JSON de la nueva tarea | No |
PUT | Reemplazar completo | PUT /tareas/42 con todos los campos | Sí |
PATCH | Modificar en parte | PATCH /tareas/42 con {"completada": true} | No necesariamente |
DELETE | Borrar | DELETE /tareas/42 | Sí |
📘 Concepto: Una operación es idempotente si repetirla varias veces produce el mismo resultado que hacerla una vez. Borrar la tarea 42 dos veces deja el sistema igual que borrarla una (ya no existe). Crear con
POSTdos veces crea dos tareas. Esto importa cuando hay reintentos por cortes de red.
Sin estado (stateless)
Cada petición debe llevar toda la información necesaria para procesarla (incluida la autenticación, normalmente en una cabecera). El servidor no recuerda nada de la petición anterior: no se usan sesiones como en una web tradicional. Esto permite escalar fácilmente (cualquier servidor puede atender cualquier petición).
Representaciones
El cliente no recibe “la tarea”, sino una representación de ella, normalmente en JSON. El formato se indica con la cabecera Content-Type: application/json.
3. Códigos de estado HTTP
El código de estado es lo primero que mira el cliente para saber si la petición ha ido bien. Devolver siempre 200 con {"error": "..."} dentro es un error de diseño frecuente.
| Código | Nombre | Cuándo usarlo |
|---|---|---|
| 200 | OK | Petición correcta con contenido (GET, PUT, PATCH) |
| 201 | Created | Recurso creado (POST). Incluye la cabecera Location |
| 204 | No Content | Correcto, sin cuerpo (DELETE, preflight CORS) |
| 400 | Bad Request | Petición mal formada (JSON inválido) |
| 401 | Unauthorized | Falta la autenticación o no es válida |
| 403 | Forbidden | Autenticado, pero sin permiso para ese recurso |
| 404 | Not Found | El recurso no existe |
| 405 | Method Not Allowed | El método no se admite en esa URI (incluye cabecera Allow) |
| 409 | Conflict | Conflicto con el estado actual (email ya registrado) |
| 415 | Unsupported Media Type | El Content-Type enviado no es JSON |
| 422 | Unprocessable Content | JSON correcto, pero los datos no son válidos |
| 429 | Too Many Requests | Se ha superado el límite de peticiones |
| 500 | Internal Server Error | Error inesperado en el servidor |
| 503 | Service Unavailable | Servicio caído temporalmente o en mantenimiento |
💡 Consejo: Regla rápida: 2xx = todo bien; 4xx = el cliente ha hecho algo mal (y debe corregirlo); 5xx = el servidor ha fallado (el cliente puede reintentar más tarde).
4. JSON en PHP
JSON (JavaScript Object Notation) es el formato estándar de intercambio de datos en las APIs: legible, ligero y soportado por todos los lenguajes.
{
"id": 42,
"titulo": "Preparar la presentación",
"completada": false,
"etiquetas": ["trabajo", "urgente"],
"responsable": { "id": 7, "nombre": "Lucía" },
"vence": null
}
De PHP a JSON
<?php
declare(strict_types=1);
$tarea = [
'id' => 42,
'titulo' => 'Preparar la presentación',
'completada' => false,
'url' => 'https://ejemplo.es/tareas/42',
];
echo json_encode($tarea), PHP_EOL;
// {"id":42,"titulo":"Preparar la presentación","completada":false,"url":"https:\/\/ejemplo.es\/tareas\/42"}
echo json_encode($tarea, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR);
// {
// "id": 42,
// "titulo": "Preparar la presentación",
// "completada": false,
// "url": "https://ejemplo.es/tareas/42"
// }
| Opción | Efecto |
|---|---|
JSON_THROW_ON_ERROR | Lanza JsonException si falla (en lugar de devolver false) |
JSON_UNESCAPED_UNICODE | Deja las tildes y eñes tal cual (ó en vez de ó) |
JSON_UNESCAPED_SLASHES | No escapa las barras / |
JSON_PRETTY_PRINT | Formatea con sangrías (útil para depurar) |
JSON_PRESERVE_ZERO_FRACTION | 10.0 sale como 10.0 y no como 10 |
⚠️ Importante: Un array PHP con índices
0, 1, 2...consecutivos se convierte en un array JSON[...]; cualquier otro (asociativo o con huecos) en un objeto{...}. Si filtras un array conarray_filter(), los índices dejan de ser consecutivos y obtendrás un objeto: usaarray_values()antes de codificar.
<?php
$numeros = array_filter([1, 2, 3, 4], fn($n) => $n % 2 === 0);
echo json_encode($numeros); // {"1":2,"3":4} ← objeto
echo json_encode(array_values($numeros)); // [2,4] ← array
Objetos que se convierten a JSON: JsonSerializable
<?php
declare(strict_types=1);
final readonly class Tarea implements JsonSerializable
{
public function __construct(
public int $id,
public string $titulo,
public bool $completada,
public DateTimeImmutable $creadaEn,
) {}
public function jsonSerialize(): array
{
return [
'id' => $this->id,
'titulo' => $this->titulo,
'completada' => $this->completada,
'creada_en' => $this->creadaEn->format(DATE_ATOM), // 2026-10-03T10:15:00+02:00
];
}
}
echo json_encode(new Tarea(1, 'Comprar pan', false, new DateTimeImmutable()), JSON_UNESCAPED_UNICODE);
// {"id":1,"titulo":"Comprar pan","completada":false,"creada_en":"2026-10-03T10:15:00+02:00"}
De JSON a PHP
<?php
declare(strict_types=1);
$json = '{"titulo": "Llamar al cliente", "completada": true}';
$datos = json_decode($json, true, flags: JSON_THROW_ON_ERROR); // true → array asociativo
var_dump($datos['completada']); // bool(true)
$objeto = json_decode($json, flags: JSON_THROW_ON_ERROR); // sin true → stdClass
echo $objeto->titulo; // Llamar al cliente
try {
json_decode('{titulo: sin comillas}', true, flags: JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
echo "JSON inválido: ", $e->getMessage(); // JSON inválido: Syntax error
}
💡 Consejo: Usa siempre
JSON_THROW_ON_ERROR. Sin esa opción, un JSON inválido devuelvenull… igual que el JSON válido"null", y tienes que acordarte de llamar ajson_last_error().
5. Diseño de nuestra API de tareas
Vamos a construir una API para gestionar una lista de tareas:
| Método | URI | Acción | Respuesta correcta |
|---|---|---|---|
GET | /api/v1/tareas | Listar (con paginación y filtro ?completada=) | 200 |
GET | /api/v1/tareas/{id} | Ver una tarea | 200 |
POST | /api/v1/tareas | Crear | 201 + Location |
PUT | /api/v1/tareas/{id} | Reemplazar | 200 |
PATCH | /api/v1/tareas/{id} | Modificar algunos campos | 200 |
DELETE | /api/v1/tareas/{id} | Borrar | 204 |
Estructura del proyecto
api-tareas/
├── public/
│ ├── index.php ← front controller: TODAS las peticiones entran aquí
│ └── cliente.html ← cliente web con fetch (sección 12)
├── src/
│ ├── http.php ← funciones para leer peticiones y escribir respuestas
│ ├── Problema.php ← excepción que se convierte en un error JSON
│ ├── seguridad.php ← CORS y autenticación
│ ├── TareaRepository.php
│ └── controladores.php ← validación y una función por operación
├── var/
│ └── tareas.sqlite ← se crea automáticamente
└── pruebas.http ← peticiones de prueba (sección 10)
La base de datos
Para que puedas probarla sin instalar nada usaremos SQLite; si prefieres MySQL, solo cambia el DSN (módulo 15). Este es el equivalente en MySQL:
CREATE TABLE tareas (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
titulo VARCHAR(200) NOT NULL,
descripcion TEXT NULL,
completada TINYINT(1) NOT NULL DEFAULT 0,
creada_en DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizada_en DATETIME NULL
) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
6. El front controller y el enrutado
📘 Concepto: Un front controller es un único fichero PHP (
public/index.php) por el que pasan todas las peticiones. Él decide, mirando el método y la URI, qué código ejecutar. Así no necesitas un fichero por URL y centralizas la gestión de errores, la autenticación y las cabeceras.
Para que el servidor integrado de PHP envíe todas las peticiones a index.php, pásalo como router script:
php -S localhost:8000 -t public public/index.php
En Apache usarías un .htaccess con mod_rewrite (lo verás en el módulo 19).
Funciones HTTP auxiliares (src/http.php)
<?php
declare(strict_types=1);
const JSON_OPCIONES = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR;
/** Envía una respuesta JSON con su código de estado y termina */
function responderJson(mixed $datos, int $estado = 200, array $cabeceras = []): never
{
http_response_code($estado);
header('Content-Type: application/json; charset=utf-8');
foreach ($cabeceras as $nombre => $valor) {
header("$nombre: $valor");
}
echo json_encode($datos, JSON_OPCIONES);
exit;
}
/** Respuesta sin cuerpo (204) */
function responderVacio(int $estado = 204, array $cabeceras = []): never
{
http_response_code($estado);
foreach ($cabeceras as $nombre => $valor) {
header("$nombre: $valor");
}
exit;
}
/** Lee y decodifica el cuerpo JSON de la petición */
function leerJson(): array
{
$tipo = $_SERVER['CONTENT_TYPE'] ?? '';
if (!str_starts_with($tipo, 'application/json')) {
throw new Problema(415, 'Tipo de contenido no soportado', 'Envía el cuerpo como application/json.');
}
$cuerpo = file_get_contents('php://input'); // El cuerpo "crudo" de la petición
try {
$datos = json_decode($cuerpo, true, 32, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
throw new Problema(400, 'JSON mal formado', $e->getMessage());
}
if (!is_array($datos) || array_is_list($datos) && $datos !== []) {
throw new Problema(400, 'JSON mal formado', 'Se esperaba un objeto JSON.');
}
return $datos;
}
📘 Concepto:
$_POSTsolo se rellena con formularios (application/x-www-form-urlencodedomultipart/form-data). Cuando un cliente envía JSON, PHP no lo decodifica automáticamente: tienes que leer el cuerpo en bruto confile_get_contents('php://input')y decodificarlo tú.
Errores con formato consistente: RFC 9457
📘 Concepto: El RFC 9457 (Problem Details for HTTP APIs) define un formato estándar para los errores de una API, con
Content-Type: application/problem+jsony estos campos:type(URI que identifica el tipo de error),title(resumen),status(código HTTP),detail(explicación concreta) einstance(la URI afectada). Puedes añadir campos propios, comoerrors.
{
"type": "https://api.ejemplo.es/problemas/validacion",
"title": "Datos no válidos",
"status": 422,
"detail": "La petición contiene 2 errores de validación.",
"instance": "/api/v1/tareas",
"errors": {
"titulo": "El título es obligatorio.",
"completada": "Debe ser true o false."
}
}
La excepción que usaremos para representarlos (src/Problema.php):
<?php
declare(strict_types=1);
final class Problema extends RuntimeException
{
public function __construct(
public readonly int $estado,
public readonly string $titulo,
public readonly string $detalle = '',
public readonly array $errores = [],
public readonly string $tipo = 'about:blank',
) {
parent::__construct($titulo, $estado);
}
public static function noEncontrado(string $recurso, int $id): self
{
return new self(404, 'Recurso no encontrado', "No existe $recurso con id $id.");
}
public static function validacion(array $errores): self
{
return new self(
422,
'Datos no válidos',
sprintf('La petición contiene %d errores de validación.', count($errores)),
$errores,
'https://api.ejemplo.es/problemas/validacion',
);
}
public function aArray(string $instancia): array
{
$datos = [
'type' => $this->tipo,
'title' => $this->titulo,
'status' => $this->estado,
'detail' => $this->detalle,
'instance' => $instancia,
];
if ($this->errores !== []) {
$datos['errors'] = $this->errores;
}
return $datos;
}
}
El front controller (public/index.php)
<?php
declare(strict_types=1);
require __DIR__ . '/../src/http.php';
require __DIR__ . '/../src/Problema.php';
require __DIR__ . '/../src/seguridad.php'; // enviarCabecerasCors() y autenticar(), secciones 8 y 9
require __DIR__ . '/../src/TareaRepository.php';
require __DIR__ . '/../src/controladores.php'; // validarTarea() y los controladores
// Si el servidor integrado pide un fichero estático existente (cliente.html), lo sirve tal cual
if (PHP_SAPI === 'cli-server' && is_file(__DIR__ . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH))) {
return false;
}
$metodo = $_SERVER['REQUEST_METHOD'];
$ruta = rtrim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') ?: '/';
// Tabla de rutas: [método, patrón, controlador]
$rutas = [
['GET', '#^/api/v1/tareas$#', 'listarTareas'],
['POST', '#^/api/v1/tareas$#', 'crearTarea'],
['GET', '#^/api/v1/tareas/(\d+)$#', 'verTarea'],
['PUT', '#^/api/v1/tareas/(\d+)$#', 'reemplazarTarea'],
['PATCH', '#^/api/v1/tareas/(\d+)$#', 'modificarTarea'],
['DELETE', '#^/api/v1/tareas/(\d+)$#', 'borrarTarea'],
];
try {
enviarCabecerasCors(); // Sección 8
if ($metodo === 'OPTIONS') {
responderVacio(204); // Preflight CORS
}
autenticar(); // Sección 9
$permitidos = [];
foreach ($rutas as [$metodoRuta, $patron, $controlador]) {
if (preg_match($patron, $ruta, $coincidencias)) {
if ($metodoRuta === $metodo) {
$parametros = array_map('intval', array_slice($coincidencias, 1));
$controlador(new TareaRepository(conexion()), ...$parametros);
}
$permitidos[] = $metodoRuta;
}
}
if ($permitidos !== []) {
throw new Problema(405, 'Método no permitido', "Usa uno de: " . implode(', ', $permitidos));
}
throw new Problema(404, 'Ruta no encontrada', "No existe la ruta $metodo $ruta.");
} catch (Problema $p) {
$cabeceras = $p->estado === 405 ? ['Allow' => implode(', ', $permitidos)] : [];
http_response_code($p->estado);
foreach ($cabeceras as $n => $v) {
header("$n: $v");
}
header('Content-Type: application/problem+json; charset=utf-8');
echo json_encode($p->aArray($ruta), JSON_OPCIONES);
} catch (Throwable $e) {
error_log((string) $e); // El detalle técnico, solo al log
http_response_code(500);
header('Content-Type: application/problem+json; charset=utf-8');
echo json_encode([
'type' => 'about:blank',
'title' => 'Error interno del servidor',
'status' => 500,
], JSON_OPCIONES);
}
function conexion(): PDO
{
static $pdo = null;
if ($pdo === null) {
$fichero = __DIR__ . '/../var/tareas.sqlite';
if (!is_dir(dirname($fichero))) {
mkdir(dirname($fichero), 0775, true);
}
$pdo = new PDO('sqlite:' . $fichero, options: [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE IF NOT EXISTS tareas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
titulo TEXT NOT NULL,
descripcion TEXT NULL,
completada INTEGER NOT NULL DEFAULT 0,
creada_en TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
actualizada_en TEXT NULL
)');
}
return $pdo;
}
💡 Consejo: Fíjate en que el
catch (Throwable $e)final nunca envía al cliente el mensaje de la excepción: unPDOExceptionpodría revelar la estructura de tu base de datos. El detalle va al log; el cliente recibe un 500 genérico.
7. El CRUD de tareas
El repositorio (src/TareaRepository.php)
<?php
declare(strict_types=1);
final class TareaRepository
{
public function __construct(private PDO $pdo) {}
/** @return array{0: list<array>, 1: int} [tareas, total] */
public function listar(int $pagina, int $porPagina, ?bool $completada): array
{
$where = $completada === null ? '' : 'WHERE completada = :completada';
$stmt = $this->pdo->prepare("SELECT COUNT(*) FROM tareas $where");
if ($completada !== null) {
$stmt->bindValue(':completada', (int) $completada, PDO::PARAM_INT);
}
$stmt->execute();
$total = (int) $stmt->fetchColumn();
$stmt = $this->pdo->prepare("SELECT * FROM tareas $where ORDER BY id DESC LIMIT :limite OFFSET :desp");
if ($completada !== null) {
$stmt->bindValue(':completada', (int) $completada, PDO::PARAM_INT);
}
$stmt->bindValue(':limite', $porPagina, PDO::PARAM_INT);
$stmt->bindValue(':desp', ($pagina - 1) * $porPagina, PDO::PARAM_INT);
$stmt->execute();
return [array_map($this->formatear(...), $stmt->fetchAll()), $total];
}
public function buscar(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM tareas WHERE id = ?');
$stmt->execute([$id]);
$fila = $stmt->fetch();
return $fila ? $this->formatear($fila) : null;
}
public function crear(array $datos): array
{
$stmt = $this->pdo->prepare(
'INSERT INTO tareas (titulo, descripcion, completada) VALUES (:titulo, :descripcion, :completada)'
);
$stmt->execute([
'titulo' => $datos['titulo'],
'descripcion' => $datos['descripcion'] ?? null,
'completada' => (int) ($datos['completada'] ?? false),
]);
return $this->buscar((int) $this->pdo->lastInsertId());
}
public function actualizar(int $id, array $datos): array
{
$stmt = $this->pdo->prepare(
'UPDATE tareas SET titulo = :titulo, descripcion = :descripcion, completada = :completada,
actualizada_en = CURRENT_TIMESTAMP WHERE id = :id'
);
$stmt->execute([
'titulo' => $datos['titulo'],
'descripcion' => $datos['descripcion'] ?? null,
'completada' => (int) $datos['completada'],
'id' => $id,
]);
return $this->buscar($id);
}
public function borrar(int $id): bool
{
$stmt = $this->pdo->prepare('DELETE FROM tareas WHERE id = ?');
$stmt->execute([$id]);
return $stmt->rowCount() > 0;
}
/** Convierte una fila de BD en la representación JSON pública */
private function formatear(array $fila): array
{
return [
'id' => (int) $fila['id'],
'titulo' => $fila['titulo'],
'descripcion' => $fila['descripcion'],
'completada' => (bool) $fila['completada'],
'creada_en' => $fila['creada_en'],
'actualizada_en' => $fila['actualizada_en'],
];
}
}
💡 Consejo: El método
formatear()separa cómo se guarda el dato (un0/1en la BD) de cómo se expone en la API (true/false). Así puedes cambiar la base de datos sin romper a tus clientes.
Validación
<?php
declare(strict_types=1);
/**
* Valida los datos de una tarea.
* $parcial = true (PATCH): solo se validan los campos presentes.
*/
function validarTarea(array $datos, bool $parcial = false): array
{
$errores = [];
$limpios = [];
if (array_key_exists('titulo', $datos) || !$parcial) {
$titulo = $datos['titulo'] ?? null;
if (!is_string($titulo) || mb_trim($titulo) === '') {
$errores['titulo'] = 'El título es obligatorio.';
} elseif (mb_strlen($titulo) > 200) {
$errores['titulo'] = 'El título no puede superar 200 caracteres.';
} else {
$limpios['titulo'] = mb_trim($titulo);
}
}
if (array_key_exists('descripcion', $datos)) {
$descripcion = $datos['descripcion'];
if ($descripcion !== null && !is_string($descripcion)) {
$errores['descripcion'] = 'Debe ser un texto o null.';
} else {
$limpios['descripcion'] = $descripcion;
}
}
if (array_key_exists('completada', $datos) || !$parcial) {
$completada = $datos['completada'] ?? false;
if (!is_bool($completada)) {
$errores['completada'] = 'Debe ser true o false.';
} else {
$limpios['completada'] = $completada;
}
}
// Campos desconocidos: mejor avisar que ignorarlos en silencio
foreach (array_diff_key($datos, array_flip(['titulo', 'descripcion', 'completada'])) as $campo => $_) {
$errores[$campo] = 'Campo no reconocido.';
}
if ($errores !== []) {
throw Problema::validacion($errores);
}
return $limpios;
}
Los controladores (src/controladores.php)
<?php
declare(strict_types=1);
function listarTareas(TareaRepository $repo): never
{
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$porPagina = min(100, max(1, (int) ($_GET['por_pagina'] ?? 10)));
$completada = match ($_GET['completada'] ?? null) {
'true', '1' => true,
'false', '0' => false,
null => null,
default => throw new Problema(400, 'Parámetro no válido', 'completada debe ser true o false.'),
};
[$tareas, $total] = $repo->listar($pagina, $porPagina, $completada);
responderJson([
'datos' => $tareas,
'meta' => [
'pagina' => $pagina,
'por_pagina' => $porPagina,
'total' => $total,
'total_paginas' => (int) ceil($total / $porPagina),
],
]);
}
function verTarea(TareaRepository $repo, int $id): never
{
$tarea = $repo->buscar($id) ?? throw Problema::noEncontrado('ninguna tarea', $id);
responderJson($tarea);
}
function crearTarea(TareaRepository $repo): never
{
$datos = validarTarea(leerJson());
$tarea = $repo->crear($datos);
responderJson($tarea, 201, ['Location' => "/api/v1/tareas/{$tarea['id']}"]);
}
function reemplazarTarea(TareaRepository $repo, int $id): never
{
$repo->buscar($id) ?? throw Problema::noEncontrado('ninguna tarea', $id);
$datos = validarTarea(leerJson()); // PUT: todos los campos
responderJson($repo->actualizar($id, $datos + ['descripcion' => null]));
}
function modificarTarea(TareaRepository $repo, int $id): never
{
$actual = $repo->buscar($id) ?? throw Problema::noEncontrado('ninguna tarea', $id);
$cambios = validarTarea(leerJson(), parcial: true); // PATCH: solo lo enviado
responderJson($repo->actualizar($id, [...$actual, ...$cambios]));
}
function borrarTarea(TareaRepository $repo, int $id): never
{
if (!$repo->borrar($id)) {
throw Problema::noEncontrado('ninguna tarea', $id);
}
responderVacio(204);
}
📘 Concepto: La diferencia entre PUT y PATCH: con
PUTel cliente envía la representación completa y lo que no envíe se considera vacío (por eso ponemosdescripcionanullsi falta). ConPATCHsolo envía lo que cambia y el resto se conserva.
8. CORS
📘 Concepto: Por seguridad, el navegador aplica la política del mismo origen: un JavaScript cargado desde
https://app.ejemplo.esno puede leer respuestas dehttps://api.ejemplo.es(otro origen) salvo que la API lo autorice expresamente con cabeceras CORS (Cross-Origin Resource Sharing). Curl, Postman o un servidor PHP no están sujetos a esta regla: solo afecta a los navegadores.
Para peticiones “no simples” (con Content-Type: application/json, con la cabecera Authorization o con métodos como PUT/DELETE), el navegador envía antes una petición OPTIONS de comprobación, llamada preflight:
sequenceDiagram
participant N as Navegador (app.ejemplo.es)
participant A as API (api.ejemplo.es)
N->>A: OPTIONS /api/v1/tareas<br/>Origin: https://app.ejemplo.es<br/>Access-Control-Request-Method: POST
A-->>N: 204 No Content<br/>Access-Control-Allow-Origin: https://app.ejemplo.es<br/>Access-Control-Allow-Methods: GET, POST, ...
N->>A: POST /api/v1/tareas (la petición real)
A-->>N: 201 Created + Access-Control-Allow-Origin
<?php
declare(strict_types=1);
function enviarCabecerasCors(): void
{
$permitidos = ['http://localhost:5173', 'https://app.ejemplo.es'];
$origen = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origen, $permitidos, true)) {
header("Access-Control-Allow-Origin: $origen");
header('Vary: Origin'); // La respuesta depende del origen (importante para cachés)
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
header('Access-Control-Max-Age: 600'); // El navegador cachea el preflight 10 minutos
}
}
⚠️ Importante:
Access-Control-Allow-Origin: *permite que cualquier web llame a tu API desde el navegador de sus visitantes. Solo es aceptable para APIs públicas sin autenticación. Si tu API usa credenciales, usa una lista blanca de orígenes. Si el cliente y la API están en el mismo origen (como nuestrocliente.html), no necesitas CORS.
9. Autenticación con token Bearer
Como la API no tiene estado, el cliente se identifica en cada petición enviando un token en la cabecera Authorization:
Authorization: Bearer 3f9a6c1e8b2d4f70a5c9e1b7d3f8a2c6e0b4d9f1a7c3e5b8d2f6a0c4e9b1d7f3
| Mecanismo | Cómo funciona | Uso típico |
|---|---|---|
| API key | Un token largo y aleatorio asociado a un cliente o usuario | Integraciones entre servidores |
| Token Bearer opaco | Un token aleatorio que el servidor busca en su base de datos | Apps propias (login → token) |
| JWT | Un token firmado que contiene los datos del usuario; el servidor solo verifica la firma | Sistemas distribuidos, SSO, OAuth 2.0 |
📘 Concepto: Un JWT (JSON Web Token) son tres partes en Base64URL separadas por puntos: cabecera, datos (claims, como el id de usuario y la caducidad) y firma. El servidor no necesita consultar la base de datos para validarlo, pero es difícil revocarlo antes de que caduque. En PHP se suele usar la librería
firebase/php-jwt. Para empezar, un token opaco guardado en tu base de datos es más sencillo y seguro.
Implementación sencilla con tokens en base de datos
CREATE TABLE api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL, -- "App móvil", "Integración ERP"...
token_hash TEXT NOT NULL UNIQUE, -- sha256 del token, nunca el token en claro
creado_en TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
<?php
declare(strict_types=1);
// Generar un token (por ejemplo, desde un script de consola)
$token = bin2hex(random_bytes(32));
conexion()->prepare('INSERT INTO api_tokens (nombre, token_hash) VALUES (?, ?)')
->execute(['Cliente web', hash('sha256', $token)]);
echo "Guarda este token, no se volverá a mostrar: $token", PHP_EOL;
<?php
declare(strict_types=1);
function autenticar(): void
{
// Las rutas GET de este ejemplo son públicas; el resto exige token
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
return;
}
$cabecera = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if (!preg_match('/^Bearer\s+([a-f0-9]{64})$/i', $cabecera, $m)) {
header('WWW-Authenticate: Bearer');
throw new Problema(401, 'No autenticado', 'Falta la cabecera Authorization: Bearer <token>.');
}
$stmt = conexion()->prepare('SELECT id FROM api_tokens WHERE token_hash = ?');
$stmt->execute([hash('sha256', $m[1])]);
if ($stmt->fetchColumn() === false) {
header('WWW-Authenticate: Bearer error="invalid_token"');
throw new Problema(401, 'No autenticado', 'El token no es válido.');
}
}
⚠️ Importante: Los tokens solo son seguros sobre HTTPS; por HTTP cualquiera en la red puede leerlos. Y algunos servidores Apache con PHP-FPM no pasan la cabecera
Authorizationa PHP: si$_SERVER['HTTP_AUTHORIZATION']llega vacía, añade en el.htaccessla reglaCGIPassAuth On.
10. Probar la API
Con curl
curl viene instalado en Windows 10/11, macOS y Linux. En PowerShell escribe curl.exe (porque curl es un alias de otro comando) y ten cuidado con las comillas; en Git Bash funciona igual que en Linux.
# Listar (con -i para ver código y cabeceras)
curl -i http://localhost:8000/api/v1/tareas
# Crear
curl -i -X POST http://localhost:8000/api/v1/tareas \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TU_TOKEN" \
-d '{"titulo": "Escribir el módulo de APIs", "completada": false}'
# Modificar parcialmente
curl -X PATCH http://localhost:8000/api/v1/tareas/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer TU_TOKEN" \
-d '{"completada": true}'
# Borrar
curl -i -X DELETE http://localhost:8000/api/v1/tareas/1 -H "Authorization: Bearer TU_TOKEN"
Respuesta al crear:
HTTP/1.1 201 Created
Location: /api/v1/tareas/1
Content-Type: application/json; charset=utf-8
{"id":1,"titulo":"Escribir el módulo de APIs","descripcion":null,"completada":false,"creada_en":"2026-10-03 08:30:12","actualizada_en":null}
Respuesta con errores de validación:
curl -s -X POST http://localhost:8000/api/v1/tareas \
-H "Content-Type: application/json" -H "Authorization: Bearer TU_TOKEN" \
-d '{"titulo": "", "completada": "sí", "prioridad": 3}'
{"type":"https://api.ejemplo.es/problemas/validacion","title":"Datos no válidos","status":422,"detail":"La petición contiene 3 errores de validación.","instance":"/api/v1/tareas","errors":{"titulo":"El título es obligatorio.","completada":"Debe ser true o false.","prioridad":"Campo no reconocido."}}
Con Postman, Thunder Client o Bruno
Son aplicaciones (o extensiones de VS Code, en el caso de Thunder Client) con interfaz gráfica para construir peticiones, guardarlas en colecciones, usar variables ({{base}}, {{token}}) y ver las respuestas formateadas. Muy cómodas cuando la API crece.
Con ficheros .http
Las extensiones REST Client de VS Code y el cliente HTTP de los IDE de JetBrains (PhpStorm) permiten guardar peticiones en un fichero de texto .http que se versiona con el proyecto. Las peticiones se separan con ###:
@base = http://localhost:8000/api/v1
@token = 3f9a6c1e8b2d4f70a5c9e1b7d3f8a2c6e0b4d9f1a7c3e5b8d2f6a0c4e9b1d7f3
### Listar tareas pendientes
GET {{base}}/tareas?completada=false&pagina=1
### Crear una tarea
POST {{base}}/tareas
Content-Type: application/json
Authorization: Bearer {{token}}
{
"titulo": "Revisar pull requests",
"descripcion": "Antes de la reunión del jueves"
}
### Marcar como completada
PATCH {{base}}/tareas/1
Content-Type: application/json
Authorization: Bearer {{token}}
{ "completada": true }
### Borrar
DELETE {{base}}/tareas/1
Authorization: Bearer {{token}}
💡 Consejo: Un fichero
pruebas.httpen el repositorio es documentación viva de tu API: cualquier compañero puede abrirlo y probar todos los endpoints con un clic.
11. Consumir APIs desde PHP
Tu código PHP también puede ser cliente de otras APIs: pasarelas de pago, servicios meteorológicos, envío de emails…
Con file_get_contents() y un contexto de stream
Sencillo y sin dependencias, suficiente para casos básicos:
<?php
declare(strict_types=1);
// GET sencillo
$json = file_get_contents('https://api.github.com/repos/php/php-src', context: stream_context_create([
'http' => [
'header' => "User-Agent: CursoPHP\r\nAccept: application/json\r\n",
'timeout' => 5,
],
]));
$repo = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
echo "php-src tiene {$repo['stargazers_count']} estrellas", PHP_EOL;
// POST con JSON a nuestra API
$contexto = stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/json\r\nAuthorization: Bearer TU_TOKEN\r\n",
'content' => json_encode(['titulo' => 'Creada desde PHP']),
'timeout' => 5,
'ignore_errors' => true, // Devuelve el cuerpo también con 4xx/5xx
],
]);
$respuesta = file_get_contents('http://localhost:8000/api/v1/tareas', false, $contexto);
// PHP 8.4+: cabeceras de la última respuesta ($http_response_header está obsoleta en 8.5)
$cabeceras = http_get_last_response_headers();
echo $cabeceras[0], PHP_EOL; // HTTP/1.1 201 Created
echo $respuesta, PHP_EOL;
Con cURL
La extensión cURL da control total: tiempos de espera, redirecciones, certificados, subida de ficheros…
<?php
declare(strict_types=1);
function peticionJson(string $metodo, string $url, ?array $cuerpo = null, ?string $token = null): array
{
$ch = curl_init($url);
$cabeceras = ['Accept: application/json'];
if ($cuerpo !== null) {
$cabeceras[] = 'Content-Type: application/json';
}
if ($token !== null) {
$cabeceras[] = "Authorization: Bearer $token";
}
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $metodo,
CURLOPT_RETURNTRANSFER => true, // Devolver el cuerpo en vez de imprimirlo
CURLOPT_HTTPHEADER => $cabeceras,
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 10,
]);
if ($cuerpo !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($cuerpo, JSON_THROW_ON_ERROR));
}
$respuesta = curl_exec($ch);
if ($respuesta === false) {
throw new RuntimeException('Error de red: ' . curl_error($ch));
}
$estado = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
// No hace falta curl_close(): desde PHP 8.0 no hace nada y en PHP 8.5 está obsoleta
return [
'estado' => $estado,
'datos' => $respuesta === '' ? null : json_decode($respuesta, true, flags: JSON_THROW_ON_ERROR),
];
}
$r = peticionJson('POST', 'http://localhost:8000/api/v1/tareas', ['titulo' => 'Desde cURL'], 'TU_TOKEN');
echo "Estado: {$r['estado']}", PHP_EOL; // Estado: 201
echo "Nueva tarea: {$r['datos']['id']}", PHP_EOL;
$r = peticionJson('GET', 'http://localhost:8000/api/v1/tareas/9999');
echo "{$r['estado']}: {$r['datos']['title']}", PHP_EOL; // 404: Recurso no encontrado
Con Guzzle
Guzzle es el cliente HTTP más popular de PHP. Simplifica todo y lanza excepciones ante errores.
composer require guzzlehttp/guzzle
<?php
declare(strict_types=1);
require __DIR__ . '/vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ConnectException;
$api = new Client([
'base_uri' => 'http://localhost:8000/api/v1/',
'timeout' => 10,
'headers' => ['Authorization' => 'Bearer TU_TOKEN', 'Accept' => 'application/json'],
]);
try {
// 'json' codifica el array y pone Content-Type: application/json automáticamente
$respuesta = $api->post('tareas', ['json' => ['titulo' => 'Creada con Guzzle']]);
$tarea = json_decode((string) $respuesta->getBody(), true, flags: JSON_THROW_ON_ERROR);
echo "Creada la tarea {$tarea['id']} (", $respuesta->getStatusCode(), ")", PHP_EOL;
$lista = $api->get('tareas', ['query' => ['completada' => 'false', 'por_pagina' => 5]]);
$datos = json_decode((string) $lista->getBody(), true, flags: JSON_THROW_ON_ERROR);
foreach ($datos['datos'] as $t) {
echo "- [{$t['id']}] {$t['titulo']}", PHP_EOL;
}
} catch (ClientException $e) { // Respuestas 4xx
$problema = json_decode((string) $e->getResponse()->getBody(), true);
echo "Error {$problema['status']}: {$problema['title']}", PHP_EOL;
} catch (ConnectException $e) { // No se pudo conectar
echo "La API no responde", PHP_EOL;
}
file_get_contents() | cURL | Guzzle | |
|---|---|---|---|
| Dependencias | Ninguna | Extensión curl | Composer |
| Facilidad | Alta para GET | Media (muchas opciones) | Alta |
| Control de errores | Manual | Manual | Excepciones por tipo |
| Peticiones en paralelo | No | Sí (curl_multi) | Sí (promesas) |
| Ideal para | Scripts rápidos | Sin dependencias, control fino | Aplicaciones profesionales |
💡 Consejo: Pon siempre un tiempo de espera (
timeout). Sin él, si la API externa se queda colgada, tu página también se queda colgada, ocupando un proceso de PHP que no atiende a nadie más.
12. Consumir la API desde el navegador con fetch
Este cliente HTML se sirve desde el mismo servidor (public/cliente.html), así que no necesita CORS. Ábrelo en http://localhost:8000/cliente.html.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mis tareas</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css">
</head>
<body class="container py-4" style="max-width: 640px">
<h1 class="mb-4">Mis tareas</h1>
<form id="formulario" class="input-group mb-2">
<input id="titulo" class="form-control" placeholder="¿Qué tienes que hacer?" required maxlength="200">
<button class="btn btn-primary">Añadir</button>
</form>
<div id="error" class="alert alert-danger d-none"></div>
<ul id="lista" class="list-group mt-3"></ul>
<script>
const API = '/api/v1/tareas';
const TOKEN = 'TU_TOKEN'; // En una app real se obtiene al iniciar sesión, nunca se escribe en el código
const lista = document.getElementById('lista');
const error = document.getElementById('error');
// Función genérica: hace la petición y lanza un Error con el "title" del Problem Details
async function api(metodo, url, cuerpo = null) {
const opciones = {
method: metodo,
headers: { 'Accept': 'application/json', 'Authorization': `Bearer ${TOKEN}` },
};
if (cuerpo !== null) {
opciones.headers['Content-Type'] = 'application/json';
opciones.body = JSON.stringify(cuerpo);
}
const respuesta = await fetch(url, opciones);
if (respuesta.status === 204) return null;
const datos = await respuesta.json();
if (!respuesta.ok) {
const detalle = datos.errors ? Object.values(datos.errors).join(' ') : (datos.detail ?? '');
throw new Error(`${datos.title}. ${detalle}`);
}
return datos;
}
function mostrarError(mensaje) {
error.textContent = mensaje;
error.classList.toggle('d-none', !mensaje);
}
function pintar(tareas) {
lista.replaceChildren();
for (const tarea of tareas) {
const li = document.createElement('li');
li.className = 'list-group-item d-flex align-items-center gap-2';
const check = document.createElement('input');
check.type = 'checkbox';
check.className = 'form-check-input';
check.checked = tarea.completada;
check.addEventListener('change', () => cambiarEstado(tarea.id, check.checked));
const texto = document.createElement('span');
texto.className = 'flex-grow-1' + (tarea.completada ? ' text-decoration-line-through text-muted' : '');
texto.textContent = tarea.titulo; // textContent: nunca innerHTML con datos (XSS)
const borrar = document.createElement('button');
borrar.className = 'btn btn-sm btn-outline-danger';
borrar.textContent = 'Borrar';
borrar.addEventListener('click', () => eliminar(tarea.id));
li.append(check, texto, borrar);
lista.append(li);
}
}
async function cargar() {
try {
const respuesta = await api('GET', `${API}?por_pagina=100`);
pintar(respuesta.datos);
mostrarError('');
} catch (e) {
mostrarError(e.message);
}
}
async function cambiarEstado(id, completada) {
try {
await api('PATCH', `${API}/${id}`, { completada });
await cargar();
} catch (e) {
mostrarError(e.message);
}
}
async function eliminar(id) {
if (!confirm('¿Borrar la tarea?')) return;
try {
await api('DELETE', `${API}/${id}`);
await cargar();
} catch (e) {
mostrarError(e.message);
}
}
document.getElementById('formulario').addEventListener('submit', async (evento) => {
evento.preventDefault();
const campo = document.getElementById('titulo');
try {
await api('POST', API, { titulo: campo.value });
campo.value = '';
await cargar();
} catch (e) {
mostrarError(e.message);
}
});
cargar();
</script>
</body>
</html>
⚠️ Importante: Fíjate en
texto.textContent = tarea.titulo. Si usarasinnerHTML, un título como<img src=x onerror=alert(1)>se ejecutaría: es el mismo XSS del módulo 16, pero en el lado del cliente. Y nunca dejes un token real escrito en el JavaScript de una página pública: cualquiera puede verlo con “Ver código fuente”.
13. Versionado y buenas prácticas
Versionado
Una vez que otros programas usan tu API, no puedes cambiarla a tu antojo: renombrar un campo rompe a todos los clientes. Por eso se versiona.
| Estrategia | Ejemplo | Comentario |
|---|---|---|
| En la URI | /api/v1/tareas, /api/v2/tareas | La más sencilla y visible; la que hemos usado |
| En una cabecera | Accept: application/vnd.ejemplo.v2+json | Más “pura”, pero menos cómoda de probar |
| Parámetro | /api/tareas?version=2 | Poco recomendable |
💡 Consejo: Añadir campos nuevos a una respuesta no rompe a los clientes (deben ignorar lo que no conocen). Quitar o renombrar campos, cambiar tipos o cambiar el significado de un código de estado sí lo hace: eso exige una nueva versión.
Buenas prácticas
| Práctica | Por qué |
|---|---|
| Sustantivos en plural en las URIs, sin verbos | Predecibilidad |
| Códigos de estado correctos | El cliente sabe qué ha pasado sin leer el cuerpo |
| Un único formato de error (RFC 9457) | El cliente escribe un solo manejador de errores |
| Validar todo lo que entra y rechazar campos desconocidos | Seguridad y detección temprana de errores del cliente |
Paginación con límite máximo (por_pagina ≤ 100) | Nadie puede pedirte un millón de filas de golpe |
Fechas en ISO 8601 (2026-10-03T10:15:00+02:00) | Formato sin ambigüedades |
Nombres de campos coherentes (snake_case o camelCase, pero siempre el mismo) | Facilidad de uso |
| HTTPS siempre, tokens en cabeceras (no en la URL) | Las URLs se guardan en logs e historiales |
| Limitar peticiones por cliente (429) | Protección frente a abusos |
| Documentar la API (OpenAPI/Swagger) | Los clientes saben qué esperar |
📘 Concepto: OpenAPI es un estándar para describir APIs REST en un fichero YAML o JSON (rutas, parámetros, esquemas de datos, respuestas). A partir de él, herramientas como Swagger UI generan documentación interactiva. Los frameworks como Symfony (con API Platform) o Laravel pueden generarlo automáticamente.
14. Ejercicios
Ejercicio 1: Explorar una API pública
Usando file_get_contents() con un contexto de stream, consulta la API pública de GitHub (https://api.github.com/users/{usuario}/repos) para un usuario cualquiera y muestra en una tabla HTML el nombre, la descripción (escapada), el lenguaje y las estrellas de cada repositorio, ordenados por estrellas. Recuerda enviar la cabecera User-Agent y controlar el caso de usuario inexistente (404).
Ejercicio 2: Montar la API de tareas
Monta el proyecto completo de este módulo y pruébalo con un fichero pruebas.http que incluya al menos: listar, listar filtrando por completada, crear, crear con errores de validación, ver una tarea inexistente, PUT, PATCH, DELETE y una petición con un método no permitido (DELETE /api/v1/tareas). Anota el código de estado que devuelve cada una.
Ejercicio 3: Búsqueda y ordenación
Añade a GET /api/v1/tareas los parámetros ?q= (búsqueda en el título con LIKE seguro) y ?orden= (valores permitidos: id, -id, titulo, -titulo, donde el guion indica descendente). Si orden tiene un valor no permitido, devuelve un 400 en formato Problem Details.
Ejercicio 4: Nuevo recurso: etiquetas
Añade el recurso /api/v1/etiquetas (CRUD completo, con nombre único) y la relación muchos a muchos con tareas: GET /api/v1/tareas/{id}/etiquetas, PUT /api/v1/tareas/{id}/etiquetas/{etiquetaId} (asignar) y DELETE /api/v1/tareas/{id}/etiquetas/{etiquetaId} (quitar). Crear una etiqueta con un nombre ya existente debe devolver 409.
Ejercicio 5: Límite de peticiones
Implementa un limitador sencillo: cada token puede hacer como máximo 60 peticiones por minuto (guarda los contadores en una tabla o en un fichero). Al superarlo, responde con 429, la cabecera Retry-After con los segundos que faltan y un cuerpo Problem Details. Añade en todas las respuestas las cabeceras informativas X-RateLimit-Limit y X-RateLimit-Remaining.
Ejercicio 6: Cliente PHP con Guzzle
Crea una clase ClienteTareas que use Guzzle y ofrezca los métodos listar(bool $soloPendientes = false): array, crear(string $titulo): array, completar(int $id): void y borrar(int $id): void. Debe traducir los errores de la API a excepciones propias (TareaNoEncontrada, DatosInvalidos con el array errors). Escribe un script de consola que la use.
Ejercicio 7: Mejorar el cliente web
Amplía cliente.html para que permita editar el título de una tarea con doble clic (usando PATCH), filtrar entre “todas”, “pendientes” y “completadas” con tres botones (usando el parámetro completada), y muestre un contador de tareas pendientes. Todo el texto debe insertarse con textContent.
Resumen
| Concepto | Sintaxis / Clave |
|---|---|
| Recurso REST | Sustantivo en plural: /api/v1/tareas, /api/v1/tareas/42 |
| Verbos | GET leer, POST crear, PUT reemplazar, PATCH modificar, DELETE borrar |
| Códigos clave | 200, 201 + Location, 204, 400, 401, 403, 404, 405, 409, 422, 429, 500 |
| Codificar JSON | json_encode($x, JSON_UNESCAPED_UNICODE \| JSON_THROW_ON_ERROR) |
| Decodificar JSON | json_decode($json, true, flags: JSON_THROW_ON_ERROR) |
| Objetos a JSON | implements JsonSerializable + jsonSerialize() |
| Leer el cuerpo | file_get_contents('php://input') |
| Responder | http_response_code(201); header('Content-Type: application/json'); |
| Errores (RFC 9457) | application/problem+json con type, title, status, detail, instance |
| Front controller | public/index.php + php -S localhost:8000 -t public public/index.php |
| CORS | Access-Control-Allow-Origin con lista blanca + respuesta a OPTIONS |
| Autenticación | Authorization: Bearer <token>; guardar hash('sha256', $token) |
| Probar | curl -i -X POST ... -H ... -d '{...}', ficheros .http, Postman |
| Cliente PHP | file_get_contents() + stream_context_create(), cURL, Guzzle |
| Cabeceras de respuesta (8.4+) | http_get_last_response_headers() |
| Cliente navegador | fetch(url, { method, headers, body: JSON.stringify(datos) }) |
| Versionado | Prefijo en la URI: /api/v1/ |