Revenons un peu sur les relations entre les ressources.
À la création d’un lieu, nous ne pouvons pas renseigner les tarifs. Nous sommes donc obligés de créer d’abord un lieu avant de rajouter ses tarifs.
Même si ce fonctionnement pourrait convenir dans certains cas, il peut aussi s’avérer utile de créer un lieu et de lui associer des tarifs avec un seul appel API. On pourra ainsi optimiser l’API et réduire les interactions entre le client et le serveur.
Nous allons donc voir dans cette partie comment arriver à un tel résultat avec Symfony.
Rappel de l'existant
Jusqu’à présent pour créer un lieu, il fallait juste renseigner le nom et l’adresse. Le payload pour la création d’un lieu ressemblait donc à :
1 2 3 4 | { "name": "Disneyland Paris", "address": "77777 Marne-la-Vallée" } |
En réalité, pour supporter la création d’un lieu avec ses tarifs, les contraintes de REST ne rentrent pas en jeu. Nous pouvons adapter librement le payload afin de rajouter toutes les informations nécessaires pour supporter la création d’un lieu avec ses tarifs avec un seul appel.
Création d'un lieu avec des tarifs
Un peu de conception
Vu que nous avons déjà une méthode pour créer des tarifs, nous allons utiliser le même payload pour la création d’un lieu pour garder une API cohérente. Le payload existant doit être maintenant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | { "name": "Disneyland Paris", "address": "77777 Marne-la-Vallée", "prices": [ { "type": "for_all", "value": 10.0 }, { "type": "less_than_12", "value": 5.75 } ] } |
L’attribut prices
est un tableau qui contiendra la liste des prix que nous voulons rajouter à la création du lieu.
Les tarifs resteront optionnels ce qui nous permettra de créer des lieux avec ou sans. Nous allons sans plus attendre appliquer ces modifications à l’appel existant.
Implémentation
Mise à jour du formulaire
La méthode pour créer un lieu reste inchangée. Nous devons juste changer le formulaire des lieux et le traitement 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 27 28 29 30 31 | <?php # src/AppBundle/Form/Type/PlaceType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class PlaceType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name'); $builder->add('address'); $builder->add('prices', CollectionType::class, [ 'entry_type' => PriceType::class, 'allow_add' => true, 'error_bubbling' => false, ]); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\Entity\Place', 'csrf_protection' => false ]); } } |
La configuration du formulaire est typique des formulaires Symfony avec une collection. La documentation officielle aborde le sujet d’une manière plus complète.
Les règles de validation pour les thèmes existent déjà. Pour les utiliser, nous devons modifier la validation de l’entité Place en rajoutant la règle Valid
. Avec cette annotation, nous disons à Symfony de valider l’attribut prices
en utilisant les contraintes de validation de l’entité Price.
1 2 3 4 5 6 7 8 9 10 11 12 13 | # src/AppBundle/Resources/config/validation.yml AppBundle\Entity\Place: constraints: - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity: name properties: name: - NotBlank: ~ - Type: string address: - NotBlank: ~ - Type: string prices: - Valid: ~ |
Notez qu’il n’y a pas d’assertions de type NotBlank
puisque l’attribut prices
est optionnel.
Traitement du formulaire
Avec les modifications que nous venons d’apporter, nous pouvons déjà tester la création d’un lieu avec des prix. Mais avant de le faire, nous allons rapidement adapter le contrôleur pour gérer la sauvegarde des prix.
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 | <?php # src/AppBundle/Controller/PlaceController.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(statusCode=Response::HTTP_CREATED, serializerGroups={"place"}) * @Rest\Post("/places") */ public function postPlacesAction(Request $request) { $place = new Place(); $form = $this->createForm(PlaceType::class, $place); $form->submit($request->request->all()); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); foreach ($place->getPrices() as $price) { $price->setPlace($place); $em->persist($price); } $em->persist($place); $em->flush(); return $place; } else { return $form; } } // ... ` |
Comme vous l’avez sûrement remarqué, toute la logique de notre API est regroupée dans les contrôleurs. Ceci n’est pas une bonne pratique et l’utilisation d’un service dédié est vivement conseillée pour une application destinée à une mise en production.
L’entité Place a été légèrement modifiée. L’attribut prices
est maintenant initialisé avec une collection vide.
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 | <?php # src/AppBundle/Entity/Place.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table(name="places", * uniqueConstraints={@ORM\UniqueConstraint(name="places_name_unique",columns={"name"})} * ) */ class Place { // ... /** * @ORM\OneToMany(targetEntity="Price", mappedBy="place") * @var Price[] */ protected $prices; public function __construct() { $this->prices = new ArrayCollection(); // ... } // ... |
En testant une création d’un lieu avec des prix :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | { "name": "Musée du Louvre", "address": "799, rue de Rivoli, 75001 Paris", "prices": [ { "type": "less_than_12", "value": 6 }, { "type": "for_all", "value": 15 } ] } |
La réponse est identique à ce que nous avons déjà eu mais les prix sont enregistrés en même temps.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | { "id": 9, "name": "Musée du Louvre", "address": "799, rue de Rivoli, 75001 Paris", "prices": [ { "id": 6, "type": "less_than_12", "value": 6 }, { "id": 7, "type": "for_all", "value": 15 } ], "themes": [] } |
Nous pouvons maintenant créer un lieu tout en rajoutant des prix et le principe peut même être élargi pour les thèmes des lieux et les préférences des utilisateurs.
Bonus : Une validation plus stricte
Création d’un lieu avec deux prix de même type
Si nous essayons de créer un lieu avec des prix du même type, nous obtenons une erreur interne car il y a une contrainte d’unicité sur l’identifiant du lieu et le type du produit.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | <?php # src/AppBundle/Entiy/Price.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 { // ... } |
Pour s’en convaincre, il suffit d’essayer de créer un nouveau lieu avec comme payload :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | { "name": "Arc de Triomphe", "address": " Place Charles de Gaulle, 75008 Paris", "prices": [ { "type": "less_than_12", "value": 0.0 }, { "type": "less_than_12", "value": 0.0 } ] } |
La réponse est sans appel :
1 2 3 4 | { "code": 500, "message": "An exception occurred while executing 'INSERT INTO prices (type, value, place_id) VALUES (?, ?, ?)' with params [\"less_than_12\", 0, 10]:\n\nSQLSTATE[23000]: Integrity constraint violation: 1062 Duplicata du champ 'less_than_12-10' pour la clef 'prices_type_place_unique'" } |
Pour corriger le problème, nous allons créer une règle de validation personnalisée.
Validation personnalisée avec Symfony
Création de la contrainte
La contrainte est la partie la plus simple à implémenter. Il suffit d’une classe pour la nommer et d’un message en cas d’erreur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | <?php # src/AppBundle/Form/Validator/Constraint/PriceTypeUnique.php namespace AppBundle\Form\Validator\Constraint; use Symfony\Component\Validator\Constraint; /** * @Annotation */ class PriceTypeUnique extends Constraint { public $message = 'A place cannot contain prices with same type'; } |
Création du validateur
Une fois que nous avons une nouvelle contrainte, il reste à créer un validateur pour gérer cette contrainte.
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 | <?php # src/AppBundle/Form/Validator/Constraint/PriceTypeUniqueValidator.php namespace AppBundle\Form\Validator\Constraint; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\ConstraintValidator; class PriceTypeUniqueValidator extends ConstraintValidator { public function validate($prices, Constraint $constraint) { if (!($prices instanceof \Doctrine\Common\Collections\ArrayCollection)) { return; } $pricesType = []; foreach ($prices as $price) { if (in_array($price->getType(), $pricesType)) { $this->context->buildViolation($constraint->message) ->addViolation(); return; // Si il y a un doublon, on arrête la recherche } else { // Sauvegarde des types de prix déjà présents $pricesType[] = $price->getType(); } } } } |
Le nom choisi n’est pas un hasard. Vu que la contrainte s’appelle PriceTypeUnique, le validateur a été nommé PriceTypeUniqueValidator afin d’utiliser les conventions de nommage de Symfony. Ainsi notre contrainte est validée en utilisant le validateur que nous venons de créer.
Ce comportement par défaut peut être modifié en étendant la méthode validatedBy
de la contrainte. La documentation officielle de Symfony apporte plus d’informations à ce sujet.
Pour utiliser notre nouvelle contrainte, nous allons modifier les règles de validation qui s’applique à un lieu :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | # src/AppBundle/Resources/config/validation.yml AppBundle\Entity\Place: constraints: - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity: name properties: name: - NotBlank: ~ - Type: string address: - NotBlank: ~ - Type: string prices: - Valid: ~ - AppBundle\Form\Validator\Constraint\PriceTypeUnique: ~ |
En testant à nouveau la création d’un lieu avec deux prix du même type, nous obtenons une belle erreur de validation avec un message clair.
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 | { "code": 400, "message": "Validation Failed", "errors": { "children": { "name": [], "address": [], "prices": { "errors": [ "A place cannot contain prices with same type" ], "children": [ { "children": { "type": [], "value": [] } }, { "children": { "type": [], "value": [] } } ] } } } } |
Comme vous avez pu le remarquer, la création d’une ressource en relation avec d’autres ressources ne relève pas trop de REST mais plutôt de la gestion des formulaires avec Symfony.
Ainsi, les connaissances que vous avez déjà pu acquérir pour la gestion des formulaires dans Symfony peuvent être exploitées pour mettre en place ces fonctionnalités.
Prendre en compte ce genre de détails d’implémentation permet de réduire le nombre d’appels API et donc améliorer les performances des applications qui doivent l’exploiter et l’expérience utilisateur par la même occasion.