diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.json b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.json new file mode 100644 index 0000000..b93acc7 --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.json @@ -0,0 +1,34 @@ +{ + "title": "API versioning et rétro-compatibilité avec Symfony", + "slug": "api-versioning-et-retro-compatibilite-avec-symfony", + "permalink": "/fr/api-versioning-et-retro-compatibilite-avec-symfony/", + "excerpt": "Lorsque vous avez besoin de faire évoluer votre API rapidement, vous êtes souvent bloqués par vos clients pour qui vous ne pouvez pas casser la compatibilité. Ce tutoriel vous explique comment mettre en place du versioning dans Symfony et comment gérer la rétro-compatibilité des sorties d'API.", + "stepTitles": [ + "Introduction", + "Configuration des fichiers de changement par version", + "Ajout du listener sur la réponse Symfony", + "Ajout de la factory pour instancier les changements", + "Ajout de fichiers de changements", + "Conclusion" + ], + "time": 15, + "date": "2018-03-04", + "cover": "/assets/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/cover.jpg", + "authors": [ + { + "name": "Vincent Composieux", + "username": "vcomposieux" + } + ], + "categories": [ + "Symfony", + "API" + ], + "tags": [ + "php", + "Symfony", + "api", + "versioning", + "compatibility" + ] +} diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.md new file mode 100644 index 0000000..c05eb94 --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/index.md @@ -0,0 +1,37 @@ +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 alors parcouru différentes solutions et j'ai choisi d'adopter [le modèle présenté par Stripe](https://stripe.com/blog/api-versioning), permettant d'appliquer une rétro-compatibilité 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 incluera 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 bénéficier de cette nouvelle version. + +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 vu technique, notre API devra alors appliquer une transformation sur le modèle de sortie afin d'assurer la rétro-compatibilité sur cette version. C'est vraiment la réponse de notre API qui sera versionnée. + +# 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](http://symfony.com/download) pour installer une version de Symfony. + +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. diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step1.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step1.md new file mode 100644 index 0000000..026fc59 --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step1.md @@ -0,0 +1,30 @@ +Nous allons donc avoir besoin de spécifier les changements de rétro-compatibilité à appliquer lorsqu'une version précédente est demandée. + +Il nous faut alors 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 rétro-compatibles + +Editez alors le fichier `app/config/parameters.yml` de votre projet (ou `config/services.yaml` sous Symfony 4) et ajoutez-y l'entrée suivante sous `parameters` : + +```yaml +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 lesquels nous souhaitons appliquer une rétro-compatibilité. + +Les changements de rétro-compatibilité 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, alors, les changements de rétro-compatibilité 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 quand à elle ne 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. diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step2.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step2.md new file mode 100644 index 0000000..14ad254 --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step2.md @@ -0,0 +1,134 @@ +Nous allons donc agir sur la réponse de Symfony, ce qui signifie que nous devons implémenter 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 +requestStack = $requestStack; + $this->changesFactory = $changesFactory; + } + + /** + * @return Request + */ + private function getRequest() + { + return $this->requestStack->getCurrentRequest(); + } +} +``` + +La structure du listener est en place. Comme vous pouvez le voir, nous y injectons le service `RequestStack` de Symfony ainsi qu'un service nommé `ChangesFactory`. Nous allons créer ce service dans les étapes juste après. + +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étro-compatibilité de notre API, par la suite. + +Ajoutons donc la méthode `onKernelResponse` qui sera déclenché par l'`EventManager` de Symfony : + +```php + /** + * @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()` : + +```php + /** + * 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 par la suite. Un premier appel à la méthode `supports()` permet de vérifier si les changements de ce fichier doivent être appliqués à cette version. + +En effet, 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 : + +```yaml +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, cela est normal, il sera déclaré dans la prochaine étape. + +# Prochaine étape + +Entrons dans le coeur du gestionnaire de changements de rétro-compatibilité en implémentant le service `ChangesFactory` qui nous permet d'instancier les classes de changements. diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step3.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step3.md new file mode 100644 index 0000000..16c488d --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step3.md @@ -0,0 +1,222 @@ +Nous allons donc implémenter la classe `Acme\VersionChanges\ChangesFactory`. + +# Ajout de la classe + +Créez donc le fichier suivant : + +```php +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é dans en tant que `parameters` Symfony ainsi que le `RequestStack` que nous allons injecter par la suite dans nos fichiers d'application de changements de versions. + +Notez que, par la suite, vous pourrez avoir besoin d'injecter Doctrine, par exemple, afin de récupérer des données en base de données, et pas simplement de les re-modeler. + +Nous avons également écrit deux méthodes `has($version)` et `get($version)` assez simples, pour retourner une version. + +Cependant, les yeux les plus aguéris auront remarqués 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 : + +```php + /** + * 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 : + +```php + /** + * 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 +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]); + } +} +``` + +Aussi, pour rappel, même si nous n'écrivons pas de tests unitaires dans ce tutoriel, principalement afin d'en simplifier sa lecture, vous devriez en ajouter afin de vous assurer du comportement de vos méthodes. + +# 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 : + +```php +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. diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step4.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step4.md new file mode 100644 index 0000000..ee98f3f --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step4.md @@ -0,0 +1,124 @@ +Comme nous l'avons vu précédemment, l'interface des fichiers de changements est assez simple. Nous allons en effet avoir besoin principalement de deux méthodes `supports()` et `apply()`. + +Ajoutons donc cette interface : + +```php +requestStack = $requestStack; + } + + /** + * @return Request + */ + public function getRequest() + { + return $this->requestStack->getCurrentRequest(); + } +} +``` + +Souvenez-vous, notre service `ChangesFactory` qui instancie ces classes de changement 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 rétro-compatibilité 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 + $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 donc bien 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és pour l'implémentation, il est temps de tester celle-ci dans la dernière étape. diff --git a/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step5.md b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step5.md new file mode 100644 index 0000000..65ccb19 --- /dev/null +++ b/_posts/codelabs/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/step5.md @@ -0,0 +1,23 @@ +Félicitations, vous avez terminés l'implémentation du versioning et de la gestion de rétro-compatibilité 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 + + +``` + +Enfin, souvenez vous que si aucun header n'est spécifié, aucune rétro-compatibilité ne sera appliquée : vous serez donc sur la version la plus récente de votre API. + +Je suis prenneur 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 retro-compatibilité 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 rétro-compatibilité. + +Au mieux, vous devez toujours avoir une seule version rétro-compatible. 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 rétro-compatible. + +Vous détenez le coeur métier de vos clients, n'hésitez pas à les pousser à évoluer sur les nouvelles versions. diff --git a/_posts/codelabs/index.json b/_posts/codelabs/index.json index 9c53681..a7aa010 100644 --- a/_posts/codelabs/index.json +++ b/_posts/codelabs/index.json @@ -1 +1 @@ -[{"title":"Mon premier tuto","slug":"mon-premier-tuto","permalink":"/fr/mon-premier-tuto/","excerpt":"Pour rendre l'expérience utilisateur de nos applications web de plus en plus agréable, nous somme de plus en plus obligé d'utiliser plusieurs technologies en même temps. Nous allons mettre en place un flux infini en utilisant un backend Symfony et un front en ReactJs.","stepTitles":["Introduction - Flux Infini avec Symfony et React","Mise en place du backend","Configurer la base de données"],"date":"2017-09-11","cover":"/assets/2017-09-11-mon-premier-tuto/cover.jpg","time":20,"authors":[{"name":"Jonathan Jalouzot","username":"captainjojo"}],"categories":["Symfony","javascript"],"tags":["php","Symfony","react","redux"],"duration":{"1":1,"2":1,"3":1,"total":3}},{"title":"Mon seconde tuto","slug":"mon-second-tuto","permalink":"/fr/mon-second-tuto/","excerpt":"Pour rendre l'expérience utilisateur de nos applications web de plus en plus agréable, nous somme de plus en plus obligé d'utiliser plusieurs technologies en même temps. Nous allons mettre en place un flux infini en utilisant un backend Symfony et un front en ReactJs.","stepTitles":["Introduction - Flux Infini avec Symfony et React","Mise en place du backend"],"date":"2017-09-11","cover":"/assets/2017-09-11-mon-premier-tuto/cover.jpg","authors":[{"name":"Jonathan Jalouzot","username":"captainjojo"}],"categories":["Symfony","javascript"],"tags":["php","Symfony","react","redux"],"duration":{"1":1,"2":1,"total":2}}] \ No newline at end of file +[{"title":"Mon premier tuto","slug":"mon-premier-tuto","permalink":"/fr/mon-premier-tuto/","excerpt":"Pour rendre l'expérience utilisateur de nos applications web de plus en plus agréable, nous somme de plus en plus obligé d'utiliser plusieurs technologies en même temps. Nous allons mettre en place un flux infini en utilisant un backend Symfony et un front en ReactJs.","stepTitles":["Introduction - Flux Infini avec Symfony et React","Mise en place du backend","Configurer la base de données"],"date":"2017-09-11","cover":"/assets/2017-09-11-mon-premier-tuto/cover.jpg","time":20,"authors":[{"name":"Jonathan Jalouzot","username":"captainjojo"}],"categories":["Symfony","javascript"],"tags":["php","Symfony","react","redux"],"duration":{"1":1,"2":1,"3":1,"total":3}},{"title":"Mon seconde tuto","slug":"mon-second-tuto","permalink":"/fr/mon-second-tuto/","excerpt":"Pour rendre l'expérience utilisateur de nos applications web de plus en plus agréable, nous somme de plus en plus obligé d'utiliser plusieurs technologies en même temps. Nous allons mettre en place un flux infini en utilisant un backend Symfony et un front en ReactJs.","stepTitles":["Introduction - Flux Infini avec Symfony et React","Mise en place du backend"],"date":"2017-09-11","cover":"/assets/2017-09-11-mon-premier-tuto/cover.jpg","authors":[{"name":"Jonathan Jalouzot","username":"captainjojo"}],"categories":["Symfony","javascript"],"tags":["php","Symfony","react","redux"],"duration":{"1":1,"2":1,"total":2}},{"title":"API versioning et rétro-compatibilité avec Symfony","slug":"api-versioning-et-retro-compatibilite-avec-symfony","permalink":"/fr/api-versioning-et-retro-compatibilite-avec-symfony/","excerpt":"Lorsque vous avez besoin de faire évoluer votre API rapidement, vous êtes souvent bloqués par vos clients pour qui vous ne pouvez pas casser la compatibilité. Ce tutoriel vous explique comment mettre en place du versioning dans Symfony et comment gérer la rétro-compatibilité des sorties d'API.","stepTitles":["Introduction","Configuration des fichiers de changement par version","Ajout du listener sur la réponse Symfony","Ajout de la factory pour instancier les changements","Ajout de fichiers de changements","Conclusion"],"time":15,"date":"2018-03-04","cover":"/assets/2018-03-04-api-versioning-et-retro-compatibilite-avec-symfony/cover.jpg","authors":[{"name":"Vincent Composieux","username":"vcomposieux"}],"categories":["Symfony","API"],"tags":["php","Symfony","api","versioning","compatibility"],"duration":{"1":1,"2":1,"3":3,"4":3,"5":3,"6":1,"total":12}}] \ No newline at end of file