Nous allons dans cette partie finaliser notre API en rajoutant un système de suggestion pour les utilisateurs. Tous les concepts de base du style d’architecture qu’est REST ont déjà été abordés. L’objectif est donc de mettre en pratique les connaissances acquises.
Énoncé
Afin de gérer les suggestions, nous partons sur un design simple. Dans l’application, nous aurons une notion de préférences et de thèmes. Chaque utilisateur pourra choisir un ou plusieurs préférences avec une note sur 10. Et de la même façon, un lieu sera lié à un ou plusieurs thèmes avec une note sur 10.
Un lieu sera suggéré à un utilisateur si au moins une préférence de l’utilisateur correspond à un des thèmes du lieu et que le niveau de correspondance est supérieur ou égale à 25.
Le niveau de correspondance est une valeur calculée qui nous permettra de quantifier à quel point un lieu pourrait intéresser un utilisateur. La méthode de calcul est détaillée ci-dessous.
Pour un utilisateur donné, il faut d’abord prendre toutes ses préférences. Ensuite pour chaque lieu enregistré dans l’application, si une des préférences de l’utilisateur correspond au thème du lieu, il faudra calculer le produit : valeur de la préférence de l’utilisateur * valeur du thème du lieu.
La somme de tous ces produits représente le niveau de correspondance pour ce lieu .
Un exemple vaut mieux que mille discours : Un utilisateur a comme préférences (art, 5), (history, 8) et (architecture, 2). Un lieu 1 a comme thèmes (architecture, 3), (sport, 2), (history, 3). et un lieu 2 a comme thèmes (art, 3), (science-fiction, 2).
Pour le lieu 1, nous avons 2 thèmes qui correspondent à ses préférences: history et architecture.
history | architecture | |
---|---|---|
utilisateur | 8 | 2 |
lieu 1 | 4 | 3 |
La valeur de correspondance est donc : $$8 * 4 + 2 * 3 = 32 + 6 = 38$$ 38 est supérieur à 25 donc c’est une suggestion valide.
Pour le lieu 2, nous avons un seul thème qui correspond : art.
art | |
---|---|
utilisateur | 5 |
lieu 2 | 3 |
La valeur de correspondance est donc : $$5 * 3 = 15$$ 15 étant inférieur à 25 donc ce n’est pas une suggestion valide.
Détails de l'implémentation
Comme pour la liste des types de tarifs, nous disposons d’une liste de préférences et de thèmes prédéfinis :
- Art (art) ;
- Architecture (architecture) ;
- Histoire (history) ;
- Sport (sport) ;
- Science-fiction (science-fiction).
Une préférence associée à un utilisateur doit avoir 3 attributs :
- id : représente l’identifiant unique de la préférence utilisateur (auto-incrémenté) ;
- name : une des valeurs parmi la liste des préférences prédéfinies ;
- value : un entier désignant le niveau de préférence sur 10.
Un thème lié à un lieu doit avoir 3 attributs :
- id : représente l’identifiant unique du thème (auto-incrémenté) ;
- name : une des valeurs parmi la liste des thèmes prédéfinies ;
- value : un entier désignant le niveau du thème sur 10.
Une préférence associée à un utilisateur doit avoir une relation bidirectionnelle avec cet utilisateur et idem pour les lieux.
Une même préférence ne peut pas être associée deux fois à un même utilisateur ou un même lieu. (ex : un utilisateur ne peut pas avoir 2 fois la préférence art) et idem pour les lieux.
Il faudra 2 tables (donc 2 entités distinctes) :
- preferences (entité Preference) pour stocker les préférences utilisateurs ;
- themes (entité Theme) pour stocker les thèmes sur les lieux.
Il faudra 3 appels API :
- un permettant d’ajouter une préférence pour un utilisateur avec sa valeur ;
- un permettant d’ajouter un thème à un lieu avec sa valeur ;
- un pour récupérer les suggestions d’un utilisateur.
Une ressource REST n’est pas forcément une entité brute de notre modèle de données. Nous pouvons utiliser un appel GET sur l’URL rest-api.local/users/1/suggestions pour récupérer la liste des suggestions pour l’utilisateur 1.
Une fois que les préférences et les thèmes seront rajoutés, les appels de listing des utilisateurs et des lieux doivent remonter respectivement les informations sur les préférences et les informations sur les thèmes. Il faudra donc penser à gérer les références circulaires.
À vous de jouer !
Travail préparatoire
Gestion des thèmes pour les lieux
Nous allons commencer notre implémentation en mettant en place la gestion des thèmes.
L’entité contiendra les champs cités plus haut avec en plus une contrainte d’unicité sur le nom d’un thème et l’identifiant du 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 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 73 74 75 76 77 78 | <?php # src/AppBundle/Entity/Theme.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table(name="themes", * uniqueConstraints={@ORM\UniqueConstraint(name="themes_name_place_unique", columns={"name", "place_id"})} * ) */ class Theme { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue */ protected $id; /** * @ORM\Column(type="string") */ protected $name; /** * @ORM\Column(type="integer") */ protected $value; /** * @ORM\ManyToOne(targetEntity="Place", inversedBy="themes") * @var Place */ protected $place; public function getId() { return $this->id; } public function setId($id) { $this->id = $id; } public function getName() { return $this->name; } public function setName($name) { $this->name = $name; } public function getValue() { return $this->value; } public function setValue($value) { $this->value = $value; } public function getPlace() { return $this->place; } public function setPlace(Place $place) { $this->place = $place; } } |
L’entité Place
doit aussi être modifiée pour avoir une relation bidirectionnelle.
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 | <?php # src/AppBundle/Entity/Place.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\OneToMany(targetEntity="Theme", mappedBy="place") * @var Theme[] */ protected $themes; public function __construct() { $this->prices = new ArrayCollection(); $this->themes = new ArrayCollection(); } // ... public function getThemes() { return $this->themes; } public function setThemes($themes) { $this->themes = $themes; } } |
Pour supporter la création de thèmes pour les lieux, nous allons créer un formulaire Symfony et les régles de validation associées.
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 | <?php # src/AppBundle/Form/Type/ThemeType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class ThemeType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name'); $builder->add('value'); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\Entity\Theme', 'csrf_protection' => false ]); } } ` |
La liste des thèmes prédéfinis est utilisée pour valider le formulaire Symfony.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | # src/AppBundle/Resources/config/validation.yml AppBundle\Entity\Theme: properties: name: - NotNull: ~ - Choice: choices: [art, architecture, history, science-fiction, sport] value: - NotNull: ~ - Type: numeric - GreaterThan: value: 0 - LessThanOrEqual: value: 10 ` |
Pour ajouter un thème, nous allons créer un nouveau contrôleur qui ressemble à quelques lignes près à ce que nous avons déjà fait jusqu’ici. Nous allons en profiter pour ajouter une méthode pour lister les thèmes d’un lieu donné.
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 | <?php # src/AppBundle/Controller/Place/ThemeController.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\ThemeType; use AppBundle\Entity\Theme; class ThemeController extends Controller { /** * @Rest\View(serializerGroups={"theme"}) * @Rest\Get("/places/{id}/themes") */ public function getThemesAction(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(); } return $place->getThemes(); } /** * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"theme"}) * @Rest\Post("/places/{id}/themes") */ public function postThemesAction(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(); } $theme = new Theme(); $theme->setPlace($place); $form = $this->createForm(ThemeType::class, $theme); $form->submit($request->request->all()); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); $em->persist($theme); $em->flush(); return $theme; } else { return $form; } } private function placeNotFound() { return \FOS\RestBundle\View\View::create(['message' => 'Place not found'], Response::HTTP_NOT_FOUND); } } |
Le fichier de routage de l’application doit être modifié en conséquence pour charger ce nouveau contrôleur.
1 2 3 4 5 6 7 8 9 | # app/config/routing.yml # ... themes: type: rest resource: AppBundle\Controller\Place\ThemeController # ... |
Il ne faut pas oublier de rajouter un nouveau groupe de sérialisation pour la gestion de ces thèmes.
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/Resources/config/serialization.yml AppBundle\Entity\Place: attributes: id: groups: ['place', 'price', 'theme'] name: groups: ['place', 'price', 'theme'] address: groups: ['place', 'price', 'theme'] prices: groups: ['place'] themes: groups: ['place'] # ... AppBundle\Entity\Theme: attributes: id: groups: ['place', 'theme'] name: groups: ['place', 'theme'] value: groups: ['place', 'theme'] place: groups: ['theme'] |
Le nouveau groupe est aussi utilisé pour configurer la sérialisation de l’entité Place
afin d’éviter les références circulaires.
Gestions des préférences
Pour la gestion des utilisateurs, nous allons suivre exactement le même schéma d’implémentation. Les extraits de code fournis se passeront donc de commentaires.
Commençons par l’entité pour la gestion des préférences et le formulaire permettant de le gérer.
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 73 74 75 76 77 78 | <?php # src/AppBundle/Entity/Preference.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table(name="preferences", * uniqueConstraints={@ORM\UniqueConstraint(name="preferences_name_user_unique", columns={"name", "user_id"})} * ) */ class Preference { /** * @ORM\Id * @ORM\Column(type="integer") * @ORM\GeneratedValue */ protected $id; /** * @ORM\Column(type="string") */ protected $name; /** * @ORM\Column(type="integer") */ protected $value; /** * @ORM\ManyToOne(targetEntity="User", inversedBy="preferences") * @var User */ protected $user; public function getId() { return $this->id; } public function setId($id) { $this->id = $id; } public function getName() { return $this->name; } public function setName($name) { $this->name = $name; } public function getValue() { return $this->value; } public function setValue($value) { $this->value = $value; } public function getUser() { return $this->user; } public function setUser(User $user) { $this->user = $user; } } |
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 | <?php # src/AppBundle/Entity/User.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; /** * @ORM\Entity() * @ORM\Table(name="users", * uniqueConstraints={@ORM\UniqueConstraint(name="users_email_unique",columns={"email"})} * ) */ class User { // ... /** * @ORM\OneToMany(targetEntity="Preference", mappedBy="user") * @var Preference[] */ protected $preferences; public function __construct() { $this->preferences = new ArrayCollection(); } // ... public function getPreferences() { return $this->preferences; } public function setPreferences($preferences) { $this->preferences = $preferences; } } |
Le formulaire associé et les règles de validation sont proches de celui des thèmes.
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 | <?php # src/AppBundle/Form/Type/PreferenceType.php namespace AppBundle\Form\Type; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; class PreferenceType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) { $builder->add('name'); $builder->add('value'); } public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults([ 'data_class' => 'AppBundle\Entity\Preference', 'csrf_protection' => false ]); } } ` |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | # src/AppBundle/Resources/config/validation.yml # ... AppBundle\Entity\Preference: properties: name: - NotNull: ~ - Choice: choices: [art, architecture, history, science-fiction, sport] value: - NotNull: ~ - Type: numeric - GreaterThan: value: 0 - LessThanOrEqual: value: 10 ` |
Un nouveau contrôleur sera aussi créé pour assurer la gestion des préférences utilisateurs via notre API.
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 | <?php # src/AppBundle/Controller/User/PreferenceController.php namespace AppBundle\Controller\User; 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\PreferenceType; use AppBundle\Entity\Preference; class PreferenceController extends Controller { /** * @Rest\View(serializerGroups={"preference"}) * @Rest\Get("/users/{id}/preferences") */ public function getPreferencesAction(Request $request) { $user = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:User') ->find($request->get('id')); /* @var $user User */ if (empty($user)) { return $this->userNotFound(); } return $user->getPreferences(); } /** * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"preference"}) * @Rest\Post("/users/{id}/preferences") */ public function postPreferencesAction(Request $request) { $user = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:User') ->find($request->get('id')); /* @var $user User */ if (empty($user)) { return $this->userNotFound(); } $preference = new Preference(); $preference->setUser($user); $form = $this->createForm(PreferenceType::class, $preference); $form->submit($request->request->all()); if ($form->isValid()) { $em = $this->get('doctrine.orm.entity_manager'); $em->persist($preference); $em->flush(); return $preference; } else { return $form; } } private function userNotFound() { return \FOS\RestBundle\View\View::create(['message' => 'User not found'], Response::HTTP_NOT_FOUND); } } |
1 2 3 4 5 6 7 8 9 | # app/config/routing.yml # ... preferences: type: rest resource: AppBundle\Controller\User\PreferenceController ` |
Les groupes de sérialisation doivent aussi être mis à jour afin d’éviter les fameuses références circulaires.
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 | # src/AppBundle/Resources/config/serialization.yml # ... AppBundle\Entity\User: attributes: id: groups: ['user', 'preference'] firstname: groups: ['user', 'preference'] lastname: groups: ['user', 'preference'] email: groups: ['user', 'preference'] preferences: groups: ['user'] AppBundle\Entity\Preference: attributes: id: groups: ['user', 'preference'] name: groups: ['user', 'preference'] value: groups: ['user', 'preference'] user: groups: ['preference'] ` |
Avec ces modifications que nous venons d’apporter, nous pouvons maintenant associer des thèmes et des préférences respectivement aux lieux et aux utilisateurs. Nous allons donc finaliser ce chapitre en rajoutant enfin les suggestions.
Proposer des suggestions aux utilisateurs
Calcul du niveau de correspondance
La technique utilisée pour trouver les suggestions n’est pas optimale. L’objectif ici est juste de présenter une méthode fonctionnelle et avoir une API complète.
L’algorithme pour calculer le niveau de correspondance va être implémenté dans l’entité User
.
À partir des thèmes d’un lieu, nous allons créer une méthode permettant de déterminer le niveau de correspondance (défini plus haut).
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 | <?php # src/AppBundle/Entity/User.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; use Doctrine\Common\Collections\ArrayCollection; /** * @ORM\Entity() * @ORM\Table(name="users", * uniqueConstraints={@ORM\UniqueConstraint(name="users_email_unique",columns={"email"})} * ) */ class User { const MATCH_VALUE_THRESHOLD = 25; // ... public function preferencesMatch($themes) { $matchValue = 0; foreach ($this->preferences as $preference) { foreach ($themes as $theme) { if ($preference->match($theme)) { $matchValue += $preference->getValue() * $theme->getValue(); } } } return $matchValue >= self::MATCH_VALUE_THRESHOLD; } } ` |
La méthode match
de l’objet Preference
permet juste de vérifier si le nom du thème est le même que celui de la préférence de l’utilisateur.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | <?php # src/AppBundle/Entity/Preference.php namespace AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Entity() * @ORM\Table(name="preferences", * uniqueConstraints={@ORM\UniqueConstraint(name="preferences_name_user_unique", columns={"name", "user_id"})} * ) */ class Preference { // ... public function match(Theme $theme) { return $this->name === $theme->getName(); } } |
Appel API pour récupérer les suggestions
Pour récupérer les suggestions, il nous suffit maintenant de créer un appel dans le contrôleur UserController.
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 | <?php # src/AppBundle/Controller/UserController.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(serializerGroups={"place"}) * @Rest\Get("/users/{id}/suggestions") */ public function getUserSuggestionsAction(Request $request) { $user = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:User') ->find($request->get('id')); /* @var $user User */ if (empty($user)) { return $this->userNotFound(); } $suggestions = []; $places = $this->get('doctrine.orm.entity_manager') ->getRepository('AppBundle:Place') ->findAll(); foreach ($places as $place) { if ($user->preferencesMatch($place->getThemes())) { $suggestions[] = $place; } } return $suggestions; } // ... private function userNotFound() { return \FOS\RestBundle\View\View::create(['message' => 'User not found'], Response::HTTP_NOT_FOUND); } } ` |
Un fait important à relever ici est que la méthode, bien qu’étant dans le contrôleur des utilisateurs, renvoie des lieux. Le groupe de sérialisation utilisé est donc place.
Pour tester, nous avons un utilisateur défini comme suit :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | { "id": 1, "firstname": "My", "lastname": "Bis", "email": "my.last@test.local", "preferences": [ { "id": 1, "name": "history", "value": 4 }, { "id": 2, "name": "art", "value": 4 }, { "id": 6, "name": "sport", "value": 3 } ] } |
Et la liste de lieux dans l’application est la suivante :
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 | [ { "id": 1, "name": "Tour Eiffel", "address": "5 Avenue Anatole France, 75007 Paris", "prices": [ { "id": 1, "type": "less_than_12", "value": 5.75 } ], "themes": [ { "id": 1, "name": "architecture", "value": 7 }, { "id": 2, "name": "history", "value": 6 } ] }, { "id": 2, "name": "Mont-Saint-Michel", "address": "50170 Le Mont-Saint-Michel", "prices": [], "themes": [ { "id": 3, "name": "history", "value": 3 }, { "id": 4, "name": "art", "value": 7 } ] } ] |
Quand nous récupérons les suggestions pour notre utilisateur, nous obtenons :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | [ { "id": 2, "name": "Mont-Saint-Michel", "address": "50170 Le Mont-Saint-Michel", "prices": [], "themes": [ { "id": 3, "name": "history", "value": 3 }, { "id": 4, "name": "art", "value": 7 } ] } ] |
Nous avons donc un lieu dans notre application qui correspondrait aux gouts de notre utilisateur.
Les fonctionnalités que nous voulons pour notre application peuvent être implémentées assez facilement sans se soucier des contraintes imposées par le style d’architecture REST. REST n’intervient que pour définir l’API à utiliser pour accéder à ces fonctionnalités et nous laisse donc la responsabilité des choix techniques et de conceptions.
Vous pouvez vous entrainer et améliorer l’API en rajoutant encore plus de fonctionnalités. Nous pouvons par exemple imaginer que chaque utilisateur à un budget et que les tarifs des lieux sont pris en compte pour améliorer les suggestions.