Una API REST en PHP sin framework, con SQLite

API REST en PHP: no hace falta un framework para entender cómo funciona una API. En un solo archivo de unas 60 líneas cabe una API de tareas con rutas, JSON, códigos de estado, validación y base de datos, y se prueba con curl.

El código completo

Toda la API está en un archivo, router.php, que recibe todas las peticiones. Se arranca con el servidor integrado (php -S localhost:8000 router.php); en producción, Nginx enviaría todas las peticiones a ese mismo archivo, como en el proyecto del curso.

<?php
declare(strict_types=1);

// API REST mínima de tareas: GET, POST y DELETE sobre /api/tareas, con SQLite.
header('Content-Type: application/json; charset=utf-8');

function responder(int $codigo, array $datos): never
{
    http_response_code($codigo);
    echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), "\n";
    exit;
}

$pdo = new PDO('sqlite:' . sys_get_temp_dir() . '/api-tareas.sqlite', options: [PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]);
$pdo->exec('CREATE TABLE IF NOT EXISTS tareas (id INTEGER PRIMARY KEY, texto TEXT NOT NULL, hecha INTEGER NOT NULL DEFAULT 0)');

$metodo = $_SERVER['REQUEST_METHOD'];
$ruta = (string) parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);

// /api/tareas o /api/tareas/{id}
if (preg_match('#^/api/tareas(?:/(\d+))?$#', $ruta, $m) !== 1) {
    responder(404, ['error' => 'Ruta desconocida']);
}
$id = isset($m[1]) ? (int) $m[1] : null;

if ($metodo === 'GET' && $id === null) {
    $tareas = $pdo->query('SELECT id, texto, hecha FROM tareas ORDER BY id')->fetchAll();
    responder(200, array_map(fn(array $t): array => ['id' => (int) $t['id'], 'texto' => $t['texto'], 'hecha' => (bool) $t['hecha']], $tareas));
}

if ($metodo === 'GET') {
    $consulta = $pdo->prepare('SELECT id, texto, hecha FROM tareas WHERE id = ?');
    $consulta->execute([$id]);
    $t = $consulta->fetch() ?: responder(404, ['error' => "No existe la tarea $id"]);
    responder(200, ['id' => (int) $t['id'], 'texto' => $t['texto'], 'hecha' => (bool) $t['hecha']]);
}

if ($metodo === 'POST' && $id === null) {
    try {
        $entrada = json_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        responder(400, ['error' => 'El cuerpo no es JSON válido']);
    }
    $texto = is_array($entrada) && is_string($entrada['texto'] ?? null) ? trim($entrada['texto']) : '';
    if ($texto === '' || mb_strlen($texto) > 200) {
        responder(422, ['error' => 'El campo «texto» es obligatorio y como mucho de 200 caracteres']);
    }
    $pdo->prepare('INSERT INTO tareas (texto) VALUES (?)')->execute([$texto]);
    $nuevo = (int) $pdo->lastInsertId();
    header("Location: /api/tareas/$nuevo");
    responder(201, ['id' => $nuevo, 'texto' => $texto, 'hecha' => false]);
}

if ($metodo === 'DELETE' && $id !== null) {
    $borrado = $pdo->prepare('DELETE FROM tareas WHERE id = ?');
    $borrado->execute([$id]);
    $borrado->rowCount() === 1 ? responder(200, ['borrada' => $id]) : responder(404, ['error' => "No existe la tarea $id"]);
}

header('Allow: GET, POST, DELETE');
responder(405, ['error' => "Método $metodo no permitido aquí"]);

Lo esencial de una API REST está en tres decisiones:

  • La ruta y el método dicen qué hacer: GET /api/tareas lista, GET /api/tareas/2 devuelve una, POST crea y DELETE borra. La expresión regular separa el id de la ruta.
  • El código de estado dice qué pasó: 200 bien, 201 creado (con la cabecera Location de lo nuevo), 400 petición mal formada, 404 no existe, 405 método no permitido, 422 datos que no pasan la validación.
  • Todo es JSON, también los errores, con la cabecera Content-Type: application/json. La función responder, de tipo never, envía la respuesta y termina, así que ninguna rama sigue ejecutándose por error.

Probarla con curl

Crear una tarea:

curl -i -X POST -H 'Content-Type: application/json' -d '{"texto": "Comprar café"}' localhost:8000/api/tareasHTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /api/tareas/1

{"id":1,"texto":"Comprar café","hecha":false}

Después de crear una segunda, la lista y una sola:

curl -i localhost:8000/api/tareasHTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

[{"id":1,"texto":"Comprar café","hecha":false},{"id":2,"texto":"Llamar al banco","hecha":false}]
curl -i localhost:8000/api/tareas/2HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"id":2,"texto":"Llamar al banco","hecha":false}

Borrar, y lo que pasa con una tarea que no existe o un método que la ruta no admite:

curl -i -X DELETE localhost:8000/api/tareas/1HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{"borrada":1}
curl -i localhost:8000/api/tareas/99HTTP/1.1 404 Not Found
Content-Type: application/json; charset=utf-8

{"error":"No existe la tarea 99"}
curl -i -X PUT localhost:8000/api/tareas/2HTTP/1.1 405 Method Not Allowed
Content-Type: application/json; charset=utf-8
Allow: GET, POST, DELETE

{"error":"Método PUT no permitido aquí"}

Validar lo que llega

Una API recibe peticiones de cualquiera, no solo de tu aplicación. Por eso el código no se fía del cuerpo: comprueba que sea JSON, que sea un objeto, que texto sea un texto y que tenga una longitud razonable:

cuerpo que no es JSONHTTP/1.1 400 Bad Request
Content-Type: application/json; charset=utf-8

{"error":"El cuerpo no es JSON válido"}
texto vacíoHTTP/1.1 422 Unknown Status Code
Content-Type: application/json; charset=utf-8

{"error":"El campo «texto» es obligatorio y como mucho de 200 caracteres"}

Ese «Unknown Status Code» del 422 lo escribe el servidor integrado de PHP, que no conoce el nombre de ese código; Nginx o Apache ponen «Unprocessable Content». Lo que leen los programas es el número. Y en la base de datos, consultas preparadas siempre: el texto de la tarea viaja como dato, nunca dentro del SQL.

Lo que falta para producción. Esta API no tiene usuarios: cualquiera podría crear o borrar tareas. Una API real añade autenticación (un token en la cabecera Authorization), comprueba en cada consulta que la tarea es de ese usuario, como en el proyecto, y limita cuántas peticiones acepta por minuto. Para algo más grande, un framework como Slim, Symfony o Laravel resuelve las rutas, la validación y los errores por ti.

Resumen

ParaUsa
Responder JSONheader('Content-Type: application/json') y json_encode
El código de estadohttp_response_code(201)
Leer un cuerpo JSONjson_decode(file_get_contents('php://input'), true, flags: JSON_THROW_ON_ERROR)
Rutas con idpreg_match('#^/api/tareas(?:/(\d+))?$#', $ruta, $m)
Método no permitido405 y la cabecera Allow

Todo el código de esta página se ejecutó con PHP 8.5.12 en un contenedor limpio: la aplicación, con el servidor integrado de PHP (php -S) y sin acceso a la red, y las peticiones, con curl desde el mismo contenedor. Las respuestas están copiadas de esa ejecución.