Tema
API REST JSON en PHP
Una API REST pequeña pero bien hecha, sin frameworks: un router que entiende GET, POST, PUT, PATCH y DELETE, parámetros en la URL (/api/productos/15), respuestas JSON con códigos HTTP correctos, CORS y autenticación por token.
Ideal para alimentar una app móvil, un frontend en React/Vue o un sitio estático que necesite datos dinámicos.
Estructura
txt
api/
├── .htaccess ← manda todo a index.php
├── index.php ← define las rutas
├── Router.php ← router mínimo
└── controladores/
└── productos.phpRouter
php
<?php
final class Router
{
private array $rutas = [];
public function __construct(private string $prefijo = '') {}
public function get(string $ruta, callable $accion): void { $this->agregar('GET', $ruta, $accion); }
public function post(string $ruta, callable $accion): void { $this->agregar('POST', $ruta, $accion); }
public function put(string $ruta, callable $accion): void { $this->agregar('PUT', $ruta, $accion); }
public function patch(string $ruta, callable $accion): void { $this->agregar('PATCH', $ruta, $accion); }
public function delete(string $ruta, callable $accion): void { $this->agregar('DELETE', $ruta, $accion); }
private function agregar(string $metodo, string $ruta, callable $accion): void
{
// Convierte "/productos/{id}" en la expresión regular "#^/productos/(?P<id>[^/]+)$#"
$regex = preg_replace('#\{(\w+)\}#', '(?P<$1>[^/]+)', $this->prefijo . $ruta);
$this->rutas[] = [$metodo, '#^' . $regex . '$#', $accion];
}
public function despachar(string $metodo, string $uri): void
{
$ruta = rtrim(parse_url($uri, PHP_URL_PATH), '/') ?: '/';
$metodoPermitido = false;
foreach ($this->rutas as [$m, $regex, $accion]) {
if (preg_match($regex, $ruta, $coincidencias)) {
if ($m !== $metodo) {
$metodoPermitido = true;
continue;
}
// Sólo los grupos con nombre ({id}, {slug}...)
$params = array_filter($coincidencias, 'is_string', ARRAY_FILTER_USE_KEY);
$accion(...$params);
return;
}
}
$metodoPermitido
? responder(['error' => 'Método no permitido'], 405)
: responder(['error' => 'Ruta no encontrada'], 404);
}
}
/** Envía JSON y termina */
function responder(mixed $datos, int $codigo = 200): never
{
http_response_code($codigo);
if ($codigo !== 204) {
echo json_encode($datos, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
exit;
}
/** Lee el cuerpo JSON de la petición */
function cuerpo_json(): array
{
$datos = json_decode(file_get_contents('php://input'), true);
if (!is_array($datos)) {
responder(['error' => 'JSON no válido'], 400);
}
return $datos;
}Punto de entrada y rutas
php
<?php
declare(strict_types=1);
require __DIR__ . '/../includes/db.php';
require __DIR__ . '/Router.php';
require __DIR__ . '/controladores/productos.php';
header('Content-Type: application/json; charset=utf-8');
// --- CORS: permite que tu frontend (otro dominio) consuma la API ---
$origenesPermitidos = ['http://localhost:5173', 'https://www.tudominio.com'];
$origen = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origen, $origenesPermitidos, true)) {
header("Access-Control-Allow-Origin: $origen");
header('Vary: Origin');
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
responder(null, 204); // respuesta al "preflight" del navegador
}
// --- Autenticación simple por token (Authorization: Bearer xxx) ---
function requiere_token(): void
{
$cabecera = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
$token = str_starts_with($cabecera, 'Bearer ') ? substr($cabecera, 7) : '';
if (!$token || !hash_equals((string) env('API_TOKEN'), $token)) {
responder(['error' => 'No autorizado'], 401);
}
}
// --- Errores no controlados → JSON 500 ---
set_exception_handler(function (Throwable $e) {
error_log($e->getMessage());
responder(['error' => env('APP_ENV') === 'local' ? $e->getMessage() : 'Error interno'], 500);
});
$router = new Router('/api');
$router->get('/productos', 'productos_listar');
$router->get('/productos/{id}', 'productos_ver');
$router->post('/productos', function () { requiere_token(); productos_crear(); });
$router->put('/productos/{id}', function (string $id) { requiere_token(); productos_actualizar($id); });
$router->delete('/productos/{id}', function (string $id) { requiere_token(); productos_eliminar($id); });
$router->get('/', fn() => responder(['api' => 'Mi tienda', 'version' => '1.0']));
$router->despachar($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']);Controlador
php
<?php
function productos_listar(): void
{
$porPagina = min(50, max(1, (int) ($_GET['limite'] ?? 20)));
$pagina = max(1, (int) ($_GET['pagina'] ?? 1));
$offset = ($pagina - 1) * $porPagina;
$total = (int) db()->query('SELECT COUNT(*) FROM productos WHERE activo = 1')->fetchColumn();
$stmt = db()->prepare("SELECT id, nombre, precio, stock FROM productos WHERE activo = 1
ORDER BY id DESC LIMIT $porPagina OFFSET $offset");
$stmt->execute();
responder([
'data' => array_map('formatear_producto', $stmt->fetchAll()),
'meta' => ['pagina' => $pagina, 'limite' => $porPagina, 'total' => $total,
'paginas' => (int) ceil($total / $porPagina)],
]);
}
function productos_ver(string $id): void
{
$stmt = db()->prepare('SELECT id, nombre, descripcion, precio, stock FROM productos WHERE id = ?');
$stmt->execute([(int) $id]);
$p = $stmt->fetch();
$p ? responder(['data' => formatear_producto($p)]) : responder(['error' => 'Producto no encontrado'], 404);
}
function productos_crear(): void
{
$d = cuerpo_json();
$errores = validar_producto($d);
if ($errores) responder(['error' => 'Datos no válidos', 'campos' => $errores], 422);
db()->prepare('INSERT INTO productos (nombre, descripcion, precio, stock) VALUES (?, ?, ?, ?)')
->execute([trim($d['nombre']), $d['descripcion'] ?? null, $d['precio'], $d['stock'] ?? 0]);
$id = (int) db()->lastInsertId();
header("Location: /api/productos/$id");
productos_ver_creado($id);
}
function productos_ver_creado(int $id): void
{
$stmt = db()->prepare('SELECT id, nombre, descripcion, precio, stock FROM productos WHERE id = ?');
$stmt->execute([$id]);
responder(['data' => formatear_producto($stmt->fetch())], 201);
}
function productos_actualizar(string $id): void
{
$d = cuerpo_json();
$errores = validar_producto($d);
if ($errores) responder(['error' => 'Datos no válidos', 'campos' => $errores], 422);
$stmt = db()->prepare('UPDATE productos SET nombre = ?, descripcion = ?, precio = ?, stock = ? WHERE id = ?');
$stmt->execute([trim($d['nombre']), $d['descripcion'] ?? null, $d['precio'], $d['stock'] ?? 0, (int) $id]);
productos_ver($id);
}
function productos_eliminar(string $id): void
{
$stmt = db()->prepare('DELETE FROM productos WHERE id = ?');
$stmt->execute([(int) $id]);
$stmt->rowCount() ? responder(null, 204) : responder(['error' => 'Producto no encontrado'], 404);
}
function validar_producto(array $d): array
{
$e = [];
if (empty(trim($d['nombre'] ?? ''))) $e['nombre'] = 'Obligatorio';
if (!isset($d['precio']) || !is_numeric($d['precio']) || $d['precio'] < 0) $e['precio'] = 'Debe ser un número positivo';
if (isset($d['stock']) && !is_int($d['stock'])) $e['stock'] = 'Debe ser entero';
return $e;
}
function formatear_producto(array $p): array
{
// Tipos correctos en el JSON (PDO devuelve DECIMAL como texto)
return [
'id' => (int) $p['id'],
'nombre' => $p['nombre'],
'descripcion' => $p['descripcion'] ?? null,
'precio' => (float) $p['precio'],
'stock' => (int) $p['stock'],
];
}.htaccess
apache
# api/.htaccess
RewriteEngine On
# Pasa la cabecera Authorization a PHP (algunos hostings la eliminan)
RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.php [QSA,L]Probar la API
bash
# Listar
curl http://localhost/api/productos?pagina=1&limite=5
# Crear (con token)
curl -X POST http://localhost/api/productos \
-H "Content-Type: application/json" \
-H "Authorization: Bearer mi-token-secreto" \
-d '{"nombre":"Taza","precio":129.5,"stock":20}'
# Eliminar
curl -X DELETE http://localhost/api/productos/3 -H "Authorization: Bearer mi-token-secreto"Desde JavaScript:
js
const res = await fetch('/api/productos?pagina=1');
const { data, meta } = await res.json();
console.log(data, meta.total);Códigos HTTP que debes usar
| Código | Cuándo |
|---|---|
| 200 OK | Lectura o actualización correcta |
| 201 Created | Recurso creado (con cabecera Location) |
| 204 No Content | Eliminado, sin cuerpo |
| 400 Bad Request | JSON mal formado |
| 401 Unauthorized | Falta token o es inválido |
| 403 Forbidden | Token válido pero sin permiso |
| 404 Not Found | No existe la ruta o el recurso |
| 422 Unprocessable | Falló la validación |
| 500 Internal Error | Error del servidor |
Buena práctica
Agrega API_TOKEN=un-valor-largo-y-aleatorio al .env (genera uno con php -r "echo bin2hex(random_bytes(32));"). Para APIs con usuarios reales usa tokens por usuario guardados en la base de datos o JWT.
Error común
Imprimir un echo o un var_dump de depuración antes del JSON. El cliente recibirá JSON inválido. Usa error_log() para depurar.