Maintenant que nous pouvons lire, écrire et supprimer des ressources, il ne reste plus qu’à apprendre à les modifier et le CRUD1 (Créer, Lire, Mettre à jour et Supprimer ) en REST n’aura plus de secret pour nous.
Dans cette partie, nous aborderons les concepts liés à la mise à jour de ressources REST et nous ferons un petit détour sur la gestion des erreurs avec FOSRestBundle.
-
Create Read Update Delete ↩
- Mise à jour complète d'une ressource
- Mise à jour partielle d'une ressource
- Notre application vu selon le modèle de Richardson
Mise à jour complète d'une ressource
Afin de gérer la mise à jour des ressources, nous devons différencier la mise à jour complète (le client de l’API veut changer toute la ressource) de la mise à jour partielle (le client de l’API veut changer juste quelques attributs de la ressource).
Déroulons notre schéma classique pour trouver les mécanismes qu’offre HTTP pour gérer la mise à jour complète d’une ressource.
Quelle est la ressource cible ?
Lorsque nous voulons modifier une ressource, la question ne se pose pas. La cible de notre requête est la ressource à mettre à jour. Donc pour mettre à jour un lieu, nous devrons faire une requête sur l’URL de celle-ci (par exemple rest-api.local/places/1).
Quel verbe HTTP ?
La différenciation entre la mise à jour complète ou partielle d’une ressource se fait avec le choix du verbe HTTP utilisé. Donc le verbe est ici d’une importance capitale.
Notre fameuse RFC 7231 décrit la méthode PUT
comme :
The PUT method requests that the state of the target resource be created or replaced with the state defined by the representation enclosed in the request message payload.
La méthode PUT
permet de créer ou de remplacer une ressource.
Le cas d’utilisation de PUT
pour créer une ressource est très rare et nous ne l’aborderons pas. Il faut juste retenir que pour que cet verbe soit utilisé pour créer une ressource, il faudrait laisser au client de l’API le choix des URL de nos ressources.
Nous l’utiliserons donc juste afin de remplacer le contenu d’une ressource par le payload de la requête, bref pour la mettre à jour en entier.
Le corps de notre requête
Le corps de la requête sera donc la nouvelle valeur que nous voulons affecter à notre ressource (toujours au format JSON comme pour la création).
Quel code de statut HTTP ?
Dans la description même de la requête PUT
, le code de statut à utiliser est explicité: 200
.
A successful PUT of a given representation would suggest that a subsequent GET on that same target resource will result in an equivalent representation being sent in a 200 (OK) response.
Juste pour rappel, comme pour la récupération d’une ressource, si le client essaye de mettre à jour une ressource inexistante, nous aurons un 404
.
Mise à jour d’un lieu
Pour une mise à jour complète, un utilisateur devra faire une requête PUT
sur l’URL rest-api.local/places/{id}
où {id}
représente l’identifiant du lieu avec comme payload, le même qu’à la création :
1 2 3 4 | { "name": "ici un nom", "address": "ici une adresse" } |
Le corps de la requête ne contient pas l’identifiant de la ressource vu qu’elle sera disponible dans l’URL.
Le routage dans notre contrôleur se rapproche beaucoup de celle pour récupérer un lieu :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | # src/AppBundle/Controller/PlaceController.php <?php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Entity\Place; class PlaceController extends Controller { /** * @Rest\View() * @Rest\Put("/places/{id}") */ public function putPlaceAction(Request $request) { } } |
Les règles de validation des informations sont exactement les mêmes qu’à la création d’un lieu. Nous allons donc exploiter le même formulaire Symfony. La seule différence ici réside dans le fait que nous devons d’abord récupérer une instance du lieu dans la base de données avant d’appliquer les mises à jour.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 | # src/AppController/PlaceController.php <?php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Form\Type\PlaceType; use AppBundle\Entity\Place; class PlaceController extends Controller { // ... /** * @Rest\View() * @Rest\Put("/places/{id}") */ public function updatePlaceAction(Request $request) { $place = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:Place') ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire /* @var $place Place */ if (empty($place)) { return new JsonResponse(['message' => 'Place not found'], Response::HTTP_NOT_FOUND); } $form = $this->createForm(PlaceType::class, $place); $form->submit($request->request->all()); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); // l'entité vient de la base, donc le merge n'est pas nécessaire. // il est utilisé juste par soucis de clarté $em->merge($place); $em->flush(); return $place; } else { return $form; } } } |
Pour tester cette méthode, nous allons utiliser Postman.
La réponse est :
1 2 3 4 5 | { "id": 2, "name": "Mont-Saint-Michel", "address": "Autre adresse Le Mont-Saint-Michel" } |
Pratiquons avec les utilisateurs
La mise à jour complète d’un utilisateur suit exactement le même modèle que celle d’un lieu. Les contraintes de validation sont identiques à celles de la création d’un utilisateur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | # src/AppBundle/Controller/UserController.php <?php namespace AppBundle\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Request; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Form\Type\UserType; use AppBundle\Entity\User; class UserController extends Controller { // ... /** * @Rest\View() * @Rest\Put("/users/{id}") */ public function updateUserAction(Request $request) { $user = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:User') ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire /* @var $user User */ if (empty($user)) { return new JsonResponse(['message' => 'User not found'], Response::HTTP_NOT_FOUND); } $form = $this->createForm(UserType::class, $user); $form->submit($request->request->all()); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); // l'entité vient de la base, donc le merge n'est pas nécessaire. // il est utilisé juste par soucis de clarté $em->merge($user); $em->flush(); return $user; } else { return $form; } } } |
Que se passe-t-il si nous faisons une requête en omettant le champ address
?
Modifions notre requête en supprimant le champ address
:
1 2 3 | { "name": "Autre-Mont-Saint-Michel" } |
La réponse est une belle erreur 400:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | { "code": 400, "message": "Validation Failed", "errors": { "children": { "name": [], "address": { "errors": [ "This value should not be blank." ] } } } } |
Cela nous permet de bien valider les données envoyées par le client mais avec cette méthode, il est dans l’obligation de connaitre tous les champs afin d’effectuer sa mise à jour.
Mise à jour partielle d'une ressource
Que faire alors si nous voulons modifier par exemple que le nom d’un lieu ?
Jusqu’à présent, nos appels API pour modifier une ressource se contente de la remplacer par une nouvelle (en gardant l’identifiant). Mais dans une API plus complète avec des ressources avec beaucoup d’attributs, nous pouvons rapidement sentir le besoin de modifier juste quelques-uns de ces attributs.
Pour cela, la seule chose que nous devons changer dans notre API c’est le verbe HTTP utilisé.
À la rencontre de PATCH
Parmi toutes les méthodes que nous avons déjà pu utiliser, PATCH
est la seule qui n’est pas spécifiée dans la RFC 7231 mais plutôt dans la RFC 5789.
Ce standard n’est pas encore validé - PROPOSED STANDARD
(au moment où ces lignes sont écrites) - mais est déjà largement utilisé.
Cette méthode doit être utilisée pour décrire un ensemble de changements à appliquer à la ressource identifiée par son URI.
Comment décrire les changements à appliquer ?
Vu que nous utilisons du JSON dans nos payload. Il existe une RFC 6902, elle aussi pas encore adoptée, qui essaye de formaliser le payload d’une requête PATCH
utilisant du JSON.
Par exemple, dans la section 4.3, nous pouvons lire la description d’une opération consistant à remplacer un champ d’une ressource :
1 | { "op": "replace", "path": "/a/b/c", "value": 42 } |
Pour le cas de notre lieu, si nous voulions un correctif (patch) pour ne changer que l’adresse, il faudrait :
1 | { "op": "replace", "path": "/address", "value": "Ma nouvelle adresse" } |
Outre le fait que cette méthode n’est pas beaucoup utilisée, sa mise en œuvre par un client est complexe et son traitement coté serveur l’est autant.
Donc par pragmatisme, nous n’allons pas utiliser PATCH
de cette façon.
Dans notre API, une requête PATCH
aura comme payload le même que celui d’une requête POST
à une grande différence près : Si un attribut n’existe pas dans le corps de la requête, nous devons conserver son ancienne valeur.
Notre requête avec comme payload :
1 2 3 | { "name": "Autre-Mont-Saint-Michel" } |
… ne devra pas renvoyer une erreur mais juste modifier le nom de notre lieu.
Mise à jour partielle d’un lieu
L’implémentation de la mise à jour partielle avec Symfony est très proche de la mise à jour complète. Il suffit de rajouter un paramètre dans la méthode submit
(clearMissing = false
) et le tour est joué.
Comme son nom l’indique, avec clearMissing
à false
, Symfony conservera tous les attributs de l’entité Place
qui ne sont pas présents dans le payload de la requête.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 | # src/AppBundle/Controller/PlaceController.php <?php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Form\Type\PlaceType; use AppBundle\Entity\Place; class PlaceController extends Controller { // ... /** * @Rest\View() * @Rest\Patch("/places/{id}") */ public function patchPlaceAction(Request $request) { $place = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:Place') ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire /* @var $place Place */ if (empty($place)) { return new JsonResponse(['message' => 'Place not found'], Response::HTTP_NOT_FOUND); } $form = $this->createForm(PlaceType::class, $place); // Le paramètre false dit à Symfony de garder les valeurs dans notre // entité si l'utilisateur n'en fournit pas une dans sa requête $form->submit($request->request->all(), false); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); // l'entité vient de la base, donc le merge n'est pas nécessaire. // il est utilisé juste par soucis de clarté $em->merge($place); $em->flush(); return $place; } else { return $form; } } } |
Nous avons ici un gros copier-coller de la méthode updatePlace
, un peu de refactoring ne sera pas de mal.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 | # src/AppBundle/Controller/PlaceController.php <?php namespace AppBundle\Controller; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Form\Type\PlaceType; use AppBundle\Entity\Place; class PlaceController extends Controller { // ... /** * @Rest\View() * @Rest\Put("/places/{id}") */ public function updatePlaceAction(Request $request) { return $this->updatePlace($request, true); } /** * @Rest\View() * @Rest\Patch("/places/{id}") */ public function patchPlaceAction(Request $request) { return $this->updatePlace($request, false); } private function updatePlace(Request $request, $clearMissing) { $place = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:Place') ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire /* @var $place Place */ if (empty($place)) { return new JsonResponse(['message' => 'Place not found'], Response::HTTP_NOT_FOUND); } $form = $this->createForm(PlaceType::class, $place); // Le paramètre false dit à Symfony de garder les valeurs dans notre // entité si l'utilisateur n'en fournit pas une dans sa requête $form->submit($request->request->all(), $clearMissing); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); $em->persist($place); $em->flush(); return $place; } else { return $form; } } } |
En relançant notre requête, la réponse est bien celle attendue :
Pratiquons avec les utilisateurs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 | # src/AppBundle/Controller/UserController.php <?php namespace AppBundle\Controller; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method; use Symfony\Bundle\FrameworkBundle\Controller\Controller; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Request; use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations use AppBundle\Form\Type\UserType; use AppBundle\Entity\User; class UserController extends Controller { // ... /** * @Rest\View() * @Rest\Patch("/users/{id}") */ public function patchUserAction(Request $request) { return $this->updateUser($request, false); } private function updateUser(Request $request, $clearMissing) { $user = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:User') ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire /* @var $user User */ if (empty($user)) { return new JsonResponse(['message' => 'User not found'], Response::HTTP_NOT_FOUND); } $form = $this->createForm(UserType::class, $user); $form->submit($request->request->all(), $clearMissing); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); $em->persist($user); $em->flush(); return $user; } else { return $form; } } } |
Au fur et à mesure de nos développements, nous nous rendons compte que notre API est très uniforme, donc facile à utiliser pour un client. Mais aussi l’implémentation serveur l’est autant. Cette uniformité facilite grandement le développement d’une API RESTful et notre productivité est décuplée !
Gestion des erreurs avec FOSRestBundle
Jusque-là, nous utilisons un objet JsonResponse
lorsque la ressource recherchée n’existe pas. Cela fonctionne bien mais nous n’utilisons pas FOSRestBundle de manière appropriée.
Au lieu de renvoyer une réponse JSON, nous allons juste renvoyer une vue FOSRestBundle et laisser le view handler le formater en JSON.
En procédant ainsi, nous pourrons plus tard exploiter toutes les fonctionnalités de ce bundle comme par exemple changer le format des réponses (par exemple renvoyer du XML) sans modifier notre code.
Pour ce faire, il suffit de remplacer toutes les lignes :
1 | return new JsonResponse(['message' => 'Place not found'], Response::HTTP_NOT_FOUND);
|
Par
1 | return \FOS\RestBundle\View\View::create(['message' => 'Place not found'], Response::HTTP_NOT_FOUND);
|
Il faudra aussi faire de même avec le contrôleur UserController.
Notre application vu selon le modèle de Richardson
Pour rappel, le modèle de Richardson est un modèle qui permet d’évaluer son application selon les principes REST.
Nous pouvons facilement affirmer que notre application est au niveau 2 vu que nous exploitons les différents verbes que propose le protocole HTTP pour interagir avec des ressources identifiées par des URIs. Nous verrons plus tard comment s’approcher du niveau 3 en exploitant d’autres bundles de Symfony à notre disposition.
Nos tableaux récapitulatifs s’étoffent encore plus et nous pouvons rajouter les opérations de mise à jour.
Opération souhaitée | Verbe HTTP |
---|---|
Lecture | GET |
Création | POST |
Suppression | DELETE |
Modification complète (remplacement) | PUT |
Modification partielle | PATCH |
Code statut | Signification |
---|---|
200 | Tout s’est bien passé et la réponse a du contenu |
204 | Tout s’est bien passé mais la réponse est vide |
400 | Les données envoyées par le client sont invalides |
404 | La ressource demandée n’existe pas |
500 | Une erreur interne a eu lieu sur le serveur |