Relations entre ressources

Maintenant que nous pouvons effectuer toutes les opérations CRUD (Créer, Lire, Mettre à jour et Supprimer) sur nos ressources, qu’est ce qui pourrait rester pour avoir une API pleinement fonctionnelle ?

Actuellement, nous avons une liste d’utilisateurs d’un côté et une liste de lieux d’un autre. Mais l’objectif de notre application est de pouvoir proposer à chaque utilisateur, selon ses centres d’intérêts, une idée de sortie en utilisant les différents lieux référencés.

Nous pouvons imaginer qu’un utilisateur passionné d’histoire ou d’architecture irait plutôt visiter un musée, un château, etc. Ou encore, selon le budget dont nous disposons, le tarif pour accéder à ces différents lieux pourrait être un élément important.

En résumé, nos ressources doivent avoir des relations entre elles et c’est ce que nous allons aborder dans cette partie du cours.

Hiérarchie entre ressources : la notion de sous-ressources

Un peu de conception

Supposons qu’un lieu ait un ou plusieurs tarifs par exemple moins de 12 ans et tout public. En termes de conception de la base de données, une relation oneToMany permet de gérer facilement cette situation et donc d’interagir avec les tarifs d’un lieu donné.

Comment matérialiser une telle relation avec une API qui suit les contraintes REST ?

Si nous créons une URI nommée rest-api.local/prices, nous pouvons effectivement accéder à nos prix comme pour les lieux ou les utilisateurs. Mais nous aurons accès à l’ensemble des tarifs appliqués pour tous les lieux de notre application.

Pour accéder aux prix d’un lieu 1, il serait tentant de rajouter un paramètre du style rest-api.local/prices?place_id=1 mais, la répétition étant pédagogique, nous allons regarder à nouveau le deuxième chapitre "Premières interactions avec les ressources" :

la RFC 3986 spécifie clairement les query strings comme étant des composants qui contiennent des données non-hiérarchiques.

Nous avons une notion d’hièrarchie entre un lieu et ses tarifs et donc cette relation doit apparaitre dans notre URI.

rest-api.local/prices/1 ferait-il l’affaire ? Sûrement pas, cette URL désigne le tarif ayant comme identifiant 1.

Pour trouver la bonne URL, nous devons commencer par le possesseur dans la relation ici c’est un lieu qui a des tarifs, donc rest-api.local/places/{id} doit être le début de notre URL. Ensuite, il suffit de rajouter l’identifiant de la collection de prix que nous appelerons prices.

En définitif, rest-api.local/places/{id}/prices permet de désigner clairement les tarifs pour le lieu ayant comme identifiant {id}.

Hiérarchie entre les ressources

Une fois que nous avons identifié notre ressource, tous les principes déjà abordés pour interagir avec une ressource s’appliquent.

Opération souhaitée Verbe HTTP URI
Récupérer tous les prix d’un lieu GET /places{id}/prices
Récupérer un seul prix d’un lieu GET /places/{id}/prices/{price_id}
Créer un nouveau prix pour un lieu POST /places{id}/prices
Suppression d’un prix pour un lieu DELETE /places/{id}/prices/{price_id}
Mise à jour complète d’un prix d’un lieu PUT /places/{id}/prices/{price_id}
Mise à jour partielle d’un prix d’un lieu PATCH /places/{id}/prices/{price_id}

Pratiquons avec les lieux

Pour mettre en pratique toutes ces informations, nous allons ajouter deux nouveaux appels à notre API :

  • un pour créer un nouveau prix ;
  • un pour lister tous les prix d’un lieu.

Nous considérons qu’un prix a deux caractéristiques :

  • un type (tout public, moins de 12 ans, etc.) ;
  • une valeur (10, 15.5, 22.75, 29.99, etc.) qui désigne le tarif en euros.

Pour l’instant, seul deux types de prix sont supportés :

  • less_than_12 pour moins de 12 ans ;
  • for_all pour tout public.

Commençons par la base de données en créant une nouvelle entité Price, nous rajouterons une contrainte d’unicité sur le type et le lieu afin de nous assurer qu’un lieu ne pourra pas avoir deux prix du même type :

 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
# src/AppBundle/Entity/Price.php
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="prices",
 *      uniqueConstraints={@ORM\UniqueConstraint(name="prices_type_place_unique", columns={"type", "place_id"})}
 * )
 */
class Price
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $type;

    /**
     * @ORM\Column(type="float")
     */
    protected $value;

    /**
     * @ORM\ManyToOne(targetEntity="Place", inversedBy="prices")
     * @var Place
     */
    protected $place;

    // tous les getters et setters
}

