Apuntes DAM
Volver al inicio

API REST de tareas con un router propio

Ejercicio de PHPDifícilUnos 90 minutos

Implementa la API REST de una lista de tareas sin framework: un router con rutas como /api/tareas/{id}, los métodos GET, POST, PATCH y DELETE, filtros por la query string, validación con 422, JSON mal formado con 400 y la diferencia entre 404 y 405. Clases, closures, expresiones regulares y JSON.

  • REST y códigos de estado HTTP
  • Closures y use (&$x)
  • Promoción de propiedades en el constructor
  • preg_match con grupos con nombre
  • json_encode y json_decode
  • parse_url y parse_str

Enunciado

Una aplicación móvil de tareas necesita un backend. La API sigue el estilo REST: la URL identifica el recurso (/api/tareas es la colección, /api/tareas/3 una tarea) y el método HTTP dice qué hacer con él. Las respuestas son JSON y el código de estado HTTP indica el resultado: 200 bien, 201 creado, 204 borrado sin contenido, 400 petición mal formada, 404 no existe, 405 método no permitido y 422 datos que no pasan la validación.

Los frameworks (Laravel, Symfony, Slim) traen un router que asocia cada ruta con su código. Aquí vas a escribir uno pequeño: cada ruta es un patrón como /api/tareas/{id} que se convierte en una expresión regular, y su acción es una closure.

Para probarlo sin servidor web, cada línea de la entrada es una petición HTTP simplificada (método, URL y cuerpo JSON), y el programa escribe la respuesta. Las tareas se guardan en memoria: una base de datos sería el siguiente paso.

Qué tiene que hacer el programa

  1. Cada línea es MÉTODO URL [cuerpo JSON] (el cuerpo es todo lo que va tras el segundo espacio). Si la línea no tiene al menos método y URL, escribe Petición no válida. La respuesta se escribe como MÉTODO URL → estado cuerpo, con el método en mayúsculas, la URL tal como venía y el cuerpo en JSON compacto sin escapar tildes ni barras; las respuestas 204 no llevan cuerpo. Una barra al final de la ruta no cuenta (/api/tareas/ es /api/tareas).
  2. Una tarea es {"id": n, "titulo": texto, "prioridad": alta|media|baja, "hecha": booleano}. Los id empiezan en 1 y no se reutilizan.
  3. GET /api/tareas → 200 con el array de tareas por orden de id; admite los filtros ?hecha=true|false y ?q=texto (título que contiene el texto sin distinguir mayúsculas), combinables. GET /api/tareas/{id} → 200 con la tarea.
  4. POST /api/tareas crea una tarea → 201 con la tarea. El título es obligatorio y, como los demás campos, se valida: título de 3 a 60 caracteres (tras quitar espacios de los extremos, que no se guardan), prioridad alta, media o baja (por defecto media), hecha true o false (por defecto false), y ningún otro campo (campo desconocido). Los errores devuelven 422 con {"errores": {campo: mensaje}}: obligatorio, entre 3 y 60 caracteres, debe ser alta, media o baja, debe ser true o false.
  5. PATCH /api/tareas/{id} cambia solo los campos que vienen (con las mismas reglas, sin título obligatorio) → 200 con la tarea completa. DELETE /api/tareas/{id} → 204.
  6. Si el cuerpo de un POST o PATCH no es un objeto JSON → 400 {"error": "JSON no válido"}. Si la tarea no existe → 404 {"error": "No existe la tarea N"}. Si la ruta no existe → 404 {"error": "Ruta no encontrada"}; si existe pero no con ese método → 405 {"error": "Método no permitido"}.

Entrada

Una petición por línea: MÉTODO URL y, en POST y PATCH, el cuerpo JSON.

Datos de referencia

Rutas de la API
MétodoRutaRespuesta correcta
GET/api/tareas?hecha=&q=200 + lista
POST/api/tareas201 + tarea creada
GET/api/tareas/{id}200 + tarea
PATCH/api/tareas/{id}200 + tarea modificada
DELETE/api/tareas/{id}204 sin cuerpo

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Crear, consultar, modificar y borrar

