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() y json_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/tareas contra 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 fetch en 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 POST dos 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 con array_filter(), los índices dejan de ser consecutivos y obtendrás un objeto: usa array_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 devuelve null… igual que el JSON válido "null", y tienes que acordarte de llamar a json_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: $_POST solo se rellena con formularios (application/x-www-form-urlencoded o multipart/form-data). Cuando un cliente envía JSON, PHP no lo decodifica automáticamente: tienes que leer el cuerpo en bruto con file_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+json y estos campos: type (URI que identifica el tipo de error), title (resumen), status (código HTTP), detail (explicación concreta) e instance (la URI afectada). Puedes añadir campos propios, como errors.

{
    "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: un PDOException podrí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 (un 0/1 en 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 PUT el cliente envía la representación completa y lo que no envíe se considera vacío (por eso ponemos descripcion a null si falta). Con PATCH solo 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.es no puede leer respuestas de https://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 nuestro cliente.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 Authorization a PHP: si $_SERVER['HTTP_AUTHORIZATION'] llega vacía, añade en el .htaccess la regla CGIPassAuth 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.http en 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 usaras innerHTML, 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/