Nous utilisons une relation bidirectionnelle car nous voulons afficher les prix d’un lieu en plus des informations de base lorsqu’un client de l’API consulte les informations de ce lieu.

 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
# src/AppBundle/Entity/Place.php
<?php

namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
 * @ORM\Entity()
 * @ORM\Table(name="places",
 *      uniqueConstraints={@ORM\UniqueConstraint(name="places_name_unique",columns={"name"})}
 * )
 */
class Place
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $name;

    /**
     * @ORM\Column(type="string")
     */
    protected $address;

    /**
     * @ORM\OneToMany(targetEntity="Price", mappedBy="place")
     * @var Price[]
     */
    protected $prices;

    public function __construct()
    {
        $this->prices = new ArrayCollection();
    }
    // tous les getters et setters
}

N’oublions pas de mettre à jour la base de données avec :

1
php bin/console doctrine:schema:update --dump-sql --force

La création d’un prix nécessite quelques règles de validation que nous devons implémenter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# src/AppBundle/Resources/config/validation.yml

# ...

AppBundle\Entity\Price:
    properties:
        type:
            - NotNull: ~
            - Choice:
                choices: [less_than_12, for_all]
        value:
            - NotNull: ~
            - Type: numeric
            - GreaterThanOrEqual:
                value: 0

Il ne reste plus qu’à créer le formulaire associé :

 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
# src/AppBundle/Form/Type/PriceType.php
<?php
namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class PriceType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        // Pas besoin de rajouter les options avec ChoiceType vu que nous allons l'utiliser via API.
        // Le formulaire ne sera jamais affiché
        $builder->add('type');
        $builder->add('value');
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Price',
            'csrf_protection' => false
        ]);
    }
}

Les deux appels seront mis en place dans un nouveau contrôleur pour des raisons de clarté. Mais il est parfaitement possible de le mettre dans le contrôleur déjà existant. Nous aurons un nouveau dossier nommé src/AppBundle/Controller/Place qui contiendra un contrôleur PriceController.

Avec ce découpage des fichiers, nous mettons en évidence la relation hiérarchique entre Place et Price.

 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
# src/AppBundle/Controller/Place/PriceController.php
<?php
namespace AppBundle\Controller\Place;

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

class PriceController extends Controller
{

    /**
     * @Rest\View()
     * @Rest\Get("/places/{id}/prices")
     */
    public function getPricesAction(Request $request)
    {

    }

     /**
     * @Rest\View(statusCode=Response::HTTP_CREATED)
     * @Rest\Post("/places/{id}/prices")
     */
    public function postPricesAction(Request $request)
    {

    }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# app/config/routing.yml
app:
    resource: "@AppBundle/Controller/DefaultController.php"
    type:     annotation

places:
    type:     rest
    resource: AppBundle\Controller\PlaceController

prices:
    type:     rest
    resource: AppBundle\Controller\Place\PriceController

users:
    type:     rest
    resource: AppBundle\Controller\UserController

Au niveau des URL utilisées dans le routage, il suffit de se référer au tableau plus haut. Finissons notre implémentation en ajoutant de la logique aux actions du contrôleur :

 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
62
63
64
65
66
67
68
69
70
71
72
# src/AppBundle/Controller/Place/PriceController.php
<?php
namespace AppBundle\Controller\Place;

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\PriceType;
use AppBundle\Entity\Price;

class PriceController extends Controller
{

    /**
     * @Rest\View()
     * @Rest\Get("/places/{id}/prices")
     */
    public function getPricesAction(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 $this->placeNotFound();
        }

        return $place->getPrices();
    }


     /**
     * @Rest\View(statusCode=Response::HTTP_CREATED)
     * @Rest\Post("/places/{id}/prices")
     */
    public function postPricesAction(Request $request)
    {
        $place = $this->get('doctrine.orm.entity_manager')
                ->getRepository('AppBundle:Place')
                ->find($request->get('id'));
        /* @var $place Place */

        if (empty($place)) {
            return $this->placeNotFound();
        }

        $price = new Price();
        $price->setPlace($place); // Ici, le lieu est associé au prix
        $form = $this->createForm(PriceType::class, $price);

        // 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());

        if ($form->isValid()) {
            $em = $this->get('doctrine.orm.entity_manager');
            $em->persist($price);
            $em->flush();
            return $price;
        } else {
            return $form;
        }
    }

