← Toutes les fiches

Fiche 17 Parcours : le web Lecture : 30 minutes

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 :

exemples/17/openapi.yaml · illustration, non exécuté
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 }
  1. Il sert de documentation : des outils comme Swagger UI en font une page interactive, où l'on peut essayer chaque route.
  2. Il génère du code : un client en TypeScript pour le front, en Kotlin pour l'application mobile.
  3. 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.

Quand l'utiliser, quand s'en passer

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 :

exemples/17/dto.php
<?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
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_USER

Deux 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.

Des DTO à la frontière : l'entité ne touche jamais le JSON En entrée, le JSON reçu contient un champ roles non prévu ; le DTO d'entrée ne garde que l'e-mail. Au centre, l'application et l'entité User, avec id, email, passwordHash, roles et internalNote. En sortie, le DTO de sortie ne garde que l'id et l'e-mail, et seul ce DTO est transformé en JSON. Entrée Sortie frontière de l'API JSON reçu "email": "…", "roles": ["ROLE_ADMIN"] DTO d'entrée garde : email ignore : roles Application et entités id email passwordHash roles internalNote Ne voit jamais le JSON, et le JSON ne la voit jamais. DTO de sortie garde : id, email JSON renvoyé {"id": 42, "email": …} Le DTO d'entrée bloque l'affectation de masse. Le DTO de sortie empêche qu'un champ sensible, ajouté un jour à l'entité, parte tout seul dans les réponses.
Schéma 17.1 · Des DTO à la frontière : l'entité ne touche jamais le JSON

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 :

exemples/17/OrderController.php · illustration, non exécuté
<?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).

Sécurité : l'objet entier, puis chacune de ses propriétés

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 clientsCasse les clients
Ajouter un champ dans une réponseRetirer ou renommer un champ d'une réponse
Ajouter une routeRetirer une route, ou changer son URL
Ajouter un paramètre facultatifRendre obligatoire un paramètre qui ne l'était pas
Accepter une nouvelle valeur en entréeChanger le type d'un champ ("42" devient 42)
Rendre une règle de validation plus soupleAjouter 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 :

  1. Publie une nouvelle version à côté de l'ancienne (/v2/orders, fiche 9), sans retirer la première.
  2. Annonce la date de fin de l'ancienne, dans la documentation et dans les réponses elles-mêmes : l'en-tête Sunset donne la date à laquelle la route disparaîtra.
  3. Mesure qui l'utilise encore avant de l'éteindre, grâce aux logs, et préviens ces clients directement.
Quand versionner

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.

  1. 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.
  2. 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.
Un JWT : trois parties séparées par des points Un JWT est formé de trois parties séparées par des points. L'en-tête et le contenu sont du JSON encodé en base64url : une fois décodés, on lit l'algorithme HS256, puis l'utilisateur 42, ses rôles et la date d'expiration. Ils ne sont pas chiffrés et n'importe qui peut les lire. La troisième partie est la signature, un HMAC-SHA256 de l'en-tête et du contenu calculé avec le secret. En-tête eyJhbGciOiJIUzI1NiIs… {"alg": "HS256", "typ": "JWT"} Contenu (claims) eyJzdWIiOiI0MiIsInJvbGVz… {"sub": "42", "roles": ["ROLE_USER"], "exp": 1800000900} Signature u7Rk2… HMAC-SHA256( en-tête.contenu, secret) . . décodé en base64url Les deux premières parties sont seulement encodées, pas chiffrées : n'importe qui peut les lire. N'y mets jamais de secret. La signature, elle, ne peut être produite qu'avec le secret : changer une seule lettre du contenu la rend fausse.
Schéma 17.2 · Un JWT : trois parties séparées par des points

Pour comprendre ce que contient un JWT et comment on le vérifie, en voici un écrit à la main :

exemples/17/jwt.php
<?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);
Sortie
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.

exemples/17/security.yaml · illustration, non exécuté
# 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 :

  1. 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.
  2. Authorization code avec PKCE : une application agit au nom d'un utilisateur, qui donne son accord sur la page du fournisseur.
Quel jeton choisir

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.

Sécurité : les erreurs classiques avec les JWT
  1. Le contenu est lisible par tous : n'y mets ni secret, ni donnée personnelle sensible.
  2. 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.
  3. Vérifie toujours l'expiration (exp), et utilise une bibliothèque éprouvée plutôt que du code maison comme celui de l'exemple.
  4. 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 localStorage d'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 :

exemples/17/rate_limiter.yaml · illustration, non exécuté
# 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'
exemples/17/RateLimitSubscriber.php · illustration, non exécuté
<?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);
        }
    }
}
  1. 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).
  2. Plafonne aussi la taille des pages : un ?limit=1000000 accepté tel quel contourne toute limitation.
  3. 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.

L'idempotence : renvoyer une requête sans la refaire L'application cliente envoie POST /orders avec une clé d'idempotence. L'API crée la commande 1 et garde la réponse sous cette clé, mais la réponse 201 se perd en route. Sans réponse, le client renvoie la même requête avec la même clé. L'API reconnaît la clé, relit la réponse enregistrée et renvoie « 201 : commande 1 » sans créer de seconde commande. Application cliente API Base de données POST + Idempotency-Key: 0b8f… crée la commande 1, garde la réponse sous la clé ✕ 201 perdu en route Pas de réponse : on renvoie la même requête, même clé. POST + la même clé clé connue : relit la réponse 201 : commande 1 (rejouée) Une seule commande en base. Sans la clé, le second POST en aurait créé une deuxième.
Schéma 17.3 · L'idempotence : renvoyer une requête sans la refaire

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é.

