Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 1421 minIntermédiaire

Créer une API REST en PHP : renvoyer du JSON

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • renvoyer du JSON avec le bon code de statut et le bon en-tête ;
  • lire et valider un corps JSON ;
  • renvoyer des erreurs au format problem+json ;
  • authentifier les appels par jeton et régler CORS.

3 exercices corrigés

Une API REST en PHP est un ensemble de routes qui renvoient des données en JSON au lieu de pages HTML : le script fixe le code de statut HTTP, envoie l'en-tête Content-Type: application/json et encode ses données avec json_encode(). Ce chapitre ajoute à Prêtmat une petite API, destinée à une future application mobile : lister le matériel, lire une fiche, enregistrer un emprunt, avec des erreurs normalisées, une authentification par jeton et des tests avec curl. Les principes d'une API REST (ressources, méthodes, codes de statut, conception, documentation OpenAPI) sont expliqués dans le cours API REST ; ici, on les met en œuvre en PHP natif.

Renvoyer du JSON

Trois choses distinguent une réponse d'API d'une page web : le code de statut a un sens précis pour le programme client, l'en-tête Content-Type annonce du JSON, et le corps est produit par json_encode().

php
<?php

declare(strict_types=1);

// src/api.php
function repondreJson(mixed $donnees, int $statut = 200, string $type = 'application/json'): never
{
    http_response_code($statut);
    header("Content-Type: $type");
    header('Cache-Control: no-store');
    echo json_encode($donnees, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    exit;
}
Option de json_encode()Effet
JSON_THROW_ON_ERRORLève une JsonException si l'encodage échoue (texte en UTF-8 invalide, par exemple), au lieu de renvoyer false en silence
JSON_UNESCAPED_UNICODEÉcrit "Vidéoprojecteur" au lieu de "Vid\u00e9oprojecteur" : même valeur, plus lisible
JSON_UNESCAPED_SLASHESÉcrit "/api/materiels/3" au lieu de "\/api\/materiels\/3"
JSON_PRETTY_PRINTIndente le résultat : pratique pour déboguer, inutile en production

Correspondance des types : un tableau associatif devient un objet JSON, une liste numérotée à partir de 0 devient un tableau, null devient null, et un objet PHP aux propriétés publiques devient un objet JSON. Piège classique : array_filter() conserve les clés, et une liste dont les clés ne se suivent plus depuis 0 devient un objet ({"1": …, "2": …}). Passe-la dans array_values() avant de l'encoder.

Les routes de l'API

Méthode et cheminRôleRéponse en cas de succèsErreurs possibles
GET /api/materielsListe du matériel, filtrable (?disponible=1)200 et un tableau JSON401
GET /api/materiels/{id}Fiche d'un matériel200 et un objet JSON401, 404
POST /api/empruntsEnregistrer un emprunt201, l'emprunt créé et l'en-tête Location400, 401, 409, 415, 422

Les codes viennent du cours API REST : 201 pour une création, 400 pour un JSON illisible, 401 sans authentification valide, 404 pour une ressource inconnue, 409 pour un conflit avec l'état de la ressource (le matériel est déjà prêté), 415 pour un corps qui n'est pas du JSON, 422 pour des données bien formées mais invalides (les codes de statut HTTP). Les routes s'ajoutent au routeur du chapitre 12, sous le préfixe /api/.

Lister et lire

Le contrôleur de l'API réutilise le dépôt de l'application web : seule la représentation change. On ne renvoie jamais une ligne de la base telle quelle, mais un tableau construit champ par champ, ce qui permet de choisir ce qu'on expose et de nommer les champs pour le client.

php
<?php

declare(strict_types=1);

namespace App\Controleur;

use App\Depot\MaterielDepot;
use App\Modele\Materiel;

final class ApiMaterielControleur
{
    public function __construct(private MaterielDepot $depot)
    {
    }

    public function liste(): void
    {
        exigerJetonApi();
        $seulementDisponibles = ($_GET['disponible'] ?? '') === '1';
        $materiels = $this->depot->tous($seulementDisponibles);

        repondreJson(array_map($this->representer(...), $materiels));
    }

    public function fiche(int $id): void
    {
        exigerJetonApi();
        $materiel = $this->depot->trouver($id);
        if ($materiel === null) {
            erreurApi(404, 'Matériel introuvable', "Aucun matériel n'a l'identifiant $id.");
        }

        repondreJson($this->representer($materiel));
    }

    private function representer(Materiel $m): array
    {
        return [
            'id' => $m->id,
            'reference' => $m->reference,
            'libelle' => $m->libelle,
            'categorie' => $m->categorie->value,
            'etat' => $m->etat->value,
            'dateAchat' => $m->dateAchat?->format('Y-m-d'),
            'url' => "/api/materiels/{$m->id}",
        ];
    }
}

GET /api/materiels/3 renvoie :

json
{"id":3,"reference":"VP-0001","libelle":"Vidéoprojecteur Epson EB-L210W","categorie":"videoprojecteur","etat":"disponible","dateAchat":"2024-03-02","url":"/api/materiels/3"}

Les dates sont au format ISO 8601 (2024-03-02, ou DATE_ATOM pour une date et une heure), le seul que tous les langages lisent sans ambiguïté. Une valeur absente est null, pas une chaîne vide (les formats d'échange).

Recevoir du JSON : php://input et json_decode

Un client d'API envoie ses données en JSON dans le corps de la requête. PHP ne remplit $_POST que pour les formulaires classiques : le corps JSON se lit dans le flux php://input, puis se décode avec json_decode().

php
<?php

declare(strict_types=1);

/** Lit le corps JSON de la requête et renvoie un tableau associatif, ou répond par une erreur. */
function lireJson(): array
{
    $type = $_SERVER['CONTENT_TYPE'] ?? '';
    if (!str_starts_with($type, 'application/json')) {
        erreurApi(415, 'Type de contenu non pris en charge', 'Le corps doit être en JSON (Content-Type: application/json).');
    }

    try {
        $donnees = json_decode(file_get_contents('php://input'), true, 32, JSON_THROW_ON_ERROR);
    } catch (JsonException) {
        erreurApi(400, 'JSON invalide', 'Le corps de la requête n\'est pas un JSON valide.');
    }

    if (!is_array($donnees) || ($donnees !== [] && array_is_list($donnees))) {   // [1, 2] est refusé
        erreurApi(400, 'JSON invalide', 'Un objet JSON est attendu.');
    }

    return $donnees;
}

Le deuxième argument true de json_decode() demande des tableaux associatifs plutôt que des objets ; 32 limite la profondeur d'imbrication. Un JSON valide peut être un simple nombre ou une chaîne : on vérifie qu'on a bien reçu un objet.

La route de création d'un emprunt valide les données, puis réutilise la fonction transactionnelle emprunter() du chapitre 8 :

php
<?php

declare(strict_types=1);

// Route POST /api/emprunts, dans ApiEmpruntControleur, qui reçoit PDO dans son constructeur
public function creer(): void
{
    $utilisateur = exigerJetonApi();
    $donnees = lireJson();

    $erreurs = [];
    $brut = $donnees['idMateriel'] ?? null;
    $idMateriel = is_int($brut) || is_string($brut)
        ? filter_var($brut, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]])
        : false;
    if ($idMateriel === false) {
        $erreurs['idMateriel'] = 'Identifiant de matériel attendu.';
    }
    $retourPrevu = lireDate($donnees, 'dateRetourPrevue');   // lireDate() : exercice du chapitre 4
    $aujourdhui = new DateTimeImmutable('today');
    if ($retourPrevu === null || $retourPrevu < $aujourdhui || $aujourdhui->diff($retourPrevu)->days > 14) {
        $erreurs['dateRetourPrevue'] = 'Date au format AAAA-MM-JJ, dans les 14 prochains jours.';
    }
    if ($erreurs !== []) {
        erreurApi(422, 'Données invalides', 'Certains champs sont invalides.', ['erreurs' => $erreurs]);
    }

    try {
        $id = emprunter($this->pdo, $idMateriel, $utilisateur['id'], $aujourdhui, $retourPrevu);
    } catch (EmpruntImpossible $e) {
        erreurApi(409, 'Emprunt impossible', $e->getMessage());
    }

    header("Location: /api/emprunts/$id");
    repondreJson([
        'id' => $id,
        'idMateriel' => $idMateriel,
        'dateDebut' => $aujourdhui->format('Y-m-d'),
        'dateRetourPrevue' => $retourPrevu->format('Y-m-d'),
    ], 201);
}

