J'ai récemment déposé une candidature pour un poste de développeur Backend Symfony chez PayGreen qui est une startup Rouennaise concurrente de Stripe qui propose de la gestion de paiement en ligne, engagée dans une démarche solidaire et éco-responsable 👍.
Ils m'ont proposé un test technique sous la forme d'un mini projet à développer dont voici l'énoncé :
Développer une petite API sous Symfony 5 avec quelques actions basiques listées ci-dessous.
Limitez-vous dans le temps ! Nous regarderons avant tout votre approche générale du développement et votre façon de coder. Merci d'éviter les outils tels qu'API Platform et de nous montrer comment vous pouvez coder par vous-même.
N'hésitez pas à nous indiquer par écrit ce que vous auriez aimé faire avec plus de temps, ou les problèmes que vous avez rencontrés.
Route d'authentification d'un utilisateur
Route permettant de créer un item "Transaction" -> Route réservée aux utilisateurs authentifiés
Route permettant de lister les utilisateurs -> Route réservée aux administrateurs
L'énoncé est assez flou, mais du coup ça me laisse aussi pas mal de liberté pour faire un peu ce que je veux. Je vais donc en profiter pour me lancer un challenge perso ;
Je vais coder une API en utilisant le minimum nécessaire de composants Symfony 5 (c'est-à-dire en partant de symfony-skeleton) et écrire un tuto complet du projet étape par étape en moins de 24h.
🏁 C'est parti ! 🏁
📝 NOTE : Je vais quand même préciser que l'équipe de PayGreen ne m'a obligé à rien du tout ; Je suis déjà en train de m'imaginer certains : "Ouais... C'est un peu abusé d'exiger autant de travail à un candidat, tout ça, blabla..." Je suis bien conscient que je vais fournir un effort environ 10 fois plus intense que ce qu'ils m'ont demandé au départ...
📝 NOTE 2 : Je vais décrire tout le projet en partant de zéro en donnant le maximum de détails, je vais donc faire comme si vous étiez un débutant total.
📝 NOTE 3 : J'ai écrit ce tuto en français, mais tous les commentaires dans le code sont en anglais. On ne devrait jamais utiliser autre chose que de l'anglais dans le code. Jamais.
TL;DR
Entrez les commandes suivantes dans votre terminal :
$ git clone https://github.com/TangoMan75/paygreen $ cd paygreen $ ./install.sh
Et l'API sera disponible ci : http://localhost:8000
- 1 ⚡ Environnement de développement
- 2 ⚡ Création du projet
- 3 ⚡ Installation des dépendances du projet
- 4 ⚡ Installation des dépendances de développement
- 5 ⚡ Création des entités
- 6 ⚡ Création de la base de donnée et du schéma
- 7 ⚡ Fixtures
- 8 ⚡ Création des encodeurs
- 9 ⚡ Contrôleurs
- 10 ⚡ Essayons d'envoyer des requêtes
- 11 ⚡ Gestion de la création d'un nouvel élément
- 12 ⚡ Création de l'authentification
- 13 ⚡ Permissions
- ⚡ Conclusion
Je vais considérer que vous êtes sur un environnement Linux, si vous êtes sur Mac certaines commandes risquent de ne pas fonctionner. Si vous êtes sur Windows 👎, formatez votre disque dur et installez la dernière version LTS d'Ubuntu on est là pour faire du code, pas pour jouer à Fortnite.
Si vous étiez en train de jouer à Fortnite et que vous venez donc de formater votre DD ; Vous aurez sûrement besoin de petits outils linux de base qui ne sont pas forcément installés par défaut sur votre système :
$ sudo apt-get install --assume-yes curl
$ sudo apt-get install --assume-yes gzip
$ sudo apt-get install --assume-yes make
$ sudo apt-get install --assume-yes tar
$ sudo apt-get install --assume-yes wget
Cette info vous sera sûrement utile si un jour vous avez besoin de déployer votre appli sur un vps ou dans un conteneur Docker.
Pour commencer nous avons besoin d'installer PHP7.4.
$ sudo apt-get install --assume-yes php7.4
Et les extensions indispensables pour faire fonctionner un projet Symfony
$ sudo apt-get install --assume-yes php7.4-curl
$ sudo apt-get install --assume-yes php7.4-gd
$ sudo apt-get install --assume-yes php7.4-intl
$ sudo apt-get install --assume-yes php7.4-mbstring
$ sudo apt-get install --assume-yes php7.4-sqlite3
$ sudo apt-get install --assume-yes php7.4-xml
$ sudo apt-get install --assume-yes php7.4-zip
Composer va nous permettre de gérer les dépendances de notre projet.
# download latest stable composer.phar
$ php -r "copy('https://getcomposer.org/composer-stable.phar', 'composer.phar');"
# install composer globally
$ sudo mv composer.phar /usr/local/bin/composer
# fix permissions
$ sudo chmod uga+x /usr/local/bin/composer
$ sync
# install symfony flex globally to speed up download of composer packages (parallelized prefetching)
$ composer global require 'symfony/flex' --prefer-dist --no-progress --no-suggest --classmap-authoritative
$ composer clear-cache
Bien qu'il ne soit pas absolument indispensable, l'outil en ligne de commande de Symfony offre un petit serveur local bien pratique.
$ curl -sS https://get.symfony.com/cli/installer | bash
# install symfony installer globally
$ sudo mv ~/.symfony/bin/symfony /usr/local/bin/symfony
$ sync
Mais si vous préférez utiliser le serveur PHP c'est au moins aussi bien.
vim est un éditeur de texte en ligne de commande, c'est ma préférence à moi pour les git rebase
interactifs (mais il n'y a vraiment pas d'obligation si vous préférez utiliser nano).
$ sudo apt-get install --assume-yes vim
# set vim as git default editor if installed
$ git config --global core.editor 'vim'
L'ASTUCE DU CHEF :
Pour quitter vim il faut simplement entrer:
:q!
Pour enregistrer un fichier et quitter:
:wq!
Git est l'outil indispensable pour versionner notre code, pour l'installer entrez cette commande dans votre terminal :
$ sudo apt-get install --assume-yes git
Et pour la configuration de base :
# default git config
$ git config --global push.default simple
# set git to use the credential memory cache
$ git config --global credential.helper cache
# set the cache to timeout after 1 hour (setting is in seconds)
$ git config --global credential.helper 'cache --timeout=3600'
# set vim as git default editor if installed
$ git config --global core.editor 'vim'
# set your username and email
$ git config --replace-all --global user.name "Votre nom"
$ git config --replace-all --global user.email "Votre email"
Il n'est pas non plus absolument indispensable, mais le client de github permet de se connecter à son compte et de créer des dépôts en ligne de commande.
$ wget https://github.com/cli/cli/releases/download/v${VERSION}/gh_1.6.1_linux_amd64.tar.gz
# extract archive
$ tar xvzf gh_1.6.1_linux_amd64.tar.gz
# install globally
$ sudo mv ./gh_1.6.1_linux_amd64/bin/gh /usr/local/bin/gh
# fix permissions
$ sudo chmod uga+x /usr/local/bin/gh
$ rm -rf gh_1.6.1_linux_amd64
$ rm -f gh_1.6.1_linux_amd64.tar.gz
$ sync
L'excellent PHPStorm met à disposition un plugin pour Symfony, pour moi c'est vraiment le meilleur outil pour coder en PHP il n'y a pas photo.
$ sudo snap install phpstorm --classic
📝 NOTE : Il est payant, mais JetBrains offre 30 jours d'essai gratuit, ensuite il faudra mettre la main à la poche ou vous contenter de Sublime Text qui n'est pas gratuit non plus, mais qui au lieu d'expirer va juste vous envoyer des notifications de temps en temps, (non, je ne vais pas aller jusqu'à vous recommander d'utiliser vim).
Nous aurons également besoin de DB Browser for SQLite (gratuit et open source) pour naviguer dans la base de donnée.
$ sudo apt-get install --assume-yes sqlitebrowser
Et nous aurons aussi besoin d'un client pour formuler les requêtes à notre API. Je recommande l'excellent Insomnia gratuit et open source qui présente aussi l'avantage de ne pas nous demander de créer un compte contrairement à Postman 👎.
$ echo 'deb https://dl.bintray.com/getinsomnia/Insomnia /' | sudo tee -a /etc/apt/sources.list.d/insomnia.list
$ wget --quiet -O - https://insomnia.rest/keys/debian-public.key.asc | sudo apt-key add -
$ sudo apt-get update
$ sudo apt-get install --assume-yes insomnia
Voilà, c'est tous les outils dont nous aurons besoin pour ce projet. Passons à la suite.
Pour initialiser un projet avec Symfony nous avons juste besoin de quelques commandes.
$ composer create-project symfony/skeleton paygreen
Cette commande installe le strict minimum pour développer une application Symfony. Il faudra ensuite installer manuellement chacune des dépendances de notre projet.
Entrons à la racine de notre projet :
$ cd paygreen
$ cat composer.json
Si tout c'est passé comme prévu, à cette étape le fichier composer.json
(qui liste les dépendances installées dans le dossier ./vendor
) doit contenir ceci :
// ...
"require": {
"php": ">=7.2.5",
"ext-ctype": "*",
"ext-iconv": "*",
"symfony/console": "5.2.*",
"symfony/dotenv": "5.2.*",
"symfony/flex": "^1.3.1",
"symfony/framework-bundle": "5.2.*",
"symfony/yaml": "5.2.*"
},
"require-dev": {
},
// ...
Maintenant, nous allons créer un nouveau dépôt ; Si vous êtes comme moi et que vous kiffez la ligne de commande, avec github-cli:
$ gh auth login
$ gh repo create paygreen
Répondez Yes aux deux questions. 👍
Sinon dans votre navigateur créez un nouveau dépôt https://github.com/new, puis il faut initialiser git dans le projet.
$ git init
$ git add .
$ git commit -m "Initial Commit"
$ git push
Les recipes (recettes) de Symfony Flex est le nouveau système de Symfony pour installer les dépendances de notre projet sous forme de packs... Inutile de vous en préoccuper pour le moment, je vous donne juste l'info en passant.
# https://packagist.org/packages/symfony/orm-pack
$ composer require orm
Doctrine est un ORM (object-relational mapping) qui nous permet de gérer la base de donnée.
Si besoin vous trouverez la documentation de Doctrine ici : https://www.doctrine-project.org ; Attention c'est poilu.
Nous allons utiliser sqlite
pour éviter d'avoir besoin d'installer PostgreSQL
ou MySQL
sur notre poste de développement. Je trouve que c'est l'idéal pour développer des prototypes, ça sera amplement suffisant pour notre petit projet.
Pour ça on va configurer le paramètre DATABASE_URL
dans le fichier .env
à la racine du projet :
DATABASE_URL=sqlite:///%kernel.project_dir%/var/database.db
# https://packagist.org/packages/sensio/framework-extra-bundle
$ composer require annotations
Il permet d'utiliser les annotations pour les routes dans nos contrôleurs.
# https://packagist.org/packages/symfony/serializer
$ composer require symfony/serializer
Le sérialiseur de Symfony va nous permettre d'encoder et de décoder des données au format json
, il va être le coeur de notre API.
# https://packagist.org/packages/symfony/security
$ composer require security
Ce paquet va nous permettre de gérer les permissions d'accès à certaines ressources de notre application et de gérer les utilisateurs de notre API.
En prod c'est tout ce que le projet aura besoin pour fonctionner, c'est fou non ?
# https://packagist.org/packages/symfony/maker-bundle
$ composer require --dev maker
La commande make
nous permet de générer du code de base tel que les contrôleurs, les entités, les repository... Et nous permet de gagner du temps.
Pour voir la liste des commandes disponibles dans le projet vous pouvez entrer :
$ bin/console
# https://symfony.com/doc/current/bundles/DoctrineFixturesBundle/index.html
$ composer require --dev orm-fixtures
Les fixtures (ou bouchons en français) persistent de fausses données dans notre base.
# https://packagist.org/packages/fakerphp/faker
$ composer require --dev fakerphp/faker
FakerPHP génère les fausses données pour les fixtures.
Si tout c'est passé comme prévu, à cette étape le fichier composer.json
doit contenir ceci :
// ...
"require": {
"php": ">=7.2.5",
"ext-ctype": "*",
"ext-iconv": "*",
"composer/package-versions-deprecated": "1.11.99.1",
"doctrine/annotations": "^1.0",
"doctrine/doctrine-bundle": "^2.2",
"doctrine/orm": "^2.8",
"sensio/framework-extra-bundle": "^6.1",
"symfony/console": "5.2.*",
"symfony/dotenv": "5.2.*",
"symfony/flex": "^1.3.1",
"symfony/framework-bundle": "5.2.*",
"symfony/proxy-manager-bridge": "5.2.*",
"symfony/security-bundle": "5.2.*",
"symfony/serializer": "5.2.*",
"symfony/yaml": "5.2.*"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "^3.4",
"fakerphp/faker": "^1.13",
"symfony/maker-bundle": "^1.29"
},
// ...
On va maintenant pouvoir vraiment rentrer dans le vif du sujet.
Les entités sont des classes qui représentent un objet, les propriétés de cet objet vont être mappées par Doctrine dans la base de donnée ; Nous aurons au final des tables avec des colones qui correspondent à ces propriétés.
Le maker va nous aider à créer nos entités :
ASTUCE : Si vous voulez être sympa avec votre canal carpien et éviter d'avoir à taper la même commande un demi-milliard de fois par minute, je vous conseille de créer un
alias
dans votre fichierbash_aliases
:echo "alias sf='php -d memory-limit=-1 ./bin/console'" >> ~/.bash_aliasesVous pouvez maintenant vous contenter de taper
sf
au lieu debin/console
; Merci qui ?
$ bin/console make:user
Après avoir entré cette commande l'interface nous guide pour la création de l'utilisateur.
📝 NOTE : Pour l'instant on ne va pas s'occuper de créer la relation avec l'entité Transaction
, c'est seulement au moment de créer l'objet Transaction
que nous demanderons au maker de le faire pour nous.
Le résultat de cette commande se trouve dans le fichier : ./src/Entity/User.php
Cette commande a aussi créé le repository qui correspond : ./src/Repository/UserRepository.php
Un Repository est une classe utilisée par Doctrine pour faire des requêtes en base de donnée. Si à l'avenir vous avez besoin de faire une requête avec des critères un peu plus élaborés que les méthodes de base, c'est à cet endroit qu'il faudra ajouter du code.
Enfin cette commande a mis à jour les paramètres de sécurité dans le fichier ./config/packages/security.yaml
On va entrer cette commande et se laisser guider à nouveau.
$ bin/console make:entity
📝 NOTE : Apparemment, le mot transaction
est réservé par Doctrine et cause une erreur lors du persist
:
Ce bug de l'espace m'a bien fait rager, il m'a bien fallu 15 minutes pour comprendre le problème... Les joies du code 😓.
Je me demande si nos amis de chez PayGreen n'auraient pas essayé de me tendre un piège. 🤔
L'entité ne peut pas s'appeler "Transaction", je la renomme en "Operation", qui est le mot qui se rapproche le plus de notre domaine sémantiquement.
Ça passe...
Au final de notre fichier ressemble à ça : ./src/Entity/Operation.php Et notre repository ressemble à ça : ./src/Repository/OperationRepository.php
La logique de cet objet est de représenter un événement, elle aura donc au minimum une propriété dateCreated
de type \DateTime
je suppose.
❗ IMPORTANT ❗
L'entité User
va avoir une relation ManyToOne
avec l'entité Operation
.
La propriété owner
d'une opération qui représente la relation avec l'utilisateur n'est pas nullable. En effet ça n'aurait pas de sens de créer une opération qui n'appartient à personne.
Les commandes suivantes vont créer la base de donnée et le schéma de notre application que nous venons de définir.
$ ./bin/console doctrine:database:create
$ ./bin/console doctrine:schema:create
Dans le dossier ./var
un fichier database.db
a été généré.
Si on ouvre ce fichier dans DB Browser on peut voir que Symfony a créé ce schéma :
CREATE TABLE "user" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"email" VARCHAR(180) NOT NULL,
"roles" CLOB NOT NULL,
"password" VARCHAR(255) NOT NULL
);
CREATE TABLE "operation" (
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
"owner_id" INTEGER NOT NULL,
"name" VARCHAR(255) NOT NULL,
"date_created" DATETIME NOT NULL,
CONSTRAINT "FK_1981A66D7E3C61F9" FOREIGN KEY("owner_id") REFERENCES "user"("id") NOT DEFERRABLE INITIALLY IMMEDIATE
);
CREATE UNIQUE INDEX "UNIQ_8D93D649E7927C74" ON "user" (
"email"
);
CREATE INDEX "IDX_1981A66D7E3C61F9" ON "operation" (
"owner_id"
);
Les fixtures vont nous éviter de devoir travailler avec des tables vides et nous permettent de tester notre application.
Là encore le maker nous aide à générer notre code.
$ bin/console make:fixture
Pour simplifier la création des relations nous allons faire une boucle dans une boucle. (C'est pas l'idéal, mais hé oh ! On a que 24h OK ! 😬)
Notre fichier va ressembler à ça : ./src/DataFixtures/AppFixtures.php
# ./src/DataFixtures/AppFixtures.php
// ...
/**
* create admin account and 10 users owning 10 transactions each.
*/
public function load(ObjectManager $manager)
{
// ..
$faker = Factory::create();
for ($i = 0; $i < 5; ++$i) {
$user = new User();
$user->setEmail($faker->email);
$user->setPassword(
$this->passwordEncoder->encodePassword(
$user,
$faker->uuid
)
);
$user->setRoles(['ROLE_USER']);
for ($j = 0; $j < 5; ++$j) {
$operation = new Operation();
$operation->setName($faker->word);
$operation->setDateCreated($faker->dateTimeBetween('-10 Days'));
$operation->setOwner($user);
$user->addOperation($operation);
$manager->persist($operation);
}
$manager->persist($user);
$manager->flush();
}
}
C'est également dans ce fichier que je vais créer le compte de l'administrateur, là encore c'est pas l'idéal... C'est juste un prototype.
# ./src/DataFixtures/AppFixtures.php
// ...
// create admin account
$user = new User();
$user->setEmail('mat@tangoman.io');
$user->setPassword(
$this->passwordEncoder->encodePassword(
$user,
'tango'
)
);
$user->setRoles(['ROLE_ADMIN']);
$manager->persist($user);
$manager->flush();
// ...
Remarquez que nous avons utilisé le UserPasswordEncoder
pour hasher le mot de passe des utilisateurs, nous ferons la même opération dans le SecurityController
pour la création de compte.
Pour info, la documentation des formateurs (les fonctions qui génèrent les trucs) de FakerPHP se trouve ici : https://fakerphp.github.io/formatters
À cette étape en entrant la commande :
$ ./bin/console doctrine:fixtures:load --no-interaction
La base de donnée devrait s'hydrater avec les bouchons générés par les fixtures. On peut le vérifier en ouvrant le fichier ./var/database.db
à l'aide de DB Browser.
Regardons dans l'onglet Browse Data:
🎇 Youpi ! Nos tables sont bien remplies ! 🎊
La commande maker va nous aider à créer les fichiers : ./src/Serializer/OperationEncoder.php et ./src/Serializer/UserEncoder.php
$ bin/console make:serializer:encoder
Nous allons nommer nos encodeurs UserEncoder
et OperationEncoder
et le nom de format sera user:json
, et operation:json
respectivement.
Chaque encodeur doit contenir une méthode pour décoder les données et les transformer si nécessaire.
Dans la méthode encode
, puisque nous allons recevoir une collection d'objets en entrée, nous allons parcourir le tableau avec array_map
et appliquer les transformations pertinentes dans la méthode encodeItem
.
# src/Serializer/OperationEncoder.php
//...
public function encode($data, $format, array $context = [])
{
// encode data as json
return $this->encoder->encode(
array_map(
[
$this,
'encodeItem',
],
$data
),
'json'
);
}
//...
Par exemple je préfère que la propriété dateCreated
d'une opération soit représentée par un timestamp
pour le client plutôt qu'un gros objet dateTime
moche. Également les relations de chaque objet sont remplacées par une IRI
, le client devra faire une autre requête pour récupérer les objets enfants.
# src/Serializer/OperationEncoder.php
//...
public function encodeItem(array $item): array
{
// transform dateTime to timestamp
$item['dateCreated'] = $item['dateCreated']['timestamp'] ?? null;
// encode relation as IRI
$item['owner'] = sprintf('/user/%s', $item['owner']['id']);
return $item;
}
//...
Un contrôleur est le cerveau d'une application ; Il définit les actions, les routes, connecte les données de la base et la logique des services... En résumé c'est l'élastique qui tient le slip de notre API.
$ bin/console make:controller
Vous commencer à avoir l'habitude, la commande ci-dessus nous guide pour la création de nos contrôleurs :
Comme vous l'avez sûrement deviné, nous allons nommer nos contrôleurs OperationController
et UserController
.
❗ IMPORTANT ❗
Puisque nos entités ont une relation, les contrôleurs doivent instancier correctement le sérialiseur pour éviter les références circulaires lors de l'encodage tel que c'est indiqué dans la documentation de Symfony :https://symfony.com/doc/current/components/serializer.html#handling-circular-references
# ./src/Controller/OperationController.php
// ...
private serializer;
public function __construct()
{
$encoder = new OperationEncoder();
$defaultContext = [
AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => function ($object, $format, $context) {
return $object->getId();
},
];
$normalizer = new ObjectNormalizer(null, null, null, null, null, null, $defaultContext);
$this->serializer = new Serializer([$normalizer], [$encoder]);
}
// ...
Nos contrôleurs contiennent quatre actions chacuns :
./src/Controller/UserController.php
verbe | route | permissions | description |
---|---|---|---|
GET |
/users | ROLE_ADMIN | Retourne la liste des utilisateurs |
POST |
/users | ROLE_ADMIN | Crée un nouvel utilisateur |
GET |
/users/{id} | IS_AUTHENTICATED_ANONYMOUSLY | Retourne un utilisateur |
DELETE |
/users/{id} | ROLE_ADMIN | Supprime un utilisateur |
./src/Controller/OperationController.php
verbe | route | permissions | description |
---|---|---|---|
GET |
/operations | ROLE_USER | Retourne la liste des operations |
POST |
/operations | ROLE_USER | Crée une nouvelle operation |
GET |
/operations/{id} | ROLE_USER | Retourne une operation |
DELETE |
/operations/{id} | ROLE_USER | Supprime une operation |
📝 NOTE : Les routes n'ont pas forcément besoin de propriété name
, Symfony attribue un nom par défaut aux actions des contrôleurs, regardons ça de plus près :
$ bin/console debug:router
Le nom des routes correspond par défaut au Namespace
de notre application, suvi du nom du contrôleur, et du nom de l'action.
Dans la mesure où nous n'allons pas utiliser Twig, et qu'il n'y pas de raison de renommer les actions tous les quatre matins, ça nous convient parfaitement.
Notre projet commence à prendre tournure : ./src/Controller/OperationController.php, ./src/Controller/UserController.php
Commençons par démarrer le serveur local de Symfony avec cette commande :
$ symfony server --no-tls
Où cette commande si vous préférez utiliser le serveur interne de PHP :
$ php -d memory-limit=-1 -S "127.0.0.1:8000" -t "./public"
Puis ouvrons Insomnia et essayons de récupérer nos données.
C'est good 👍.
Essayons de supprimer un élément maintenant.
Yes ! Ça marche !!
Pour la gestion du POST
c'est un peu plus difficile parcequ'il va falloir gérer la création des relations ; On va commencer par l'entité User
.
Dans le UserEncoder
on va devoir transformer les opérations enfants de notre item
.
Pour ça nous avons besoin d'injecter le OperationRepository
dans le constructeur de l'encodeur.
# ./src/Serializer/UserEncoder.php
use App\Repository\OperationRepository;
// ...
private $operationRepository;
public function __construct(OperationRepository $operationRepository)
{
$this->operationRepository = $operationRepository;
// ...
Sans oublier de passer l'argument depuis le contrôleur également.
# ./src/Controller/UserController.php
use App\Repository\OperationRepository;
// ...
public function __construct(OperationRepository $operationRepository)
{
$encoder = new UserEncoder($operationRepository);
// ...
L'action create
du contrôleur va désérialiser le contenu de la requête du client, puis persister l'objet et le retourner au format json
avec son identifiant (quand un objet possède un id
c'est qu'il a bien été persisté).
Comme il faut aussi persister les relations de notre objet User
, nous demandons à la méthode decode
d'aller chercher' en base de donnée chacune des opérations que l'utilisateur possède.
# ./src/Serializer/UserEncoder.php
// ...
public function decode($data, $format, array $context = [])
{
// ...
// create relations
$operations = [];
foreach ($data['operations'] ?? [] as $operation) {
// get id from IRI string
$iri = explode('/', $operation);
// request each object from database
$operations[] = $this->operationRepository->find(\intval(end($iri)));
}
$data['operations'] = $operations;
// ...
Je ne rentre pas dans les détails, mais ci-dessus je récupère l'id
d'une opération en transformant le dernier élément de la chaîne de caractères en entier ; Il va de soit qu'en production on aurait pris au moins le soin de vérifier la validité de l'IRI.
Il faudra faire plus ou moins la même chose pour l'entité Operation
dans le contrôleur, mais dans la méthode decode
de OperationEncoder
ça sera plus simple puisqu'il ne peut y avoir qu'un seul User
lié à une opération.
# ./src/Serializer/OperationEncoder.php
// ...
public function decode($data, $format, array $context = [])
{
// ...
// create relation
$iri = explode('/', $data['owner']);
$data['owner'] = $this->userRepository->find(\intval(end($iri)));
// ...
N'oublions pas qu'une opération ne peut pas exister sans utilisateur pour la créer !
Postons une opération nommée test_create_operation
dont le propriétaire porte l'id
numéro 1
(il existe déjà normalement, c'est l'administrateur) :
Vérifions que le User
avec l'id 1
possède bien l'opération avec l'id 26
que l'API vient de nous retourner.
🎺 Ça marche ! JOIE ! 🎷
Tout est prêt pour la dernière étape.
Nous voulons permettre aux utilisateurs de s'enregistrer et de se connecter en json
à l'aide du header Content-Type: application/json
dans l'entête de la requête.
Le SecurityController
contient trois actions :
./src/Controller/SecurityController.php
verbe | route | permissions | description |
---|---|---|---|
POST | /register | Création d'un compte utilisateur | |
POST | /login | Connexion d'un compte utilisateur | |
GET | /logout | IS_AUTHENTICATED_REMEMBERED | Déconnexion d'une session |
La commande debug:router
nous retourne ce magnifique tableau, tout va bien :
Le firewall (pare-feu) de Symfony centralise toute la configuration de la sécurité de notre application. Faites correspondre votre configuration avec le fichier ci-dessous.
# ./config/packages/security.yaml
security:
# ...
providers:
app_user_provider:
entity:
class: App\Entity\User
property: email
# ...
main:
anonymous: true
lazy: true
json_login:
check_path: app_security_login
logout:
path: app_security_logout
invalidate_session: true
provider: app_user_provider
Un utilisateur peut créer un compte sur l'API en enregistrant son email et son mot de passes en POST
au format json
avec le type Content-Type: application/json
contenu dans le header de la requête à l'adresse http://localhost:8000/register
{
"email": "mat@tangoman.io",
"password": "tango"
}
Essayons dans Insomnia:
🎉 OUIIIii ! ÇA MARCHE !
La documentatioon de Symfony nous indique la marche à suivre pour créer un système de login en json
: https://symfony.com/doc/current/security/json_login_setup.html
L'utilisateur peut maintenant se connecter à l'API en envoyant son nom d'utilisateur (l'email par défaut) et son mot de passes en POST
au format json
avec le type Content-Type: application/json
contenu dans le header de la requête à l'adresse http://localhost:8000/login
{
"username": "mat@tangoman.io",
"password": "tango"
}
Et finalement l'utilisateur peut se déconnecter en faisant une requête GET
à l'adresse http://localhost:8000/logout
Un administrateur doit automatiquement avoir les mêmes droits qu'un utilisateur, ajoutons ce paramètre dans la config du firewall.
# ./config/packages/security.yaml
security:
# ...
role_hierarchy:
ROLE_ADMIN: ROLE_USER
Il faut maintenant ajouter les annotations @isGranted
sur les actions des contrôleurs pour limiter l'accès aux routes uniquement aux utilisateurs autorisés. Nous allons suivre les indications de la documentation de Symfony : https://symfony.com/doc/current/bundles/SensioFrameworkExtraBundle/annotations/security.html
Un utilisateur peut créer une opération uniquement s'il est connecté, sinon le serveur lui envoie une erreur 401
:
# ./src/Controller/OperationController.php
// ...
/**
* @Route("/operations", methods={"POST"})
* @isGranted("IS_AUTHENTICATED_FULLY", statusCode=401, messsage="Unauthorized")
*
* @param Request $request
* @param EntityManagerInterface $entityManager
*
* @return Response
*/
public function create(Request $request, EntityManagerInterface $entityManager): Response
{
// ...
Les administrateurs uniquement sont autorisés à obtenir la liste les utilisateurs.
# ./src/Controller/UserController.php
// ...
/**
* @Route("/users", methods={"GET"})
* @isGranted("ROLE_ADMIN", statusCode=401, messsage="Unauthorized")
*
* @param UserRepository $userRepository
*
* @return Response
*/
public function list(UserRepository $userRepository): Response
{
//...
Voilà ! Essayons de nous connecter... Par défaut, à la connection l'application nous retourne l'utilisateur avec ses roles ;
🎉 C'EST UN SUCCÈS ! 🎉
Et voilà, cette petite victoire sonne la fin de mon marathon !
Et si vous avez suivi jusque-là, félicitations à vous aussi les amis !
J'espère que vous avez pris goût à Linux, PHP et Symfony !
Pour me contacter :
- En premier lieu, j'aurai pris le temps d'écrire des tests unitaires, test d'intégration et des tests fonctionnels avec PHPUnit et Panther.
- J'aurai implémenté les permissions avec des voteurs. https://symfony.com/doc/current/security/voters.html
- Pour le moment rien n'empêche une personne mal intentionnée de créer des milliers de comptes avec un bot, un envoi d'email pour une vérification ça ne ferait pas de mal.
- J'aurai mis en place un système de pagination pour les listes.
- J'aurai géré le
PUT
et lePATCH
. - J'aurai géré les erreurs avec des réponses en
json
qui vont bien. - J'aurai fait une commande pour créer des utilisateurs (admins).
- J'aurai implémenté des relations bidirectionnelles dans les entités avec des
persist={cascade}
- J'aurai conteneurisé l'API et mis en place
PostgreSQL
avec Docker. - J'aurai mis en place l'intégration et le déploiement continu avec GitHub Actions.
- J'aurai mis en place Traefik pour gérer le reverse proxy, le SSL et le load balancing.