Entrada

POST /api/tareas {"titulo": "Preparar el examen de DWES", "prioridad": "alta"}
POST /api/tareas {"titulo": "  Comprar café  "}
GET /api/tareas
PATCH /api/tareas/2 {"hecha": true}
GET /api/tareas?hecha=true
GET /api/tareas?q=EXAMEN
DELETE /api/tareas/1
GET /api/tareas/1
GET /api/tareas/

Salida por consola

POST /api/tareas → 201 {"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}
POST /api/tareas → 201 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}
GET /api/tareas → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false},{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}]
PATCH /api/tareas/2 → 200 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}
GET /api/tareas?hecha=true → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]
GET /api/tareas?q=EXAMEN → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}]
DELETE /api/tareas/1 → 204
GET /api/tareas/1 → 404 {"error":"No existe la tarea 1"}
GET /api/tareas/ → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]

Errores de la API

Entrada

POST /api/tareas {"titulo": "ab", "prioridad": "urgente", "color": "rojo"}
POST /api/tareas {titulo: mal}
POST /api/tareas [1, 2]
POST /api/tareas {"prioridad": "baja"}
PUT /api/tareas
GET /api/usuarios
PATCH /api/tareas/9 {"hecha": true}
POST

Salida por consola

POST /api/tareas → 422 {"errores":{"titulo":"entre 3 y 60 caracteres","prioridad":"debe ser alta, media o baja","color":"campo desconocido"}}
POST /api/tareas → 400 {"error":"JSON no válido"}
POST /api/tareas → 400 {"error":"JSON no válido"}
POST /api/tareas → 422 {"errores":{"titulo":"obligatorio"}}
PUT /api/tareas → 405 {"error":"Método no permitido"}
GET /api/usuarios → 404 {"error":"Ruta no encontrada"}
PATCH /api/tareas/9 → 404 {"error":"No existe la tarea 9"}
Petición no válida

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. Del patrón a la expresión regular

Sustituye cada {nombre} por un grupo con nombre que acepte cualquier cosa menos la barra, y ancla el resultado al principio y al final. Los grupos con nombre aparecen en el array de preg_match con su nombre como clave.

php
$regex = "#^" . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $this->patron) . "$#";
if (!preg_match($regex, $ruta, $m)) return null;
return array_filter($m, "is_string", ARRAY_FILTER_USE_KEY);
2. 404 o 405

Recorre las rutas: si una encaja con la URL, apunta que la ruta existe y, si además coincide el método, ejecuta su acción. Si al final la ruta existía pero ningún método coincidía, la respuesta es 405.

3. Closures que comparten estado

Las acciones son funciones anónimas que necesitan el array de tareas. Con use (&$tareas) reciben una referencia: si la modifican, cambia el array de verdad (sin & trabajarían sobre una copia).

4. Leer y validar el JSON

json_decode($cuerpo, true) devuelve un array asociativo, o null si el JSON está mal. Comprueba además que sea un objeto y no una lista (array_is_list). Valida con una función que devuelva el array de errores: si no está vacío, responde 422.

5. La URL y la query string

parse_url($url, PHP_URL_PATH) da la ruta y PHP_URL_QUERY la query string, que parse_str convierte en array. json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) escribe tildes y barras tal cual.

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

🐘PHPAPI REST de tareas con un router propioDifícil

Ejemplo

