Concevoir et consommer des API
Une API, c'est une promesse faite à du code que tu ne contrôles pas. Et quand c'est toi qui appelles, c'est toi qui dépends des promesses des autres.
La fiche 9 a posé les bases de REST : des ressources, des méthodes, des codes de statut, des erreurs au bon format. Cette fiche va plus loin, dans les deux sens. D'abord quand tu fournis une API : ce que tu exposes, comment tu la fais évoluer, comment tu la protèges. Ensuite quand tu consommes celle d'un autre : un transporteur, un prestataire de paiement, un partenaire. C'est là que se cachent la plupart des incidents réels : un délai jamais configuré, une requête rejouée deux fois, un webhook accepté sans vérification.
Le fil rouge reste la boutique des fiches précédentes : une API pour passer des commandes, et un transporteur à appeler pour expédier les colis.
1.Une API est un contrat
Quand tu modifies une page Twig, tu modifies aussi tout ce qui l'utilise, dans le même commit. Une API, elle, est utilisée par du code que tu ne contrôles pas : une application mobile installée sur des milliers de téléphones, le front d'une autre équipe, le serveur d'un client. Tu ne peux pas les mettre à jour le jour où tu changes un champ. L'API est donc un contrat : une promesse sur ce qu'on peut envoyer et sur ce qu'on recevra.
Ce contrat s'écrit avec OpenAPI, un format standard (en YAML ou en JSON) qui décrit chaque route, ses paramètres, ses réponses et leurs schémas :
openapi: 3.1.0
info:
title: API de la boutique
version: 1.0.0
paths:
/api/orders:
post:
summary: Passer une commande
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: true
schema: { type: string, format: uuid }
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/PlaceOrderRequest' }
responses:
'201':
description: Commande créée
content:
application/json:
schema: { $ref: '#/components/schemas/OrderResponse' }
'409': { description: Stock insuffisant }
'422': { description: Données invalides }
'429': { description: Trop de requêtes, réessayer après l'en-tête Retry-After }
components:
securitySchemes:
bearerAuth: { type: http, scheme: bearer }
schemas:
PlaceOrderRequest:
type: object
required: [lines]
properties:
lines:
type: object
additionalProperties: { type: integer, minimum: 1, maximum: 10 }
OrderResponse:
type: object
required: [id, status, totalInCents]
properties:
id: { type: string }
status: { type: string, enum: [placed, paid, shipped] }
totalInCents: { type: integer }- Il sert de documentation : des outils comme Swagger UI en font une page interactive, où l'on peut essayer chaque route.
- Il génère du code : un client en TypeScript pour le front, en Kotlin pour l'application mobile.
- Il se vérifie : un test peut contrôler que les réponses réelles respectent le schéma (section 9).
Deux façons de l'obtenir. Écrire le code d'abord et générer le contrat à partir de lui : c'est ce que font NelmioApiDocBundle ou API Platform dans Symfony. Écrire le contrat d'abord et coder ensuite : plus lent au départ, mais le front et le back peuvent travailler en parallèle sur une base validée ensemble.
Indispensable dès qu'une autre équipe, un client ou une application mobile utilise ton API.
Facultatif pour quelques routes JSON appelées par ton propre front, dans le même dépôt : des tests fonctionnels suffisent souvent à tenir le contrat.
2.Exposer des DTO, pas des entités
Le réflexe le plus courant, et le plus risqué : renvoyer l'entité Doctrine telle quelle avec $this->json($user), et remplir l'entité directement avec le JSON reçu. Voici ce que ça donne :
<?php
declare(strict_types=1);
// L'entité : tout ce que l'application sait d'un utilisateur
final class User
{
/** @param list<string> $roles */
public function __construct(
public int $id,
public string $email,
public string $passwordHash,
public array $roles,
public ?string $internalNote,
) {
}
}
// Le DTO de sortie : seulement ce que le client a le droit de voir
final readonly class UserResponse
{
public function __construct(public int $id, public string $email)
{
}
public static function fromUser(User $user): self
{
return new self($user->id, $user->email);
}
}
// Le DTO d'entrée : seulement les champs que le client a le droit de modifier
final readonly class UpdateProfileRequest
{
public function __construct(public string $email)
{
}
/** @param array<string, mixed> $data */
public static function fromArray(array $data): self
{
return new self((string) ($data['email'] ?? ''));
}
}
function newUser(): User
{
return new User(42, 'alice@example.com', '$2y$12$empreinte.fictive', ['ROLE_USER'],
'Client difficile');
}
echo "Sortie, en sérialisant l'entité :\n";
echo json_encode(newUser(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), "\n";
echo "Sortie, en sérialisant le DTO :\n";
echo json_encode(UserResponse::fromUser(newUser())), "\n\n";
// Le client envoie un champ qu'on n'attendait pas
$json = '{"email": "alice@example.org", "roles": ["ROLE_ADMIN"]}';
$body = json_decode($json, true, flags: JSON_THROW_ON_ERROR);
$user = newUser();
foreach ($body as $field => $value) { // entrée liée directement à l'entité
if (property_exists($user, $field)) {
$user->$field = $value;
}
}
echo 'Entrée liée à l\'entité, rôles : ' . implode(', ', $user->roles) . "\n";
$user = newUser();
$request = UpdateProfileRequest::fromArray($body); // entrée passée par un DTO
$user->email = $request->email;
echo 'Entrée passée par un DTO, rôles : ' . implode(', ', $user->roles) . "\n";Sortie, en sérialisant l'entité :
{"id":42,"email":"alice@example.com","passwordHash":"$2y$12$empreinte.fictive","roles":["ROLE_USER"],"internalNote":"Client difficile"}
Sortie, en sérialisant le DTO :
{"id":42,"email":"alice@example.com"}
Entrée liée à l'entité, rôles : ROLE_ADMIN
Entrée passée par un DTO, rôles : ROLE_USERDeux fuites en une seule entité. En sortie, l'empreinte du mot de passe et une note interne partent dans la réponse. En entrée, un client devient administrateur en ajoutant un champ. C'est l'affectation de masse (mass assignment), déjà croisée en fiche 6.
La parade est un DTO (Data Transfer Object) : une petite classe sans logique, qui décrit exactement ce qui entre ou ce qui sort.
Dans Symfony, #[MapRequestPayload] transforme le JSON reçu en DTO et le valide. Si les données sont invalides, la requête est refusée avec un code 422 avant même d'entrer dans le contrôleur :
<?php
declare(strict_types=1);
namespace App\Api;
use Symfony\Component\Validator\Constraints as Assert;
// Ce que le client a le droit d'envoyer, et les règles de forme
final readonly class PlaceOrderRequest
{
/** @param array<string, int> $lines */
public function __construct(
#[Assert\NotBlank]
#[Assert\All([new Assert\Range(min: 1, max: 10)])]
public array $lines,
) {
}
}
// Ce que le client a le droit de voir
final readonly class OrderResponse
{
public function __construct(
public string $id,
public string $status,
public int $totalInCents,
) {
}
}
namespace App\Controller;
use App\Api\OrderResponse;
use App\Api\PlaceOrderRequest;
use App\Application\PlaceOrder;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\CurrentUser;
final class OrderController extends AbstractController
{
#[Route('/api/orders', methods: ['POST'])]
public function place(
// JSON invalide : réponse 422, sans même entrer dans la méthode
#[MapRequestPayload] PlaceOrderRequest $request,
#[CurrentUser] User $user,
PlaceOrder $placeOrder,
): JsonResponse {
$order = $placeOrder($user, $request->lines);
return $this->json(
new OrderResponse(
$order->getId(),
$order->getStatus()->value,
$order->getTotalInCents(),
),
201,
['Location' => '/api/orders/' . $order->getId()],
);
}
}C'est une classe de plus par route, et des correspondances à écrire entre le DTO et l'entité. En échange, ton entité peut changer sans que le contrat change, et inversement. C'est exactement l'adaptateur d'entrée de l'architecture hexagonale (fiche 16).
Le classement OWASP propre aux API (édition 2023) place en première position l'accès à l'objet d'un autre, en changeant un identifiant dans l'URL (l'IDOR, fiche 10), et en troisième l'accès à une propriété qu'on ne devrait ni lire ni modifier. Les DTO traitent la troisième. Pour la première, vérifie toujours que l'objet demandé appartient bien à l'utilisateur connecté, avec un Voter.
3.Faire évoluer sans casser
Une API vit : on ajoute des champs, on en renomme, on en retire. La question à se poser avant chaque changement : un client écrit pour l'ancienne version fonctionne-t-il encore ?
| Sans risque pour les clients | Casse les clients |
|---|---|
| Ajouter un champ dans une réponse | Retirer ou renommer un champ d'une réponse |
| Ajouter une route | Retirer une route, ou changer son URL |
| Ajouter un paramètre facultatif | Rendre obligatoire un paramètre qui ne l'était pas |
| Accepter une nouvelle valeur en entrée | Changer le type d'un champ ("42" devient 42) |
| Rendre une règle de validation plus souple | Ajouter une valeur à une liste fermée (un enum) que les clients lisent |
Le dernier cas surprend souvent : un client qui fait un match sur le statut d'une commande plante le jour où apparaît un statut refunded qu'il ne connaît pas. D'où une règle à donner aux clients, et à suivre toi-même quand tu consommes une API : ignore les champs que tu ne connais pas, et prévois un cas par défaut pour les valeurs inconnues.
Quand un changement incompatible est inévitable :
- Publie une nouvelle version à côté de l'ancienne (
/v2/orders, fiche 9), sans retirer la première. - Annonce la date de fin de l'ancienne, dans la documentation et dans les réponses elles-mêmes : l'en-tête
Sunsetdonne la date à laquelle la route disparaîtra. - Mesure qui l'utilise encore avant de l'éteindre, grâce aux logs, et préviens ces clients directement.
Ne crée pas une v2 pour chaque ajout : la plupart des évolutions sont compatibles, et chaque version maintenue en parallèle double le travail. Une nouvelle version se justifie pour un changement incompatible, sur une API utilisée par des clients que tu ne peux pas mettre à jour toi-même.
4.Authentifier : jetons, JWT, OAuth2
Une API appelée par une application mobile ou un autre serveur n'utilise pas de cookie de session (fiche 9) : chaque requête porte un jeton dans l'en-tête Authorization: Bearer …. Il en existe deux grandes sortes.
- Le jeton opaque est une simple chaîne aléatoire. Le serveur la cherche en base pour savoir à qui elle appartient. On le révoque en le supprimant.
- Le JWT (JSON Web Token) contient lui-même l'identité et les droits, et il est signé. Le serveur vérifie la signature, sans base de données.
Pour comprendre ce que contient un JWT et comment on le vérifie, en voici un écrit à la main :
<?php
declare(strict_types=1);
// Un JWT signé avec HMAC-SHA256, écrit à la main pour voir ce qu'il contient.
// En vrai, utilise une bibliothèque éprouvée : celle-ci n'est là que pour comprendre.
const SECRET = 'secret-de-demonstration-ne-pas-utiliser';
function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'), true)
?: throw new RuntimeException('encodage invalide');
}
function signature(string $header, string $payload): string
{
return base64UrlEncode(hash_hmac('sha256', "$header.$payload", SECRET, true));
}
/** @param array<string, mixed> $claims */
function createToken(array $claims): string
{
$header = base64UrlEncode(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = base64UrlEncode(json_encode($claims));
return "$header.$payload." . signature($header, $payload);
}
/** @return array<string, mixed> */
function verifyToken(string $token, int $now): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
throw new RuntimeException('format invalide');
}
[$header, $payload, $signature] = $parts;
$alg = json_decode(base64UrlDecode($header), true)['alg'] ?? 'aucun';
if ($alg !== 'HS256') { // on ne laisse JAMAIS le jeton choisir
throw new RuntimeException("algorithme refusé : $alg");
}
if (!hash_equals(signature($header, $payload), $signature)) {
throw new RuntimeException('signature invalide');
}
$claims = json_decode(base64UrlDecode($payload), true, flags: JSON_THROW_ON_ERROR);
if ($claims['exp'] < $now) {
throw new RuntimeException('jeton expiré');
}
return $claims;
}
function attempt(string $label, string $token, int $now): void
{
try {
$claims = verifyToken($token, $now);
$roles = implode(', ', $claims['roles']);
echo "Accepté $label : utilisateur {$claims['sub']}, $roles\n";
} catch (RuntimeException $e) {
echo "Refusé $label : {$e->getMessage()}\n";
}
}
$now = 1_800_000_000; // une date fixe, pour une sortie stable
$token = createToken(['sub' => '42', 'roles' => ['ROLE_USER'], 'exp' => $now + 900]);
[$header, $payload] = explode('.', $token);
echo "Le jeton a trois parties : en-tête, contenu, signature.\n";
echo 'Contenu, lisible par tous sans la clé : ' . base64UrlDecode($payload) . "\n\n";
attempt('jeton intact', $token, $now);
$forged = base64UrlEncode(json_encode(
['sub' => '42', 'roles' => ['ROLE_ADMIN'], 'exp' => $now + 900],
));
attempt('contenu modifié', "$header.$forged." . explode('.', $token)[2], $now);
$none = base64UrlEncode(json_encode(['alg' => 'none', 'typ' => 'JWT']));
attempt('« alg: none »', "$none.$forged.", $now);
attempt('une heure plus tard', $token, $now + 3600);Le jeton a trois parties : en-tête, contenu, signature.
Contenu, lisible par tous sans la clé : {"sub":"42","roles":["ROLE_USER"],"exp":1800000900}
Accepté jeton intact : utilisateur 42, ROLE_USER
Refusé contenu modifié : signature invalide
Refusé « alg: none » : algorithme refusé : none
Refusé une heure plus tard : jeton expiréL'avantage du JWT est de ne pas interroger la base à chaque requête. Son défaut est symétrique : on ne peut pas le révoquer avant son expiration, puisque le serveur ne le cherche nulle part. D'où une durée de vie courte (quelques minutes), renouvelée par un second jeton, dit de rafraîchissement, que le serveur, lui, peut révoquer.
Dans Symfony, l'authentificateur access_token lit le jeton de l'en-tête et le confie à une classe à toi, qui renvoie l'utilisateur correspondant. Pour les JWT, LexikJWTAuthenticationBundle fait le travail de création et de vérification.
# config/packages/security.yaml (extrait)
security:
firewalls:
api:
pattern: ^/api
stateless: true # pas de session : chaque requête porte son jeton
access_token:
# La classe qui transforme le jeton reçu en utilisateur (ou le refuse)
token_handler: App\Security\ApiTokenHandler
access_control:
- { path: ^/api, roles: IS_AUTHENTICATED_FULLY }OAuth2, enfin, n'est pas un format de jeton : c'est un protocole de délégation, qui permet à une application d'obtenir un jeton sans jamais voir le mot de passe de l'utilisateur. « Se connecter avec Google » en est un exemple. Deux cas sont à connaître :
- Client credentials : un serveur s'identifie auprès d'un autre, sans utilisateur. C'est le cas typique d'un appel à une API de paiement.
- Authorization code avec PKCE : une application agit au nom d'un utilisateur, qui donne son accord sur la page du fournisseur.
Pour ta propre application et ton propre front, un jeton opaque ou une session suffisent, et se révoquent simplement. Le JWT sert quand plusieurs services doivent vérifier l'identité sans partager de base. OAuth2 sert quand d'autres applications accèdent aux données de tes utilisateurs. N'écris jamais ton propre serveur OAuth2 : utilise un fournisseur ou une bibliothèque éprouvée.
- Le contenu est lisible par tous : n'y mets ni secret, ni donnée personnelle sensible.
- N'accepte que l'algorithme que tu as choisi. Laisser le jeton choisir ouvre la porte à
alg: none, un jeton sans signature que certaines bibliothèques acceptaient. - Vérifie toujours l'expiration (
exp), et utilise une bibliothèque éprouvée plutôt que du code maison comme celui de l'exemple. - Un jeton qui fuit donne l'accès jusqu'à son expiration : HTTPS obligatoire, jamais de jeton dans une URL ni dans un log, et pas de stockage dans le
localStoraged'un navigateur (fiche 9).
5.Limiter le nombre de requêtes
Sans limite, un seul client peut épuiser ton serveur : un script mal écrit qui boucle, un concurrent qui aspire ton catalogue, un attaquant qui essaie des milliers de mots de passe. La limitation de débit (rate limiting) fixe un nombre maximal de requêtes par client et par période. Au-delà, l'API répond 429 Too Many Requests, avec un en-tête Retry-After qui dit quand réessayer.
Le composant RateLimiter de Symfony gère les compteurs. On déclare le limiteur, puis on l'applique à toutes les routes de l'API au même endroit :
# config/packages/rate_limiter.yaml
framework:
rate_limiter:
api:
# Fenêtre glissante : pas de rafale au début de chaque période
policy: sliding_window
limit: 300
interval: '15 minutes'<?php
declare(strict_types=1);
namespace App\EventSubscriber;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\RateLimiter\RateLimiterFactory;
// Appliqué à toutes les routes /api, sans y penser dans chaque contrôleur
final class RateLimitSubscriber
{
public function __construct(
private RateLimiterFactory $apiLimiter, // le limiteur « api » de la configuration
private Security $security,
) {
}
// Priorité 5 : après le pare-feu (priorité 8), qui a identifié l'utilisateur
#[AsEventListener(event: KernelEvents::REQUEST, priority: 5)]
public function onRequest(RequestEvent $event): void
{
$request = $event->getRequest();
if (!$event->isMainRequest() || !str_starts_with($request->getPathInfo(), '/api')) {
return;
}
// Un compteur par utilisateur connu, sinon par adresse IP
$key = $this->security->getUser()?->getUserIdentifier() ?? $request->getClientIp();
$limit = $this->apiLimiter->create($key)->consume();
if (!$limit->isAccepted()) {
// 429, avec l'en-tête Retry-After : dans combien de secondes réessayer
$seconds = $limit->getRetryAfter()->getTimestamp() - time();
throw new TooManyRequestsHttpException($seconds);
}
}
}- Compte par utilisateur quand il est connu, sinon par adresse IP. Derrière un proxy, l'adresse IP n'est fiable que si les proxys de confiance sont configurés (fiche 9).
- Plafonne aussi la taille des pages : un
?limit=1000000accepté tel quel contourne toute limitation. - Protège la connexion encore plus fort : le pare-feu de Symfony propose pour ça l'option
login_throttling.
6.L'idempotence : rejouer sans refaire
Sur un réseau, une réponse peut se perdre alors que la requête a bien été traitée. Le client ne sait pas si sa commande est passée. Il recommence. Est-ce grave ? Ça dépend de la méthode.
Une opération est idempotente quand la faire une fois ou dix fois donne le même résultat. HTTP le garantit pour GET, PUT et DELETE : supprimer deux fois la commande 42 laisse la commande 42 supprimée. Mais pas pour POST : le rejouer crée une seconde commande, ou débite deux fois une carte.
La solution répandue est la clé d'idempotence : le client génère un identifiant unique pour son intention (« cette commande-là »), l'envoie dans l'en-tête Idempotency-Key, et le renvoie à l'identique s'il réessaie. Le serveur garde la réponse associée à chaque clé.
<?php
declare(strict_types=1);
// Une API de commandes qui accepte une clé d'idempotence (en-tête Idempotency-Key)
final class OrderApi
{
/** @var array<int, array<string, mixed>> */
private array $orders = [];
/** @var array<string, array{fingerprint: string, response: string}> */
private array $replies = [];
/** @param array<string, mixed> $body */
public function post(array $body, ?string $idempotencyKey = null): string
{
$fingerprint = hash('sha256', json_encode($body));
if ($idempotencyKey !== null && isset($this->replies[$idempotencyKey])) {
$reply = $this->replies[$idempotencyKey];
if ($reply['fingerprint'] !== $fingerprint) {
return '422 : cette clé a déjà servi pour une autre requête';
}
return $reply['response'] . ' (réponse rejouée, rien de recréé)';
}
$id = count($this->orders) + 1;
$this->orders[$id] = $body;
$response = "201 : commande $id créée";
if ($idempotencyKey !== null) {
$this->replies[$idempotencyKey] = [
'fingerprint' => $fingerprint,
'response' => $response,
];
}
return $response;
}
public function count(): int
{
return count($this->orders);
}
}
$cart = ['lines' => ['KB-01' => 1]];
echo "Sans clé : la réponse se perd, l'application renvoie la requête\n";
$api = new OrderApi();
$api->post($cart); // réponse perdue en route
echo ' ' . $api->post($cart) . "\n";
echo " commandes en base : {$api->count()}\n\n";
echo "Avec une clé, générée une fois par le client et renvoyée telle quelle\n";
$api = new OrderApi();
$key = '0b8f3c2e-5d1a-4e7b-9c6f-2a4d8e1f7b30';
$api->post($cart, $key); // réponse perdue en route
echo ' ' . $api->post($cart, $key) . "\n";
echo " commandes en base : {$api->count()}\n";
echo ' ' . $api->post(['lines' => ['MS-02' => 3]], $key) . "\n";Sans clé : la réponse se perd, l'application renvoie la requête 201 : commande 2 créée commandes en base : 2 Avec une clé, générée une fois par le client et renvoyée telle quelle 201 : commande 1 créée (réponse rejouée, rien de recréé) commandes en base : 1 422 : cette clé a déjà servi pour une autre requête
- Une même clé avec un autre contenu est une erreur du client : on la refuse au lieu de deviner.
- En vrai, les clés vont dans une table avec une contrainte d'unicité (fiche 7), et expirent après un délai, souvent 24 heures.
- Deux requêtes avec la même clé peuvent arriver en même temps : la contrainte d'unicité fait échouer la seconde, qui doit alors attendre ou répondre 409.
- L'en-tête
Idempotency-Keyest en cours de normalisation à l'IETF, l'organisme qui publie les standards d'Internet. Des prestataires de paiement comme Stripe l'utilisent déjà.
Indispensable pour tout POST qui coûte cher s'il est doublé : un paiement, une commande, un envoi de colis, un e-mail à un client.
Inutile pour GET, PUT et DELETE bien implémentés, et pour une action sans conséquence, comme enregistrer une préférence d'affichage.
7.Appeler une API externe
Changeons de côté : c'est toi qui appelles le transporteur. Son API sera parfois lente, parfois en panne, parfois en train de changer. Ton application doit le supporter sans tomber avec lui.
Première règle : toujours un délai maximal. Sans lui, une requête vers un partenaire figé attend indéfiniment, garde un processus PHP occupé, et quelques dizaines de requêtes suffisent à bloquer tout ton site. Avec le composant HttpClient, on configure un client dédié par partenaire :
# config/packages/framework.yaml (extrait)
framework:
http_client:
scoped_clients:
# Injecté par son nom : HttpClientInterface $carrierClient
carrier.client:
base_uri: '%env(CARRIER_API_URL)%'
headers:
Authorization: 'Bearer %env(CARRIER_API_TOKEN)%'
timeout: 2.5 # secondes sans recevoir le moindre octet
max_duration: 10 # durée maximale de la requête entière
retry_failed:
max_retries: 3
delay: 500 # millisecondes avant la première nouvelle tentative
multiplier: 2 # puis 1 000, puis 2 000
jitter: 0.25 # plus ou moins 25 % de hasardLe reste de l'application ne voit pas ce client, mais un port (fiche 16). L'adaptateur envoie une clé d'idempotence, pour qu'un nouvel essai ne crée jamais deux colis :
<?php
declare(strict_types=1);
namespace App\Infrastructure\Carrier;
use App\Application\Port\Carrier;
use Symfony\Contracts\HttpClient\HttpClientInterface;
// L'adaptateur de sortie (fiche 16) : le reste de l'application ne voit que le port Carrier
final class HttpCarrier implements Carrier
{
public function __construct(private HttpClientInterface $carrierClient)
{
}
public function createShipment(string $orderId): string
{
$response = $this->carrierClient->request('POST', '/shipments', [
'json' => ['order' => $orderId],
// La même clé à chaque essai pour cette commande : jamais deux colis
'headers' => ['Idempotency-Key' => 'ship-' . $orderId],
]);
// toArray() lève une exception sur un code 3xx, 4xx ou 5xx, et si le délai est dépassé
return $response->toArray()['id'];
}
}Deuxième règle : réessayer intelligemment. Une erreur 503 ou un délai dépassé sont souvent passagers. Mais réessayer immédiatement, cinq fois de suite, aggrave la surcharge du partenaire. On attend de plus en plus longtemps entre deux essais : c'est l'attente exponentielle (exponential backoff).
<?php
declare(strict_types=1);
// 5xx, 429, délai dépassé : réessayer peut marcher
final class TemporaryFailure extends RuntimeException
{
}
// 4xx : réessayer ne changera rien
final class PermanentFailure extends RuntimeException
{
}
/**
* Réessaie avec une attente qui double à chaque échec, plus un peu de hasard (jitter).
*
* @param callable(int): void $sleep reçoit l'attente en millisecondes
*/
function withRetry(callable $call, callable $sleep, int $maxAttempts = 5): mixed
{
for ($attempt = 1; ; $attempt++) {
try {
return $call();
} catch (TemporaryFailure $e) {
if ($attempt === $maxAttempts) {
throw $e;
}
$delay = min(30_000, 500 * 2 ** ($attempt - 1)); // 500, 1 000, 2 000, 4 000 ms…
$delay += mt_rand(0, intdiv($delay, 4)); // jusqu'à 25 % de hasard
$sleep($delay);
}
}
}
/** Un partenaire simulé, qui donne les réponses prévues dans l'ordre */
function partner(array $answers): callable
{
return function () use (&$answers): string {
$answer = array_shift($answers) ?? 503;
echo " réponse $answer"; // la suite de la ligne vient après
return match (true) {
$answer === 200 => 'OK',
$answer >= 500 => throw new TemporaryFailure("erreur $answer"),
default => throw new PermanentFailure("erreur $answer"),
};
};
}
// En test, on n'attend pas vraiment : on note l'attente demandée
$sleep = function (int $ms): void {
echo ', on attend ' . number_format($ms, 0, ',', ' ') . " ms\n";
};
mt_srand(7); // hasard reproductible : sortie stable
foreach (['Panne passagère' => [503, 502, 200], 'Requête invalide' => [400],
'Partenaire en panne' => []] as $label => $answers) {
echo "$label :\n";
try {
echo ' : ' . withRetry(partner($answers), $sleep) . "\n";
} catch (TemporaryFailure | PermanentFailure $e) {
$why = $e instanceof PermanentFailure ? 'inutile de réessayer' : 'trop d\'essais';
echo ", abandon : $why\n";
}
}Panne passagère : réponse 503, on attend 617 ms réponse 502, on attend 1 047 ms réponse 200 : OK Requête invalide : réponse 400, abandon : inutile de réessayer Partenaire en panne : réponse 503, on attend 597 ms réponse 503, on attend 1 218 ms réponse 503, on attend 2 394 ms réponse 503, on attend 4 109 ms réponse 503, abandon : trop d'essais
- Ne réessaie que ce qui peut réussir : un 503, un 429 ou un délai dépassé, oui. Un 400 ou un 404, non : la même requête échouera de la même façon.
- Ne réessaie un
POSTque s'il est idempotent. Par prudence, l'optionretry_failedde Symfony ne rejoue pas les méthodes non idempotentes après toutes les erreurs : la liste exacte est dans sa documentation. Une fois la clé d'idempotence en place, tu peux élargir cette liste avec l'optionhttp_codes. - Respecte
Retry-Afterquand le partenaire te dit d'attendre : Symfony le fait pour toi.
Troisième règle : prévoir la panne longue. Si le transporteur est en panne pendant une heure, aucun nombre d'essais ne suffit, et le client ne doit pas attendre devant sa page. La commande est enregistrée tout de suite, et l'envoi du colis part dans un message Messenger traité en arrière-plan (fiche 16). Messenger le réessaie plus tard, puis le range dans une file d'échec à examiner. Pour un partenaire très souvent en panne, un disjoncteur (circuit breaker) cesse même de l'appeler pendant un moment après une série d'échecs.
- Les jetons des partenaires sont des secrets : dans des variables d'environnement ou le coffre de Symfony, jamais dans le code (fiche 10).
- Ne désactive jamais la vérification du certificat TLS (
verify_peer: false) pour « faire marcher » un appel : n'importe qui sur le réseau pourrait lire et modifier les échanges. - Si l'URL appelée vient d'un utilisateur, c'est une SSRF en puissance (fiche 10).
- Une réponse de partenaire est une donnée extérieure : valide-la comme une requête d'utilisateur avant de l'enregistrer ou de l'afficher.
8.Recevoir des webhooks
Plutôt que de demander toutes les minutes « le paiement est-il passé ? », le prestataire t'appelle quand quelque chose arrive : c'est un webhook, une requête HTTP envoyée par lui vers une route de ton application. Problème : cette route est publique. N'importe qui peut y envoyer « paiement réussi, 4 990 centimes ».
La parade est une signature. Toi et le prestataire partagez un secret. Il calcule un HMAC (une empreinte qu'on ne peut produire qu'avec le secret) de l'horodatage et du corps de la requête, et l'envoie dans un en-tête. Tu refais le calcul : si le résultat est identique, la requête vient bien de lui et n'a pas été modifiée.
<?php
declare(strict_types=1);
// Le secret partagé avec le partenaire, lu en vrai dans une variable d'environnement
const WEBHOOK_SECRET = 'whsec_demonstration_ne_pas_utiliser';
const TOLERANCE = 300; // 5 minutes
// Ce que fait le partenaire : il signe l'horodatage et le corps BRUT de la requête
function sign(string $body, int $timestamp, string $secret = WEBHOOK_SECRET): string
{
return "t=$timestamp,v1=" . hash_hmac('sha256', "$timestamp.$body", $secret);
}
// Ce que fait ton application, AVANT de décoder le JSON
function verify(string $body, string $header, int $now): void
{
if (preg_match('/^t=(\d+),v1=([a-f0-9]{64})$/', $header, $match) !== 1) {
throw new RuntimeException('en-tête de signature mal formé');
}
[, $timestamp, $received] = $match;
if (abs($now - (int) $timestamp) > TOLERANCE) {
throw new RuntimeException('trop ancien : ce pourrait être un rejeu');
}
$expected = hash_hmac('sha256', "$timestamp.$body", WEBHOOK_SECRET);
if (!hash_equals($expected, $received)) {
throw new RuntimeException('signature invalide');
}
}
/** @var array<string, true> $processed les événements déjà traités */
$processed = [];
/** @param array<string, true> $processed */
function receive(
string $label,
string $body,
string $header,
int $now,
array &$processed,
): void {
try {
verify($body, $header, $now);
$event = json_decode($body, true, flags: JSON_THROW_ON_ERROR);
if (isset($processed[$event['id']])) {
echo "200 ignoré $label : {$event['id']} déjà traité\n";
return;
}
$processed[$event['id']] = true;
echo "200 traité $label : {$event['type']}, {$event['amount']} centimes\n";
} catch (RuntimeException $e) {
echo "400 refusé $label : {$e->getMessage()}\n";
}
}
$now = 1_800_000_000;
$body = '{"id": "evt_1", "type": "payment.succeeded", "amount": 4990}';
receive('livraison normale', $body, sign($body, $now), $now, $processed);
receive('même événement renvoyé', $body, sign($body, $now + 60), $now + 60, $processed);
$tampered = str_replace('4990', '1', $body);
receive('montant modifié en route', $tampered, sign($body, $now), $now, $processed);
$forged = sign($body, $now, 'secret-devine');
receive('signé avec un autre secret', $body, $forged, $now, $processed);
receive('requête rejouée 1 h après', $body, sign($body, $now), $now + 3600, $processed);200 traité livraison normale : payment.succeeded, 4990 centimes 200 ignoré même événement renvoyé : evt_1 déjà traité 400 refusé montant modifié en route : signature invalide 400 refusé signé avec un autre secret : signature invalide 400 refusé requête rejouée 1 h après : trop ancien : ce pourrait être un rejeu
- Vérifie sur le corps brut, tel que reçu, avant de décoder le JSON : décoder puis réencoder change les espaces, et la signature ne correspond plus.
- Compare avec
hash_equals(), jamais avec===, qui permet de deviner la signature en mesurant le temps de réponse (fiche 2). - Refuse les requêtes trop anciennes : une requête valide interceptée ne doit pas pouvoir être rejouée le lendemain.
- Attends-toi à recevoir deux fois le même événement : les prestataires réessaient tant que tu n'as pas répondu 2xx. Retiens les identifiants déjà traités : c'est l'idempotence, vue du côté de celui qui reçoit.
- Réponds vite, puis traite en arrière-plan avec Messenger : un traitement long fait expirer la requête, et le prestataire la renvoie.
Chaque prestataire a son propre format de signature : suis toujours sa documentation, ou sa bibliothèque officielle. Le composant Webhook de Symfony fournit un cadre pour recevoir et vérifier ces requêtes.
9.Tester une API
Deux sortes de tests se complètent. Le test fonctionnel appelle ton API comme le ferait un client et vérifie le contrat : le code de statut, les en-têtes, les champs présents, et surtout absents. Le test unitaire de l'adaptateur vérifie ce que tu envoies au partenaire, avec MockHttpClient, sans qu'aucune requête ne parte sur le réseau :
<?php
declare(strict_types=1);
// Test fonctionnel : l'API vue de l'extérieur
namespace App\Tests\Api;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
final class OrderApiTest extends WebTestCase
{
private const HEADERS = ['HTTP_AUTHORIZATION' => 'Bearer jeton-de-test'];
public function testPlacingAnOrderReturns201WithoutInternalFields(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/orders', ['lines' => ['KB-01' => 2]],
self::HEADERS);
self::assertResponseStatusCodeSame(201);
self::assertResponseHasHeader('Location');
$content = $client->getResponse()->getContent();
$data = json_decode($content, true, flags: JSON_THROW_ON_ERROR);
self::assertSame(['id', 'status', 'totalInCents'], array_keys($data));
}
public function testAQuantityOfZeroIsRejected(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/orders', ['lines' => ['KB-01' => 0]],
self::HEADERS);
self::assertResponseStatusCodeSame(422);
}
public function testWithoutTokenTheApiAnswers401(): void
{
$client = static::createClient();
$client->jsonRequest('POST', '/api/orders', ['lines' => ['KB-01' => 1]]);
self::assertResponseStatusCodeSame(401);
}
}
// Test unitaire de l'adaptateur : aucune requête ne part sur le réseau
namespace App\Tests\Infrastructure\Carrier;
use App\Infrastructure\Carrier\HttpCarrier;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpClient\MockHttpClient;
use Symfony\Component\HttpClient\Response\MockResponse;
final class HttpCarrierTest extends TestCase
{
public function testItSendsTheSameIdempotencyKeyForAnOrder(): void
{
$response = new MockResponse('{"id": "SHP-1"}', ['http_code' => 201]);
$carrier = new HttpCarrier(new MockHttpClient($response, 'https://carrier.example'));
self::assertSame('SHP-1', $carrier->createShipment('CMD-1001'));
self::assertSame('POST', $response->getRequestMethod());
$headers = $response->getRequestOptions()['headers'];
self::assertContains('Idempotency-Key: ship-CMD-1001', $headers);
}
}- Teste les cas d'erreur autant que le cas nominal : 401 sans jeton, 403 sur la commande d'un autre, 422 sur des données invalides, 429 au-delà de la limite.
- Vérifie que les champs internes n'apparaissent pas : c'est le test qui protège des fuites de la section 2.
- Si tu as un contrat OpenAPI, une bibliothèque de validation peut vérifier que chaque réponse de tes tests le respecte. Le contrat et le code ne peuvent alors plus diverger sans que tu le saches.
- N'appelle jamais le vrai partenaire dans la suite de tests : elle deviendrait lente, payante et fragile. Les prestataires proposent souvent un environnement de test (sandbox) pour des essais manuels.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Sérialiser l'entité Doctrine | Une empreinte de mot de passe ou une note interne dans la réponse | Un DTO de sortie |
| Remplir l'entité avec le JSON reçu | Un client se donne des droits (affectation de masse) | Un DTO d'entrée, validé |
| Renommer un champ « pour faire propre » | Les applications mobiles déjà installées plantent | Ajouter le nouveau champ, déprécier l'ancien |
| Un JWT valable 30 jours | Un jeton volé donne l'accès pendant un mois | Quelques minutes, et un jeton de rafraîchissement révocable |
| Laisser le JWT choisir son algorithme | Un jeton alg: none accepté sans signature | Fixer l'algorithme attendu |
| Aucune limite de requêtes | Un script qui boucle fait tomber l'API | RateLimiter, 429 et Retry-After |
Rejouer un POST sans clé d'idempotence | Deux commandes, deux débits, deux colis | Une clé par intention, renvoyée à chaque essai |
| Aucun délai maximal sur les appels sortants | Un partenaire figé bloque tout ton site | timeout et max_duration |
| Réessayer immédiatement, en boucle | Le partenaire surchargé ne se relève pas | Une attente exponentielle avec du hasard, et un nombre d'essais limité |
| Accepter un webhook sans vérifier sa signature | N'importe qui déclare un paiement réussi | HMAC sur le corps brut, hash_equals(), horodatage |
| Appeler le vrai partenaire dans les tests | Des tests lents, payants et qui échouent au hasard | MockHttpClient |
Checklist avant d'ouvrir une API
- Chaque route reçoit et renvoie des DTO, jamais des entités.
- Le contrat est écrit (OpenAPI) ou tenu par des tests, et les clients savent où le lire.
- Je sais quels changements cassent mes clients, et j'ai un plan pour les faire.
- Chaque route vérifie l'authentification, et chaque objet renvoyé appartient à l'utilisateur.
- Les jetons expirent vite, et je sais comment en révoquer un.
- Une limite de requêtes protège l'API, et la taille des pages est plafonnée.
- Les
POSTcoûteux acceptent une clé d'idempotence. - Chaque appel sortant a un délai maximal, et les nouveaux essais attendent de plus en plus longtemps.
- Les webhooks reçus sont vérifiés, dédoublonnés et traités en arrière-plan.
- Les tests couvrent les cas d'erreur et vérifient l'absence des champs internes.
Exercice : expédier un colis, une seule fois
Le fichier exemples/17/exercice.php simule l'API d'un transporteur qui se comporte mal, comme une vraie : la première réponse se perd, la deuxième est un 503. Le code qui l'appelle réessaie aussitôt, en boucle. Aujourd'hui, il affiche :
essai 1 échoué (délai dépassé, réponse perdue), on recommence aussitôt essai 2 échoué (503 Service Unavailable), on recommence aussitôt Colis créé : SHP-2 2 colis enregistrés chez le transporteur pour 1 commande
- Explique, étape par étape, pourquoi le transporteur a enregistré deux colis.
- Corrige
shipOrder()pour envoyer une clé d'idempotence, stable pour une même commande. Le transporteur ne doit plus enregistrer qu'un seul colis. Ne modifie pasFakeCarrierApi. - Ajoute une attente exponentielle entre les essais, avec un peu de hasard. Comme dans l'exemple de la section 7, reçois la fonction d'attente en paramètre et affiche les attentes au lieu de dormir vraiment.
- Ajoute une exception
PermanentFailurepour les erreurs 4xx, et vérifie qu'elle n'est jamais réessayée. - Range l'appel derrière un port
Carrier(fiche 16), et écris un adaptateur en mémoire qui permet de testershipOrder()sans la simulation. - Question de recul : le transporteur est en panne pendant une heure. Que voit le client qui vient de payer ? Comment Messenger change-t-il ça ?
docker compose run --rm php php exemples/17/exercice.php
Conseils
- Écris l'API que tu aimerais appeler. Lis la documentation d'une API reconnue pour sa qualité, comme celle de Stripe : noms constants, erreurs claires, exemples partout. Puis compare avec la tienne.
- Pense au client qui ne se met jamais à jour. Avant chaque changement, demande-toi ce qu'il devient pour une application installée il y a un an.
- Lis les logs de tes appels sortants. Combien de temps répond le transporteur ? Combien d'erreurs par jour ? Sans ces chiffres, tes délais et tes nouveaux essais sont réglés au hasard (fiche 13).
- Ne réinvente ni la sécurité ni les protocoles. Les exemples de cette fiche écrivent un JWT et une signature à la main pour que tu comprennes. En production, utilise les composants de Symfony et les bibliothèques officielles des prestataires.
Documentation officielle
- La spécification OpenAPI 3.1 (en anglais)
- Transformer le corps d'une requête en DTO avec
#[MapRequestPayload] - L'authentification par jeton d'accès
- Le composant RateLimiter
- Le composant HttpClient : délais, nouveaux essais et tests avec
MockHttpClient - Le composant Webhook et Messenger, pour traiter en arrière-plan
- Les méthodes idempotentes dans la norme HTTP (RFC 9110, en anglais)
- Les bonnes pratiques des JWT (RFC 8725, en anglais)
- Le projet de norme Idempotency-Key (en anglais)
- Le Top 10 OWASP de la sécurité des API, édition 2023 (en anglais)