Le JSON a ses propres types : idMateriel peut arriver en entier (4), en chaîne ("4"), mais aussi en booléen, en tableau ou en null. Le test is_int() || is_string() écarte les types inattendus, car filter_var(true, FILTER_VALIDATE_INT) renvoie 1 ; filter_var() refuse ensuite "abc" ou "4.5". Les règles de gestion restent dans emprunter(), partagée avec l'application web : un seul endroit à maintenir.

Les erreurs au format problem+json

Un client d'API doit pouvoir lire les erreurs aussi facilement que les succès. La RFC 9457 normalise un format : un objet JSON avec un type, un title, le status et un detail, envoyé avec le type application/problem+json (erreurs normalisées).

php
<?php

declare(strict_types=1);

function erreurApi(int $statut, string $titre, string $detail, array $extensions = []): never
{
    repondreJson([
        'type' => 'about:blank',
        'title' => $titre,
        'status' => $statut,
        'detail' => $detail,
        ...$extensions,
    ], $statut, 'application/problem+json');
}

Une demande d'emprunt d'un matériel déjà prêté reçoit :

json
{"type":"about:blank","title":"Emprunt impossible","status":409,"detail":"Ce matériel est déjà prêté."}

Pour les erreurs imprévues, le gestionnaire d'exceptions global (chapitre 15) répond, pour les adresses qui commencent par /api/, par une erreur 500 au même format, sans aucun détail technique.

