MODULE 08

API REST PHP

Construire une API RESTful avec PHP natif : routage, JSON, authentification JWT et CORS.

1 — Principes REST

REST (Representational State Transfer) est un style d'architecture pour les APIs Web. Les ressources sont identifiées par des URLs et manipulées via les méthodes HTTP.

MéthodeURLActionIdempotent
GET/api/tasksLister toutes les tâches
GET/api/tasks/42Récupérer la tâche #42
POST/api/tasksCréer une nouvelle tâche
PUT/api/tasks/42Remplacer la tâche #42
PATCH/api/tasks/42Modifier partiellement
DELETE/api/tasks/42Supprimer la tâche #42

2 — Routage PHP natif

<?php
// index.php — point d'entrée unique (configure .htaccess : RewriteRule . index.php)

header('Content-Type: application/json; charset=utf-8');

$method = $_SERVER['REQUEST_METHOD'];
$path   = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Extraire /api/tasks/42 → ['', 'api', 'tasks', '42']
$segments = explode('/', trim($path, '/'));

// Router simple
match(true) {
    $method === 'GET'  && $segments[0] === 'api' && $segments[1] === 'tasks' && !isset($segments[2])
        => handleGetAll(),
    $method === 'GET'  && $segments[0] === 'api' && $segments[1] === 'tasks' && isset($segments[2])
        => handleGetOne((int)$segments[2]),
    $method === 'POST' && $segments[0] === 'api' && $segments[1] === 'tasks'
        => handleCreate(),
    $method === 'PUT'  && $segments[0] === 'api' && $segments[1] === 'tasks' && isset($segments[2])
        => handleUpdate((int)$segments[2]),
    $method === 'DELETE' && $segments[0] === 'api' && $segments[1] === 'tasks' && isset($segments[2])
        => handleDelete((int)$segments[2]),
    default => respond(404, ['error' => 'Route non trouvée']),
};

3 — Entrées/Sorties JSON

<?php
// ── Réponse JSON ──────────────────────────────────────────
function respond(int $code, mixed $data): never {
    http_response_code($code); // 200, 201, 400, 404, 401, 403, 500...
    echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
    exit; // never return type : la fonction ne retourne jamais
}

// ── Lire le body JSON (POST/PUT/PATCH) ────────────────────
function getJsonBody(): array {
    $raw = file_get_contents('php://input'); // lit le corps brut de la requête
    if (!$raw) return [];

    $data = json_decode($raw, true);         // true = tableau associatif
    if (json_last_error() !== JSON_ERROR_NONE) {
        respond(400, ['error' => 'JSON invalide : ' . json_last_error_msg()]);
    }
    return $data;
}

// ── Validation ────────────────────────────────────────────
function valider(array $data, array $regles): array {
    $erreurs = [];
    foreach ($regles as $champ => $regle) {
        if ($regle === 'required' && empty($data[$champ])) {
            $erreurs[] = "$champ est requis";
        }
    }
    return $erreurs;
}

// Exemple d'utilisation
function handleCreate(): never {
    $body    = getJsonBody();
    $erreurs = valider($body, ['titre' => 'required']);
    if ($erreurs) respond(422, ['errors' => $erreurs]);

    // Sauvegarder...
    respond(201, ['id' => 42, 'message' => 'Créé avec succès', 'data' => $body]);
}

4 — Authentification JWT (simplifié)

<?php
// JWT = JSON Web Token : trois parties encodées en base64 séparées par des points
// header.payload.signature

function base64url_encode(string $data): string {
    // base64url : remplace +/ par -_ et supprime les = de padding
    return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}

function creerToken(array $payload, string $secret): string {
    $header    = base64url_encode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
    $payload['iat'] = time();            // issued at
    $payload['exp'] = time() + 3600;     // expire dans 1h
    $body      = base64url_encode(json_encode($payload));
    $signature = base64url_encode(hash_hmac('sha256', "$header.$body", $secret, true));
    return "$header.$body.$signature";
}

function verifierToken(string $token, string $secret): ?array {
    $parts = explode('.', $token);
    if (count($parts) !== 3) return null;

    [$header, $body, $sig] = $parts;
    // Recalculer la signature et comparer en temps constant (anti timing-attack)
    $expected = base64url_encode(hash_hmac('sha256', "$header.$body", $secret, true));
    if (!hash_equals($expected, $sig)) return null;

    $payload = json_decode(base64_decode(strtr($body, '-_', '+/')), true);
    if ($payload['exp'] < time()) return null; // token expiré

    return $payload;
}

// Middleware d'authentification
function requireAuth(): array {
    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (!preg_match('/^Bearer\s+(.+)$/i', $authHeader, $m)) {
        respond(401, ['error' => 'Token manquant']);
    }
    $payload = verifierToken($m[1], $_ENV['JWT_SECRET'] ?? 'dev-secret');
    if (!$payload) respond(401, ['error' => 'Token invalide ou expiré']);
    return $payload;
}

5 — CORS & Headers

<?php
// CORS : Cross-Origin Resource Sharing
// Nécessaire quand le frontend (ex: React sur :3000) appelle l'API (PHP sur :8000)

function setCorsHeaders(): void {
    $allowed = ['https://monsite.com', 'http://localhost:3000'];
    $origin  = $_SERVER['HTTP_ORIGIN'] ?? '';

    // Whitelist : n'autoriser que les origines connues (jamais Access-Control-Allow-Origin: *)
    // pour les APIs avec authentification
    if (in_array($origin, $allowed, true)) {
        header("Access-Control-Allow-Origin: $origin");
        header('Access-Control-Allow-Credentials: true');
    }

    header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With');
    header('Access-Control-Max-Age: 86400'); // cache preflight 24h
}

setCorsHeaders();

// Gérer les requêtes OPTIONS (preflight CORS)
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204); // No Content
    exit;
}

6 — Codes HTTP & Gestion des erreurs

<?php
// Codes HTTP standard pour une API REST
// 200 OK           — lecture/mise à jour réussie
// 201 Created      — création réussie (+ header Location: /api/tasks/42)
// 204 No Content   — suppression réussie (pas de body)
// 400 Bad Request  — données invalides (manquantes ou mal formées)
// 401 Unauthorized — non authentifié (token absent ou invalide)
// 403 Forbidden    — authentifié mais pas autorisé (mauvais rôle)
// 404 Not Found    — ressource introuvable
// 405 Method Not Allowed — méthode HTTP non supportée
// 422 Unprocessable Entity — validation échouée (données sémantiquement incorrectes)
// 429 Too Many Requests   — rate limiting
// 500 Internal Server Error — erreur serveur

// Handler global d'erreurs
set_exception_handler(function (Throwable $e): never {
    error_log($e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
    $code = $e instanceof InvalidArgumentException ? 400
          : ($e instanceof RuntimeException ? 500 : 500);
    respond($code, [
        'error'   => 'Erreur serveur',
        'message' => defined('APP_DEBUG') && APP_DEBUG ? $e->getMessage() : 'Contactez l\'admin',
    ]);
});

// Exemple : réponse de liste paginée
function paginate(array $items, int $page, int $perPage): array {
    $total = count($items);
    return [
        'data'  => array_slice($items, ($page - 1) * $perPage, $perPage),
        'meta'  => ['total' => $total, 'page' => $page, 'per_page' => $perPage,
                    'last_page' => (int)ceil($total / $perPage)],
        'links' => ['next' => $page < ceil($total / $perPage) ? "?page=" . ($page + 1) : null],
    ];
}