Introduction
Lorsque vous travaillez sur une API (principalement REST) et que vous souhaitez sortir de nouvelles fonctionnalités, une problématique montre souvent le bout de son nez : vous ne pouvez pas mettre en production avant que les applications clientes de votre application soient compatibles avec ces changements.
Afin de pouvoir livrer rapidement de nouvelles fonctionnalités ou encore des modifications au niveau du schéma de données, il vous faut alors mettre en place du versioning sur votre API.
Malheureusement, les manières de traiter réellement le sujet sont assez floues aujourd'hui.
J'ai donc parcouru différentes solutions et j'ai choisi d'adopter le modèle présenté par Stripe, permettant d'appliquer une retrocompatibilité du modèle de données pour les versions précédentes.
Objectif
Pour la suite de ce tutoriel Codelabs, nous allons imaginer que nous développons une API et que nous souhaitons sortir une nouvelle version 1.2.0 en production, qui inclura des changements au niveau de notre modèle de données par rapport aux versions précédentes.
L'objectif est alors de sortir en production notre nouvelle version et que chaque client qui appelle notre API sans spécifier de version particulière puisse en bénéficier.
En revanche, si un client, lors de sa requête, spécifie une version (comme 1.1.0 par exemple), alors il doit continuer à récupérer le même modèle de données que précédemment.
D'un point de vue technique, notre API devra appliquer une transformation sur le modèle de sortie afin d'assurer la retrocompatibilité sur cette version. C'est vraiment la réponse de notre API qui sera versionnée.
stepT