Authentifier les appels

Deux cas se présentent :

  • Le client est une page de Prêtmat (du JavaScript qui appelle fetch('/api/materiels') depuis le même site) : le navigateur envoie le cookie de session, et exigerConnexion() suffit. Mais alors les requêtes POST de l'API sont exposées à la CSRF comme les formulaires : il faut exiger le jeton, envoyé par le script dans un en-tête (X-CSRF-Token).
  • Le client est une application mobile ou un autre serveur : il n'a pas de cookie de session. Il envoie un jeton dans l'en-tête Authorization: Bearer …, que le navigateur n'ajoute jamais de lui-même, ce qui écarte la CSRF.

Pour Prêtmat, chaque utilisateur de l'application mobile reçoit un jeton aléatoire ; la base n'en stocke que l'empreinte SHA-256, avec une date d'expiration, comme pour un jeton de réinitialisation de mot de passe :

sql
CREATE TABLE jeton_api (
  id INT AUTO_INCREMENT PRIMARY KEY,
  id_utilisateur INT NOT NULL,
  empreinte CHAR(64) NOT NULL UNIQUE,
  expire_le DATETIME NOT NULL,
  CONSTRAINT fk_jeton_utilisateur FOREIGN KEY (id_utilisateur) REFERENCES utilisateur (id)
);
php
<?php

declare(strict_types=1);

