Versionner une API Symfony sans dupliquer ses classes
Cet article part d'un exercice, à faire dans le navigateur : Un nom par version d'API, et des groupes à écarter →
Une API qui change de version ne peut pas abandonner l'ancienne du jour au lendemain. Des partenaires, une application mobile qui n'est pas à jour, un script écrit il y a deux ans : tous lisent encore la v1. Les données sont les mêmes, mais les noms de champs et la forme de la réponse ont changé.
Avec le Serializer de Symfony, les groupes savaient déjà choisir quelles propriétés sortent. Ils ne disaient rien de comment elles s'appellent. Symfony 8.2 (encore en développement) comble ce manque : #[SerializedName] et #[SerializedPath] acceptent désormais un argument groups, et un nouveau contexte, ignored_groups, écarte un groupe pour une seule réponse.
L'exercice de Pratique qui présente ces nouveautés consiste à supprimer des boucles. Cet article prend un peu de recul : comment servait-on deux versions jusqu'ici, ce que la nouvelle approche change, et les pièges à connaître avant de l'adopter.
Le point de départ
Une librairie publie ses livres à trois endroits. La v1, que des partenaires lisent encore, répond à plat avec ses anciens noms :
{ "isbn": "978-2-07-036024-5", "nom": "Le Petit Prince", "prix_centimes": 790 }
La v2 a renommé le titre et range le prix avec sa devise :
{ "isbn": "978-2-07-036024-5", "titre": "Le Petit Prince", "prix": { "centimes": 790, "devise": "EUR" } }
Le back-office lit la même forme que la v2, avec le stock en plus. La classe Livre porte des groupes v1, v2 et interne, mais une seule façon de nommer chaque propriété.
Ce qu'on faisait jusqu'ici
Une classe par version
La solution la plus explicite : un LivreV1 et un LivreV2, chacun avec ses noms, et un peu de code pour passer de l'entité à l'un ou à l'autre. Le contrat de chaque version se lit d'un coup d'œil, et c'est un vrai avantage.
Le prix à payer arrive avec le temps. Chaque champ ajouté à la v2 pose la question de la v1. Chaque correction de format se fait en deux endroits. Et une troisième version multiplie le tout. Pour des versions qui diffèrent par trois noms de champs, c'est beaucoup de code à maintenir.
Une boucle après le serializer
La solution la plus courante, et celle de l'exercice avant correction : sérialiser avec le groupe de la version, puis retoucher le tableau.
$livres = []; foreach ($this->normalizer->normalize($this->catalogue->livres(), 'json', ['groups' => 'v1']) as $livre) { $livres[] = [ 'isbn' => $livre['isbn'], 'nom' => $livre['titre'], 'prix_centimes' => $livre['prix']['centimes'], ]; }
Ça marche, mais le contrat de la v1 vit maintenant dans un contrôleur, loin de la classe qu'il décrit. Le jour où quelqu'un ajoute un champ au groupe v1, il ne sort pas : la boucle ne le connaît pas. Et la boucle ne fonctionne que dans un sens. Si la v1 accepte aussi des écritures, il faut une seconde boucle, à l'envers, pour la désérialisation.
Un normalizer ou un name converter maison
La solution la plus « Symfony » : un normalizer qui décore ObjectNormalizer pour les livres en v1, ou un name converter qui renomme selon le contexte. Le renommage marche alors dans les deux sens, et il reste hors du contrôleur.
En pratique, je vois ces classes vieillir mal. Le name converter est global au Serializer : il reçoit chaque propriété de chaque classe, et doit tester lui-même la classe et la version. Le normalizer décoré doit trouver sa place parmi les autres, et ne pas oublier les objets imbriqués. Beaucoup d'infrastructure pour exprimer « en v1, titre s'appelle nom ».
Ce que Symfony 8.2 change
Un nom par groupe
Les deux attributs deviennent répétables et prennent un argument groups :
#[Groups(['v1', 'v2'])] #[SerializedName('nom', groups: 'v1')] public readonly string $titre, #[Groups(['v1', 'v2'])] #[SerializedName('prix_centimes', groups: 'v1')] #[SerializedPath('[prix][centimes]', groups: 'v2')] public readonly int $prixEnCentimes,
Sérialisé avec le groupe v1, le titre sort sous nom ; avec v2, il garde son nom PHP. Le prix sort à plat sous prix_centimes en v1, et imbriqué sous prix.centimes en v2. Le contrat de chaque version est revenu sur la classe, et le contrôleur se réduit à une ligne :
return new JsonResponse($this->normalizer->normalize($this->catalogue->livres(), 'json', ['groups' => 'v1']));
Le renommage vaut aussi à la désérialisation : un corps v1 qui envoie nom remplit bien $titre, sans seconde boucle. La configuration YAML et XML suit le mouvement, avec une clé serialized et un élément <serialized>.
Écarter un groupe
Le stock appartient aux groupes v2 et interne. Le back-office sérialise v2 et le voit ; l'API publique sérialise aussi v2, et ne doit pas le voir. Avant 8.2, il fallait un unset() après coup, ou un groupe de plus sur chaque autre propriété. Le contexte AbstractNormalizer::IGNORED_GROUPS règle ça :
$this->normalizer->normalize($this->catalogue->livres(), 'json', [ AbstractNormalizer::GROUPS => 'v2', AbstractNormalizer::IGNORED_GROUPS => 'interne', ]);
Une propriété qui appartient à un groupe écarté ne sort pas, même si elle appartient aussi à un groupe demandé. L'exclusion l'emporte. Avec un context builder, c'est withIgnoredGroups('interne').
Les pièges
Le nom sans groupe sert de repli
Un #[SerializedName] sans argument groups vaut pour tous les groupes. Combiné à un nom par groupe, il devient le repli : le nom du groupe l'emporte quand ce groupe est demandé, sinon c'est le nom sans groupe qui s'applique.
C'est commode, mais ça se retourne vite. Dans l'exercice, le prix portait au départ un #[SerializedPath('[prix][centimes]')] sans groupe. Ajoutez-lui un #[SerializedName('prix_centimes', groups: 'v1')] sans toucher au chemin, et la v1 trouve deux réponses : un nom pour son groupe, et un chemin de repli. À la sérialisation, le chemin l'emporte sans un mot : le prix reste sous prix.centimes, et prix_centimes n'apparaît jamais. À la désérialisation, le Serializer refuse de choisir et lance une LogicException (« Found SerializedName and SerializedPath attributes »). Dans les deux cas, il faut rattacher le chemin au seul groupe v2.
Le cas silencieux est le plus dangereux. Ma règle : dès qu'une propriété a un nom par groupe, je donne un groupe à chacun de ses noms et chemins. Le repli implicite est la première chose qu'on oublie en relisant la classe.
Plusieurs groupes demandés : la déclaration décide
Que se passe-t-il si on demande ['v1', 'v2'] et que la propriété a un nom pour chacun ? Le premier nom déclaré dont le groupe est demandé gagne. L'ordre des groupes dans le contexte ne compte pas. C'est un choix volontaire, pour que deux contrôleurs qui écrivent les groupes dans un ordre différent obtiennent la même réponse. Mais ça veut dire qu'on ne choisit pas une version en ajoutant un groupe devant les autres. Une réponse, c'est une version, donc un seul groupe de version.
Une propriété sans groupe disparaît
Ce n'est pas nouveau, mais on le redécouvre à chaque fois qu'on introduit des groupes : dès que le contexte demande des groupes, une propriété sans #[Groups] ne sort plus. Symfony 8.2 ajoute une option pour s'en sortir sans annoter toute la classe : avec AbstractNormalizer::ENABLE_DEFAULT_GROUPS, les propriétés sans groupe rejoignent les groupes Default et le nom court de la classe (Livre), à condition de demander l'un d'eux dans le contexte, par exemple ['v1', 'Default'].
ignored_groups n'est pas #[Ignore]
Les deux retirent des propriétés, mais pas au même endroit :
#[Ignore]est posé sur la propriété, dans la classe. La propriété ne sort jamais, nulle part, et n'entre jamais.ignored_groupsest passé dans le contexte. Il ne vaut que pour cet appel : le back-office, qui ne le passe pas, voit toujours le stock.ignored_attributes, qui existe depuis longtemps, retire des propriétés par leur nom. Il marche, mais chaque contrôleur doit connaître la liste, et une propriété ajoutée au groupeinterneplus tard ne sera pas écartée. Avecignored_groups, la décision reste sur la classe.
Un détail qui compte pour une API : l'exclusion vaut aussi à la désérialisation. Un stock envoyé par un client de l'API publique est ignoré, comme une clé inconnue. Attention si la propriété est un argument obligatoire du constructeur, comme dans l'exercice : la désérialisation échoue alors faute de valeur. Donnez-lui une valeur par défaut, ou gardez cette propriété hors du constructeur.
Le contrat se teste sur la réponse
Le risque de ces attributs, c'est qu'une modification de la classe change une version sans que personne ne s'en aperçoive. Je teste donc chaque version sur la réponse exacte, pas sur la présence de quelques clés :
$this->assertSame( ['isbn' => '978-2-07-036024-5', 'nom' => 'Le Petit Prince', 'prix_centimes' => 790], $this->lire('/api/v1/livres')[0], );
Un assertSame sur le tableau entier échoue si un champ apparaît, disparaît ou change de nom. C'est exactement ce qu'un partenaire de la v1 remarquerait. Les tests de l'exercice font de même pour la v2 et le back-office.
Quand garder deux classes
Les noms par groupe conviennent quand les versions partagent le même modèle et ne diffèrent que par la forme : des noms, une imbrication, quelques champs en plus ou en moins. C'est le cas le plus fréquent, et c'est là que la nouveauté supprime le plus de code.
Je garde des classes séparées quand les versions divergent sur le fond : un champ calculé différemment, une ressource découpée en deux, une validation qui n'est plus la même. Empiler des attributs pour chaque version sur une seule propriété devient alors illisible, et la classe dédiée redevient la solution claire. Un bon indicateur : si une propriété porte plus de deux noms différents, la question mérite d'être posée.
Ce que j'en retiens
Symfony 8.2 ne change pas la façon de penser les groupes, il complète ce qu'ils savaient faire. Un groupe choisissait déjà les champs d'une version ; il en donne maintenant aussi les noms et la forme. Et ignored_groups remplace les unset() qui traînaient dans les contrôleurs. Le contrat de chaque version revient sur la classe qu'il décrit, et il vaut dans les deux sens.
L'exercice se fait en quelques minutes dans le navigateur, sur une version de développement de Symfony 8.2. Les trois requêtes de l'onglet « Requêtes » doivent répondre exactement comme avant, sans une seule boucle dans le contrôleur.