Legacy to DDD : Partie 3 - Tester et faire du neuf avec Domain-Driven Design en PHP
Cet article fait suite à nos deux précédents articles sur le DDD et son application à un projet vieillissant, réalise en CakePHP. Dans la première phase, nous avons passé deux actions essentielles en mode DDD : une lecture et une écriture.
Puis nous avons creuser les avantages que nous offre le DDD. À présent revenons sur la qualité de notre code et offrons nous des prespectives d’infrastructure plus moderne.
Pour retrouver les premières parties :
- Legacy to DDD : Partie 1 - Comment transformer son code existant en utilisant des concepts concrets de Domain Driven Development
- Legacy to DDD : Partie 2 - Comment moderniser votre application PHP avec le Domain-Driven Design
6. Tester
6.1. un cœur éprouvé par phpunit
Revenons dans la continuité du point 4.3. Passons à l’écriture de quelques test phpunit. Mais avant cela, il va nous falloir faire de la place car Cakephp vient avec sa propre configuration de PHPunit.
1mv tests Cakephp
2mkdir tests
3mv Cakephp tests
4mv phpunit.xml.dist tests/Cakephp/phpunit.xml.dist
Après cela il faut changer quelques chemins dans tests/Cakephp/phpunit.xml
et tests/Cakephp/bootstrap.php
, puis l’ensemble des noms de domaine des tests cakephp.
1n̶a̶m̶e̶s̶p̶a̶c̶e̶ ̶A̶p̶p̶\̶\̶T̶e̶s̶t̶\̶\̶T̶e̶s̶t̶C̶a̶s̶e̶\̶\̶C̶o̶n̶t̶r̶o̶l̶l̶e̶r̶;̶
2namespace App\\Test\\Cakephp\\TestCase\\Controller;
Les tests cakephp peuvent ainsi toujours être lancés avec cette commande.
1docker-compose exec web vendor/bin/phpunit --config tests/Cakephp/phpunit.xml.dist
À noter qu’ils ne passent pas dans l’actuel car il ne trouve pas les fixtures. Mais nous pouvons maintenant écrire nos propres tests. Savoir identifier quelles parties doivent être testées unitairement en priorité est simplifié par la découpe du code. Notamment, les validateurs que nous avons définis méritent toute notre attention pour pouvoir leur faire confiance. Écrivons donc un test en exemple pour le validateur de l’input de modification d’un bookmark.
1namespace App\Test\Application\UpdateBookmark;
2
3use Application\UpdateBookmark\UpdateBookmarkInput;
4use Application\UpdateBookmark\UpdateBookmarkValidator;
5use PHPUnit\Framework\TestCase;
6
7class UpdateBookmarkValidatorTest extends TestCase
8{
9 private UpdateBookmarkValidator $validator;
10
11 public function setUp(): void
12 {
13 $this->validator = new UpdateBookmarkValidator();
14 }
15
16 public function testValidInput()
17 {
18 $input = new UpdateBookmarkInput(
19 id: 12,
20 title: 'Just a title',
21 url: 'http://example.com',
22 description: 'A simple descr',
23 tagsTitle: [],
24 );
25
26 $this->assertEmpty($this->validator->validate($input));
27 }
28
29 public function testTitleTooShort()
30 {
31 $input = new UpdateBookmarkInput(
32 id: 12,
33 title: 'Ju',
34 url: 'http://example.com',
35 description: 'A simple descr',
36 tagsTitle: [],
37 );
38
39 $this->assertEquals($this->validator->validate($input), ['Title must be at last 3 char long.']);
40 }
41}
On met ici en évidence que notre code est découpé de manière très atomique : chaque partie ne fait qu’une petite chose. Et ça devient alors très facile de la tester et de gagner en confiance sur le comportement de l’application. Le gain n’est pas évident dans ce cas, mais sur un projet complexe, on aura d’autant plus éclaté la complexité en petites briques parfaitement appréhendables.
Pour lancer ce test, il va nous falloir configurer phpunit dans le fichier ./phpunit.xml
1<?xml version="1.0" encoding="UTF-8"?>
2<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/9.3/phpunit.xsd"
4 colors="true"
5>
6 <testsuites>
7 <testsuite name="Application">
8 <directory>tests/Application/</directory>
9 </testsuite>
10 <testsuite name="Domain">
11 <directory>tests/Domain/</directory>
12 </testsuite>
13 </testsuites>
14</phpunit>
Puis lancer les tests.
1docker-compose exec web vendor/bin/phpunit
Pour compléter cette partie sur les tests, voici le tests du validateur d’update d’un bookmark
1namespace App\Test\Domain\Bookmark\Validator;
2
3use Domain\Bookmark\Context\CurrentUserProvider;
4use Domain\Bookmark\Model\Bookmark;
5use Domain\Bookmark\Model\User;
6use Domain\Bookmark\Validator\BookmarkUpdaterValidator;
7use PHPUnit\Framework\TestCase;
8
9class BookmarkUpdaterValidatorTest extends TestCase
10{
11 private BookmarkUpdaterValidator $validator;
12
13 public function setUp(): void
14 {
15 $this->currentUserProvider = $this->createMock(CurrentUserProvider::class);
16 $this->validator = new BookmarkUpdaterValidator($this->currentUserProvider);
17 }
18
19 public function testSameUser()
20 {
21 $user = $this->createMock(User::class);
22 $user->id = 12;
23 $this->currentUserProvider->method('getCurrentUser')->willReturn($user);
24
25 $bookmark = $this->createMock(Bookmark::class);
26 $user2 = $this->createMock(User::class);
27 $user2->id = 12;
28 $bookmark->user = $user2;
29
30 self::assertEmpty($this->validator->validate($bookmark));
31 }
32
33 public function testDifferentUser()
34 {
35 $user = $this->createMock(User::class);
36 $user->id = 12;
37 $this->currentUserProvider->method('getCurrentUser')->willReturn($user);
38
39 $bookmark = $this->createMock(Bookmark::class);
40 $user2 = $this->createMock(User::class);
41 $user2->id = 13;
42 $bookmark->user = $user2;
43
44 self::assertEquals($this->validator->validate($bookmark), ['You cannot modify that bookmark since you are not the owner']);
45 }
46
47 public function testCannotFindCurrentUser()
48 {
49 $this->currentUserProvider->method('getCurrentUser')->willReturn(null);
50
51 $bookmark = $this->createMock(Bookmark::class);
52 $user2 = $this->createMock(User::class);
53 $user2->id = 13;
54 $bookmark->user = $user2;
55
56 self::assertEquals($this->validator->validate($bookmark), ['You cannot modify that bookmark since you are not the owner']);
57 }
58}
Comme notre validateur a besoin du système pour retrouver l’utilisateur courant, on vient le simuler par un *mock*. Ensuite on lui fait faire ce qu’on veut selon les cas que l’on suite produire. Puis lancer les tests.
1docker-compose exec web vendor/bin/phpunit
2
3
4
5Time: 24 ms, Memory: 6.00 MB
6
7OK (5 tests, 5 assertions)
Voilà une bonne petite stack de tests, prête à recevoir tout les tests unitaires que vous voudriez écrire ! Pour clore cette partie, nous pouvons aussi appliquer php-cs-fixer aux tests depuis le fichier de configuration .php-cs-fixer.php
1$finder = PhpCsFixer\Finder::create()
2 ->in(__DIR__ . '/src')
3 ->in(__DIR__ . '/tests');
4 …
5
6
7
8docker-compose exec web tools/php-cs-fixer/vendor/bin/php-cs-fixer fix src
Je compile tout ça dans ce commit pour vous : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/9c04e75c5646ead8ed2eab5482eaa5696e591cdc
6.2. Tests de bout en bout
Les tests unitaires sont pertinents pour s’assurer que les différents éléments rempliront bien leur rôle. Cependant, on ne peut pas se passer des tests d’un utilisateur humain qui va réellement se connecter à l’application et réaliser les différentes actions pour s’assurer que tout est opérationnel.
Alors, on va s’équiper d’un outil qui peut faire ce boulot de manière automatisée ! Il y a quelques années, j’aurais proposé Behat pour cette tâche, mais aujourd’hui, voici Cypress ! Ajoutons-le à notre projet. Pour cela vous aurez besoin d’avoir pré-installé nodejs 14+ et npm.
1npm init
2npm install cypress --save-dev
On peut lancer immédiatement l’interface de cypress
1npx cypress open
Écrivons notre premier test sans attendre.
1// cypress/e2e/bookmark.cy.js
2describe('Bookmarks management', () => {
3 it('shows bookmarks', () => {
4 cy.visit('http://0.0.0.0:9050');
5 cy.get('input[name=email]').type('user@example.com');
6 cy.get('input[name=password]').type('password');
7 cy.get('button').click();
8
9 cy.url().should('contain', '/bookmarks')
10 cy.get('table tbody tr').should('have.length', 5);
11 cy.contains('LetsEncrypt').parent('tr').contains('View').click();
12
13 cy.url().should('contain', '/bookmarks/view/')
14 cy.contains('https://letsencrypt.org');
15 cy.contains('Free open Certificate Authority');
16 })
17})
Ce test réalise les actions suivantes :
- Chargement de la page de login
- On entre email, mot de passe, puis on valide la connexion
- On vérifie qu’on est bien sur la page de la liste des bookmarks, puis on clic sur l’élément Let’s encrypt
- On vérifie qu’on est bien sur la page pour afficher un bookmark et son contenu
Cypress nous offre même un affichage en temps réel des actions qu’il réalise, et il s'arrête quand une condition n’est pas remplie.
Voilà un bien faible effort pour apporter une grosse valeur au projet : on sait que les fonctions essentielles sont remplies !
On voit cependant que ces tests sont dépendants de l’état courant de l’application, ce qui peut mener à des erreurs. Par exemple, si on renomme le bookmark LetsEncrypt en Free SSL Service, on voit bien que le test ne fonctionnera plus puisqu’il ne trouvera pas la chaîne de caractères. Pour résoudre ce problème, nous allons mettre en place un reset des données que nous réalisons avant chaque test, pour être sûr de nos données.
1// cypress/e2e/bookmark.cy.js
2describe('Bookmarks management', () => {
3 before(() => {
4 cy.request('http://0.0.0.0:9050/dataReset/dbReset');
5 });
6 …
7})
Puis il nous implémenter ce contrôleur côté Cakephp
1namespace App\Controller;
2
3use Cake\Http\Response;
4use Cake\Datasource\ConnectionManager;
5
6class DataResetController extends AppController
7{
8 public function initialize(): void
9 {
10 parent::initialize();
11 $this->Auth->allow('dbReset'); // Pour dire à Cakephp que les anonymes peuvent accéder à ce contrôleur
12 }
13
14 public function dbReset()
15 {
16 $conn = ConnectionManager::get('default');
17 $conn->execute(file_get_contents(__DIR__.'/../../../config/schema/app.sql'));
18 $conn->execute(file_get_contents(__DIR__.'/../../../config/schema/i18n.sql'));
19 $conn->execute(file_get_contents(__DIR__.'/../../../config/schema/sessions.sql'));
20
21 return new Response();
22 }
23
24 public function isAuthorized()
25 {
26 return true;
27 }
28}
Nous l’avons fait ici d’une manière rapide et non sécurisée. Pour une véritable application, il aurait été bien mieux de l’autoriser uniquement en environnement de test et de dev, et d’utiliser le système de fixtures de Cakephp. On va s’en tenir au minimum fonctionnel. Ensuite, on voit qu’on peut immédiatement mettre en commun certaines parties du code du test, à commencer par le nom de domaine pour atteindre l’application. Un peu de configuration ici : ./cypress.config.js
1const { defineConfig } = require("cypress");
2
3module.exports = defineConfig({
4 e2e: {
5 baseUrl: 'http://0.0.0.0:9050',
6 },
7});
8
9
10
11// cypress/e2e/view-bookmark.cy.js
12describe('Bookmarks management', () => {
13 before(() => {
14 c̶y̶.̶r̶e̶q̶u̶e̶s̶t̶(̶'̶h̶t̶t̶p̶:̶/̶/̶0̶.̶0̶.̶0̶.̶0̶:̶9̶0̶5̶0̶/̶d̶a̶t̶a̶R̶e̶s̶e̶t̶/̶d̶b̶R̶e̶s̶e̶t̶'̶)̶;̶
15 cy.request('/dataReset/dbReset');
16 });
17
18 it('shows bookmarks', () => {
19 c̶y̶.̶v̶i̶s̶i̶t̶(̶'̶h̶t̶t̶p̶:̶/̶/̶0̶.̶0̶.̶0̶.̶0̶:̶9̶0̶5̶0̶'̶)̶;̶
20 cy.visit('/');
21 …
22 })
23})
Autre point : on commence le test par se connecter à l’application. On se doute bien que ce sera une action réalisée de manière récurrente, on va donc la placer à part.
1// cypress/support/commands.js
2Cypress.Commands.add(
3 "userConnect",
4 (email = 'user@example.com', password = 'password') => {
5 cy.visit('/users/login');
6 cy.get('input[name=email]').type(email);
7 cy.get('input[name=password]').type(password);
8 cy.get('button').click();
9 cy.url().should('contain', '/bookmarks')
10 }
11);
Puis on s’en sert dans notre test
1describe('Bookmarks management', () => {
2 it('shows bookmarks', () => {
3 c̶y̶.̶v̶i̶s̶i̶t̶(̶'̶/̶'̶)̶;̶
4 c̶y̶.̶g̶e̶t̶(̶'̶i̶n̶p̶u̶t̶[̶n̶a̶m̶e̶=̶e̶m̶a̶i̶l̶]̶'̶)̶.̶t̶y̶p̶e̶(̶'̶u̶s̶e̶r̶@̶e̶x̶a̶m̶p̶l̶e̶.̶c̶o̶m̶'̶)̶;̶
5 c̶y̶.̶g̶e̶t̶(̶'̶i̶n̶p̶u̶t̶[̶n̶a̶m̶e̶=̶p̶a̶s̶s̶w̶o̶r̶d̶]̶'̶)̶.̶t̶y̶p̶e̶(̶'̶p̶a̶s̶s̶w̶o̶r̶d̶'̶)̶;̶
6 c̶y̶.̶g̶e̶t̶(̶'̶b̶u̶t̶t̶o̶n̶'̶)̶.̶c̶l̶i̶c̶k̶(̶)̶;̶
7 cy.userConnect();
8
9 cy.url().should('contain', '/bookmarks');
10 cy.get('table tbody tr').should('have.length', 5);
11 cy.contains('LetsEncrypt').parent('tr').contains('View').click();
12
13 cy.url().should('contain', '/bookmarks/view/')
14 cy.contains('https://letsencrypt.org');
15 cy.contains('Free open Certificate Authority');
16 })
17})
Ajoutons un nouveau test pour modifier un bookmark
1describe('Bookmarks management', () => {
2 …
3 it('modifies a bookmark', () => {
4 cy.userConnect();
5
6 cy.url().should('contain', '/bookmarks')
7 cy.contains('LetsEncrypt').parent('tr').contains('Edit').click();
8
9 cy.url().should('contain', '/bookmarks/edit/')
10 cy.get('input[name="title"]').type(" goood");
11 cy.get('textarea[name="description"]').type(" Some more content.");
12 cy.get('button[type="submit"]').click();
13
14 cy.url().should('contain', '/bookmarks')
15 cy.contains('The bookmark has been saved.');
16 cy.contains('LetsEncrypt goood');
17 })
18})
Nous voici avec un joli petit outil, qui va nous assurer que les fonctions essentielles de l’application sont toujours remplies !
À noter que Cypress peut être lancé sans présentation visuelle, très utile pour lancer les tests dans le cadre d’une plateforme d’intégration continue. Il génère même des vidéos du déroulé des actions, précieux pour identifier les raisons d’une erreur.
1npx cypress run
Le résumé dans ce commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/f7a71e3f6a4d81e2c76c610c870c01fb139f7ae6
6.3. Une pseudo CI
Pour résumer, on s’est fait une belle petite stack de vérification de la qualité du code produit ! Si on osait, on exécuterai ces vérifications au moment de créer un commit. On va éditer le fichier ./git/hooks/pre-commit
1#!/bin/sh
2
3EXIT_STATUS=0
4docker-compose exec -T web tools/php-cs-fixer/vendor/bin/php-cs-fixer fix || EXIT_STATUS=$?
5docker-compose exec -T web vendor/bin/phpstan analyse -l 9 ./src/Application ./src/Domain || EXIT_STATUS=$?
6docker-compose exec -T web vendor/bin/phpunit || EXIT_STATUS=$?
7docker-compose exec -T web vendor/bin/phparkitect check || EXIT_STATUS=$?
8exit $EXIT_STATUS
Cette technique n’est pas toujours pertinente, car on a parfois juste envie de créer un commit rapidement. Mais il est aussi très pratique de connaître au plus tôt les erreurs provoqués par nos derniers changements sans avoir à attendre une CI souvent surchargée. Quoi qu’il en soit, je vous conseille de limiter les vérifications faites ici aux plus rapides, c'est-à-dire d’en exclure les tests de bout en bout de cypress.
7. Faire du neuf
7.1. Nouvelle infra en cohabitation
À présent que nous avons une situation bien saine et solide, le métier vient vers nous et nous annonce que Google est intéressé par notre projet et souhaite communiquer avec notre application, via son API. Bien entendu, nous n’avons rien mis en place de ce côté.
C’est l’occasion rêvée pour relever le défi et mettre en place l’outil d’api parfait : Api-Platform.
Avant de commencer, nous allons isoler la configuration de Cakephp pour ne pas entrer en conflit avec celle de Symfony.
1mv config configCakephp
Il nous faut alors configurer cakephp dans ce sens
1// configCakephp/paths.php
2 …
3 d̶e̶f̶i̶n̶e̶(̶'̶C̶O̶N̶F̶I̶G̶'̶,̶ ̶R̶O̶O̶T̶ ̶.̶ ̶D̶S̶ ̶.̶ ̶'̶c̶o̶n̶f̶i̶g̶'̶ ̶.̶ ̶D̶S̶)̶;̶
4 define('CONFIG', ROOT . DS . 'configCakephp' . DS);
5 …
6
7
8
9// webroot/index.php
10 …
11 $̶s̶e̶r̶v̶e̶r̶ ̶=̶ ̶n̶e̶w̶ ̶S̶e̶r̶v̶e̶r̶(̶n̶e̶w̶ ̶A̶p̶p̶l̶i̶c̶a̶t̶i̶o̶n̶(̶d̶i̶r̶n̶a̶m̶e̶(̶_̶_̶D̶I̶R̶_̶_̶)̶ ̶.̶ ̶'̶/̶c̶o̶n̶f̶i̶g̶'̶)̶)̶;̶
12 $server = new Server(new Application(dirname(__DIR__) . '/configCakephp'));
13 …
Continuons en installant les briques essentielles de Symfony, puis api-plaform.
1docker-compose exec web composer require symfony/framework-bundle:* --with-all-dependencies
2docker-compose exec web composer require symfony/yaml symfony/runtime symfony/flex symfony/dotenv api-platform/api-pack
3docker-compose exec web composer require --dev symfony/profiler-pack
Il nous faut maintenant diriger les requêtes HTTP vers Cakephp ou Symfony selon le path demandé.
1# .docker/php/vhost.conf
2upstream phpfcgi {
3 server localhost:9000;
4}
5
6server {
7 listen 80;
8 server_name localhost;
9 root /app/public;
10 client_max_body_size 80M;
11
12 location / {
13 try_files $uri @rewriteapp;
14 }
15
16 location /api {
17 rewrite ^(.*)$ /symfony.php/$1 last;
18 }
19
20 location /_profiler {
21 rewrite ^(.*)$ /symfony.php/$1 last;
22 }
23
24 location @rewriteapp {
25 r̶e̶w̶r̶i̶t̶e̶ ̶^̶(̶.̶*̶)̶$̶ ̶/̶i̶n̶d̶e̶x̶.̶p̶h̶p̶/̶$̶1̶ ̶l̶a̶s̶t̶;̶
26 rewrite ^(.*)$ /cakephp.php/$1 last;
27 }
28
29 l̶o̶c̶a̶t̶i̶o̶n̶ ̶~̶ ̶^̶/̶i̶n̶d̶e̶x̶.̶p̶h̶p̶(̶/̶|̶$̶)̶ ̶{̶
30 location ~ ^/.*.php(/|$) {
31 include fastcgi_params;
32 fastcgi_pass localhost:9000;
33 fastcgi_split_path_info ^(.+\.php)(/.*)$;
34 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
35 fastcgi_param HTTPS off;
36 }
37}
il nous faut rassembler les deux dossier racine pour le serveur http.
1mv public/index.php webroot/symfony.php
2rm public
3mv webroot public
4mv public/index.php public/cakephp.php
On relance pour prendre en compte ces changement
1docker-compose up --build
Puis, comme nous l’avons fait pour Cakephp, nous allons limiter symfony à une partie du dossier src
1mkdir src/Symfony
2mv src/Controller src/Symfony/Controller
3rm src/Repository
4rm src/Entity
5rm src/ApiResource
6mv src/Kernel.php src/Symfony/Kernel.php
Ajoutons cette nouvelle partie des noms de domaine à composer.json
1 "autoload": {
2 "psr-4": {
3 …
4 "App\\Symfony\\": "src/Symfony"
5 }
6 },
Ne pas oublier de mettre à jour l’autoload
1docker-compose exec web composer dump-autoload
On rencontre un autre souci également. Avec nos noms de domaines non standard dans le dossier src, on commence à avoir quelques soucis désagréables. Nous allons donc les uniformiser en les commençant tous pas ```App/```.
1n̶a̶m̶e̶s̶p̶a̶c̶e̶ ̶A̶p̶p̶l̶i̶c̶a̶t̶i̶o̶n̶\̶G̶e̶t̶B̶o̶o̶k̶m̶a̶r̶k̶;̶
2namespace App\Application\GetBookmark;
3
4u̶s̶e̶ ̶D̶o̶m̶a̶i̶n̶\̶B̶o̶o̶k̶m̶a̶r̶k̶\̶M̶o̶d̶e̶l̶\̶B̶o̶o̶k̶m̶a̶r̶k̶;̶
5use App\Domain\Bookmark\Model\Bookmark;
6u̶s̶e̶ ̶D̶o̶m̶a̶i̶n̶\̶B̶o̶o̶k̶m̶a̶r̶k̶\̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶\̶B̶o̶o̶k̶m̶a̶r̶k̶R̶e̶p̶o̶s̶i̶t̶o̶r̶y̶;̶
7use App\Domain\Bookmark\Repository\BookmarkRepository;
8…
Puis mettre à jour le composer.json en fonction de cela.
1 …
2 "autoload": {
3 "psr-4": {
4 "App\\Application\\": "src/Application",
5 "App\\Domain\\": "src/Domain",
6 "App\\Symfony\\": "src/Symfony",
7 "App\\": "src/cakephp"
8 }
9 },
10 …
11
12
13
14docker-compose exec web composer dump-autoload
Je vous épargne toutes les modifications mais vous les trouverez dans le commit que voici : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/270537eff57ce397208158681e38d93b4864b28d
Il nous faut également demander à Symfony de publier les assets de ses bundles pour les rendre accessibles dans le dossier public.
1docker-compose exec web bin/console assets:install --symlink
Au final, ça fonctionne ! Quand on se rend sur http://0.0.0.0:9050/users/login, on peut se connecter et réaliser toutes les opérations dans l’application Cakephp. Et quand on se rend sur http://0.0.0.0:9050/api/docs, on voit la page de swagger de description de l’Api, qui est vide pour le moment.
7.2. Nouvelle Doctrine
Nous allons utiliser Doctrine, l’ORM qui poussé par Symfony. Mais Doctrine a un paradigme opposé à l’ORM de Cakephp. La référence du modèle de données est basé sur les fichiers de définition des entités pour Doctrine, alors que Cakephp se base sur la structure de la base de données déjà existante.
Pour être en mesure d’utiliser là même base de données pour les deux infrastructures, il nous faut donc reproduire le modèle de données sous la forme de la configuration Doctrine. Nous allons le faire au format XML. Voici les 3 objets métiers : user, tag et bookmark.
1<?xml version="1.0" encoding="utf-8"?>
2<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
3 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4 xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
5 <entity name="App\Domain\Bookmark\Model\Tag" table="tags">
6 <unique-constraints>
7 <unique-constraint columns="title" name="title" />
8 </unique-constraints>
9 <id name="id" type="integer">
10 <generator strategy="AUTO" />
11 </id>
12 <field name="title" nullable="true" />
13 <field name="created" type="datetime_immutable" nullable="true" />
14 <field name="modified" type="datetime_immutable" nullable="true" />
15 <many-to-many field="bookmarks" target-entity="App\Domain\Bookmark\Model\Bookmark" mapped-by="tags">
16 <cascade>
17 <cascade-remove/>
18 </cascade>
19 </many-to-many>
20 </entity>
21</doctrine-mapping>
22
23
24
25<?xml version="1.0" encoding="utf-8"?>
26<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
27 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
28 xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
29 <entity name="App\Domain\Bookmark\Model\User" table="users">
30 <id name="id" type="integer">
31 <generator strategy="AUTO" />
32 </id>
33 <field name="email" />
34 <field name="password" />
35 <field name="created" type="datetime_immutable" nullable="true" />
36 <field name="modified" type="datetime_immutable" nullable="true" />
37 <one-to-many field="bookmarks" target-entity="App\Domain\Bookmark\Model\Bookmark" mapped-by="user">
38 <cascade>
39 <cascade-remove/>
40 </cascade>
41 </one-to-many>
42 </entity>
43</doctrine-mapping>
44
45
46
47<?xml version="1.0" encoding="utf-8"?>
48<doctrine-mapping xmlns="http://doctrine-project.org/schemas/orm/doctrine-mapping"
49 xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
50 xsi:schemaLocation="http://doctrine-project.org/schemas/orm/doctrine-mapping https://www.doctrine-project.org/schemas/orm/doctrine-mapping.xsd">
51 <entity name="App\Domain\Bookmark\Model\Bookmark" table="bookmarks">
52 <indexes>
53 <index columns="user_id" name="user_key" />
54 </indexes>
55 <id name="id" type="integer">
56 <generator />
57 </id>
58 <field name="title" length="50" nullable="true" />
59 <field name="description" nullable="true" length="2048" />
60 <field name="url" length="2048" nullable="true" />
61 <field name="created" type="datetime_immutable" nullable="true" />
62 <field name="modified" type="datetime_immutable" nullable="true" />
63 <many-to-one field="user" target-entity="User" inversed-by="bookmarks">
64 <join-columns>
65 <join-column name="user_id" nullable="false"/>
66 </join-columns>
67 </many-to-one>
68 <many-to-many field="tags" target-entity="Tag" inversed-by="bookmarks">
69 <join-table name="bookmarks_tags">
70 <join-columns>
71 <join-column name="bookmark_id"/>
72 </join-columns>
73 <inverse-join-columns>
74 <join-column name="tag_id"/>
75 </inverse-join-columns>
76 </join-table>
77 </many-to-many>
78 </entity>
79</doctrine-mapping>
Ces fichiers servent à indiquer à Doctrine comment les différentes propriétés des objets doivent être stockées en base de données, permettant de le transformer automatiquement vers la bdd, et quand on récupère les données de la bdd. C’est d’ailleurs le sens de l’acronyme ORM : Object Relation Mapper.
Comme vous pouvez le voir, les objets que nous souhaitons faire connaître à Doctrine sont directement les objets métiers tel que nous les avons défini dans la couche modèle. Cela nous évitera de mettre en place un mécanisme de conversion entre entité Doctrine et objet métier, comme nous l’avons fait pour la partie Cakephp. Cependant, nous ne devrions pas mettre les définitions des champs de la table dans les fichiers domaine (annotation), car on va mélanger les couches domaine et infrastructure en faisant cela. Voilà pourquoi nous avons choisi le format XML pour ces définitions.
Pour être complet, il nous faut modifier le fichier SQL de base (côté Cakephp) pour correspondre au nommages de Doctrine et pour fixer des types mieux adaptés.
1## configCakephp/schema/app.sql
2…
3CREATE TABLE bookmarks (
4 …
5 d̶e̶s̶c̶r̶i̶p̶t̶i̶o̶n̶ ̶T̶E̶X̶T̶,
6 description VARCHAR(2048),
7 u̶r̶l̶ ̶T̶E̶X̶T̶,
8 url VARCHAR(2048),
9 …
10);
11
12CREATE TABLE bookmarks_tags (
13 …
14 PRIMARY KEY (bookmark_id,tag_id),
15 KEY IDX_CD7027B7BAD26311 (tag_id),
16 F̶O̶R̶E̶I̶G̶N̶ ̶K̶E̶Y̶ ̶t̶a̶g̶_̶k̶e̶y̶(̶t̶a̶g̶_̶i̶d̶)̶ ̶R̶E̶F̶E̶R̶E̶N̶C̶E̶S̶ ̶t̶a̶g̶s̶ ̶(̶i̶d̶)̶,
17 FOREIGN KEY bookmarks_tags_ibfk_1(tag_id) REFERENCES tags (id),
18 F̶O̶R̶E̶I̶G̶N̶ ̶K̶E̶Y̶ ̶b̶o̶o̶k̶m̶a̶r̶k̶_̶k̶e̶y̶(̶b̶o̶o̶k̶m̶a̶r̶k̶_̶i̶d̶)̶ ̶R̶E̶F̶E̶R̶E̶N̶C̶E̶S̶ ̶b̶o̶o̶k̶m̶a̶r̶k̶s̶ ̶(̶i̶d̶)̶
19 FOREIGN KEY bookmarks_tags_ibfk_2(bookmark_id) REFERENCES bookmarks (id)
20);
La difficulté à manipuler deux infrastructures comme nous le faisons là, c’est qu’elles doivent rester en cohérence dans leur connaissance du monde extérieur, et notamment de la base de données. Il faut décider laquelle des deux sera maître pour gérer le modèle en bdd, et laquelle suivra cette structure. Pour notre cas, nous garderons Cakephp comme responsable dans cette tâche.
Si vous avez bien suivi, nous avons mis en place une incompatibilité dans la configuration du mapping de l’objet bookmark. En effet, la propriété url de cet objet n’est pas une chaîne, mais un value-object. Il va nous falloir expliquer à Doctrine comment stocker et récupérer cette donnée. Pour cela, on va créer un nouveau type Doctrine.
1namespace App\Symfony\Doctrine\Type;
2
3use App\Domain\Bookmark\ValueObject\Url;
4use Doctrine\DBAL\Platforms\AbstractPlatform;
5use Doctrine\DBAL\Types\StringType;
6
7class UrlType extends StringType
8{
9 public function convertToPHPValue($value, AbstractPlatform $platform): ?Url
10 {
11 if (!$value) {
12 return null;
13 }
14
15 return Url::fromPersistedString($value);
16 }
17
18 public function convertToDatabaseValue($value, AbstractPlatform $platform): ?string
19 {
20 if (!$value instanceof Url) {
21 return null;
22 }
23
24 return $value->value;
25 }
26
27 public function getName(): string
28 {
29 return 'vo_url';
30 }
31
32 public function requiresSQLCommentHint(AbstractPlatform $platform): bool
33 {
34 return true;
35 }
36}
Cette classe est finalement assez claire. On définit un nouveau type qui étend le type string, car on va bien le stocker sous la forme d’une chaîne de caractères. On a là deux méthodes essentielles : comment on la convertit pour php, et comment on la convertit pour la bdd. Simple. Le reste est détail technique. Précisons à Doctrine que ce nouveau type existe.
1## config/packages/doctrine.yaml
2doctrine:
3 dbal:
4 url: '%env(resolve:DATABASE_URL)%'
5 types:
6 vo_url: App\Symfony\Doctrine\Type\UrlType
7 …
Puis utilisons le type dans notre mapping ```config/entity/bookmark/Bookmark.orm.xml```
1 <̶f̶i̶e̶l̶d̶ ̶n̶a̶m̶e̶=̶"̶u̶r̶l̶"̶ ̶l̶e̶n̶g̶t̶h̶=̶"̶2̶0̶4̶8̶"̶ ̶n̶u̶l̶l̶a̶b̶l̶e̶=̶"̶t̶r̶u̶e̶"̶ ̶/̶>̶
2 <field type="vo_url" name="url" length="2048" nullable="true" />
Pour aider Doctrine à s’y retrouver, il faut aussi ajouter un commentaire à la colonne dans la bdd.
1#configCakephp/schema/app.sql
2CREATE TABLE bookmarks (
3 …
4 u̶r̶l̶ ̶V̶A̶R̶C̶H̶A̶R̶(̶2̶0̶4̶8̶)̶,
5 url VARCHAR(2048) COMMENT '(DC2Type:vo_url)',
6 …
7);
On peut ajouter autant de types que l’on souhaite, ce qui nous permet de passer vraiment toutes les propriétés sous la forme de value-object. Et pour les value-object qui détiennent plusieurs données, comme une adresse postale, Doctrine propose un concept d’embeddable qui permet de le répartir dans plusieurs champs de bdd.
Nous voilà avec une configuration doctrine complète. Commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/472a2ea7dcb8525c4ab946b19b54c8fc84d6b33c
On peut ajouter la commande suivante à notre série de commande de validation de la qualité ```docker-compose exec web bin/console do:sc:va```
7.3. Because I’m API
Il est maintenant possible d’exposer nos objets sur une Api contre un peu de configuration.
1## config/packages/api_platform.yaml
2api_platform:
3 defaults:
4 pagination_items_per_page: 20
5 pagination_maximum_items_per_page: 100
6 collection:
7 pagination:
8 page_parameter_name: page
9 items_per_page_parameter_name: itemsPerPage
10 mapping:
11 paths:
12 - '%kernel.project_dir%/config/api_platform/bookmark'
13 formats:
14 jsonld: [ 'application/json-ld' ]
15 html: [ 'text/html' ]
16 json: [ 'application/json' ]
17 form: [ 'multipart/form-data' ]
18 patch_formats:
19 json: [ 'application/merge-patch+json' ]
20 swagger:
21 versions: [ 3 ]
22 api_keys:
23 apiKey:
24 name: Authorization
25 type: header
On reste là très proche de la configuration par défaut d’Api-Platform. Le point notable est uniquement le chemin pour trouver les différents mapping des objets exposés par l’Api. Voici. celui d’un bookmark.
1## config/api_platform/bookmark/bookmark.yaml
2App\Domain\Bookmark\Model\Bookmark:
3 operations:
4 ApiPlatform\Metadata\GetCollection: ~
5 ApiPlatform\Metadata\Get: ~
On définit ici les deux opérations possibles : obtenir la liste des bookmarks, et obtenir un bookmark sur la base de son id.
On peut lancer la requête immédiatement, l’interface de swagger n’est peut-être pas la plus confortable mais elle est d’ores et déjà disponible sur http://0.0.0.0:9050/api/docs. On se heurte alors à une erreur : Cannot assign Doctrine\\ORM\\PersistentCollection to property App\\Domain\\Bookmark\\Model\\User::$bookmarks of type array
. La raison en est simple, Doctrine ne travaille pas avec des tableaux, mais avec des objets qu’il nomme collections, pour les listes d’entités. C’est très pratique pour gérer les paginations. Imaginez que 100.000 tags soient associés à notre bookmark : la simple requête serait insupportable au serveur.
Bref, il n’y a pas vraiment de solutions de contournement, et nous allons faire une entorse à notre DDD en définissant cette collection comme valide pour les propriétés des objets métier.
1namespace App\Domain\Bookmark\Model;
2
3use App\Domain\Bookmark\ValueObject\Url;
4use DateTimeImmutable;
5use Doctrine\Common\Collections\Collection;
6
7class Bookmark
8{
9 …
10 @̶v̶a̶r̶ ̶a̶r̶r̶a̶y̶<̶T̶a̶g̶>̶
11 p̶u̶b̶l̶i̶c̶ ̶a̶r̶r̶a̶y̶ ̶$̶t̶a̶g̶s̶;̶
12 /** @var array<Tag>|Collection<int, Tag> */
13 public array|Collection $tags;
14 …
15}
On fait de même avec les propriétés App\Domain\Bookmark\Model\Tag::bookmarks
puis App\Domain\Bookmark\Model\User::bookmarks
. Puis on relance. Nouvelle erreur : A circular reference has been detected when serializing the object of class \"Proxies\\__CG__\\App\\Domain\\Bookmark\\Model\\User\" (configured limit: 1).
. La raison en est tout simple : il recherche un bookmark, qui appartient à un utilisateur, qui a plusieurs bookmark, dont chacun appartient à un utilisateur, qui a plusieurs … etc. etc. Vous voyez la boucle. Pour y remédier, nous allons en même temps améliorer l’api. En fait on aurait envie de pouvoir lui dire que champ doit être affiché en fonction du contexte. On va va définir cela au niveau de la sérialisation dont le boulot est de transformer un objet métier dans sa représentation en une chaîne de caractères. Dans notre cas, il s’agit de JSON. Définissons les propriétés accessibles depuis un « groupe » de sérialisation.
1## config/serializer/bookmark.yaml
2App\Domain\Bookmark\Model\Bookmark:
3 attributes:
4 title:
5 groups: ['bookmark:read']
6 url:
7 groups: ['bookmark:read']
8 description:
9 groups: ['bookmark:read']
10 user:
11 groups: ['bookmark:read']
12 tags:
13 groups: ['bookmark:read']
14
15
16
17## config/serializer/user.yaml
18App\Domain\Bookmark\Model\User:
19 attributes:
20 email:
21 groups: ['bookmark:read']
22
23
24
25App\Domain\Bookmark\Model\Tag:
26 attributes:
27 title:
28 groups: ['bookmark:read']
Puis on utilise ce groupe.
1App\Domain\Bookmark\Model\Bookmark:
2 normalizationContext:
3 groups: ['bookmark:read']
4 operations:
5 ApiPlatform\Metadata\GetCollection: ~
6 ApiPlatform\Metadata\Get: ~
Nous voilà équipés d’un outillage pour personnaliser très finement ce que nous voudrons faire sortir via l’Api.
Voyons le résultat lorsque nous faisons la requête sur notre point d’Api : http://0.0.0.0:9050/api/bookmarks?page=1
1{
2 "@context": "/api/contexts/Bookmark",
3 "@id": "/api/bookmarks",
4 "@type": "hydra:Collection",
5 "hydra:member": [
6 {
7 "@id": "/api/bookmarks/1",
8 "@type": "Bookmark",
9 "title": "CakePHP",
10 "url": [],
11 "description": "Build Fast, Grow Solid",
12 "user": {
13 "@type": "User",
14 "@id": "/api/.well-known/genid/e8eb4f8fc151ce96ea2e",
15 "email": "user@example.com"
16 },
17 "tags": [
18 {
19 "@type": "Tag",
20 "@id": "/api/.well-known/genid/adb9b627219a0b6f37c6",
21 "title": "development"
22 },
23 …
24 ],
25 },
26 …
27 ],
28 "hydra:totalItems": 5
29}
Si vous regardez bien, il y a un problème ici : l’url est donnée sous la forme d’un tableau vide. C’est dû au serializer qui n’a pas su comment transformer la value-object Url en représentation valide pour la transmettre. Nous allons donc lui expliquer comment la sérialiser.
1namespace App\Symfony\Serializer;
2
3use App\Domain\Bookmark\ValueObject\Url;
4use Exception;
5use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
6
7final class UrlSerializer implements NormalizerInterface
8{
9 /**
10 * {@inheritDoc}
11 */
12 public function normalize($object, $format = null, array $context = [])
13 {
14 if (!$object instanceof Url) {
15 throw new Exception('Invalid Type. Url Required.');
16 }
17
18 return $object->value;
19 }
20
21 /**
22 * {@inheritDoc}
23 */
24 public function supportsNormalization($data, $format = null): bool
25 {
26 return $data instanceof Url;
27 }
28}
Puis il nous faut déclarer cette classe au composant de serialisation de Symfony. Nous allons le faire de manière générique en lui demandant d’enregistrer toute classe qui implémente NormalizerInterface
.
1# config/services.yaml
2services:
3
4 _instanceof:
5 Symfony\Component\Serializer\Normalizer\NormalizerInterface:
6 tags: [ 'serializer.normalizer' ]
Il faudra en faire de même pour tous les value-object, mais on pourra avantagement créer une interface pour les value object qui sont basée sur une seule valeur, et utiliser le même normaliseur.
1{
2 "@context": "/api/contexts/Bookmark",
3 "@id": "/api/bookmarks",
4 "@type": "hydra:Collection",
5 "hydra:member": [
6 {
7 "@id": "/api/bookmarks/2",
8 "@type": "Bookmark",
9 "title": "Mozilla",
10 "url": "http://mozilla.org",
11 "description": "Internet for People, Not Profit",
12 "user": {
13 "@type": "User",
14 "@id": "/api/.well-known/genid/66f826e80d3bc5f817ed",
15 "email": "user@example.com"
16 },
17 "tags": [
18 {
19 "@type": "Tag",
20 "@id": "/api/.well-known/genid/3bd8be0d138e12f11261",
21 "title": "servo"
22 },
23 …
24 ]
25 },
26 …
27 ],
28 "hydra:totalItems": 5
29}
D’un point de vue DDD, nous avons réalisé cette action sans jamais descendre explicitement dans la couche Domaine, nous n’avons pas utiliser le point d’entrée GetBookmark, mais nous allons laisser Doctrine faire ce travail pour nous. On peut tout à fait voir cette démarche comme contraire au DDD, mais c’est un compromis que nous assumons.
La couche domaine étant indépendante, nous sommes en mesure de la faire utiliser par plusieurs application de nature très différentes comme cette API ou le fullstack de Cakephp.
Finalement, nous avons mis en place cette API sans grand effort ! Presque uniquement par de la configuration. Voilà un résultat tout à fait encourageant !
Le commit de ces changements : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/61d1063526dd389f2d9cb8c92780468dab1d821c
7.4. Un point d’API en écriture
Ajoutons mettons la possibilité de modifier un bookmark depuis l’API.
1# config/api_platform/bookmark/bookmark.yaml
2
3App\Domain\Bookmark\Model\Bookmark:
4 …
5 operations:
6 …
7 ApiPlatform\Metadata\Patch:
8 input: App\Application\UpdateBookmark\UpdateBookmarkInput
9 processor: ApiPlatform\Symfony\Messenger\Processor
Nous lui disons ici que nous ajoutons une action de PATCH, c’est-à-dire de mettre à jour certaines données du bookmark. Pour cela nous lui donnons un input, qu’Api-Platform va tâcher de construire sur la base des paramètres passées par la requête HTTP. Puis nous lui précisons le processeur, la classe qui sera en mesure traiter la demande.
Nous faisons le choix de passer par le système de message de Symfony. Ce système permet d'émettre des messages, sous la forme d'une classe php (ce sera notre input), qui est ensuite intercepté par une autre classe capable de les traiter (notre handler).
1docker-compose exec web composer require symfony/messenger
Il nous faut maintenant définir que nos handler sont en mesures de traiter des messages envoyés via le messenger. Pour le faire de manière gloable, nous allons passer par une interface.
1namespace App\Application\UpdateBookmark;
2
3use App\Application\Handler;
4…
5
6c̶l̶a̶s̶s̶ ̶U̶p̶d̶a̶t̶e̶B̶o̶o̶k̶m̶a̶r̶k̶H̶a̶n̶d̶l̶e̶r̶
7class UpdateBookmarkHandler implements Handler
8{
9 …
10}
L’interface est vide, elle nous sert juste a bien identifier nos affaires.
1namespace App\Application;
2
3interface Handler
4{
5}
Et maintenant, prévenons Symfony qu’il peut utiliser nos handler pour son messenger.
1services:
2 …
3 _instanceof:
4 …
5 App\Application\Handler:
6 tags: [ 'messenger.message_handler' ]
À présent, il nous faut implémenter côté infrastructure symfony l’outillage demandé par le domaine. Commençons par les repository.
1namespace App\Symfony\Repository;
2
3use App\Domain\Bookmark\Model\Bookmark;
4use App\Domain\Bookmark\Repository\BookmarkRepository as DomainBookmarkRepository;
5use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
6use Doctrine\Persistence\ManagerRegistry;
7
8class BookmarkRepostiory extends ServiceEntityRepository implements DomainBookmarkRepository
9{
10 public function __construct(ManagerRegistry $registry)
11 {
12 parent::__construct($registry, Bookmark::class);
13 }
14
15 public function findById(int $id): ?Bookmark
16 {
17 return $this->find($id);
18 }
19
20 public function persist(Bookmark $bookmark): void
21 {
22 $this->getEntityManager()->persist($bookmark);
23 $this->getEntityManager()->flush();
24 }
25}
26
27
28
29namespace App\Symfony\Repository;
30
31use App\Domain\Bookmark\Model\Tag;
32use App\Domain\Bookmark\Repository\TagRepository as DomainTagRepository;
33use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
34use Doctrine\Persistence\ManagerRegistry;
35
36class TagRepostiory extends ServiceEntityRepository implements DomainTagRepository
37{
38 public function __construct(ManagerRegistry $registry)
39 {
40 parent::__construct($registry, Tag::class);
41 }
42
43 public function findByTitle(string $title): ?Tag
44 {
45 return $this->findOneBy(['title' => $title]);
46 }
47}
Il va nous falloir implémenter l'interface pour retrouver l’utilisateur courant, mais puisque nous n’avons pas de système de sécurité côté symfony pour le moment, on va juste retourner null.
1namespace App\Symfony\Security;
2
3use App\Domain\Bookmark\Context\CurrentUserProvider as DomainCurrentUserProvider;
4use App\Domain\Bookmark\Model\User;
5
6class CurrentUserProvider implements DomainCurrentUserProvider
7{
8 public function getCurrentUser(): ?User
9 {
10 return null;
11 }
12}
Pour faire fonctionner notre modification, il va falloir autoriser que la modification soit faite par un anonyme.
1namespace App\Domain\Bookmark\Validator;
2
3class BookmarkUpdaterValidator
4{
5 public function validate(Bookmark $bookmark): array
6 {
7 …
8 i̶f̶ ̶(̶n̶u̶l̶l̶ ̶=̶=̶=̶ ̶$̶c̶u̶r̶r̶e̶n̶t̶U̶s̶e̶r̶ ̶|̶|̶ ̶$̶c̶u̶r̶r̶e̶n̶t̶U̶s̶e̶r̶-̶>̶i̶d̶ ̶!̶=̶=̶ ̶$̶b̶o̶o̶k̶m̶a̶r̶k̶-̶>̶u̶s̶e̶r̶-̶>̶i̶d̶)̶ ̶{̶
9 if (null !== $currentUser && $currentUser->id !== $bookmark->user->id) {
10 …
11 }
12}
À présent, précisons à Doctrine qu’un tage peut être créé si un nouveau tag associé à un bookmark est identifié.
1 <many-to-many field="tags" target-entity="Tag" inversed-by="bookmarks">
2 <cascade>
3 <cascade-persist />
4 </cascade>
5 …
6 </many-to-one>
C’est parti pour faire une nouvelle requête.
1curl -X 'PATCH' \
2 'http://0.0.0.0:9050/api/bookmarks/1' \
3 -H 'accept: application/json-ld' \
4 -H 'Content-Type: application/merge-patch+json' \
5 -d '{
6 "id": 1,
7 "title": "Nouveau nom",
8 "url": "http://example.com",
9 "description": "Juste du blabla",
10 "tagsTitle": [
11 "Nouveau tag"
12 ]
13}'
Et voici le contenu de la réponse en 200.
1{
2 "@context": "/api/contexts/Bookmark",
3 "@id": "/api/bookmarks/1",
4 "@type": "Bookmark",
5 "title": "Nouveau nom",
6 "url": "http://example.com",
7 "description": "Juste du blabla",
8 "user": {
9 "@type": "User",
10 "@id": "/api/.well-known/genid/a14f9cfdaf0001fcfb0a",
11 "email": "user@example.com"
12 },
13 "tags": [
14 {
15 "@type": "Tag",
16 "@id": "/api/.well-known/genid/8274830f19af4a9f57c6",
17 "title": "Nouveau tag"
18 }
19 ]
20}
Le patch fonctionne parfaitement :) Voici le commit : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/04dfcff3d279619c4cb216f59975b3cc826b0eb0
Renvoyer l’élément modifié ou juste une réponse 201 est un choix technique à faire. Les deux présentent leurs avantages et leurs inconvénients.
7.5. Donner de bons retours d’Api
Testons maintenant de transmettre cette même requête, mais avec une erreur.
1curl -X 'PATCH' \
2 'http://0.0.0.0:9050/api/bookmarks/1' \
3 -H 'accept: application/json-ld' \
4 -H 'Content-Type: application/merge-patch+json' \
5 -d '{
6 "id": 1,
7 "title": "Nouveau nom",
8 "url": "pas d’url ici",
9 "description": "Juste du blabla",
10 "tagsTitle": [
11 "Nouveau tag"
12 ]
13}'
Et voici le contenu de la réponse en 500.
1{
2 "@context":"\/api\/contexts\/Error",
3 "@type":"hydra:Error",
4 "hydra:title":"An error occurred",
5 "hydra:description":"Errors occured with your request",
6 …
7}
Voilà une erreur qui n’est pas très explicite. L'exécution a été stoppée par la levée de l’exception. Pour pouvoir rendre de meilleures erreurs, nous allons nous interdire de lever des exceptions depuis les handler, mais renvoyer un objet qui a collecté ces erreurs à la place.
1namespace App\Application\UpdateBookmark;
2
3u̶s̶e̶ ̶A̶p̶p̶\̶D̶o̶m̶a̶i̶n̶\̶B̶o̶o̶k̶m̶a̶r̶k̶\̶E̶x̶c̶e̶p̶t̶i̶o̶n̶\̶V̶i̶o̶l̶a̶t̶i̶o̶n̶C̶o̶l̶l̶e̶c̶t̶i̶o̶n̶E̶x̶c̶e̶p̶t̶i̶o̶n̶;̶
4use App\Domain\Bookmark\Violation\ViolationCollector;
5
6class UpdateBookmarkHandler implements Handler
7{
8 …
9 public function __invoke(
10 UpdateBookmarkInput $input
11 )̶:̶ ̶B̶o̶o̶k̶m̶a̶r̶k̶ ̶{̶
12 ): Bookmark|ViolationCollector {
13 …
14 if (count($errors)) {
15 t̶h̶r̶o̶w̶ ̶n̶e̶w̶ ̶V̶i̶o̶l̶a̶t̶i̶o̶n̶C̶o̶l̶l̶e̶c̶t̶i̶o̶n̶E̶x̶c̶e̶p̶t̶i̶o̶n̶(̶'̶E̶r̶r̶o̶r̶s̶ ̶o̶c̶c̶u̶r̶e̶d̶ ̶w̶i̶t̶h̶ ̶y̶o̶u̶r̶ ̶r̶e̶q̶u̶e̶s̶t̶'̶,̶ ̶$̶e̶r̶r̶o̶r̶s̶)̶;̶
16 return new ViolationCollector($errors);
17 }
18 …
19 }
20}
Avec un peu de recul, nous sommes en train de passer d’un tableau de string que nous nommons «erreur», à un objet collection qui va détenir une liste de violations, qui pourra détenir plus de valeur métier. Créons cette violation d’abord.
1namespace App\Domain\Bookmark\Violation;
2
3class Violation
4{
5 public function __construct(
6 public readonly string $message,
7 public readonly ?string $propertyPath = null,
8 ) {
9 }
10}
Puis le collecteur qui portera ces violations.
1namespace App\Domain\Bookmark\Violation;
2
3class ViolationCollector
4{
5 /**
6 * @var array
7 */
8 private array $violations = [];
9
10 public function collect(Violation $violation): void
11 {
12 $this->violations[] = $violation;
13 }
14
15 public function hasViolations(): bool
16 {
17 return count($this->violations) > 0;
18 }
19
20 /**
21 * @return Violation[]
22 */
23 public function getViolations(): array
24 {
25 return $this->violations;
26 }
27}
Nous pouvons maintenant modifier toutes nos création d’erreurs pour leur apporter bien plus de sens.
1namespace App\Application\UpdateBookmark;
2
3use App\Domain\Bookmark\Violation\Violation;
4use App\Domain\Bookmark\Violation\ViolationCollector;
5
6class UpdateBookmarkValidator
7{
8 public function __construct (
9 private readonly ViolationCollector $violationCollector,
10 ) {
11 }
12
13 public function validate(
14 UpdateBookmarkInput $input
15 )̶:̶ ̶a̶r̶r̶a̶y̶ ̶{̶
16 ): void {
17 $̶v̶i̶o̶l̶a̶t̶i̶o̶n̶s̶ ̶=̶ ̶[̶]̶;̶
18 if (mb_strlen($input->title) < 3) {
19 $̶v̶i̶o̶l̶a̶t̶i̶o̶n̶s̶[̶]̶ ̶=̶ ̶'̶T̶i̶t̶l̶e̶ ̶m̶u̶s̶t̶ ̶b̶e̶ ̶a̶t̶ ̶l̶a̶s̶t̶ ̶3̶ ̶c̶h̶a̶r̶ ̶l̶o̶n̶g̶.̶'̶;̶
20 $this->violationCollector->collect(new Violation('Title must be at last 3 char long.', 'title'));
21 }
22 if (mb_strlen($input->title) > 1024) {
23 $̶v̶i̶o̶l̶a̶t̶i̶o̶n̶s̶[̶]̶ ̶=̶ ̶'̶T̶i̶t̶l̶e̶ ̶c̶a̶n̶n̶o̶t̶ ̶b̶e̶ ̶m̶o̶r̶e̶ ̶t̶h̶a̶n̶ ̶1̶0̶2̶4̶ ̶c̶h̶a̶r̶ ̶l̶o̶n̶g̶.̶'̶;̶
24 $this->violationCollector->collect(new Violation('Title cannot be more than 1024 char long.', 'title'));
25 }
26
27 r̶e̶t̶u̶r̶n̶ ̶$̶v̶i̶o̶l̶a̶t̶i̶o̶n̶s̶;̶
28 }
29}
Puis on fait de même pour les autres validateurs. Toutes les violations se trouvent ainsi rassemblées en un seul endroit. On peut mettre à jour notre handler.
1namespace App\Application\UpdateBookmark;
2
3…
4use App\Domain\Bookmark\Violation\Violation;
5use App\Domain\Bookmark\Violation\ViolationCollector;
6
7class UpdateBookmarkHandler implements Handler
8{
9 public function __construct(
10 …
11 private readonly ViolationCollector $violationCollector,
12 ) {
13 }
14
15 public function __invoke(
16 UpdateBookmarkInput $input,
17 ): Bookmark|ViolationCollector {
18 $̶e̶r̶r̶o̶r̶s̶ ̶=̶ ̶$̶t̶h̶i̶s̶-̶>̶i̶n̶p̶u̶t̶V̶a̶l̶i̶d̶a̶t̶o̶r̶-̶>̶v̶a̶l̶i̶d̶a̶t̶e̶(̶$̶i̶n̶p̶u̶t̶)̶;̶
19 $this->inputValidator->validate($input);
20 $bookmark = $this->bookmarkRepository->findById($input->id);
21 if (!$bookmark) {
22 $̶e̶r̶r̶o̶r̶s̶[̶]̶ ̶=̶ ̶'̶B̶o̶o̶k̶m̶a̶r̶k̶ ̶d̶o̶e̶s̶ ̶n̶o̶t̶ ̶e̶x̶i̶s̶t̶s̶.̶'̶;̶
23 $this->violationCollector->collect(new Violation('Bookmark does not exists.'));
24 } else {
25 $̶e̶r̶r̶o̶r̶s̶ ̶=̶ ̶a̶r̶r̶a̶y̶_̶m̶e̶r̶g̶e̶(̶$̶e̶r̶r̶o̶r̶s̶,̶ ̶$̶t̶h̶i̶s̶-̶>̶u̶p̶d̶a̶t̶e̶V̶a̶l̶i̶d̶a̶t̶o̶r̶-̶>̶v̶a̶l̶i̶d̶a̶t̶e̶(̶$̶b̶o̶o̶k̶m̶a̶r̶k̶)̶)̶;̶
26 $this->updateValidator->validate($bookmark);
27 }
28 …
29 i̶f̶ ̶(̶c̶o̶u̶n̶t̶(̶$̶e̶r̶r̶o̶r̶s̶)̶)̶ ̶{̶
30 if ($this->violationCollector->hasViolations()) {
31 t̶h̶r̶o̶w̶ ̶n̶e̶w̶ ̶V̶i̶o̶l̶a̶t̶i̶o̶n̶C̶o̶l̶l̶e̶c̶t̶i̶o̶n̶E̶x̶c̶e̶p̶t̶i̶o̶n̶(̶'̶E̶r̶r̶o̶r̶s̶ ̶o̶c̶c̶u̶r̶e̶d̶ ̶w̶i̶t̶h̶ ̶y̶o̶u̶r̶ ̶r̶e̶q̶u̶e̶s̶t̶'̶,̶ ̶$̶e̶r̶r̶o̶r̶s̶)̶;̶
32 return $this->violationCollector;
33 }
34 …
35 }
36}
Quand tout est ok, on relance un test de modification en curl. Et on obtient un tableau vide ! Cela vient du fait que le serializer n’a pas su comment donner une version de sortie de la classe ViolationCollector. Nous allons donc lui expliquer.
1namespace App\Symfony\Serializer;
2
3use ApiPlatform\Symfony\Validator\Exception\ValidationException;
4use App\Domain\Bookmark\Violation\Violation;
5use App\Domain\Bookmark\Violation\ViolationCollector;
6use LogicException;
7use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
8use Symfony\Component\Validator\ConstraintViolation;
9use Symfony\Component\Validator\ConstraintViolationInterface;
10use Symfony\Component\Validator\ConstraintViolationList;
11
12final class ViolationCollectorSerializer implements NormalizerInterface
13{
14 /**
15 * {@inheritDoc}
16 */
17 public function normalize($object, $format = null, array $context = [])
18 {
19 if (!$object instanceof ViolationCollector) {
20 throw new LogicException();
21 }
22
23 if (!$object->hasViolations()) {
24 throw new \LogicException('ViolationCollection cannot be empty');
25 }
26
27 $constraintViolations = array_map(static function (Violation $violation): ConstraintViolationInterface {
28 return new ConstraintViolation(
29 message: $violation->message,
30 messageTemplate: $violation->message,
31 parameters: [],
32 root: null,
33 propertyPath: $violation->propertyPath,
34 invalidValue: null,
35 code: md5($violation->propertyPath.$violation->message),
36 );
37 }, $object->getViolations());
38
39 throw new ValidationException(new ConstraintViolationList($constraintViolations));
40 }
41
42 /**
43 * {@inheritDoc}
44 */
45 public function supportsNormalization($data, $format = null): bool
46 {
47 return $data instanceof ViolationCollector;
48 }
49}
Ici on utilise la capacité d’Api-Platform a récupérer les erreurs des contraintes de Symfony, pour en faire un affichage correct en sortie. Nous transformons donc nos violations en contraintes Symfony. Petit test.
1curl -X 'PATCH' \
2 'http://0.0.0.0:9050/api/bookmarks/1' \
3 -H 'accept: application/json-ld' \
4 -H 'Content-Type: application/merge-patch+json' \
5 -d '{
6 "id": 1,
7 "title": "Nouveau nom",
8 "url": "http://example.com",
9 "description": "Juste du blabla",
10 "tagsTitle": [
11 "Nouveau tag"
12 ]
13}'
Et voici le contenu de la réponse en 400.
1{
2 "@context": "/api/contexts/ConstraintViolationList",
3 "@type": "ConstraintViolationList",
4 "hydra:title": "An error occurred",
5 "hydra:description": "url: Invalid URL",
6 "violations": [
7 {
8 "propertyPath": "url",
9 "message": "Invalid URL",
10 "code": "e6bd3d3d1bd3b70daf08e5bed1b6bad5"
11 }
12 ]
13}
Voilà une réponse tout à fait exploitable, même pour une application frontend ! Malgré sa cure de rafraîchissement, en regardant à nouveau le Handler, je dois avouer qu’il fait trop de tâches. Je ne peux résister de mettre en place une factory pour créer les value-object.
1namespace App\Domain\Bookmark\ValueObject;
2
3use App\Domain\Bookmark\Violation\Violation;
4use App\Domain\Bookmark\Violation\ViolationCollector;
5use Exception;
6
7class ValueObjectFactory
8{
9 public function __construct(
10 private readonly ViolationCollector $violationCollector,
11 ) {
12 }
13
14 public function makeFromString(string $class, string $value, string $propertyPath): ?ValueObjectBasedOnString
15 {
16 if (!class_exists($class)) {
17 throw new Exception(sprintf('Cannot create value object %s', $class));
18 }
19 $interfaces = class_implements($class) ?: [];
20 if (!in_array(ValueObjectBasedOnString::class, $interfaces)) {
21 throw new Exception(sprintf('Cannot create value object %s', $class));
22 }
23
24 try {
25 return $class::fromString($value);
26 } catch (InvalidValueException $exception) {
27 $this->violationCollector->collect(new Violation($exception->getMessage(), $propertyPath));
28 }
29
30 return null;
31 }
32}
Et on modifie le handler pour y faire appel.
1namespace App\Application\UpdateBookmark;
2
3use App\Domain\Bookmark\ValueObject\ValueObjectFactory;
4
5class UpdateBookmarkHandler implements Handler
6{
7 public function __construct(
8 …
9 private readonly ValueObjectFactory $valueObjectFactory,
10 ) {
11 }
12
13 public function __invoke(
14 UpdateBookmarkInput $input,
15 ): Bookmark|ViolationCollector {
16 …
17 t̶r̶y̶ ̶{̶
18 $̶u̶r̶l̶ ̶=̶ ̶U̶r̶l̶:̶:̶f̶r̶o̶m̶S̶t̶r̶i̶n̶g̶(̶$̶i̶n̶p̶u̶t̶-̶>̶u̶r̶l̶)̶;̶
19 }̶ ̶c̶a̶t̶c̶h̶ ̶(̶I̶n̶v̶a̶l̶i̶d̶V̶a̶l̶u̶e̶E̶x̶c̶e̶p̶t̶i̶o̶n̶ ̶$̶e̶x̶c̶e̶p̶t̶i̶o̶n̶)̶ ̶{̶
20 $̶u̶r̶l̶ ̶=̶ ̶n̶u̶l̶l̶;̶
21 $̶t̶h̶i̶s̶-̶>̶v̶i̶o̶l̶a̶t̶i̶o̶n̶C̶o̶l̶l̶e̶c̶t̶o̶r̶-̶>̶c̶o̶l̶l̶e̶c̶t̶(̶n̶e̶w̶ ̶V̶i̶o̶l̶a̶t̶i̶o̶n̶(̶$̶e̶x̶c̶e̶p̶t̶i̶o̶n̶-̶>̶g̶e̶t̶M̶e̶s̶s̶a̶g̶e̶(̶)̶,̶ ̶'̶u̶r̶l̶'̶)̶)̶;̶
22 }̶
23 $url = $this->valueObjectFactory->makeFromString(Url::class, $input->url, 'url');
24 …
25 }
26}
Notre value object implémente maintenant cette simple interface.
1namespace App\Domain\Bookmark\ValueObject;
2
3interface ValueObjectBasedOnString
4{
5 public static function fromString(string $value): self;
6}
Il nous faut aussi mettre à jour le container de services côté Cakephp pour injecter ces nouvelles dépendances.
1namespace App\Controller\Component;
2
3class ContainerComponent extends Component
4{
5 public function __construct(ComponentRegistry $registry, array $config = [])
6 {
7 …
8 $this->container[ViolationCollector::class] = new ViolationCollector();
9 $this->container[ValueObjectFactory::class] = new ValueObjectFactory($this->container[ViolationCollector::class]);
10 $̶t̶h̶i̶s̶-̶>̶c̶o̶n̶t̶a̶i̶n̶e̶r̶[̶U̶p̶d̶a̶t̶e̶B̶o̶o̶k̶m̶a̶r̶k̶V̶a̶l̶i̶d̶a̶t̶o̶r̶:̶:̶c̶l̶a̶s̶s̶]̶ ̶=̶ ̶n̶e̶w̶ ̶U̶p̶d̶a̶t̶e̶B̶o̶o̶k̶m̶a̶r̶k̶V̶a̶l̶i̶d̶a̶t̶o̶r̶(̶$̶t̶h̶i̶s̶-̶>̶c̶o̶n̶t̶a̶i̶n̶e̶r̶[̶V̶i̶o̶l̶a̶t̶i̶o̶n̶C̶o̶l̶l̶e̶c̶t̶o̶r̶:̶:̶c̶l̶a̶s̶s̶]̶)̶;̶
11 $this->container[UpdateBookmarkValidator::class] = new UpdateBookmarkValidator();
12 $̶t̶h̶i̶s̶-̶>̶c̶o̶n̶t̶a̶i̶n̶e̶r̶[̶B̶o̶o̶k̶m̶a̶r̶k̶U̶p̶d̶a̶t̶e̶r̶V̶a̶l̶i̶d̶a̶t̶o̶r̶:̶:̶c̶l̶a̶s̶s̶]̶ ̶=̶ ̶n̶e̶w̶ ̶B̶o̶o̶k̶m̶a̶r̶k̶U̶p̶d̶a̶t̶e̶r̶V̶a̶l̶i̶d̶a̶t̶o̶r̶(̶$̶t̶h̶i̶s̶-̶>̶C̶u̶r̶r̶e̶n̶t̶U̶s̶e̶r̶P̶r̶o̶v̶i̶d̶e̶r̶)̶;̶
13 $this->container[BookmarkUpdaterValidator::class] = new BookmarkUpdaterValidator($this->CurrentUserProvider, $this->container[ViolationCollector::class]);
14 $this->container[UpdateBookmarkHandler::class] = new UpdateBookmarkHandler(
15 …
16 $this->container[ViolationCollector::class],
17 $this->container[ValueObjectFactory::class],
18 );
19 }
20 …
21}
Au final, nous voici un handler beaucoup plus propre et compréhensible, avec une première partie sur la validation comprenant la création des value objects et une deuxième sur la mise à jour à proprement parler.
En voici le commit avec des modifications précises : https://github.com/vibby/cakephp-bookmarker-tutorial/commit/11ba291131b4dbc1c23c9e526ce5ed83011805bc
Je pourrais encore trouver de nombreux axes d’améliorations, mais je crois que nous allons nous arrêter ici :)
J’espère que vous avez apprécié le voyage !
Le mot de la fin
Que de chemin parcouru ! Notre application est maintenant opérationnelle et bien découpée en terme de responsabilités. Clairement, cette approche est un compromis entre le respect des principes du DDD et le pargmatisme d’un projet concret préexistant. Elle permet de limiter au maximum les dépendances entre les briqus logiciels, qui est un gage de meilleure tenue dans le temps par une maintenabilité grandement améliorée.
Cependant, un découpage plus important apporte également nécessairement plus de code et certains pourait se sentir perdus dans cette organisation. Autre point négatif : la mise en place d’un tel paradigme nécessite l’implication de tous les acteurs du projet, y compris la partie « métier ». Mais le gain est énorme pour une application qui a pour objectif de vivre longtemps. D’ailleurs, depuis mon experience, toute l’équipe a une meilleure confiance dans le projet dans ce type d’architecture.
Chez Troopers, nous avons travaillés sur 2 projets importants avec ce type de conversion. Si vous découvrez le DDD, je vous propose de commencer par le bouquin d’Eric Evans à l’origine de toute l’aventure : Domain-Driven Design: Tackling Complexity in the Heart of Software. https://www.chasse-aux-livres.fr/prix/0321125215/domain-driven-design-eric-evans
Pour aller plus loin sur le sujet, je vous conseille l’excellent article d’Alex So Yes : https://alexsoyes.com/ddd-domain-driven-design/#utiliser-ddd-dans-son-projet-tout-de-suite Vous avez un héritage logiciel ? Vous aimeriez en valoriser la partie métier, et moderniser son infrastructure ? Assurément cette méthode est faite pour vous, en séparant l’un de l’autre !