function exigerJetonApi(): array
{
    $entete = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (preg_match('/^Bearer ([0-9a-f]{64})$/', $entete, $m) !== 1) {
        header('WWW-Authenticate: Bearer');
        erreurApi(401, 'Authentification requise', 'Jeton absent ou mal formé.');
    }

    $stmt = bdd()->prepare(
        'SELECT u.id, u.nom, u.role
         FROM jeton_api j JOIN utilisateur u ON u.id = j.id_utilisateur
         WHERE j.empreinte = :empreinte AND j.expire_le > NOW()'
    );
    $stmt->execute(['empreinte' => hash('sha256', $m[1])]);
    $utilisateur = $stmt->fetch();

    if ($utilisateur === false) {
        header('WWW-Authenticate: Bearer');
        erreurApi(401, 'Authentification requise', 'Jeton invalide ou expiré.');
    }
    return $utilisateur;
}

Le jeton (64 caractères hexadécimaux produits par bin2hex(random_bytes(32))) n'est montré qu'une fois à sa création ; si la base fuit, les empreintes ne permettent pas de s'en servir. Avec Apache et PHP-FPM, l'en-tête Authorization n'est pas toujours transmis à PHP : la directive CGIPassAuth On le rétablit. Le cours API REST compare les clés d'API, les jetons opaques, les JWT et OAuth 2.0 (authentifier une API).

Appeler l'API depuis un autre domaine : CORS

Si le front qui appelle l'API est servi depuis un autre domaine (une application React sur https://app.sorbier.example, par exemple : appeler une API en React), le navigateur bloque la lecture des réponses, sauf si l'API l'autorise explicitement avec les en-têtes CORS. Une requête avec un en-tête Authorization ou un corps JSON déclenche d'abord une requête de vérification (OPTIONS, dite preflight).

php
<?php

declare(strict_types=1);

const ORIGINES_AUTORISEES = ['https://app.sorbier.example'];

$origine = $_SERVER['HTTP_ORIGIN'] ?? '';
if (in_array($origine, ORIGINES_AUTORISEES, true)) {
    header("Access-Control-Allow-Origin: $origine");
    header('Vary: Origin');
    header('Access-Control-Allow-Headers: Authorization, Content-Type');
    header('Access-Control-Allow-Methods: GET, POST');
}
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
    http_response_code(204);
    exit;
}

N'autorise que les origines dont tu as besoin, jamais * pour une API qui manipule des données personnelles. Une application mobile native n'est pas concernée par CORS : c'est une règle des navigateurs (CORS et sécurité des API).

Tester l'API avec curl

bash
# Liste du matériel disponible (jeton de démonstration dans une variable)
curl -i -H "Authorization: Bearer $JETON" "http://localhost:8000/api/materiels?disponible=1"

# Créer un emprunt
curl -i -X POST http://localhost:8000/api/emprunts \
  -H "Authorization: Bearer $JETON" \
  -H "Content-Type: application/json" \
  -d '{"idMateriel": 1, "dateRetourPrevue": "2026-11-05"}'

L'option -i affiche la ligne de statut et les en-têtes de la réponse : vérifie le code (HTTP/1.1 201 Created), le Content-Type et l'en-tête Location, pas seulement le corps. Bruno, Postman ou l'extension REST Client de VS Code font la même chose avec une interface, et permettent d'enregistrer une collection de requêtes de test (tester une API). Teste aussi les cas d'erreur : sans jeton (401), avec un JSON mal formé (400), un matériel prêté (409), une date trop lointaine (422).

Exercices

Exercice 1 sur 3 Choisir les codes de statut Réussi À revoir

Pour chaque requête vers l'API de Prêtmat, donne le code de statut attendu.

  1. GET /api/materiels/99 avec un jeton valide ; le matériel 99 n'existe pas.
  2. POST /api/emprunts sans en-tête Authorization.
  3. POST /api/emprunts avec un corps {"idMateriel": 2, "dateRetourPrevue": "2026-11-05"} ; le matériel 2 est prêté.
  4. POST /api/emprunts avec un corps idMateriel=1 et Content-Type: application/x-www-form-urlencoded.
  5. POST /api/emprunts avec {"idMateriel": 1, "dateRetourPrevue": "05/11/2026"}.
  6. DELETE /api/materiels/1.