Entrada (lo que se escribe por teclado)
POST /api/tareas {"titulo": "Preparar el examen de DWES", "prioridad": "alta"}
POST /api/tareas {"titulo": "  Comprar café  "}
GET /api/tareas
PATCH /api/tareas/2 {"hecha": true}
GET /api/tareas?hecha=true
GET /api/tareas?q=EXAMEN
DELETE /api/tareas/1
GET /api/tareas/1
GET /api/tareas/
Salida esperada
POST /api/tareas → 201 {"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}
POST /api/tareas → 201 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}
GET /api/tareas → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false},{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":false}]
PATCH /api/tareas/2 → 200 {"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}
GET /api/tareas?hecha=true → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]
GET /api/tareas?q=EXAMEN → 200 [{"id":1,"titulo":"Preparar el examen de DWES","prioridad":"alta","hecha":false}]
DELETE /api/tareas/1 → 204
GET /api/tareas/1 → 404 {"error":"No existe la tarea 1"}
GET /api/tareas/ → 200 [{"id":2,"titulo":"Comprar café","prioridad":"media","hecha":true}]
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
php
1<?php
2/** Una ruta: método, patrón con {parámetros} y la función que la atiende. */
3final class Ruta {
4    public function __construct(public string $metodo, public string $patron, public Closure $accion) {}
5
6    /** Los parámetros de la ruta si la URL encaja con el patrón, o null. */
7    public function encaja(string $ruta): ?array {
8        $regex = "#^" . preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $this->patron) . "$#";
9        if (!preg_match($regex, $ruta, $m)) return null;
10        return array_filter($m, "is_string", ARRAY_FILTER_USE_KEY);
11    }
12}
13
14final class Respuesta {
15    public function __construct(public int $estado, public mixed $cuerpo = null) {}
16}
17
18final class Router {
19    /** @var Ruta[] */
20    private array $rutas = [];
21
22    public function add(string $metodo, string $patron, Closure $accion): void {
23        $this->rutas[] = new Ruta($metodo, $patron, $accion);
24    }
25
26    public function despachar(string $metodo, string $ruta, array $consulta, ?string $cuerpo): Respuesta {
27        $rutaExiste = false;
28        foreach ($this->rutas as $r) {
29            $params = $r->encaja($ruta);
30            if ($params === null) continue;
31            $rutaExiste = true;
32            if ($r->metodo === $metodo) return ($r->accion)($params, $consulta, $cuerpo);
33        }
34        // Si la ruta existe pero no con ese método, es 405, no 404
35        return $rutaExiste ? new Respuesta(405, ["error" => "Método no permitido"]) : new Respuesta(404, ["error" => "Ruta no encontrada"]);
36    }
37}
38
39$tareas = [];
40$siguiente = 1;
41const PRIORIDADES = ["alta", "media", "baja"];
42
43/** Valida los campos que vienen en $datos; con $nueva, el título es obligatorio. Devuelve los errores. */
44function validar(array $datos, bool $nueva): array {
45    $errores = [];
46    if ($nueva && !array_key_exists("titulo", $datos)) {
47        $errores["titulo"] = "obligatorio";
48    } elseif (array_key_exists("titulo", $datos) && (!is_string($datos["titulo"]) || mb_strlen(trim($datos["titulo"])) < 3 || mb_strlen(trim($datos["titulo"])) > 60)) {
49        $errores["titulo"] = "entre 3 y 60 caracteres";
50    }
51    if (array_key_exists("prioridad", $datos) && !in_array($datos["prioridad"], PRIORIDADES, true)) {
52        $errores["prioridad"] = "debe ser alta, media o baja";
53    }
54    if (array_key_exists("hecha", $datos) && !is_bool($datos["hecha"])) {
55        $errores["hecha"] = "debe ser true o false";
56    }
57    $permitidos = ["titulo", "prioridad", "hecha"];
58    foreach (array_diff(array_keys($datos), $permitidos) as $campo) $errores[$campo] = "campo desconocido";
59    return $errores;
60}
61
62/** El cuerpo JSON como array asociativo, o null si no es un objeto JSON. */
63function json(?string $cuerpo): ?array {
64    $datos = json_decode($cuerpo ?? "", true);
65    return is_array($datos) && !array_is_list($datos) ? $datos : (($cuerpo ?? "") === "{}" ? [] : null);
66}
67
68$router = new Router();
69
70$router->add("GET", "/api/tareas", function (array $p, array $q) use (&$tareas): Respuesta {
71    $lista = array_values($tareas);
72    if (isset($q["hecha"])) $lista = array_values(array_filter($lista, fn($t) => $t["hecha"] === ($q["hecha"] === "true")));
73    if (isset($q["q"])) $lista = array_values(array_filter($lista, fn($t) => mb_stripos($t["titulo"], $q["q"]) !== false));
74    return new Respuesta(200, $lista);
75});
76
77$router->add("POST", "/api/tareas", function (array $p, array $q, ?string $cuerpo) use (&$tareas, &$siguiente): Respuesta {
78    $datos = json($cuerpo);
79    if ($datos === null) return new Respuesta(400, ["error" => "JSON no válido"]);
80    if ($errores = validar($datos, true)) return new Respuesta(422, ["errores" => $errores]);
81    $tarea = ["id" => $siguiente++, "titulo" => trim($datos["titulo"]), "prioridad" => $datos["prioridad"] ?? "media", "hecha" => $datos["hecha"] ?? false];
82    $tareas[$tarea["id"]] = $tarea;
83    return new Respuesta(201, $tarea);
84});
85
86$router->add("GET", "/api/tareas/{id}", function (array $p) use (&$tareas): Respuesta {
87    return isset($tareas[$p["id"]]) ? new Respuesta(200, $tareas[$p["id"]]) : new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]);
88});
89
90$router->add("PATCH", "/api/tareas/{id}", function (array $p, array $q, ?string $cuerpo) use (&$tareas): Respuesta {
91    if (!isset($tareas[$p["id"]])) return new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]);
92    $datos = json($cuerpo);
93    if ($datos === null) return new Respuesta(400, ["error" => "JSON no válido"]);
94    if ($errores = validar($datos, false)) return new Respuesta(422, ["errores" => $errores]);
95    if (isset($datos["titulo"])) $datos["titulo"] = trim($datos["titulo"]);
96    $tareas[$p["id"]] = array_merge($tareas[$p["id"]], $datos);
97    return new Respuesta(200, $tareas[$p["id"]]);
98});
99
100$router->add("DELETE", "/api/tareas/{id}", function (array $p) use (&$tareas): Respuesta {
101    if (!isset($tareas[$p["id"]])) return new Respuesta(404, ["error" => "No existe la tarea {$p['id']}"]);
102    unset($tareas[$p["id"]]);
103    return new Respuesta(204);
104});
105
106foreach (explode("\n", stream_get_contents(STDIN)) as $linea) {
107    $linea = trim($linea);
108    if ($linea === "") continue;
109    $partes = explode(" ", $linea, 3);
110    if (count($partes) < 2) {
111        echo "Petición no válida\n";
112        continue;
113    }
114    [$metodo, $url] = $partes;
115    $ruta = parse_url($url, PHP_URL_PATH) ?? "";
116    parse_str(parse_url($url, PHP_URL_QUERY) ?? "", $consulta);
117    $r = $router->despachar(strtoupper($metodo), rtrim($ruta, "/") ?: "/", $consulta, $partes[2] ?? null);
118    $json = $r->cuerpo === null ? "" : " " . json_encode($r->cuerpo, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
119    echo strtoupper($metodo) . " $url → {$r->estado}$json\n";
120}

El router separa el «a dónde va cada petición» del «qué hace cada una»: registrar una ruta nueva no toca nada más. Convertir los patrones en expresiones regulares con grupos con nombre es exactamente lo que hacen los routers de los frameworks.

Los códigos de estado son parte de la API: un cliente decide qué hacer según el código antes de leer el cuerpo. Distinguir 400 (no entiendo la petición), 404 (no existe), 405 (existe, pero no así) y 422 (entiendo la petición, pero los datos no valen) es lo que hace una API predecible.

Las closures con use (&$tareas) comparten el almacén en memoria; en una aplicación real ese estado viviría en una base de datos y se inyectaría un repositorio, pero la estructura de las acciones sería la misma.

La validación es una función que acumula errores por campo en lugar de parar en el primero: el cliente puede mostrar todos los problemas del formulario de una vez. PATCH reutiliza las mismas reglas sin exigir el título.

Para ir más allá

  • Guarda las tareas en SQLite con PDO y paginación (?pagina=2&tam=10).
  • Protege la API con un token en la cabecera Authorization y responde 401 si falta.
  • Sirve la API de verdad con php -S localhost:8000 index.php y pruébala con curl o con fetch desde una página.

Dónde se explica