API REST de tareas con un router propio
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
- 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, escribePetición no válida. La respuesta se escribe comoMÉ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). - Una tarea es
{"id": n, "titulo": texto, "prioridad": alta|media|baja, "hecha": booleano}. Los id empiezan en 1 y no se reutilizan. GET /api/tareas→ 200 con el array de tareas por orden de id; admite los filtros?hecha=true|falsey?q=texto(título que contiene el texto sin distinguir mayúsculas), combinables.GET /api/tareas/{id}→ 200 con la tarea.POST /api/tareascrea 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.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.- 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
| Método | Ruta | Respuesta correcta |
|---|---|---|
| GET | /api/tareas?hecha=&q= | 200 + lista |
| POST | /api/tareas | 201 + 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}
POSTSalida 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álidaGuí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.
$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.
Ejemplo
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/
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}]Solución explicada
Ver la solución completa
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.phpy pruébala con curl o con fetch desde una página.