From 805230361eca3b3d04f2217dcd022384950d9647 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Fri, 3 May 2024 14:01:45 +0200 Subject: [PATCH 1/6] Practice 1: Publish and receive my first message --- .env.dist | 7 + composer.json | 1 + composer.lock | 242 ++++++++++++++++++++++++++++++++++- config/bundles.php | 1 + config/packages/mercure.yaml | 8 ++ docker-compose.yml | 12 ++ symfony.lock | 12 ++ 7 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 config/packages/mercure.yaml diff --git a/.env.dist b/.env.dist index eeafa62..dccee71 100644 --- a/.env.dist +++ b/.env.dist @@ -7,3 +7,10 @@ MYSQL_VERSION=8.0.27 MYSQL_DATABASE=databasorus MYSQL_USER=user MYSQL_PASSWORD=password + +MERCURE_URL=http://mercure/.well-known/mercure +MERCURE_PUBLIC_URL=http://localhost:81/.well-known/mercure +MERCURE_SERVER_NAME=:80 +MERCURE_CORS_ALLOWED_ORIGINS="http://localhost" +MERCURE_PUBLISHER_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! +MERCURE_SUBSCRIBER_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! diff --git a/composer.json b/composer.json index f760f5c..94a5088 100644 --- a/composer.json +++ b/composer.json @@ -22,6 +22,7 @@ "symfony/http-client": "^7.0", "symfony/intl": "^7.0", "symfony/mailer": "^7.0", + "symfony/mercure-bundle": "^0.3.8", "symfony/mime": "^7.0", "symfony/monolog-bundle": "^3.10", "symfony/notifier": "^7.0", diff --git a/composer.lock b/composer.lock index a3c72ad..44cc58d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "e1d68fda857d3a6a99a9c9d4e0c1c4dd", + "content-hash": "c8a29d7d9743605bbbbbff21d5cf0908", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1505,6 +1505,79 @@ ], "time": "2023-10-18T10:00:55+00:00" }, + { + "name": "lcobucci/jwt", + "version": "5.3.0", + "source": { + "type": "git", + "url": "https://github.com/lcobucci/jwt.git", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/lcobucci/jwt/zipball/08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "reference": "08071d8d2c7f4b00222cc4b1fb6aa46990a80f83", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "ext-sodium": "*", + "php": "~8.1.0 || ~8.2.0 || ~8.3.0", + "psr/clock": "^1.0" + }, + "require-dev": { + "infection/infection": "^0.27.0", + "lcobucci/clock": "^3.0", + "lcobucci/coding-standard": "^11.0", + "phpbench/phpbench": "^1.2.9", + "phpstan/extension-installer": "^1.2", + "phpstan/phpstan": "^1.10.7", + "phpstan/phpstan-deprecation-rules": "^1.1.3", + "phpstan/phpstan-phpunit": "^1.3.10", + "phpstan/phpstan-strict-rules": "^1.5.0", + "phpunit/phpunit": "^10.2.6" + }, + "suggest": { + "lcobucci/clock": ">= 3.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Lcobucci\\JWT\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Luís Cobucci", + "email": "lcobucci@gmail.com", + "role": "Developer" + } + ], + "description": "A simple library to work with JSON Web Token and JSON Web Signature", + "keywords": [ + "JWS", + "jwt" + ], + "support": { + "issues": "https://github.com/lcobucci/jwt/issues", + "source": "https://github.com/lcobucci/jwt/tree/5.3.0" + }, + "funding": [ + { + "url": "https://github.com/lcobucci", + "type": "github" + }, + { + "url": "https://www.patreon.com/lcobucci", + "type": "patreon" + } + ], + "time": "2024-04-11T23:07:54+00:00" + }, { "name": "monolog/monolog", "version": "3.6.0", @@ -4193,6 +4266,173 @@ ], "time": "2024-03-28T09:20:36+00:00" }, + { + "name": "symfony/mercure", + "version": "v0.6.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure.git", + "reference": "304cf84609ef645d63adc65fc6250292909a461b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure/zipball/304cf84609ef645d63adc65fc6250292909a461b", + "reference": "304cf84609ef645d63adc65fc6250292909a461b", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "symfony/deprecation-contracts": "^2.0|^3.0|^4.0", + "symfony/http-client": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-foundation": "^4.4|^5.0|^6.0|^7.0", + "symfony/polyfill-php80": "^1.22", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "symfony/event-dispatcher": "^4.4|^5.0|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/phpunit-bridge": "^5.2|^6.0|^7.0", + "symfony/stopwatch": "^4.4|^5.0|^6.0|^7.0", + "twig/twig": "^2.0|^3.0|^4.0" + }, + "suggest": { + "symfony/stopwatch": "Integration with the profiler performances" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "0.6.x-dev" + }, + "thanks": { + "name": "dunglas/mercure", + "url": "https://github.com/dunglas/mercure" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Component\\Mercure\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Mercure Component", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure/issues", + "source": "https://github.com/symfony/mercure/tree/v0.6.5" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure", + "type": "tidelift" + } + ], + "time": "2024-04-08T12:51:34+00:00" + }, + { + "name": "symfony/mercure-bundle", + "version": "v0.3.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/mercure-bundle.git", + "reference": "e21ad84694b84c9a3c94bedf4edd82b66728abfc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mercure-bundle/zipball/e21ad84694b84c9a3c94bedf4edd82b66728abfc", + "reference": "e21ad84694b84c9a3c94bedf4edd82b66728abfc", + "shasum": "" + }, + "require": { + "lcobucci/jwt": "^3.4|^4.0|^5.0", + "php": ">=7.1.3", + "symfony/config": "^4.4|^5.0|^6.0|^7.0", + "symfony/dependency-injection": "^4.4|^5.4|^6.0|^7.0", + "symfony/http-kernel": "^4.4|^5.0|^6.0|^7.0", + "symfony/mercure": "^0.6.1", + "symfony/web-link": "^4.4|^5.0|^6.0|^7.0" + }, + "require-dev": { + "symfony/phpunit-bridge": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/stopwatch": "^4.3.7|^5.0|^6.0|^7.0", + "symfony/ux-turbo": "*", + "symfony/var-dumper": "^4.3.7|^5.0|^6.0|^7.0" + }, + "suggest": { + "symfony/messenger": "To use the Messenger integration" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-main": "0.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Bundle\\MercureBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kévin Dunglas", + "email": "dunglas@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony MercureBundle", + "homepage": "https://symfony.com", + "keywords": [ + "mercure", + "push", + "sse", + "updates" + ], + "support": { + "issues": "https://github.com/symfony/mercure-bundle/issues", + "source": "https://github.com/symfony/mercure-bundle/tree/v0.3.8" + }, + "funding": [ + { + "url": "https://github.com/dunglas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/mercure-bundle", + "type": "tidelift" + } + ], + "time": "2023-12-03T22:26:24+00:00" + }, { "name": "symfony/mime", "version": "v7.0.6", diff --git a/config/bundles.php b/config/bundles.php index 3106eee..74d202e 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -12,4 +12,5 @@ Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], ]; diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml new file mode 100644 index 0000000..859f91f --- /dev/null +++ b/config/packages/mercure.yaml @@ -0,0 +1,8 @@ +mercure: + hubs: + default: + url: '%env(MERCURE_URL)%' + public_url: '%env(MERCURE_PUBLIC_URL)%' + jwt: + secret: '%env(MERCURE_PUBLISHER_JWT_KEY)%' + publish: '*' diff --git a/docker-compose.yml b/docker-compose.yml index 6f9f8ba..a15ed93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,18 @@ services: volumes: - db-data:/var/lib/mysql:rw restart: unless-stopped + mercure: + image: dunglas/mercure + restart: unless-stopped + command: /usr/bin/caddy run --config /etc/caddy/Caddyfile.dev + environment: + SERVER_NAME: ${MERCURE_SERVER_NAME} + MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_PUBLISHER_JWT_KEY} + MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_SUBSCRIBER_JWT_KEY} + MERCURE_EXTRA_DIRECTIVES: | + cors_origin ${MERCURE_CORS_ALLOW_ORIGIN} + ports: + - 81:80 volumes: db-data: ~ diff --git a/symfony.lock b/symfony.lock index e355d56..49abc32 100644 --- a/symfony.lock +++ b/symfony.lock @@ -353,6 +353,18 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/mercure-bundle": { + "version": "0.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "0.3", + "ref": "d097c114aae82c5bc88d48ac164fe523f1003292" + }, + "files": [ + "config/packages/mercure.yaml" + ] + }, "symfony/mime": { "version": "v5.3.8" }, From 26b52b0492d63c0f033f4d9af9c169062251b4e6 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Fri, 3 May 2024 15:25:13 +0200 Subject: [PATCH 2/6] Practice 2: basic usages --- public/js/dinosaurs.js | 79 +++++++++++++++++++++++++ src/Controller/DinosaursController.php | 63 +++++++++++++++++--- templates/_dinosaur-list-item.html.twig | 16 +++++ templates/dinosaurs-list.html.twig | 40 ++++++------- 4 files changed, 167 insertions(+), 31 deletions(-) create mode 100644 public/js/dinosaurs.js create mode 100644 templates/_dinosaur-list-item.html.twig diff --git a/public/js/dinosaurs.js b/public/js/dinosaurs.js new file mode 100644 index 0000000..32f5b52 --- /dev/null +++ b/public/js/dinosaurs.js @@ -0,0 +1,79 @@ +const alertContainer = document.querySelector('#alert-container') +const template = document.querySelector('#dinosaur-item-template') +const dinosaurList = document.querySelector('#dinosaurs-list') + +const hub = new URL('http://localhost:81/.well-known/mercure') +hub.searchParams.append('topic', 'http://localhost/dinosaurs') + +const es = new EventSource(hub); + +const displayToast = (message) => { + alertContainer.innerHTML = `
${message}
` + + window.setTimeout(() => { + const alert = document.querySelector('.alert') + alert.parentNode.removeChild(alert) + }, 5000) +} + +const addDinosaur = (id, name, link, message) => { + const clone = template.content.cloneNode(true) + const dinosaurTemplateNameContainer = clone.querySelector('.dinosaur-name') + const dinosaurTemplateLinkContainer = clone.querySelector('.dinosaur-link') + + dinosaurTemplateNameContainer.innerHTML = name + dinosaurTemplateLinkContainer.href = link + dinosaurTemplateLinkContainer['data-id'] = id + + dinosaurList.append(clone) + + displayToast(message) +} + +const updateDinosaur = (id, name, message) => { + const dinosaur = document.querySelector(`[data-id='${id}']`) + + if(dinosaur) { + dinosaur.querySelector('.dinosaur-name').innerHTML = name + + displayToast(message) + } +} + +const removeDinosaur = (id) => { + const dinosaur = document.querySelector(`[data-id='${id}']`) + + if(dinosaur) { + dinosaur.parentNode.removeChild(dinosaur) + + displayToast(message) + } +} + +es.onmessage = e => { + const message = JSON.parse(e.data) + + switch(message.type) { + case 'created': + addDinosaur( + message.id, + message.name, + message.link, + message.message + ) + break + case 'updated': + updateDinosaur( + message.id, + message.name, + message.message + ) + break + case 'deleted': + removeDinosaur( + message.id, + message.message + ) + break + } +} diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php index 4ced07c..a1216fa 100644 --- a/src/Controller/DinosaursController.php +++ b/src/Controller/DinosaursController.php @@ -10,12 +10,16 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; +use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\Mercure\Update; final class DinosaursController extends AbstractController { #[Route('/dinosaurs', name: 'app_list_dinosaurs')] - public function list(Request $request, ManagerRegistry $doctrine): Response - { + public function list( + Request $request, + ManagerRegistry $doctrine + ): Response { $q = null; $form = $this->createForm(SearchType::class); @@ -58,8 +62,11 @@ public function single(string $id, ManagerRegistry $doctrine): Response } #[Route('/dinosaurs/create', name: 'app_create_dinosaur')] - public function create(Request $request, ManagerRegistry $doctrine): Response - { + public function create( + Request $request, + ManagerRegistry $doctrine, + HubInterface $hub + ): Response { $form = $this->createForm(DinosaurType::class); $form->handleRequest($request); @@ -73,6 +80,17 @@ public function create(Request $request, ManagerRegistry $doctrine): Response $this->addFlash('success', 'The dinosaur has been created!'); + $hub->publish(new Update( + 'http://localhost/dinosaurs', + json_encode([ + 'type' => 'created', + 'id' => $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $this->generateUrl('app_single_dinosaur', ['id' => $dinosaur->getId()]), + 'message' => "The dinosaur {$dinosaur->getName()} has been created !" + ]) + )); + return $this->redirectToRoute('app_list_dinosaurs'); } @@ -86,12 +104,18 @@ public function create(Request $request, ManagerRegistry $doctrine): Response name: 'app_edit_dinosaur', requirements: ['id' => '\d+'] )] - public function edit(Request $request, int $id, ManagerRegistry $doctrine): Response - { + public function edit( + Request $request, + int $id, + ManagerRegistry $doctrine, + HubInterface $hub + ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) ->find($id); + $oldName = $dinosaur->getName(); + if (false === $dinosaur) { throw $this->createNotFoundException('The dinosaur you are looking for does not exists.'); } @@ -108,6 +132,17 @@ public function edit(Request $request, int $id, ManagerRegistry $doctrine): Resp $this->addFlash('success', 'The dinosaur has been edited!'); + $hub->publish(new Update( + 'http://localhost/dinosaurs', + json_encode([ + 'type' => 'updated', + 'id' => $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $this->generateUrl('app_single_dinosaur', ['id' => $dinosaur->getId()]), + 'message' => "The dinosaur {$oldName} has been edited !" + ]), + )); + return $this->redirectToRoute('app_list_dinosaurs'); } @@ -121,8 +156,11 @@ public function edit(Request $request, int $id, ManagerRegistry $doctrine): Resp name: 'app_remove_dinosaur', requirements: ['id' => '\d+'] )] - public function remove(int $id, ManagerRegistry $doctrine): Response - { + public function remove( + int $id, + ManagerRegistry $doctrine, + HubInterface $hub + ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) ->find($id); @@ -137,6 +175,15 @@ public function remove(int $id, ManagerRegistry $doctrine): Response $this->addFlash('success', 'The dinosaur has been removed!'); + $hub->publish(new Update( + 'http://localhost/dinosaurs', + json_encode([ + 'type' => 'deleted', + 'id' => $id, + 'message' => "The dinosaur {$dinosaur->getName()} has been removed !" + ]) + )); + return $this->redirectToRoute('app_list_dinosaurs'); } } diff --git a/templates/_dinosaur-list-item.html.twig b/templates/_dinosaur-list-item.html.twig new file mode 100644 index 0000000..04eea41 --- /dev/null +++ b/templates/_dinosaur-list-item.html.twig @@ -0,0 +1,16 @@ + + twbs +
+ {{ dinosaur|default(null) is not same as null ? dinosaur.name : '' }} +
+
diff --git a/templates/dinosaurs-list.html.twig b/templates/dinosaurs-list.html.twig index e5588f9..3da7009 100644 --- a/templates/dinosaurs-list.html.twig +++ b/templates/dinosaurs-list.html.twig @@ -3,33 +3,27 @@ {% block title %}Dinosaurs{% endblock %} {% block stylesheets %} -{{ parent() }} - + {{ parent() }} + {% endblock %} {% block javascripts %} - + + {% endblock %} {% block body %} -
-
- {% for dinosaur in dinosaurs %} - - twbs -
- {{ dinosaur.name }} -
-
- {% endfor %} -
-
+
+
+ +
+
+ {% for dinosaur in dinosaurs %} + {{ include('_dinosaur-list-item.html.twig', {dinosaur: dinosaur}) }} + {% endfor %} +
+
+ {% endblock %} From 9b4f68d730f9bce66159bdf5bb59998059c04f95 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Mon, 6 May 2024 12:03:25 +0200 Subject: [PATCH 3/6] Practice 2: basic usages (improvements) --- public/js/dinosaurs.js | 54 ++++++++++++------------ src/Controller/DinosaursController.php | 52 +++++++++-------------- src/Realtime/Trigger.php | 26 ++++++++++++ src/Realtime/Trigger/DinosaurCreated.php | 27 ++++++++++++ src/Realtime/Trigger/DinosaurDeleted.php | 23 ++++++++++ src/Realtime/Trigger/DinosaurUpdated.php | 28 ++++++++++++ src/Service/Realtime/Publisher.php | 26 ++++++++++++ 7 files changed, 179 insertions(+), 57 deletions(-) create mode 100644 src/Realtime/Trigger.php create mode 100644 src/Realtime/Trigger/DinosaurCreated.php create mode 100644 src/Realtime/Trigger/DinosaurDeleted.php create mode 100644 src/Realtime/Trigger/DinosaurUpdated.php create mode 100644 src/Service/Realtime/Publisher.php diff --git a/public/js/dinosaurs.js b/public/js/dinosaurs.js index 32f5b52..68138be 100644 --- a/public/js/dinosaurs.js +++ b/public/js/dinosaurs.js @@ -40,7 +40,7 @@ const updateDinosaur = (id, name, message) => { } } -const removeDinosaur = (id) => { +const removeDinosaur = (id, message) => { const dinosaur = document.querySelector(`[data-id='${id}']`) if(dinosaur) { @@ -50,30 +50,32 @@ const removeDinosaur = (id) => { } } -es.onmessage = e => { +es.addEventListener('created', e => { const message = JSON.parse(e.data) - switch(message.type) { - case 'created': - addDinosaur( - message.id, - message.name, - message.link, - message.message - ) - break - case 'updated': - updateDinosaur( - message.id, - message.name, - message.message - ) - break - case 'deleted': - removeDinosaur( - message.id, - message.message - ) - break - } -} + addDinosaur( + message.id, + message.name, + message.link, + message.message + ) +}) + +es.addEventListener('updated', e => { + const message = JSON.parse(e.data) + + updateDinosaur( + message.id, + message.name, + message.message + ) +}) + +es.addEventListener('deleted', e => { + const message = JSON.parse(e.data) + + removeDinosaur( + message.id, + message.message + ) +}) diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php index a1216fa..7966659 100644 --- a/src/Controller/DinosaursController.php +++ b/src/Controller/DinosaursController.php @@ -5,13 +5,15 @@ use App\Entity\Dinosaur; use App\Form\Type\DinosaurType; use App\Form\Type\SearchType; +use App\Service\Realtime\Publisher; +use App\Realtime\Trigger\DinosaurCreated; +use App\Realtime\Trigger\DinosaurDeleted; +use App\Realtime\Trigger\DinosaurUpdated; use Doctrine\Persistence\ManagerRegistry; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Annotation\Route; -use Symfony\Component\Mercure\HubInterface; -use Symfony\Component\Mercure\Update; final class DinosaursController extends AbstractController { @@ -65,7 +67,7 @@ public function single(string $id, ManagerRegistry $doctrine): Response public function create( Request $request, ManagerRegistry $doctrine, - HubInterface $hub + Publisher $publisher ): Response { $form = $this->createForm(DinosaurType::class); @@ -80,15 +82,12 @@ public function create( $this->addFlash('success', 'The dinosaur has been created!'); - $hub->publish(new Update( - 'http://localhost/dinosaurs', - json_encode([ - 'type' => 'created', - 'id' => $dinosaur->getId(), - 'name' => $dinosaur->getName(), - 'link' => $this->generateUrl('app_single_dinosaur', ['id' => $dinosaur->getId()]), - 'message' => "The dinosaur {$dinosaur->getName()} has been created !" - ]) + $publisher->publish(new DinosaurCreated( + $dinosaur, + $this->generateUrl( + 'app_single_dinosaur', + ['id' => $dinosaur->getId()] + ) )); return $this->redirectToRoute('app_list_dinosaurs'); @@ -108,7 +107,7 @@ public function edit( Request $request, int $id, ManagerRegistry $doctrine, - HubInterface $hub + Publisher $publisher ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) @@ -132,15 +131,13 @@ public function edit( $this->addFlash('success', 'The dinosaur has been edited!'); - $hub->publish(new Update( - 'http://localhost/dinosaurs', - json_encode([ - 'type' => 'updated', - 'id' => $dinosaur->getId(), - 'name' => $dinosaur->getName(), - 'link' => $this->generateUrl('app_single_dinosaur', ['id' => $dinosaur->getId()]), - 'message' => "The dinosaur {$oldName} has been edited !" - ]), + $publisher->publish(new DinosaurUpdated( + $dinosaur, + $oldName, + $this->generateUrl( + 'app_single_dinosaur', + ['id' => $dinosaur->getId()] + ) )); return $this->redirectToRoute('app_list_dinosaurs'); @@ -159,7 +156,7 @@ public function edit( public function remove( int $id, ManagerRegistry $doctrine, - HubInterface $hub + Publisher $publisher ): Response { $dinosaur = $doctrine ->getRepository(Dinosaur::class) @@ -175,14 +172,7 @@ public function remove( $this->addFlash('success', 'The dinosaur has been removed!'); - $hub->publish(new Update( - 'http://localhost/dinosaurs', - json_encode([ - 'type' => 'deleted', - 'id' => $id, - 'message' => "The dinosaur {$dinosaur->getName()} has been removed !" - ]) - )); + $publisher->publish(new DinosaurDeleted($id, $dinosaur)); return $this->redirectToRoute('app_list_dinosaurs'); } diff --git a/src/Realtime/Trigger.php b/src/Realtime/Trigger.php new file mode 100644 index 0000000..235e460 --- /dev/null +++ b/src/Realtime/Trigger.php @@ -0,0 +1,26 @@ + $topics + * @param array $data + */ + public function __construct( + public string $type, + public array $topics, + private array $data + ) { + } + + public function jsonSerialize(): array + { + return $this->data; + } +} diff --git a/src/Realtime/Trigger/DinosaurCreated.php b/src/Realtime/Trigger/DinosaurCreated.php new file mode 100644 index 0000000..dbc7f08 --- /dev/null +++ b/src/Realtime/Trigger/DinosaurCreated.php @@ -0,0 +1,27 @@ + $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $link, + 'message' => "The dinosaur {$dinosaur->getName()} has been created !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/DinosaurDeleted.php b/src/Realtime/Trigger/DinosaurDeleted.php new file mode 100644 index 0000000..90f8e0a --- /dev/null +++ b/src/Realtime/Trigger/DinosaurDeleted.php @@ -0,0 +1,23 @@ + $id, + 'message' => "The dinosaur {$dinosaur->getName()} has been removed !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/DinosaurUpdated.php b/src/Realtime/Trigger/DinosaurUpdated.php new file mode 100644 index 0000000..433be32 --- /dev/null +++ b/src/Realtime/Trigger/DinosaurUpdated.php @@ -0,0 +1,28 @@ + $dinosaur->getId(), + 'name' => $dinosaur->getName(), + 'link' => $link, + 'message' => "The dinosaur {$oldName} has been edited !" + ] + ); + } +} diff --git a/src/Service/Realtime/Publisher.php b/src/Service/Realtime/Publisher.php new file mode 100644 index 0000000..2cc27ef --- /dev/null +++ b/src/Service/Realtime/Publisher.php @@ -0,0 +1,26 @@ +hub->publish(new Update( + $trigger->topics, + json_encode($trigger), + type: $trigger->type + )); + } +} From 7184f98e7e873e053a6d3f1959de69594fd05064 Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Mon, 6 May 2024 15:43:51 +0200 Subject: [PATCH 4/6] Practice 3: discovery --- .env.dist | 3 +- config/packages/mercure.yaml | 2 +- config/services.yaml | 4 ++ docker-compose.yml | 6 +- public/js/dinosaurs.js | 80 +++++++++++++---------- src/Controller/DinosaursController.php | 3 +- src/Listener/MercureDiscoveryListener.php | 24 +++++++ 7 files changed, 82 insertions(+), 40 deletions(-) create mode 100644 src/Listener/MercureDiscoveryListener.php diff --git a/.env.dist b/.env.dist index dccee71..07a293b 100644 --- a/.env.dist +++ b/.env.dist @@ -12,5 +12,4 @@ MERCURE_URL=http://mercure/.well-known/mercure MERCURE_PUBLIC_URL=http://localhost:81/.well-known/mercure MERCURE_SERVER_NAME=:80 MERCURE_CORS_ALLOWED_ORIGINS="http://localhost" -MERCURE_PUBLISHER_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! -MERCURE_SUBSCRIBER_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! +MERCURE_JWT_KEY=!ChangeThisMercureHubJWTSecretKey! diff --git a/config/packages/mercure.yaml b/config/packages/mercure.yaml index 859f91f..4a27845 100644 --- a/config/packages/mercure.yaml +++ b/config/packages/mercure.yaml @@ -4,5 +4,5 @@ mercure: url: '%env(MERCURE_URL)%' public_url: '%env(MERCURE_PUBLIC_URL)%' jwt: - secret: '%env(MERCURE_PUBLISHER_JWT_KEY)%' + secret: '%env(MERCURE_JWT_KEY)%' publish: '*' diff --git a/config/services.yaml b/config/services.yaml index 1d3246d..c2965de 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -24,5 +24,9 @@ services: - '../src/Entity/' - '../src/Kernel.php' + App\Listener\MercureDiscoveryListener: + tags: + - { name: kernel.event_listener, event: ResponseEvent, method: onKernelResponse } + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/docker-compose.yml b/docker-compose.yml index a15ed93..3721932 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,10 +47,10 @@ services: command: /usr/bin/caddy run --config /etc/caddy/Caddyfile.dev environment: SERVER_NAME: ${MERCURE_SERVER_NAME} - MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_PUBLISHER_JWT_KEY} - MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_SUBSCRIBER_JWT_KEY} + MERCURE_PUBLISHER_JWT_KEY: ${MERCURE_JWT_KEY} + MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_KEY} MERCURE_EXTRA_DIRECTIVES: | - cors_origin ${MERCURE_CORS_ALLOW_ORIGIN} + cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS} ports: - 81:80 diff --git a/public/js/dinosaurs.js b/public/js/dinosaurs.js index 68138be..caf5440 100644 --- a/public/js/dinosaurs.js +++ b/public/js/dinosaurs.js @@ -2,10 +2,16 @@ const alertContainer = document.querySelector('#alert-container') const template = document.querySelector('#dinosaur-item-template') const dinosaurList = document.querySelector('#dinosaurs-list') -const hub = new URL('http://localhost:81/.well-known/mercure') -hub.searchParams.append('topic', 'http://localhost/dinosaurs') - -const es = new EventSource(hub); +const dicoverMercureHub = async () => fetch(window.location) + .then(response => { + const hubUrl = response + .headers + .get('Link') + .match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; + + return new URL(hubUrl); + } +) const displayToast = (message) => { alertContainer.innerHTML = `
${message}
` @@ -50,32 +56,40 @@ const removeDinosaur = (id, message) => { } } -es.addEventListener('created', e => { - const message = JSON.parse(e.data) - - addDinosaur( - message.id, - message.name, - message.link, - message.message - ) -}) - -es.addEventListener('updated', e => { - const message = JSON.parse(e.data) - - updateDinosaur( - message.id, - message.name, - message.message - ) -}) - -es.addEventListener('deleted', e => { - const message = JSON.parse(e.data) - - removeDinosaur( - message.id, - message.message - ) -}) +document.addEventListener('DOMContentLoaded', async () => { + const hubUrl = await dicoverMercureHub(); + + hubUrl.searchParams.append('topic', 'http://localhost/dinosaurs') + + const es = new EventSource(hubUrl); + + es.addEventListener('created', e => { + const message = JSON.parse(e.data) + + addDinosaur( + message.id, + message.name, + message.link, + message.message + ) + }) + + es.addEventListener('updated', e => { + const message = JSON.parse(e.data) + + updateDinosaur( + message.id, + message.name, + message.message + ) + }) + + es.addEventListener('deleted', e => { + const message = JSON.parse(e.data) + + removeDinosaur( + message.id, + message.message + ) + }) +}); diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php index 7966659..1a97af3 100644 --- a/src/Controller/DinosaursController.php +++ b/src/Controller/DinosaursController.php @@ -13,6 +13,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Mercure\Discovery; use Symfony\Component\Routing\Annotation\Route; final class DinosaursController extends AbstractController @@ -20,7 +21,7 @@ final class DinosaursController extends AbstractController #[Route('/dinosaurs', name: 'app_list_dinosaurs')] public function list( Request $request, - ManagerRegistry $doctrine + ManagerRegistry $doctrine, ): Response { $q = null; $form = $this->createForm(SearchType::class); diff --git a/src/Listener/MercureDiscoveryListener.php b/src/Listener/MercureDiscoveryListener.php new file mode 100644 index 0000000..d31efd2 --- /dev/null +++ b/src/Listener/MercureDiscoveryListener.php @@ -0,0 +1,24 @@ +getRequest(); + + $this->discovery->addLink($request); + } +} From b565614c7f97d9c97f8f022cd065428a2812c4ca Mon Sep 17 00:00:00 2001 From: AntoineGonzalez <45924026+AntoineGonzalez@users.noreply.github.com> Date: Tue, 7 May 2024 13:51:56 +0200 Subject: [PATCH 5/6] Practice 4: Authorization with Cookie --- config/services.yaml | 9 +++ public/js/activity.js | 63 +++++++++++++++++++ public/js/dinosaurs.js | 2 +- src/Controller/ActivityController.php | 18 ++++++ src/Controller/DinosaursController.php | 1 - src/Listener/AuthenticationListener.php | 36 +++++++++++ src/Listener/MercureAuthorizationListener.php | 41 ++++++++++++ src/Realtime/Trigger.php | 5 +- src/Realtime/Trigger/DinosaurCreated.php | 2 +- src/Realtime/Trigger/DinosaurDeleted.php | 2 +- src/Realtime/Trigger/DinosaurUpdated.php | 2 +- src/Realtime/Trigger/UserLoggedIn.php | 22 +++++++ src/Realtime/Trigger/UserLoggetOut.php | 22 +++++++ src/Service/Realtime/Publisher.php | 3 +- templates/activity.html.twig | 18 ++++++ templates/base.html.twig | 28 ++++++--- 16 files changed, 256 insertions(+), 18 deletions(-) create mode 100644 public/js/activity.js create mode 100644 src/Controller/ActivityController.php create mode 100644 src/Listener/AuthenticationListener.php create mode 100644 src/Listener/MercureAuthorizationListener.php create mode 100644 src/Realtime/Trigger/UserLoggedIn.php create mode 100644 src/Realtime/Trigger/UserLoggetOut.php create mode 100644 templates/activity.html.twig diff --git a/config/services.yaml b/config/services.yaml index c2965de..35c5c1c 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -28,5 +28,14 @@ services: tags: - { name: kernel.event_listener, event: ResponseEvent, method: onKernelResponse } + App\Listener\MercureAuthorizationListener: + tags: + - { name: kernel.event_listener, event: ResponseEvent, method: onKernelResponse } + + App\Listener\AuthenticationListener: + tags: + - { name: kernel.event_listener, event: loginSuccessEvent, method: onLoginSuccess } + - { name: kernel.event_listener, event: loginFailureEvent, method: onLoginFailure } + # add more service definitions when explicit configuration is needed # please note that last definitions always *replace* previous ones diff --git a/public/js/activity.js b/public/js/activity.js new file mode 100644 index 0000000..ea22d89 --- /dev/null +++ b/public/js/activity.js @@ -0,0 +1,63 @@ +const activityContainer = document.querySelector('#activity-container') + + +const dicoverMercureHub = async () => fetch(window.location) + .then(response => { + const hubUrl = response + .headers + .get('Link') + .match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1]; + + return new URL(hubUrl); + } +) + +const addLogItem = (message) => { + const item = document.createElement('li') + + item.setAttribute('class', 'alert alert-primary') + item.setAttribute('role', 'alert') + + item.innerHTML = message + + activityContainer.append(item) +} + +document.addEventListener('DOMContentLoaded', async () => { + const hubUrl = await dicoverMercureHub(); + + hubUrl.searchParams.append('topic', 'http://localhost/activity') + hubUrl.searchParams.append('topic', 'http://localhost/dinosaurs') + + const es = new EventSource(hubUrl, { withCredentials: true }); + + es.addEventListener('login', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('logout', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('created', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('updated', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) + + es.addEventListener('deleted', e => { + const message = JSON.parse(e.data) + + addLogItem(message.message) + }) +}); diff --git a/public/js/dinosaurs.js b/public/js/dinosaurs.js index caf5440..007ffe3 100644 --- a/public/js/dinosaurs.js +++ b/public/js/dinosaurs.js @@ -61,7 +61,7 @@ document.addEventListener('DOMContentLoaded', async () => { hubUrl.searchParams.append('topic', 'http://localhost/dinosaurs') - const es = new EventSource(hubUrl); + const es = new EventSource(hubUrl, { withCredentials: true }); es.addEventListener('created', e => { const message = JSON.parse(e.data) diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php new file mode 100644 index 0000000..d8e143d --- /dev/null +++ b/src/Controller/ActivityController.php @@ -0,0 +1,18 @@ +render('activity.html.twig'); + } +} diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php index 1a97af3..85ffc62 100644 --- a/src/Controller/DinosaursController.php +++ b/src/Controller/DinosaursController.php @@ -13,7 +13,6 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\Mercure\Discovery; use Symfony\Component\Routing\Annotation\Route; final class DinosaursController extends AbstractController diff --git a/src/Listener/AuthenticationListener.php b/src/Listener/AuthenticationListener.php new file mode 100644 index 0000000..331a3cc --- /dev/null +++ b/src/Listener/AuthenticationListener.php @@ -0,0 +1,36 @@ +getAuthenticatedToken()->getUserIdentifier(); + + $this->publisher->publish(new UserLoggetIn($identifier)); + } + + #[AsEventListener(event: LogoutEvent::class)] + public function onLogout(LogoutEvent $event): void + { + $identifier = $event->getToken()->getUserIdentifier(); + + $this->publisher->publish(new UserLoggetOut($identifier)); + } +} diff --git a/src/Listener/MercureAuthorizationListener.php b/src/Listener/MercureAuthorizationListener.php new file mode 100644 index 0000000..4ecb4e9 --- /dev/null +++ b/src/Listener/MercureAuthorizationListener.php @@ -0,0 +1,41 @@ +security->isGranted('ROLE_USER')) { + $topics[] = 'http://localhost/dinosaurs'; + } + + if ($this->security->isGranted('ROLE_ADMIN')) { + $topics[] = 'http://localhost/activity'; + } + + if (empty($topics)) { + return; + } + + $request = $event->getRequest(); + + $this->authorization->setCookie($request, $topics); + } +} diff --git a/src/Realtime/Trigger.php b/src/Realtime/Trigger.php index 235e460..32d9392 100644 --- a/src/Realtime/Trigger.php +++ b/src/Realtime/Trigger.php @@ -6,7 +6,7 @@ use JsonSerializable; -abstract class Trigger implements JsonSerializable +abstract readonly class Trigger implements JsonSerializable { /** * @param array $topics @@ -15,7 +15,8 @@ abstract class Trigger implements JsonSerializable public function __construct( public string $type, public array $topics, - private array $data + private array $data, + public bool $isPrivate = true ) { } diff --git a/src/Realtime/Trigger/DinosaurCreated.php b/src/Realtime/Trigger/DinosaurCreated.php index dbc7f08..59a54d1 100644 --- a/src/Realtime/Trigger/DinosaurCreated.php +++ b/src/Realtime/Trigger/DinosaurCreated.php @@ -7,7 +7,7 @@ use App\Entity\Dinosaur; use App\Realtime\Trigger; -class DinosaurCreated extends Trigger +final readonly class DinosaurCreated extends Trigger { public function __construct( Dinosaur $dinosaur, diff --git a/src/Realtime/Trigger/DinosaurDeleted.php b/src/Realtime/Trigger/DinosaurDeleted.php index 90f8e0a..21b0e99 100644 --- a/src/Realtime/Trigger/DinosaurDeleted.php +++ b/src/Realtime/Trigger/DinosaurDeleted.php @@ -7,7 +7,7 @@ use App\Entity\Dinosaur; use App\Realtime\Trigger; -class DinosaurDeleted extends Trigger +final readonly class DinosaurDeleted extends Trigger { public function __construct($id, Dinosaur $dinosaur) { diff --git a/src/Realtime/Trigger/DinosaurUpdated.php b/src/Realtime/Trigger/DinosaurUpdated.php index 433be32..33b5e8a 100644 --- a/src/Realtime/Trigger/DinosaurUpdated.php +++ b/src/Realtime/Trigger/DinosaurUpdated.php @@ -7,7 +7,7 @@ use App\Entity\Dinosaur; use App\Realtime\Trigger; -class DinosaurUpdated extends Trigger +final readonly class DinosaurUpdated extends Trigger { public function __construct( Dinosaur $dinosaur, diff --git a/src/Realtime/Trigger/UserLoggedIn.php b/src/Realtime/Trigger/UserLoggedIn.php new file mode 100644 index 0000000..2ce06bc --- /dev/null +++ b/src/Realtime/Trigger/UserLoggedIn.php @@ -0,0 +1,22 @@ + $userIdentifier, + 'message' => "The user {$userIdentifier} has been logged in !" + ] + ); + } +} diff --git a/src/Realtime/Trigger/UserLoggetOut.php b/src/Realtime/Trigger/UserLoggetOut.php new file mode 100644 index 0000000..f7b5950 --- /dev/null +++ b/src/Realtime/Trigger/UserLoggetOut.php @@ -0,0 +1,22 @@ + $userIdentifier, + 'message' => "The user {$userIdentifier} has been logged out !" + ] + ); + } +} diff --git a/src/Service/Realtime/Publisher.php b/src/Service/Realtime/Publisher.php index 2cc27ef..97756af 100644 --- a/src/Service/Realtime/Publisher.php +++ b/src/Service/Realtime/Publisher.php @@ -20,7 +20,8 @@ public function publish(Trigger $trigger): void $this->hub->publish(new Update( $trigger->topics, json_encode($trigger), - type: $trigger->type + type: $trigger->type, + private: $trigger->isPrivate )); } } diff --git a/templates/activity.html.twig b/templates/activity.html.twig new file mode 100644 index 0000000..af75758 --- /dev/null +++ b/templates/activity.html.twig @@ -0,0 +1,18 @@ +{% extends 'base.html.twig' %} + +{% block title %}Activity panel{% endblock %} + +{% block javascripts %} + + +{% endblock %} + +{% block body %} +
+

Activity Panel

+ +
    + {# Here will go the realtime activity messages #} +
+
+{% endblock %} diff --git a/templates/base.html.twig b/templates/base.html.twig index 1fabcb1..a8d5bbc 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -36,8 +36,8 @@ {% if 'ROLE_ADMIN' in app.user.roles %} {% endif %} {% endif %} + {% else %}