Mettre à jour des ressources

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.


  1. Create Read Update Delete 

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.

Cinématique de la mise à jour complète

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}{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.

Requête de mise à jour complète avec Postman

La réponse est :

1
2
3
4
5
  {
    "id": 2,
    "name": "Mont-Saint-Michel",
    "address": "Autre adresse Le Mont-Saint-Michel"
  }
Réponse à la requête de mise à jour dans Postman

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.

Cinématique de la mise à jour partielle d’une ressource

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 :

Requête de mise à jour partielle avec Postman
Réponse de la mise à jour dans Postman

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.

Le niveau de maturité de notre application

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