Voir la réponseMasquer la réponse de l'exercice 1
  1. 404 : la ressource n'existe pas, au format problem+json.
  2. 401, avec l'en-tête WWW-Authenticate: Bearer.
  3. 409 : la requête est correcte, mais elle entre en conflit avec l'état du matériel.
  4. 415 : l'API n'accepte que du JSON.
  5. 422 : le JSON est bien formé, mais la date n'est pas au format attendu ; la réponse précise le champ dans erreurs.
  6. 405, avec l'en-tête Allow: GET : la route existe pour GET, pas pour DELETE (le routeur du chapitre 12 le gère).

Tu avais trouvé ?

Exercice 2 sur 3 Relire un point d'API Réussi À revoir

Relève les problèmes de ce fichier api/utilisateurs.php, écrit pour l'application mobile.

php
<?php
$id = $_GET['id'];
$u = $pdo->query("SELECT * FROM utilisateur WHERE id = $id")->fetch();
if (!$u) {
    echo '{"erreur": "introuvable"}';
} else {
    echo '{"id": ' . $u['id'] . ', "nom": "' . $u['nom'] . '", "email": "' . $u['email'] . '", "mot_de_passe": "' . $u['mot_de_passe'] . '"}';
}
Voir la réponseMasquer la réponse de l'exercice 2
  • Aucune authentification : n'importe qui peut lire les utilisateurs, en essayant les identifiants un par un.
  • Injection SQL par $id, concaténé sans validation.
  • Exposition excessive : l'empreinte du mot de passe est renvoyée. Même hachée, elle n'a rien à faire hors du serveur (elle permet une attaque par dictionnaire hors ligne). L'email est une donnée personnelle à n'exposer qu'à qui en a besoin.
  • JSON construit à la main : un nom contenant un guillemet (O"Brien) ou une barre oblique inverse produit un JSON invalide. json_encode() s'impose.
  • Pas de code de statut : « introuvable » est renvoyé avec un code 200, le client croit à un succès. Il faut 404, au format problem+json.
  • Pas de Content-Type: application/json : PHP envoie text/html par défaut.

Version corrigée, dans l'application organisée : une route GET /api/utilisateurs/{id} (l'identifiant arrive en entier), exigerJetonApi() puis une vérification que l'appelant est gestionnaire ou demande sa propre fiche, une requête préparée qui ne sélectionne que id et nom, et repondreJson() ou erreurApi(404, …).

Tu avais trouvé ?

Exercice 3 sur 3 Un tableau devenu objet Réussi À revoir

Une application mobile attend un tableau JSON pour la liste du matériel disponible, mais reçoit parfois {"0": {…}, "2": {…}} et plante. Le code est :

php
<?php
$tous = array_map($this->representer(...), $this->depot->tous(false));
repondreJson(array_filter($tous, fn(array $m): bool => $m['etat'] === 'disponible'));

Explique et corrige.

Voir la réponseMasquer la réponse de l'exercice 3

array_filter() conserve les clés d'origine. Quand le matériel d'indice 1 est filtré, les clés restantes (0, 2, …) ne se suivent plus : json_encode() ne peut pas en faire un tableau JSON et produit un objet. Quand rien n'est filtré, les clés se suivent et l'on obtient un tableau : le bogue est intermittent, donc difficile à reproduire.

Correction immédiate : repondreJson(array_values(array_filter(…))). Meilleure correction : filtrer en SQL avec $this->depot->tous(true), qui ne lit que le matériel disponible, au lieu de tout charger pour en jeter une partie.

Tu avais trouvé ?

Chapitre terminé !

Fais les 3 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Configuration, secrets et sécurité en productionRevoir le sommaire du cours