diff --git a/.env.dist b/.env.dist
index eeafa62..07a293b 100644
--- a/.env.dist
+++ b/.env.dist
@@ -7,3 +7,9 @@ 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_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..4a27845
--- /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_JWT_KEY)%'
+ publish: '*'
diff --git a/config/services.yaml b/config/services.yaml
index 1d3246d..35c5c1c 100644
--- a/config/services.yaml
+++ b/config/services.yaml
@@ -24,5 +24,18 @@ services:
- '../src/Entity/'
- '../src/Kernel.php'
+ App\Listener\MercureDiscoveryListener:
+ 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/docker-compose.yml b/docker-compose.yml
index 6f9f8ba..3721932 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_JWT_KEY}
+ MERCURE_SUBSCRIBER_JWT_KEY: ${MERCURE_JWT_KEY}
+ MERCURE_EXTRA_DIRECTIVES: |
+ cors_origins ${MERCURE_CORS_ALLOWED_ORIGINS}
+ ports:
+ - 81:80
volumes:
db-data: ~
diff --git a/public/js/activity.js b/public/js/activity.js
new file mode 100644
index 0000000..d973042
--- /dev/null
+++ b/public/js/activity.js
@@ -0,0 +1,68 @@
+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 {
+ url: new URL(hubUrl),
+ token: response.headers.get('X-Mercure-JWT'),
+ }
+ }
+)
+
+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 { url, token } = await dicoverMercureHub()
+
+ url.searchParams.append('topic', 'http://localhost/activity')
+ url.searchParams.append('topic', 'http://localhost/dinosaurs')
+
+ const options = token ? { headers: { Authorization: `Bearer ${token}` }, withCredentials: true } : {}
+
+ const es = new EventSourcePolyfill(url, options)
+
+ 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
new file mode 100644
index 0000000..c1929d1
--- /dev/null
+++ b/public/js/dinosaurs.js
@@ -0,0 +1,100 @@
+const alertContainer = document.querySelector('#alert-container')
+const template = document.querySelector('#dinosaur-item-template')
+const dinosaurList = document.querySelector('#dinosaurs-list')
+
+const dicoverMercureHub = async () => fetch(window.location)
+ .then(response => {
+ const hubUrl = response
+ .headers
+ .get('Link')
+ .match(/<([^>]+)>;\s+rel=(?:mercure|"[^"]*mercure[^"]*")/)[1];
+
+ return {
+ url: new URL(hubUrl),
+ token: response.headers.get('X-Mercure-JWT'),
+ }
+ }
+)
+
+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, message) => {
+ const dinosaur = document.querySelector(`[data-id='${id}']`)
+
+ if(dinosaur) {
+ dinosaur.parentNode.removeChild(dinosaur)
+
+ displayToast(message)
+ }
+}
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const { url, token } = await dicoverMercureHub();
+
+ url.searchParams.append('topic', 'http://localhost/dinosaurs')
+
+ const options = token ? { headers: { Authorization: `Bearer ${token}` }, withCredentials: true } : {}
+
+ const es = new EventSourcePolyfill(url, options);
+
+ 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/public/js/eventsource.min.js b/public/js/eventsource.min.js
new file mode 100644
index 0000000..689c7cd
--- /dev/null
+++ b/public/js/eventsource.min.js
@@ -0,0 +1,6 @@
+/** @license
+ * eventsource.js
+ * Available under MIT License (MIT)
+ * https://github.com/Yaffle/EventSource/
+ */
+!function(e){"use strict";var r,H=e.setTimeout,N=e.clearTimeout,j=e.XMLHttpRequest,o=e.XDomainRequest,t=e.ActiveXObject,n=e.EventSource,i=e.document,w=e.Promise,d=e.fetch,a=e.Response,h=e.TextDecoder,s=e.TextEncoder,p=e.AbortController;function c(){this.bitsNeeded=0,this.codePoint=0}"undefined"==typeof window||void 0===i||"readyState"in i||null!=i.body||(i.readyState="loading",window.addEventListener("load",function(e){i.readyState="complete"},!1)),null==j&&null!=t&&(j=function(){return new t("Microsoft.XMLHTTP")}),null==Object.create&&(Object.create=function(e){function t(){}return t.prototype=e,new t}),Date.now||(Date.now=function(){return(new Date).getTime()}),null==p&&(r=d,d=function(e,t){var n=t.signal;return r(e,{headers:t.headers,credentials:t.credentials,cache:t.cache}).then(function(e){var t=e.body.getReader();return n._reader=t,n._aborted&&n._reader.cancel(),{status:e.status,statusText:e.statusText,headers:e.headers,body:{getReader:function(){return t}}}})},p=function(){this.signal={_reader:null,_aborted:!1},this.abort=function(){null!=this.signal._reader&&this.signal._reader.cancel(),this.signal._aborted=!0}}),c.prototype.decode=function(e){function t(e,t,n){if(1===n)return 128>>t<=e&&e<>t<=e&&e<>t<=e&&e<>t<=e&&e<>6?3:31>10)))+String.fromCharCode(56320+(i-65535-1&1023)))}return this.bitsNeeded=o,this.codePoint=i,r};function u(){}null!=h&&null!=s&&function(){try{return"test"===(new h).decode((new s).encode("test"),{stream:!0})}catch(e){}return!1}()||(h=c);function I(e){this.withCredentials=!1,this.readyState=0,this.status=0,this.statusText="",this.responseText="",this.onprogress=u,this.onload=u,this.onerror=u,this.onreadystatechange=u,this._contentType="",this._xhr=e,this._sendTimeout=0,this._abort=u}function l(e){return e.replace(/[A-Z]/g,function(e){return String.fromCharCode(e.charCodeAt(0)+32)})}function f(e){for(var t=Object.create(null),n=e.split("\r\n"),r=0;rrender('activity.html.twig');
+ }
+}
diff --git a/src/Controller/DinosaursController.php b/src/Controller/DinosaursController.php
index 4ced07c..85ffc62 100644
--- a/src/Controller/DinosaursController.php
+++ b/src/Controller/DinosaursController.php
@@ -5,6 +5,10 @@
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;
@@ -14,8 +18,10 @@
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 +64,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,
+ Publisher $publisher
+ ): Response {
$form = $this->createForm(DinosaurType::class);
$form->handleRequest($request);
@@ -73,6 +82,14 @@ public function create(Request $request, ManagerRegistry $doctrine): Response
$this->addFlash('success', 'The dinosaur has been created!');
+ $publisher->publish(new DinosaurCreated(
+ $dinosaur,
+ $this->generateUrl(
+ 'app_single_dinosaur',
+ ['id' => $dinosaur->getId()]
+ )
+ ));
+
return $this->redirectToRoute('app_list_dinosaurs');
}
@@ -86,12 +103,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,
+ Publisher $publisher
+ ): 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 +131,15 @@ public function edit(Request $request, int $id, ManagerRegistry $doctrine): Resp
$this->addFlash('success', 'The dinosaur has been edited!');
+ $publisher->publish(new DinosaurUpdated(
+ $dinosaur,
+ $oldName,
+ $this->generateUrl(
+ 'app_single_dinosaur',
+ ['id' => $dinosaur->getId()]
+ )
+ ));
+
return $this->redirectToRoute('app_list_dinosaurs');
}
@@ -121,8 +153,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,
+ Publisher $publisher
+ ): Response {
$dinosaur = $doctrine
->getRepository(Dinosaur::class)
->find($id);
@@ -137,6 +172,8 @@ public function remove(int $id, ManagerRegistry $doctrine): Response
$this->addFlash('success', 'The dinosaur has been removed!');
+ $publisher->publish(new DinosaurDeleted($id, $dinosaur));
+
return $this->redirectToRoute('app_list_dinosaurs');
}
}
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..a6f32c6
--- /dev/null
+++ b/src/Listener/MercureAuthorizationListener.php
@@ -0,0 +1,50 @@
+security->isGranted('ROLE_USER')) {
+ $topics[] = 'http://localhost/dinosaurs';
+ }
+
+ if ($this->security->isGranted('ROLE_ADMIN')) {
+ $topics[] = 'http://localhost/activity';
+ }
+
+ if (empty($topics)) {
+ return;
+ }
+
+ $response = $event->getResponse();
+
+ // Generate the JWT token
+ $JWTfactory = $this->hubInterface->getFactory();
+
+ if (null === $JWTfactory) {
+ throw new \RuntimeException('The hub factory is not available.');
+ }
+
+ $token = $JWTfactory->create($topics);
+
+ $response->headers->set('X-Mercure-JWT', $token);
+ }
+}
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);
+ }
+}
diff --git a/src/Realtime/Trigger.php b/src/Realtime/Trigger.php
new file mode 100644
index 0000000..32d9392
--- /dev/null
+++ b/src/Realtime/Trigger.php
@@ -0,0 +1,27 @@
+ $topics
+ * @param array $data
+ */
+ public function __construct(
+ public string $type,
+ public array $topics,
+ private array $data,
+ public bool $isPrivate = true
+ ) {
+ }
+
+ 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..59a54d1
--- /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..21b0e99
--- /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..33b5e8a
--- /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/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
new file mode 100644
index 0000000..97756af
--- /dev/null
+++ b/src/Service/Realtime/Publisher.php
@@ -0,0 +1,27 @@
+hub->publish(new Update(
+ $trigger->topics,
+ json_encode($trigger),
+ type: $trigger->type,
+ private: $trigger->isPrivate
+ ));
+ }
+}
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"
},
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 @@
+
+
+
+ {{ dinosaur|default(null) is not same as null ? dinosaur.name : '' }}
+
+
diff --git a/templates/activity.html.twig b/templates/activity.html.twig
new file mode 100644
index 0000000..49f59cc
--- /dev/null
+++ b/templates/activity.html.twig
@@ -0,0 +1,19 @@
+{% 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 %}
-
Create a dinosaur
@@ -45,7 +45,7 @@
{% endif %}
-
{% if 'ROLE_ADMIN' in app.user.roles %}
-
Create a species
@@ -64,8 +64,16 @@
{% endif %}
-
+ Activity
+
+
+
+
Logout
@@ -73,7 +81,7 @@
{% else %}
-
-
Login
diff --git a/templates/dinosaurs-list.html.twig b/templates/dinosaurs-list.html.twig
index e5588f9..de8812f 100644
--- a/templates/dinosaurs-list.html.twig
+++ b/templates/dinosaurs-list.html.twig
@@ -3,33 +3,28 @@
{% block title %}Dinosaurs{% endblock %}
{% block stylesheets %}
-{{ parent() }}
-
+ {{ parent() }}
+
{% endblock %}
{% block javascripts %}
-
+
+
+
{% endblock %}
{% block body %}
-
-
-
+
+
+
+
+
+ {% for dinosaur in dinosaurs %}
+ {{ include('_dinosaur-list-item.html.twig', {dinosaur: dinosaur}) }}
+ {% endfor %}
+
+
+
+ {{ include('_dinosaur-list-item.html.twig') }}
+
{% endblock %}