exemples/17/idempotency.php
<?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";
Sortie
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
  1. Une même clé avec un autre contenu est une erreur du client : on la refuse au lieu de deviner.
  2. 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.
  3. 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.
  4. L'en-tête Idempotency-Key est en cours de normalisation à l'IETF, l'organisme qui publie les standards d'Internet. Des prestataires de paiement comme Stripe l'utilisent déjà.
Quand l'utiliser, quand s'en passer

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 :

exemples/17/http_client.yaml · illustration, non exécuté
# 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 hasard

Le 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 :

exemples/17/HttpCarrier.php · illustration, non exécuté
<?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).

exemples/17/backoff.php
<?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";
    }
}
Sortie
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
Réessayer en laissant au partenaire le temps de revenir En haut, sans attente : cinq tentatives en quelques millisecondes, toutes en échec, qui aggravent la surcharge. En bas, avec une attente exponentielle : un échec, 0,5 s d'attente, un échec, 1 s, un échec, 2 s, un échec, 4 s, puis un succès, car le partenaire est revenu au bout de 6 secondes. Sans attente 5 échecs en quelques millisecondes : la surcharge empire. Attente exponentielle 0,5 s 1 s 2 s 4 s ✕ ✕ ✕ ✕ ✓ le partenaire revient 0 s 1 s 2 s 3 s 4 s 5 s 6 s 7 s L'attente double à chaque échec, avec un peu de hasard (le jitter) : sinon, tous les clients tombés en même temps réessaient au même instant, et refont tomber le partenaire.
Schéma 17.4 · Réessayer en laissant au partenaire le temps de revenir
  1. 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.
  2. Ne réessaie un POST que s'il est idempotent. Par prudence, l'option retry_failed de 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'option http_codes.
  3. Respecte Retry-After quand 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.

Sécurité : ce que tu envoies et ce que tu crois
  1. Les jetons des partenaires sont des secrets : dans des variables d'environnement ou le coffre de Symfony, jamais dans le code (fiche 10).
  2. 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.
  3. Si l'URL appelée vient d'un utilisateur, c'est une SSRF en puissance (fiche 10).
  4. 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.

exemples/17/webhook.php
<?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);
Sortie
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
Recevoir un webhook : vérifier avant de croire Le partenaire envoie une requête POST avec un en-tête Signature qui contient un horodatage et un HMAC du corps, calculé avec le secret partagé. Ton application vérifie dans l'ordre : l'horodatage a moins de 5 minutes, le HMAC recalculé sur le corps brut est identique (comparé avec hash_equals), l'événement n'a pas déjà été traité. Elle répond 200 tout de suite et traite en arrière-plan. Un attaquant qui ne connaît pas le secret produit une fausse signature, refusée avec un code 400. Partenaire signe avec le secret POST /webhooks/payment Signature: t=1800000000, v1=5f2c… {"id": "evt_1", …} Ton application 1. Horodatage récent (5 min) ? sinon : rejeu possible, 400 2. Recalculer le HMAC du corps brut, avec le secret 3. Comparer : hash_equals() différent : 400 4. Événement déjà traité ? oui : 200, sans rien refaire 5. Répondre 200 tout de suite, traiter en arrière-plan Attaquant ne connaît pas le secret fausse signature : refusée (400) Le secret ne voyage jamais : seule la signature accompagne la requête.
Schéma 17.5 · Recevoir un webhook : vérifier avant de croire
  1. 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.
  2. Compare avec hash_equals(), jamais avec ===, qui permet de deviner la signature en mesurant le temps de réponse (fiche 2).
  3. Refuse les requêtes trop anciennes : une requête valide interceptée ne doit pas pouvoir être rejouée le lendemain.
  4. 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.
  5. 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 :

exemples/17/ApiTest.php · illustration, non exécuté
<?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);
    }
}
  1. 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.
  2. Vérifie que les champs internes n'apparaissent pas : c'est le test qui protège des fuites de la section 2.
  3. 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.
  4. 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ègeCe qui arriveLa parade
Sérialiser l'entité DoctrineUne empreinte de mot de passe ou une note interne dans la réponseUn DTO de sortie
Remplir l'entité avec le JSON reçuUn 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 plantentAjouter le nouveau champ, déprécier l'ancien
Un JWT valable 30 joursUn jeton volé donne l'accès pendant un moisQuelques minutes, et un jeton de rafraîchissement révocable
Laisser le JWT choisir son algorithmeUn jeton alg: none accepté sans signatureFixer l'algorithme attendu
Aucune limite de requêtesUn script qui boucle fait tomber l'APIRateLimiter, 429 et Retry-After
Rejouer un POST sans clé d'idempotenceDeux commandes, deux débits, deux colisUne clé par intention, renvoyée à chaque essai
Aucun délai maximal sur les appels sortantsUn partenaire figé bloque tout ton sitetimeout et max_duration
Réessayer immédiatement, en boucleLe partenaire surchargé ne se relève pasUne attente exponentielle avec du hasard, et un nombre d'essais limité
Accepter un webhook sans vérifier sa signatureN'importe qui déclare un paiement réussiHMAC sur le corps brut, hash_equals(), horodatage
Appeler le vrai partenaire dans les testsDes tests lents, payants et qui échouent au hasardMockHttpClient

Checklist avant d'ouvrir une API

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 :

Sortie
  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
  1. Explique, étape par étape, pourquoi le transporteur a enregistré deux colis.
  2. 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 pas FakeCarrierApi.
  3. 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.
  4. Ajoute une exception PermanentFailure pour les erreurs 4xx, et vérifie qu'elle n'est jamais réessayée.
  5. Range l'appel derrière un port Carrier (fiche 16), et écris un adaptateur en mémoire qui permet de tester shipOrder() sans la simulation.
  6. 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

  1. É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.
  2. 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.
  3. 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).
  4. 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