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
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_ERROR | Lè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_PRINT | Indente 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 chemin | Rôle | Réponse en cas de succès | Erreurs possibles |
|---|---|---|---|
GET /api/materiels | Liste du matériel, filtrable (?disponible=1) | 200 et un tableau JSON | 401 |
GET /api/materiels/{id} | Fiche d'un matériel | 200 et un objet JSON | 401, 404 |
POST /api/emprunts | Enregistrer un emprunt | 201, l'emprunt créé et l'en-tête Location | 400, 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
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 :
{"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
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
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
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 :
{"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, etexigerConnexion()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 :
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
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
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
# 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.
GET /api/materiels/99avec un jeton valide ; le matériel 99 n'existe pas.POST /api/empruntssans en-têteAuthorization.POST /api/empruntsavec un corps{"idMateriel": 2, "dateRetourPrevue": "2026-11-05"}; le matériel 2 est prêté.POST /api/empruntsavec un corpsidMateriel=1etContent-Type: application/x-www-form-urlencoded.POST /api/empruntsavec{"idMateriel": 1, "dateRetourPrevue": "05/11/2026"}.DELETE /api/materiels/1.
Voir la réponseMasquer la réponse de l'exercice 1
- 404 : la ressource n'existe pas, au format problem+json.
- 401, avec l'en-tête
WWW-Authenticate: Bearer. - 409 : la requête est correcte, mais elle entre en conflit avec l'état du matériel.
- 415 : l'API n'accepte que du JSON.
- 422 : le JSON est bien formé, mais la date n'est pas au format attendu ; la réponse précise le champ dans
erreurs. - 405, avec l'en-tête
Allow: GET: la route existe pourGET, pas pourDELETE(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
$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 envoietext/htmlpar 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
$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é ?