    private function placeNotFound()
    {
        return \FOS\RestBundle\View\View::create(['message' => 'Place not found'], Response::HTTP_NOT_FOUND);
    }
}

Le principe reste le même qu’avec les différentes actions que nous avons déjà implémentées. Il faut juste noter que lorsque nous créons un prix, nous pouvons lui associer un lieu en récupérant l’identifiant du lieu qui est dans l’URL de la requête.

Pour tester nos nouveaux appels, nous allons créer un nouveau prix pour le lieu. Voici le payload JSON utilisé :

1
2
3
4
{
    "type": "less_than_12",
    "value":  5.75
}

Requête :

Corps de la requête de création d’un lieu avec Postman

Réponse :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "error": {
    "code": 500,
    "message": "Internal Server Error",
    "exception": [
      {
        "message": "A circular reference has been detected (configured limit: 1).",
        "class": "Symfony\\Component\\Serializer\\Exception\\CircularReferenceException",
        "trace": [ "..." ]
      }
    ]
  }
}

Nous obtenons une belle erreur interne ! Pourquoi une exception avec comme message A circular reference has been detected (configured limit: 1). ?

Les groupes avec le sérialiseur de Symfony

Nous venons de faire face à une erreur assez commune lorsque nous travaillons avec un sérialiseur sur des entités avec des relations.

Le problème que nous avons ici est simple à expliquer. Lorsque le sérialiseur affiche un prix, il doit sérialiser le type, la valeur mais aussi le lieu associé.

Nous aurons donc :

1
2
3
4
5
6
7
8
{
    "id": 1,
    "type": "less_than_12",
    "value": 5.75,
    "place": {
        "..." : "..."
    }
}

Les choses se gâtent lorsque le sérialiseur va essayer de transformer le lieu contenu dans notre objet. Ce lieu contient lui-même l’objet prix qui devra être sérialisé à nouveau. Et la boucle se répète à l’infini.

Référence circulaire

Pour prévenir ce genre de problème, le sérialiseur de Symfony utilise la notion de groupe. L’objectif des groupes est de définir les attributs qui seront sérialisés selon la vue que nous voulons afficher.

Reprenons le cas de notre prix pour mieux comprendre. Lorsque nous affichons les informations sur un prix, ce qui nous intéresse c’est :

  • son identifiant ;
  • son type ;
  • sa valeur ;
  • et son lieu associé.

Jusque-là notre problème reste entier. Mais lorsque nous allons afficher ce fameux lieu, nous devons limiter les informations affichées. Ainsi, nous pouvons décider que le lieu embarqué dans la réponse ne doit contenir que :

  • son identifiant ;
  • son nom ;
  • et son adresse.

Le champ prices doit être ignoré.

Tous ces attributs peuvent représenter un groupe : price. À chaque fois que le sérialiseur est utilisé en spécifiant le groupe price alors seul ces attributs seront sérialisés.

De la même façon, lorsque nous voudrons afficher un lieu, tous les attributs seront affichés en excluant un seul attribut : le champ place de l’objet Price.

La configuration Symfony pour obtenir un tel comportement est assez simple :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# src/AppBundle/Resources/config/serialization.yml
AppBundle\Entity\Place:
    attributes:
        id:
            groups: ['place', 'price']
        name:
            groups: ['place', 'price']
        address:
            groups: ['place', 'price']
        prices:
            groups: ['place']


AppBundle\Entity\Price:
    attributes:
        id:
            groups: ['place', 'price']
        type:
            groups: ['place', 'price']
        value:
            groups: ['place', 'price']
        place:
            groups: ['price']

Ce fichier de configuration définit deux choses essentielles:

  • Si nous utilisons le groupe price avec le sérialiseur, seuls les attributs dans ce groupe seront affichés ;
  • et de la même façon, seuls les attributs dans le groupe place seront affichés si celui-ci est utilisé avec notre sérialiseur.

Il est aussi possible de déclarer les règles de sérialisations avec des annotations sur nos entités. Pour en savoir plus, il est préférable de consulter la documentation officielle. Les fichiers de configuration peuvent aussi être placés dans un dossier src/AppBundle/Resources/config/serialization/ afin de mieux les isoler.

Pour l’utiliser dans notre contrôleur avec FOSRestBundle, la modification à faire est très simple. Il suffit d’utiliser l’attribut serializerGroups de l’annotation View.

 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
# src/AppBundle/Controller/Place/PriceController.php
<?php
namespace AppBundle\Controller\Place;

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\PriceType;
use AppBundle\Entity\Price;

class PriceController extends Controller
{

