Pour avoir une API totalement REST, il faut respecter tous les niveaux du modèle de maturité de Richardson et, en particulier, il faut que notre API soit autodécouvrable.
En d’autres termes, lorsque nous accédons à une ressource, nous devons avoir directement les URL pour explorer cette ressource.
Un exemple typique : lorsque nous récupérons la liste des livres à disposition, nous devons, pour respecter cette contrainte, également récupérer les URL de chacun de ces livres, ainsi que, le cas échéant, les URL pour les mettre à jour et les modifier.
C’est ce qu’on appelle “The Glory of REST” (en français, la gloire du REST).
Installez HATEOAS et JMSSerializer
Nous pourrions mettre ce dernier niveau en œuvre à la main, en récupérant nous-mêmes les routes que nous voulons indiquer, et en les sérialisant en même temps que les données.
Cependant, c’est assez fastidieux à faire, et nous risquerions de ne pas respecter les standards… De plus, les projets nécessitant le niveau 3 de Richardson sont souvent des projets relativement complexes, et il est bien plus pratique de déléguer cette tâche à un outil externe.
Cet outil va être dans notre cas : HATEOAS.
Cet outil a cependant besoin d’un serializer un peu plus abouti que celui de Symfony pour fonctionner, le JMSSerializer.
Heureusement, tout est fourni dans un seul et même bundle :
composer require willdurand/hateoas-bundle
Composer va vous demander si vous voulez exécuter des recettes, celles-ci permettent de créer notamment les fichiers de configuration nécessaires à ces librairies, dites oui. :)
Remplacez le sérialiser de Symfony par JMSSerializer
Maintenant que le bundle est installé, et avant de regarder le fonctionnement de HATEOAS, il va falloir utiliser le nouveau serializer.
Un nouveau fichier, jms_serializer.yaml
, devrait avoir été créé. Nous allons commencer par le mettre à jour :
# config\packages\jms_serializer.yaml
jms_serializer:
visitors:
xml_serialization:
format_output: '%kernel.debug%'
property_naming:
id: jms_serializer.identical_property_naming_strategy
Le début devrait normalement être présent, mais nous allons ajouter la propriété property_naming
.
Celle-ci nous permet de continuer à utiliser le camel case dans nos appels ( coverText
et pas cover_text
), comme nous l’a proposé Symfony par défaut jusqu’à présent.
Mettez à jour les contrôleurs et les entités
Commençons par le fichier BookController.php
.
Il va falloir remplacer le use
concernant le serializer de Symfony par un use
qui concerne JMSSerialiser.
<?php
// src\Controller\BookController.php
// ...
// Supprimez cette ligne :
use Symfony\Component\Serializer\SerializerInterface;
// Ajoutez celle ci :
use JMS\Serializer\Serializer;
Mon éditeur de texte me souligne en rouge certains éléments :
C’est parce que même si on utilise toujours un $serializer
qui vient de SerializerInterface
, il ne s’agit plus du même serializer.
On est passé à JMSSerializer, et donc son fonctionnement diffère légèrement. Notamment en ce qui concerne le contexte de sérialisation, c'est-à-dire les informations qui permettent de savoir comment sérialiser (ici, la gestion des groupes, par exemple).
JMSSerializer étant construit de la même manière, nous aurons peu de choses à changer, mais parmi elles, il faut mettre à jour la syntaxe pour utiliser les “groups”.
Et voici le nouveau code
<?php
// src\Controller\BookController.php
// ...
use JMS\Serializer\SerializationContext;
use JMS\Serializer\SerializerInterface;
// ...
#[Route('/api/books/{id}', name: 'detailBook', methods: ['GET'])]
public function getDetailBook(Book $book, SerializerInterface $serializer): JsonResponse
{
$context = SerializationContext::create()->setGroups(['getBooks']);
$jsonBook = $serializer->serialize($book, 'json', $context);
return new JsonResponse($jsonBook, Response::HTTP_OK, [], true);
}
Pour faire simple, JMS crée un objet SerializationContext
et lui passe le groupe : c'est le même principe qu'avant, mais avec une écriture différente.
À ce stade, si on teste on obtient…
Nous avons bien nos trois éléments comme demandé, mais ils sont vides. Cela ressemble très fort à ce qui se passerait si on indiquait un mauvais nom de groupe.
C’est presque le cas ! En fait, vu que nous sommes passés sur JMSSerializer, nous devons maintenant également modifier les use
qui sont dans les entités.
<?php
// src\Entity\Book.php et src\Entity\Author.php
// ...
// Annotation à supprimer
use Symfony\Component\Serializer\Annotation\Groups;
// Annotation à rajouter :
use JMS\Serializer\Annotation\Groups;
On relance, et maintenant nous avons le bon résultat :
Gérez le cas du PUT
Souvenez-vous, dans le cas de l’update, nous récupérions l’entité visée, et nous nous étions arrangés pour désérialiser directement à l’intérieur.
Par exemple, quand nous éditions le livre avec l’id 42, Symfony nous fournissait l’entité correspondant à l’id 42 et désérialisait en utilisant cette entité pour base.
C’est faisable également avec jms_serializer
, mais c’est moins pratique à mettre en place, comme vous pouvez le voir dans la documentation de JMSSerializer (en anglais). Je vais donc en profiter pour vous montrer une autre technique, plus simple mais très classique. Elle consiste tout simplement à :
récupérer l’entité correspondant à l’id (grâce au ParamConverter, nous l’obtenons directement dans $currentBook) ;
désérialiser cette entité dans une nouvelle entité vierge (dans $newbook) ;
recopier les éléments de l’une dans l’autre ($currentBook->set…).
<?php
// src\Controller\BookController.php
// ...
#[Route('/api/books/{id}', name:"updateBook", methods:['PUT'])]
#[IsGranted('ROLE_ADMIN', message: 'Vous n\'avez pas les droits suffisants pour éditer un livre')]
public function updateBook(Request $request, SerializerInterface $serializer, Book $currentBook, EntityManagerInterface $em, AuthorRepository $authorRepository, ValidatorInterface $validator, TagAwareCacheInterface $cache): JsonResponse
{
$newBook = $serializer->deserialize($request->getContent(), Book::class, 'json');
$currentBook->setTitle($newBook->getTitle());
$currentBook->setCoverText($newBook->getCoverText());
// On vérifie les erreurs
$errors = $validator->validate($currentBook);
if ($errors->count() > 0) {
return new JsonResponse($serializer->serialize($errors, 'json'), JsonResponse::HTTP_BAD_REQUEST, [], true);
}
$content = $request->toArray();
$idAuthor = $content['idAuthor'] ?? -1;
$currentBook->setAuthor($authorRepository->find($idAuthor));
$em->persist($currentBook);
$em->flush();
// On vide le cache.
$cache->invalidateTags(["booksCache"]);
return new JsonResponse(null, JsonResponse::HTTP_NO_CONTENT);
}
Et voilà, désormais JMS_Serializer
est en place.
Mettez en place HATEOAS
Jusqu’ici, nous avons certes remplacé le serializer de Symfony par JMSSerializer, mais sur notre projet, cela ne change encore rien. Nous l’avons fait uniquement parce que HATEOAS réclame ce nouveau serializer pour fonctionner.
Le niveau 3 du modèle de Richardson explique que lorsqu’on récupère une entité, on doit en même temps récupérer des informations sur les autres routes possibles pour cette entité.
Voyons comment mettre cela en place avec HATEOAS, sur notre entité Book :
<?php
// src\Entity\Book.php
// ...
use Hateoas\Configuration\Annotation as Hateoas;
// ...
/**
* @Hateoas\Relation(
* "self",
* href = @Hateoas\Route(
* "detailBook",
* parameters = { "id" = "expr(object.getId())" }
* ),
* exclusion = @Hateoas\Exclusion(groups="getBooks")
* )
*
*/
#[ORM\Entity(repositoryClass: BookRepository::class)]
class Book
{
Les annotations de HATEOAS définissent une relation, c’est-à-dire un nouveau lien qui va apparaître. Le premier lien est self
, il s’agit du lien vers la ressource que l’on est en train de regarder.
Le paramètre href
va contenir la route concernée. Ici, nous avons detailBook
, car c’est la route pour avoir le détail d’un livre. Nous passons le paramètre id
à cette route.
Nous voyons également ici un paramètre : exclusion
. Celui-ci permet de déterminer dans quel cas les informations vont apparaître ou pas. Ici, nous précisons simplement le groupe.
Faisons pareil pour le delete
et le update
, et ajoutons ces éléments sous le premier:
<?php
// src\Entity\Book.php
// ...
/*
* @Hateoas\Relation(
* "delete",
* href = @Hateoas\Route(
* "deleteBook",
* parameters = { "id" = "expr(object.getId())" },
* ),
* exclusion = @Hateoas\Exclusion(groups="getBooks", excludeIf = "expr(not is_granted('ROLE_ADMIN'))"),
* )
*
* @Hateoas\Relation(
* "update",
* href = @Hateoas\Route(
* "updateBook",
* parameters = { "id" = "expr(object.getId())" },
* ),
* exclusion = @Hateoas\Exclusion(groups="getBooks", excludeIf = "expr(not is_granted('ROLE_ADMIN'))"),
* )
*
*/
#[ORM\Entity(repositoryClass: BookRepository::class)]
#[ApiResource()]
class Book
(...)
Notez le paramètre “exclusion” qui évolue, nous précisons maintenant que le lien sera exclu de la liste si l’utilisateur ne possède pas le ROLE_ADMIN
. En effet, seul l’administrateur peut effectuer un delete
ou un update
.
Voyons maintenant le résultat :
Vous pouvez constater l’apparition d’un nouvel élément, _links
, qui contient directement les liens vers les ressources self
, delete
et update
.
Voyons également ce qui se passe si on récupère tous les livres :
Les liens sont bien mis à jour et en plus, le tout respecte le standard HAL qui est un des plus utilisés pour mettre en œuvre le niveau 3 du modèle de Richardson.
Allez plus loin
Nous allons nous arrêter ici, mais sachez qu’il est possible de configurer plus finement ces éléments.
Il est également possible de spécifier des ressources associées (embed), ce qui dans certains cas peut s’avérer très pratique, notamment si vous avez besoin d’intégrer une ressource externe dans votre API.
Je vais vous laisser explorer les possibilités offertes par HATEOAS grâce à la documentation officielle (en anglais).
Exercez-vous
Nous avons mis à jour notre projet pour utiliser JMS et HATEOAS pour les livres, mais il reste les auteurs à gérer de la même manière ! :)
N’hésitez pas à essayer d’autres options grâce à la documentation officielle pour avoir une idée des possibilités offertes par cette librairie.
En résumé
HATEOAS est une bibliothèque qui permet de gérer facilement l’autodécouvrabilité.
JMSSerializer est un serializer, au même titre que le serializer de Symfony, mais qui possède certaines fonctionnalités supplémentaires nécessaires à HATEOAS.
Pour le versioning, on essaie généralement d’éviter de passer les informations sur la version attendue dans l’URL, et on préfère utiliser le champ
Accept
.Un service permet de déporter un traitement à l’extérieur d’un contrôleur, ce qui est pratique notamment si ce traitement peut être demandé par plusieurs contrôleurs.
Bravo à vous ! Il vous reste un dernier chapitre dans cette deuxième partie du cours. Découvrons comment versionner une API - suivez-moi !