Gestion du numéro de version
Pour la suite de cet article, j'ai choisi de partir sur un numéro de version spécifié en header de requête, type X-Accept-Version: 1.1.0.
À vous de choisir ce qui vous conviendra le mieux, mais je trouve la solution du header plus simple à maintenir et surtout, lorsque vous décidez de ne plus supporter une version, cela n'a pas d'impact sur les endpoints d'appel à votre API, vous renvoyez simplement la dernière version de votre API.
Pré-requis
Avant de débuter l'implémentation technique il vous faut disposer d'une instance Symfony. Vous pouvez vous rendre sur http://symfony.com/download pour en installer une version.
Cet article n'est pas spécifique à Symfony 4, cependant, si vous souhaitez installer cette dernière version vous pouvez directement utiliser composer :
$ composer create-project symfony/skeleton api-versioning
Prochaine étape
Une fois la logique claire, nous pouvons commencer à implémenter la configuration des changements en fonction du numéro de version dans notre application Symfony.
Configuration des fichiers de changement par version
Nous allons donc avoir besoin de spécifier les changements de retrocompatibilité à appliquer lorsqu'une version précédente est demandée.
Il nous faut implémenter une liste des versions dans la configuration de Symfony avec, pour chaque version, le namespace complet du fichier qui contient les versions à appliquer.
Spécifions les versions retrocompatibles
Editez le fichier app/config/parameters.yml de votre projet (ou config/services.yaml sous Symfony 4) et ajoutez l'entrée suivante, sous parameters :
parameters: versions: 1.1.0: Acme\VersionChanges\VersionChanges110 1.0.0: Acme\VersionChanges\VersionChanges100 0.9.0: Acme\VersionChanges\VersionChanges009 0.8.0: Acme\VersionChanges\VersionChanges008
Nous spécifions une liste de la version la plus récente à la plus ancienne.
Note : La version actuelle (1.2.0) n'apparaît pas dans cette liste, car il s'agit ici uniquement de la liste des versions sur lesquelles nous souhaitons appliquer une retrocompatibilité.
Les changements de retrocompatibilité seront alors appliqués dans ce même ordre.
Ainsi, dans le cas ou un client ajoute un header X-Accept-Version: 0.9.0 dans ses requêtes, les changements de retrocompatibilité des versions seront joués respectivement dans l'ordre 1.1.0, 1.0.0 puis 0.9.0.
La version 0.8.0 ne devra pas être jouée, car elle correspond à un modèle encore plus ancien que celui demandé.
Prochaine étape
Cette configuration doit ensuite être interprêtée par Symfony, et les changements nécessaires appliqués à la réponse de votre API en fonction de la version demandée.
Ajout du listener sur la réponse Symfony
Nous allons donc agir sur la réponse de Symfony en implémentant un listener sur l'événement kernel.response du framework.
Ajout de la classe
Commençons donc par créer la classe PHP du listener. Créez un ficier Acme\Event\Listener\VersionChangesListener :
<?php namespace Acme\Event\Listener; use Acme\VersionChanges\ChangesFactory; use Symfony\Component\HttpKernel\Event\FilterResponseEvent; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\RequestStack; class VersionChangesListener { /** * @var RequestStack */ protected $requestStack; /** * @var ChangesFactory */ protected $changesFactory; /** * Constructor. * * @param RequestStack $requestStack * @param ChangesFactory $changesFactory */ public function __construct(RequestStack $requestStack, ChangesFactory $changesFactory) { $this->requestStack = $requestStack; $this->changesFactory = $changesFactory; } /** * @return Request */ private function getRequest() { return $this->requestStack->getCurrentRequest(); } }
La structure du listener est en place. Nous y avons injecté le service RequestStack de Symfony ainsi qu'un service nommé ChangesFactory. Nous allons créer ce service dans les étapes suivantes.
Le service RequestStack va nous servir à récupérer le numéro de version demandé en header de la requête et ChangesFactory s'occupera de nous instancier et de nous retourner les classes de changements de rétrocompatibilité de notre API.
Ajoutons donc la méthode onKernelResponse qui sera déclenchée par l'EventManager de Symfony :
/** * @param FilterResponseEvent $event */ public function onKernelResponse(FilterResponseEvent $event) { $version = $this->getRequest()->headers->get('X-Accept-Version'); $versionChanges = $this->changesFactory->getHistory($version); if (!$versionChanges) { return; } $data = json_decode($event->getResponse()->getContent(), true); $data = $this->apply($versionChanges, $data); $response = $event->getResponse(); $response->setContent(json_encode($data)); $event->setResponse($response); }
Nous récupérons ici la valeur de la version envoyée dans le header X-Accept-Version, demandons au service ChangesFactory de nous récupérer l'historique des changements à jouer pour cette version, puis, si nous en trouvons, nous récupérons les données de la version actuelle et appelons une méthode apply($versionChanges, $data) que nous allons déclarer dès maintenant afin de jouer les changements.
Les nouvelles données seront ensuite mises à jour dans l'object réponse de Symfony et envoyées au client.
Pour jouer les changements, il nous manque donc la méthode apply() :
/** * Apply given version changes for given data. * * @param array $versionChanges * @param array $data * * @return array */ private function apply($versionChanges, $data) { foreach ($versionChanges as $version => $changes) { if (!$changes->supports($data)) { continue; } $data = $changes->apply($data); } return $data; }
On commence à deviner l'interface qui sera implémentée par les fichiers d'application de changements. Un premier appel à la méthode supports() permet de vérifier si les changements de ce fichier doivent être appliqués à cette version.
Dans certains cas (certains endpoints d'API), les données renvoyées ne seront jamais impactées par ces changements. Cette méthode permet de s'assurer que les changements doivent bien être appliqués.
Enfin, $changes->apply() joue les changements nécessaires.
Ajout du service Symfony
Afin que notre listener soit effectif, il ne nous reste plus qu'à déclarer le service dans l'injection de dépendance du framework :
acme.event.version_changes_listener: class: Acme\Event\Listener\VersionChangesListener arguments: ["@request_stack", "@acme.version.changes_factory"] tags: - { name: kernel.event_listener, event: kernel.response, method: onKernelResponse }
Le service acme.version.changes_factory est manquant à ce niveau car déclaré dans la prochaine étape.
Prochaine étape
Entrons dans le coeur du gestionnaire de changements de retrocompatibilité en implémentant le service ChangesFactory qui nous permet d'instancier les classes de changements.
Ajout de la factory pour instancier les changements
A présent implémentons la classe Acme\VersionChanges\ChangesFactory.
Ajout de la classe
Créez donc le fichier suivant :
<?php namespace Acme\VersionChanges; use Symfony\Component\HttpFoundation\RequestStack; class ChangesFactory { /** * @var array */ private $versions; /** * @var RequestStack */ private $request; /** * @param array $versions * @param RequestStack $requestStack */ public function __construct(array $versions, RequestStack $requestStack) { $this->versions = $versions; $this->requestStack = $requestStack; $this->prepare(); } /** * @param string $version * * @return bool */ public function has($version) { return isset($this->versions[$version]); } /** * @param string $version * * @return AbstractVersionChanges|null */ public function get($version) { if (!$this->has($version)) { return; } return $this->versions[$version]; } }
Cette classe prend donc en entrée le tableau de versions déclaré en tant que parameters Symfony ainsi que le RequestStack que nous irons injecter dans nos fichiers d'application de changements de versions.
Notez que, par la suite, vous pourrez avoir besoin d'injecter Doctrine afin de récupérer des données en base de données et pas simplement de les remodeler.
Nous avons également écrit deux méthodes, has($version) et get($version), assez simples pour retourner une version.
Cependant, les yeux les plus aguerris auront remarqué la présence dans le constructeur de l'appel à la méthode prepare() qui va nous permettre d'instancier les namespaces fournis dans la configuration en classes PHP utilisables.
La méthode à ajouter est la suivante :
/** * Prepares class instances from class name. * * @throws \RuntimeException When version changes class does not exist or does not implement VersionChangesInterface. */ protected function prepare() { foreach ($this->versions as $version => $class) { if (!class_exists($class)) { throw new \RuntimeException(sprintf('Unable to find class "%s".', $class)); } if (!$class instanceof VersionChangesInterface) { throw new \RuntimeException(sprintf('Class "%s" does not implement VersionChangesInterface.', $class)); } $instance = new $class($this->requestStack); $this->versions[$version] = $instance; } }
Enfin, le listener implémenté dans l'étape précédente avait besoin d'une méthode getHistory($version) qui avait pour objectif de nous retourner les fichiers de changements de version (instanciés) à jouer en fonction de la version courante.
Nous ajoutons donc la méthode :
/** * Returns compatibility changes history for a given version. * * @param string $version * * @return array|null */ public function getHistory($version) { if (!$this->has($version)) { return; } $index = array_search($version, array_keys($this->versions)); return array_slice($this->versions, 0, $index + 1); }
Ainsi, dans le cas ou une version 1.0.0 est demandée, seuls les fichiers de changements 1.0.1 et 1.0.0 seront joués. Les versions précédentes tel que 0.0.9 seront ignorées.
Pour vous aider à mieux comprendre la façon dont cet historique de version est récupéré, voici comment serait testé unitairement (avec PHPUnit) cette méthode :
<?php namespace Tests\Acme\VersionChanges; use Acme\VersionChanges\ChangesFactory; use Symfony\Component\HttpFoundation\RequestStack; class ChangesFactoryTest extends \PHPUnit_Framework_TestCase { /** * * @var array */ protected $versions; /** * * @var RequestStack */ protected $requestStack; /** * * @var ChangesFactory */ protected $changesFactory; /** * {@inheritdoc} */ protected function setUp() { $this->request = $this->getMockBuilder('Symfony\Component\HttpFoundation\RequestStack') ->disableOriginalConstructor() ->getMock(); $this->versions = [ '1.1.0' => 'Acme\VersionChanges\VersionChange110', '1.0.0' => 'Acme\VersionChanges\VersionChange100', '0.9.0' => 'Acme\VersionChanges\VersionChange090', '0.8.0' => 'Acme\VersionChanges\VersionChange080', ]; $this->changesFactory = new ChangesFactory($this->versions, $this->requestStack); } /** * {@inheritdoc} */ protected function tearDown() { $this->request = null; $this->versions = null; $this->changesFactory = null; } /** * Test getHistory() when version 1.1.0 */ public function testGetHistoryWithVersion110() { $history = $this->versionChanges->getHistory('1.1.0'); $this->assertCount(1, $history); $this->assertInstanceOf('Acme\VersionChanges\VersionChange110', $history[0]); } /** * Test getHistory() when version 1.0.0 */ public function testGetHistoryWithVersion100() { $history = $this->versionChanges->getHistory('1.0.0'); $this->assertCount(2, $history); $this->assertInstanceOf('Acme\VersionChanges\VersionChange110', $history[0]); $this->assertInstanceOf('Acme\VersionChanges\VersionChange100', $history[1]); } }
Pour rappel, n'oubliez pas de vous assurer du comportement de vos méthodes en écrivant des tests unitaires.
Ajout du service Symfony
Afin que ce service soit injecté par l'injection de dépendance de Symfony, nous devons également déclarer le service :
acme.version.changes_factory: class: Acme\VersionChanges\ChangesFactory arguments: ["%versions%", "@request_stack"]
Prochaine étape
Notre structure est prête, il ne nous reste plus qu'à implémenter les fichiers de changements, dans l'étape suivante.
Ajout de fichiers de changements
Comme constaté précédemment, l'interface des fichiers de changements est assez simple. En effet, nous allons avoir principalement besoin de deux méthodes supports() et apply().
Ajoutons donc cette interface :
<?php namespace Acme\VersionChanges; interface VersionChangesInterface { /** * Apply version changes for current request. * * @param array $data * * @return array */ public function apply(array $data); /** * Returns if this version changes is supported for current request. * * @param array $data * * @return bool */ public function supports(array $data); }
Ajout de la classe abstraite
Cette interface sera implémentée par la classe abstraite qui étendra de nos fichiers de versions.
Celle-ci va principalement nous permettre d'abstraire l'injection des différents services dans les fichiers de changements de version.
Ajoutons donc la classe abstraite Acme\VersionChanges\AbstractVersionChanges :
<?php namespace Acme\VersionChanges; use Symfony\Component\HttpFoundation\RequestStack; abstract class AbstractVersionChanges implements VersionChangesInterface { /** * RequestStack */ protected $requestStack; /** * Constructor. * * @param RequestStack $requestStack */ public function __construct(RequestStack $requestStack) { $this->requestStack = $requestStack; } /** * @return Request */ public function getRequest() { return $this->requestStack->getCurrentRequest(); } }
Souvenez-vous, notre service ChangesFactory qui instancie ces classes de changements injecte le service RequestStack, c'est précisément à cet endroit que nous en avons besoin.
Ajout d'une classe de changements de version (exemple)
Nous allons maintenant pouvoir ajouter une classe de changements de version.
Imaginons donc que nous ajoutons la classe Acme\VersionChanges\Version101.php qui permettra la retrocompatibilité sur la version 1.0.1 de notre API.
Par exemple, celle-ci aura pour objectif de supprimer les entrées de type taxonomy des réponses de notre API car il s'agit d'une fonctionnalité active uniquement depuis la version 1.0.2.
Créons donc le fichier de changement suivant :
<?php namespace Acme\VersionChanges; class VersionChanges101 extends AbstractVersionChanges { /** * {@inheritdoc} */ public function apply(array $data) { foreach ($data['results'] as $key => $result) { if ('taxonomy' == $result['type']) { unset($data['results'][$key]); } } return $data; } /** * {@inheritdoc} */ public function supports(array $data) { return isset($data['results']) && array_search('taxonomy', array_column($data['results'], 'type')); } }
Dans cet exemple, nous avons la méthode supports() qui vérifie que des contenus de type taxonomy sont bien présents dans la réponse de cette requête et qu'ils doivent donc être supprimés. C'est ensuite la méthode apply() qui s'occupe de supprimer les contenus et de retourner les données mises à jour.
En fonction des cas, ces fichiers peuvent se complexifier mais généralement, ils restent simple et rapide à implémenter par les développeurs lors de l'ajout de fonctionnalités présentant des cas de cassage de compatibilité (breaking changes).
Prochaine étape
Nous en avons terminé pour l'implémentation, il est temps de tester celle-ci dans la dernière étape.
Conclusion
Félicitations, vous avez terminés l'implémentation du versioning et de la gestion de retrocompatibilité dans votre API !
Utilisation
Vous pouvez dès maintenant tester votre implémentation en effectuant des requêtes à votre API et en spécifiant un header de version de la façon suivante :
$ curl -H 'X-Accept-Version: 1.0.1' http://monapi.local
<données retro-compatibles>
Enfin, souvenez vous que si aucun header n'est spécifié, aucune retrocompatibilité ne sera appliquée : vous serez donc sur la version la plus récente de votre API.
Je suis preneur de feedbacks sur cette implémentation donc n'hésitez pas à me contacter si vous avez des soucis de mise en place ou d'utilisation.
Conclusion
Bien que les fichiers de retrocompatibilité soient simples à mettre en place par les développeurs, il ne faut pas se faire avoir par vos clients (d'API) et gérer trop de versions de retrocompatibilité.
Au mieux, vous devez toujours avoir une seule version retrocompatible. Cependant, dans certains cas comme le déploiement d'application mobile, vous êtes dépendants des utilisateurs qui ne font pas forcément les mises à jour dès la sortie et devez donc garder une ou deux versions supplémentaires retrocompatibles.
Vous détenez le coeur métier de vos clients, n'hésitez pas à les pousser à évoluer sur les nouvelles versions.