    /**
     * @Rest\View(serializerGroups={"price"})
     * @Rest\Get("/places/{id}/prices")
     */
    public function getPricesAction(Request $request)
    {
         // ...
    }


     /**
     * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"price"})
     * @Rest\Post("/places/{id}/prices")
     */
    public function postPricesAction(Request $request)
    {
        // ...
    }

    private function placeNotFound()
    {
        return \FOS\RestBundle\View\View::create(['message' => 'Place not found'], Response::HTTP_NOT_FOUND);
    }
}

Pour tester cette configuration, récupérons la liste des prix du lieu 1.

Requête Postman pour récupérer les prix d’un lieu

La réponse ne contient que les attributs que nous avons affectés au groupe price.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[
  {
    "id": 1,
    "type": "less_than_12",
    "value": 5.75,
    "place": {
      "id": 1,
      "name": "Tour Eiffel",
      "address": "5 Avenue Anatole France, 75007 Paris"
    }
  }
]

De la même façon, nous devons modifier le contrôleur des lieux pour définir le ou les groupes à utiliser pour la sérialisation des réponses.

 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
62
63
64
65
66
67
68
69
70
71
# 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(serializerGroups={"place"})
     * @Rest\Get("/places")
     */
    public function getPlacesAction(Request $request)
    {
        // ...
    }

    /**
     * @Rest\View(serializerGroups={"place"})
     * @Rest\Get("/places/{id}")
     */
    public function getPlaceAction(Request $request)
    {
        // ...
    }

     /**
     * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"place"})
     * @Rest\Post("/places")
     */
    public function postPlacesAction(Request $request)
    {
        // ...
    }

     /**
     * @Rest\View(statusCode=Response::HTTP_NO_CONTENT, serializerGroups={"place"})
     * @Rest\Delete("/places/{id}")
     */
    public function removePlaceAction(Request $request)
    {
        // ...
    }

     /**
     * @Rest\View(serializerGroups={"place"})
     * @Rest\Put("/places/{id}")
     */
    public function updatePlaceAction(Request $request)
    {
        // ...
    }

    /**
     * @Rest\View(serializerGroups={"place"})
     * @Rest\Patch("/places/{id}")
     */
    public function patchPlaceAction(Request $request)
    {
        // ...
    }

    // ...
}

Et récupérons le lieu 1 pour voir la réponse :

Requête Postman pour récupérer un lieu
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
{
  "id": 1,
  "name": "Tour Eiffel",
  "address": "5 Avenue Anatole France, 75007 Paris",
  "prices": [
    {
      "id": 1,
      "type": "less_than_12",
      "value": 5.75
    }
  ]
}

Grâce aux groupes, les références circulaires ne sont plus qu’un mauvais souvenir.

Les groupes du sérialiseur de Symfony ne sont supportés que depuis la version 2.0 de FOSRestBundle. Dans le cas où vous utilisez une version de FOSRestBundle inférieure à la 2.0, il faudra alors utiliser le JMSSerializerBundle à la place du sérialiseur de base de Symfony.

Mise à jour de la suppression d'une ressource

Avec la gestion des relations entre ressources, la méthode de suppression des lieux doit être revue. En effet, vu qu’un lieu peut avoir des prix, nous devons nous assurer qu’à sa suppression tous les prix qui lui sont associés le seront aussi. Sans cela, la clé étrangère empêcherait toute suppression d’un lieu ayant des prix.

La modification à effectuer reste cependant assez minime.

 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
<?php
# src/AppBundle/Controller/PlaceController.php

namespace AppBundle\Controller;

// ...

class PlaceController extends Controller
{
// ...

     /**
     * @Rest\View(statusCode=Response::HTTP_NO_CONTENT, serializerGroups={"place"})
     * @Rest\Delete("/places/{id}")
     */
    public function removePlaceAction(Request $request)
    {
        $em = $this->get('doctrine.orm.entity_manager');
        $place = $em->getRepository('AppBundle:Place')
                    ->find($request->get('id'));
        /* @var $place Place */

        if (!$place) {
            return;
        }

        foreach ($place->getPrices() as $price) {
            $em->remove($price);
        }
        $em->remove($place);
        $em->flush();
    }
// ...
}

Avec ce chapitre, nous venons de faire un tour complet des concepts de base pour développer une API RESTful. Les possibilités d’évolution de notre API sont nombreuses et ne dépendent que de notre imagination.

Maintenant que les sous ressources n’ont plus de secrets pour nous, nous allons implémenter la fonctionnalité de base de notre API : Proposer une idée de sortie à un utilisateur.