Programación Web ITI-07

Cómo construir y consumir una API REST de Usuarios en PHP


Una API REST sencilla pero completa: sin frameworks, con caché, CRUD completo y respuestas JSON estandarizadas. En este post te explico cómo la construí y cómo consumirla desde cualquier cliente.


¿Qué hace esta API?

  • CRUD completo sobre usuarios (GET, POST, PUT, DELETE)
  • Caché en archivo JSON con TTL de 5 minutos
  • Filtros en GET: por ID, por rol, búsqueda por nombre, límite de resultados y selección de campos
  • CORS habilitado para cualquier origen
  • Respuestas estandarizadas con status, code, message y data

Estructura de archivos

/
├── api.php          # Router principal y manejadores HTTP
├── helpers.php      # Funciones reutilizables (caché, validaciones, respuestas)
├── usuarios.json    # "Base de datos" en JSON
└── cache.json       # Almacén de caché

El código

helpers.php — El motor de la API

Contiene todas las funciones auxiliares organizadas en bloques:

Constantes y rutas de archivos:

define('USUARIOS_FILE', __DIR__ . '/usuarios.json');
define('CACHE_FILE',    __DIR__ . '/cache.json');
define('CACHE_TTL',     300); // 5 minutos

Lectura y escritura de usuarios:

function leerUsuarios() {
    if (!file_exists(USUARIOS_FILE)) {
        return ['usuarios' => []];
    }
    $json = file_get_contents(USUARIOS_FILE);
    return json_decode($json, true) ?? ['usuarios' => []];
}

function guardarUsuarios($data) {
    $json = json_encode(
        ['usuarios' => $data],
        JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE
    );
    file_put_contents(USUARIOS_FILE, $json);
}

Sistema de caché:

// Obtener dato del caché (devuelve null si expiró o no existe)
function obtenerDelCache($clave) {
    $cache = limpiarCacheExpirado();
    if (!isset($cache[$clave])) return null;

    $item  = $cache[$clave];
    $ahora = time();

    if (isset($item['timestamp']) && ($ahora - $item['timestamp'] > CACHE_TTL)) {
        return null;
    }

    return $item['data'] ?? null;
}

// Guardar un dato en caché con timestamp actual
function guardarEnCache($clave, $datos) {
    $cache = leerCache();
    $cache[$clave] = [
        'data'      => $datos,
        'timestamp' => time()
    ];
    guardarCache($cache);
}

// Invalida todo el caché (o solo una clave)
function invalidarCache($clave = null) {
    if ($clave === null) {
        guardarCache([]);
        return;
    }
    $cache = leerCache();
    unset($cache[$clave]);
    guardarCache($cache);
}

El caché se invalida automáticamente en cada operación de escritura (POST, PUT, DELETE), y se limpia de entradas expiradas antes de cada lectura.

Validaciones:

function validarCamposObligatorios($datos, $campos = ['nombre', 'email', 'rol']) {
    foreach ($campos as $campo) {
        if (!isset($datos[$campo]) || trim($datos[$campo]) === '') {
            return ['valido' => false, 'mensaje' => "El campo '$campo' es obligatorio"];
        }
    }
    return ['valido' => true];
}

function validarEmail($email) {
    return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}

Respuesta estandarizada:

function responder($status, $code, $mensaje = '', $datos = null) {
    http_response_code($code);

    $respuesta = ['status' => $status, 'code' => $code];

    if ($mensaje) $respuesta['message'] = $mensaje;

    if ($datos !== null) {
        if (is_array($datos) && isset($datos['total'])) {
            $respuesta['total'] = $datos['total'];
            $respuesta['data']  = $datos['data'];
        } else {
            $respuesta['data'] = $datos;
        }
    }

    echo json_encode($respuesta, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
    exit;
}

api.php — El router

Maneja los headers CORS, detecta el método HTTP y delega a la función correspondiente:

header('Content-Type: application/json; charset=UTF-8');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');

if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(200);
    exit;
}

require_once __DIR__ . '/helpers.php';

$metodo = $_SERVER['REQUEST_METHOD'];
$id     = $_GET['id'] ?? null;

switch ($metodo) {
    case 'GET':    manejarGET($id);    break;
    case 'POST':   manejarPOST();      break;
    case 'PUT':    manejarPUT($id);    break;
    case 'DELETE': manejarDELETE($id); break;
    default:       responder('error', 405, 'Método HTTP no permitido');
}

Manejador GET con caché:

function manejarGET($id) {
    $rol    = $_GET['rol']    ?? null;
    $buscar = $_GET['buscar'] ?? null;
    $limite = $_GET['limite'] ?? null;
    $campos = $_GET['campos'] ?? null;

    // Clave única basada en todos los parámetros
    $claveCaché = 'usuarios_' . md5(json_encode(compact('id','rol','buscar','limite','campos')));

    $datosEnCaché = obtenerDelCache($claveCaché);
    if ($datosEnCaché !== null) {
        responder('success', 200, '', $datosEnCaché);
    }

    $usuarios = leerUsuarios()['usuarios'];

    if ($id) {
        $usuario = buscarUsuarioPorId($usuarios, $id);
        if (!$usuario) responder('error', 404, 'Usuario no encontrado');
        if ($campos)   $usuario = seleccionarCampos([$usuario], $campos)[0];
        guardarEnCache($claveCaché, $usuario);
        responder('success', 200, '', $usuario);
    }

    if ($rol)    $usuarios = filtrarPorRol($usuarios, $rol);
    if ($buscar) $usuarios = buscarPorNombre($usuarios, $buscar);
    if ($limite) $usuarios = limitarResultados($usuarios, $limite);
    if ($campos) $usuarios = seleccionarCampos($usuarios, $campos);

    $usuarios  = array_values($usuarios);
    $respuesta = ['total' => count($usuarios), 'data' => $usuarios];

    guardarEnCache($claveCaché, $respuesta);
    responder('success', 200, '', $respuesta);
}

Manejador POST:

function manejarPOST() {
    $rawInput = file_get_contents('php://input');
    if (empty($rawInput))              responder('error', 400, 'Body vacío o inválido');

    $input = json_decode($rawInput, true);
    if ($input === null)               responder('error', 400, 'JSON inválido');

    $validacion = validarCamposObligatorios($input);
    if (!$validacion['valido'])        responder('error', 400, $validacion['mensaje']);

    $usuarios   = leerUsuarios()['usuarios'];
    $nuevoId    = obtenerProximoId($usuarios);

    $nuevoUsuario = [
        'id'     => $nuevoId,
        'nombre' => $input['nombre'],
        'email'  => $input['email'],
        'rol'    => $input['rol']
    ];

    // Preservar campos extra opcionales
    foreach ($input as $clave => $valor) {
        if (!in_array($clave, ['nombre', 'email', 'rol'])) {
            $nuevoUsuario[$clave] = $valor;
        }
    }

    $usuarios[] = $nuevoUsuario;
    guardarUsuarios($usuarios);
    invalidarCache();

    responder('success', 201, 'Usuario creado exitosamente', $nuevoUsuario);
}

Manejador PUT:

function manejarPUT($id) {
    if (!$id) responder('error', 400, 'El parámetro "id" es requerido');

    $input = json_decode(file_get_contents('php://input'), true);
    if (empty($input)) responder('error', 400, 'No hay campos para actualizar');

    $usuarios = leerUsuarios()['usuarios'];
    $indice   = encontrarIndiceUsuario($usuarios, $id);
    if ($indice === -1) responder('error', 404, 'Usuario no encontrado');

    // Merge: los campos no enviados mantienen su valor original
    $usuarioActual = $usuarios[$indice];
    foreach ($input as $clave => $valor) {
        $usuarioActual[$clave] = $valor;
    }

    $usuarios[$indice] = $usuarioActual;
    guardarUsuarios($usuarios);
    invalidarCache();

    responder('success', 200, 'Usuario actualizado exitosamente', $usuarioActual);
}

Manejador DELETE:

function manejarDELETE($id) {
    if (!$id) responder('error', 400, 'El parámetro "id" es requerido');

    $usuarios = leerUsuarios()['usuarios'];
    $indice   = encontrarIndiceUsuario($usuarios, $id);
    if ($indice === -1) responder('error', 404, 'Usuario no encontrado');

    $usuarioEliminado = $usuarios[$indice];
    unset($usuarios[$indice]);

    guardarUsuarios(array_values($usuarios));
    invalidarCache();

    responder('success', 200, 'Usuario eliminado exitosamente', $usuarioEliminado);
}

Estructura de datos

usuarios.json — almacena todos los usuarios:

{
    "usuarios": [
        { "id": 1, "nombre": "Ana Updated", "email": "ana@ejemplo.com", "rol": "admin" },
        { "id": 3, "nombre": "Marta López", "email": "marta@ejemplo.com", "rol": "viewer" },
        { "id": 4, "nombre": "test2",        "email": "test2@test2.com",  "rol": "admin"  }
    ]
}

cache.json — guarda respuestas cacheadas con su timestamp:

{
    "usuarios_21b9fbe5059d18217996ad562afffdc5": {
        "data": {
            "total": 3,
            "data": [
                {
                    "id": 1,
                    "nombre": "Ana Updated",
                    "email": "ana@ejemplo.com",
                    "rol": "admin"
                },
                {
                    "id": 3,
                    "nombre": "Marta López",
                    "email": "marta@ejemplo.com",
                    "rol": "viewer"
                },
                {
                    "id": 4,
                    "nombre": "test2",
                    "email": "test2@test2.com",
                    "rol": "admin"
                }
            ]
        },
        "timestamp": 1772726700
    }
}

Cómo consumir la API

GET — Listar todos los usuarios

curl -X GET "http://tu-servidor/api.php"
{
    "status": "success",
    "code": 200,
    "total": 3,
    "data": [
        { "id": 1, "nombre": "Ana Updated", "email": "ana@ejemplo.com", "rol": "admin" },
        ...
    ]
}

GET — Filtros disponibles

# Por ID
curl "http://tu-servidor/api.php?id=1"

# Por rol
curl "http://tu-servidor/api.php?rol=admin"

# Búsqueda por nombre (parcial, case-insensitive)
curl "http://tu-servidor/api.php?buscar=ana"

# Limitar resultados
curl "http://tu-servidor/api.php?limite=2"

# Seleccionar solo ciertos campos
curl "http://tu-servidor/api.php?campos=id,nombre,email"

# Combinados
curl "http://tu-servidor/api.php?rol=admin&campos=id,nombre&limite=5"

POST — Crear usuario

curl -X POST "http://tu-servidor/api.php" \
     -H "Content-Type: application/json" \
     -d '{"nombre": "Carlos Ruiz", "email": "carlos@ejemplo.com", "rol": "editor"}'
{
    "status": "success",
    "code": 201,
    "message": "Usuario creado exitosamente",
    "data": {
        "id": 5,
        "nombre": "Carlos Ruiz",
        "email": "carlos@ejemplo.com",
        "rol": "editor"
    }
}

PUT — Actualizar usuario

curl -X PUT "http://tu-servidor/api.php?id=5" \
     -H "Content-Type: application/json" \
     -d '{"nombre": "Carlos R. Actualizado"}'

Solo se actualizan los campos enviados. El resto se preserva tal como estaba.

DELETE — Eliminar usuario

curl -X DELETE "http://tu-servidor/api.php?id=5"
{
    "status": "success",
    "code": 200,
    "message": "Usuario eliminado exitosamente",
    "data": {
        "id": 5,
        "nombre": "Carlos R. Actualizado",
        "email": "carlos@ejemplo.com",
        "rol": "editor"
    }
}

Consumo desde JavaScript (Fetch API)

const BASE_URL = 'http://tu-servidor/api.php';

// Listar usuarios
const res  = await fetch(BASE_URL);
const json = await res.json();
console.log(json.data);

// Crear usuario
await fetch(BASE_URL, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ nombre: 'Luis', email: 'luis@ejemplo.com', rol: 'viewer' })
});

// Actualizar usuario
await fetch(`${BASE_URL}?id=1`, {
    method: 'PUT',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ rol: 'admin' })
});

// Eliminar usuario
await fetch(`${BASE_URL}?id=1`, { method: 'DELETE' });

Tabla de endpoints

MétodoURLDescripción
GET/api.phpLista todos los usuarios
GET/api.php?id=1Obtiene un usuario por ID
GET/api.php?rol=adminFiltra por rol
GET/api.php?buscar=anaBusca por nombre
GET/api.php?limite=5Limita resultados
GET/api.php?campos=id,nombreSelecciona campos
POST/api.phpCrea un usuario nuevo
PUT/api.php?id=1Actualiza un usuario
DELETE/api.php?id=1Elimina un usuario

Códigos de respuesta

CódigoSignificado
200OK — operación exitosa
201Created — usuario creado
400Bad Request — faltan campos o JSON inválido
404Not Found — usuario no encontrado
405Method Not Allowed — método HTTP no soportado

Posibles mejoras

  • Autenticación con JWT o API keys en el header Authorization
  • Validación de email llamando a validarEmail() antes de crear/actualizar
  • Base de datos real (MySQL/PostgreSQL) en lugar de JSON plano
  • Paginación con parámetros pagina y por_pagina
  • Caché en memoria con Redis para mayor rendimiento
  • Logs de errores y auditoría de cambios

Una API funcional, sin dependencias, que puedes tener corriendo en cualquier servidor PHP en minutos.