Commit 7cd30e1d authored by Franck Tempia-Bonda's avatar Franck Tempia-Bonda
Browse files

add a mercure listening daemon command

add a mercure publish command
parent 95cebbb5
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=47c1c61f8fa736b5ff703be53edf9070
###< symfony/framework-bundle ###
###> symfony/mercure-bundle ###
# See https://symfony.com/doc/current/mercure.html#configuration
# The URL of the Mercure hub, used by the app to publish updates (can be a local URL)
MERCURE_URL=http://mercure/.well-known/mercure
# The public URL of the Mercure hub, used by the browser to connect
MERCURE_PUBLIC_URL=http://127.0.0.1:8001/.well-known/mercure
# The secret used to sign the JWTs
MERCURE_JWT_PUBLISHER="!ChangeMe!"
MERCURE_JWT_SUBSCRIBER="!ChangeMe!"
###< symfony/mercure-bundle ###
TARGET_REPOSITORY=hal
\ No newline at end of file
.idea
###> symfony/framework-bundle ###
/.env.local
......
docker_exec_php = docker compose exec php
docker_exec_php_console = docker compose exec php bin/console
up: ## Start containers
docker compose up
ssh: ## Open the php docker container terminal
$(docker_exec_php) /bin/sh
help:
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
.DEFAULT_GOAL := help
\ No newline at end of file
# Mercure listener demo project
This project is based on Symfony 5.3 and PHP 8.
It's a proof of concept and can be a good start to implements :
- subscribing to your repository events (the relations) submitted to a mercure server,
- pushing your own events to a mercure server.
You are free to reuse or modify this project.
# Project files
- `config/packages/mercure.yaml` : configuration for the mercure bundle
- `src/Models` : contains the publication models
- `src/Command` : contains both publish/listen commands
# Run the project
## Prerequisite
Docker have to be available on your system.
You can connect to eh php container with `make ssh`
## Configuration
### Use project provided server
The `.env` file contain configuration for the mercure server provided in [docker-compose.yml](./docker-compose.yml)
### Use Nakala mercure dev server
If you want to use the Nakala mercure dev server, create a `.env.local` file containing following configuration data :
```
MERCURE_URL=http://127.0.0.1:82/.well-known/mercure
MERCURE_PUBLIC_URL=http://127.0.0.1:82/.well-known/mercure
MERCURE_JWT_PUBLISHER="!PublisherKey!"
MERCURE_JWT_SUBSCRIBER="!SubscriberKey!"
```
### Configuration data details
- `MERCURE_URL` : the internal url of mercure server (different from public if you use a docker container)
- `MERCURE_PUBLIC_URL` : the external url of mercure server
- `MERCURE_JWT_PUBLISHER` : the secret passphrase key to be able to publish to mercure server
- `MERCURE_JWT_SUBSCRIBER` : the secret passphrase key to be able to subscribe to mercure server
- `TARGET_REPOSITORY` : the target repository you want to publish or subscribe for
## Listen to published events
Command is here : [DaemonRunCommand.php](src/Command/DaemonRunCommand.php)
Start container
```
docker compose up
# via make
make up
```
Install dependencies
```
# in your host
docker compose exec php composer install
# in docker
composer install
```
Launch daemon
```
# in your host
docker compose exec php php bin/console daemon:run
# in docker
php bin/console daemon:run
```
Reach mercure dev UI [http://localhost:8001/.well-known/mercure/ui/](http://localhost:8001/.well-known/mercure/ui/).
In the publish part, topics field, enter `hal`
In the data field, enter :
```json
{
"topic": "hal",
"iri": "http:\/\/nakala.local\/10.34847\/nkl.28227db0",
"relations": [
{
"iri": "hal-03110771v1",
"relation": "IsReferencedBy",
"action": "remove",
"comment": "A comment about the relation",
"repository": "hal"
},
{
"iri": "hal-03442576v1",
"relation": "HasPart",
"action": "remove",
"comment": null,
"repository": "hal"
}
]
}
```
Dameon must have responded to your publication by displaying :
```
[OK] Nakala data http://nakala.local/10.34847/nkl.28227db0 remove relation IsReferencedBy to hal-03110771v1 with
comment : "A "right" comment about the relation"
[OK] Nakala data http://nakala.local/10.34847/nkl.28227db0 remove relation HasPart to hal-03442576v1 with comment : ""
```
## Publish events
Command is here : [PublishCommand.php](src/Command/PublishCommand.php)
Publish an event from
```
# from docker
php bin/console app:publish
```
Dameon must have responded to your publication by displaying :
```
[OK] Nakala data http://nakala.local/10.34847/nkl.28227db0 add relation IsReferencedBy to hal-03110771v1 with comment :
"A comment about the relation"
[OK] Nakala data http://nakala.local/10.34847/nkl.28227db0 remove relation Cites to hal-03442576v1 with comment : ""
```
## Publication models
You can find the publication models here :
- [MercurePublication](src/Model/MercurePublication.php)
- [MercureDataRelation](src/Model/MercureDataRelation.php)
......@@ -4,18 +4,17 @@
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"php": ">=7.2.5",
"php": ">=8.0",
"ext-ctype": "*",
"ext-iconv": "*",
"symfony/console": "5.3.*",
"symfony/dotenv": "5.3.*",
"symfony/flex": "^1.3.1",
"symfony/framework-bundle": "5.3.*",
"symfony/mercure-bundle": "^0.3.3",
"symfony/runtime": "5.3.*",
"symfony/yaml": "5.3.*"
},
"require-dev": {
},
"config": {
"optimize-autoloader": true,
"preferred-install": {
......
This diff is collapsed.
......@@ -2,4 +2,5 @@
return [
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true],
];
......@@ -3,14 +3,8 @@ framework:
secret: '%env(APP_SECRET)%'
#csrf_protection: true
http_method_override: false
# Enables session support. Note that the session will ONLY be started if you read or write from it.
# Remove or comment this section to explicitly disable session support.
session:
handler_id: null
cookie_secure: auto
cookie_samesite: lax
storage_factory_id: session.storage.factory.native
enabled: false
#esi: true
#fragments: true
......
mercure:
hubs:
publisher:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_PUBLISHER)%'
publish: '%relation_repositories%'
subscriber:
url: '%env(MERCURE_URL)%'
public_url: '%env(MERCURE_PUBLIC_URL)%'
jwt:
secret: '%env(MERCURE_JWT_SUBSCRIBER)%'
subscribe: '%targetRepository%'
\ No newline at end of file
......@@ -4,7 +4,8 @@
# Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters:
targetRepository: '%env(TARGET_REPOSITORY)%'
relation_repositories: ['hal', 'nakala']
services:
# default configuration for services in *this* file
_defaults:
......
version: '3'
services:
###> symfony/mercure-bundle ###
mercure:
ports:
- "8001:80"
###< symfony/mercure-bundle ###
version: '3'
services:
###> symfony/mercure-bundle ###
mercure:
image: dunglas/mercure
restart: unless-stopped
environment:
SERVER_NAME: ':80'
MERCURE_PUBLISHER_JWT_KEY: '!ChangeMe!'
MERCURE_SUBSCRIBER_JWT_KEY: '!ChangeMe!'
# Set the URL of your Symfony project (without trailing slash!) as value of the cors_origins directive
MERCURE_EXTRA_DIRECTIVES: |
cors_origins http://127.0.0.1:8000
# Comment the following line to disable the development mode
command: /usr/bin/caddy run -config /etc/caddy/Caddyfile.dev
volumes:
- mercure_data:/data
- mercure_config:/config
###< symfony/mercure-bundle ###
php:
build: ./docker
restart: unless-stopped
volumes:
- ./:/var/www/html/:delegated
environment:
- COMPOSER_MEMORY_LIMIT=-1
volumes:
###> symfony/mercure-bundle ###
mercure_data:
mercure_config:
###< symfony/mercure-bundle ###
FROM php:8.0-fpm-alpine
RUN apk update; \
apk upgrade;
# composer
RUN cd /var/www \
&& php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \
&& php composer-setup.php --install-dir=/usr/local/bin --filename=composer \
&& php -r "unlink('composer-setup.php');"
\ No newline at end of file
<?php
namespace App\Command;
use App\Model\MercurePublication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Component\HttpClient\HttpClient;
use Symfony\Component\Mercure\HubInterface;
#[AsCommand(
name: 'daemon:run',
description: 'Run mercure daemon listening to an event',
)]
class DaemonRunCommand extends Command
{
private string $respository;
public function __construct(
private HubInterface $subscriber,
private ContainerBagInterface $containerBag,
) {
parent::__construct();
$this->respository = $containerBag->get('targetRepository');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/**
* You may want to listen to your repository events
*/
$url = $this->subscriber->getUrl().'?topic='.$this->respository;
$io->block('Listening to '.$url);
$options = [
'auth_bearer' => $this->subscriber->getFactory()->create([$this->respository]),
];
$client = HttpClient::create($options);
$client = new EventSourceHttpClient($client, 10);
$source = $client->connect($url);
while ($source) {
foreach ($client->stream($source, 10) as $r => $chunk) {
if ($chunk->isTimeout()) {
/**
* You can log timeouts if needed
*/
continue;
}
/**
* Each time mercure is hit by an event concerning your repository, this code will be executed
*/
if ($chunk instanceof ServerSentEvent) {
/**
* @var MercurePublication $response
*/
$response = json_decode($chunk->getData());
/**
* The response contains one publication, containing one or several relations
*/
foreach ($response->relations as $relation) {
/**
* ...code your own logic
*
* You may want to persist relations in your database after doing some checking :
* - do the target exist in my database?
* - do the relation exists in Datacite Reference?
* - do the relation already exists?
* - ...
*
*/
$io->success(
sprintf(
'Nakala data %s %s relation %s to %s with comment : "%s"',
$response->iri,
$relation->action,
$relation->relation,
$relation->iri,
$relation->comment,
)
);
}
}
}
}
return Command::SUCCESS;
}
}
<?php
namespace App\Command;
use App\Model\MercureDataRelation;
use App\Model\MercurePublication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
use Symfony\Component\Mercure\HubInterface;
use Symfony\Component\Mercure\Update;
#[AsCommand(
name: 'app:publish',
description: 'Publish a relation to a repository using mercure',
)]
class PublishCommand extends Command
{
private string $respository;
public function __construct(
private HubInterface $publisher,
private ContainerBagInterface $containerBag,
) {
parent::__construct();
$this->respository = $containerBag->get('targetRepository');
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
/**
* Multiples relations can be added when relations concern the same IRI and target repository (the topic here)
*
* In this example, the user specified two relations :
* 1. he added IsReferencedBy towards hal-03110771v1
* 2. he removed Cites towards hal-03442576v1
*/
//Relation 1
$relation = new MercureDataRelation();
$relation->iri = 'hal-03110771v1';
$relation->relation = 'IsReferencedBy';
$relation->action = 'add';
$relation->comment = 'A comment about the relation';
$relations[] = $relation;
//Relation 2
$relation = new MercureDataRelation();
$relation->iri = 'hal-03442576v1';
$relation->relation = 'Cites';
$relation->action = 'remove';
$relations[] = $relation;
$publication = new MercurePublication();
$publication->iri = "http://nakala.local/10.34847/nkl.28227db0";
$publication->topic = $this->respository;
$publication->relations = $relations;
$io->block(sprintf('Will try to reach and send data to Mercure server at %s', $this->publisher->getUrl()));
/**
* Objects are json-encoded and then sent via a new Update(),
* then the Update is passed to a publish() method
*/
$update = new Update(
$publication->topic,
json_encode($publication),
true
);
try {
$this->publisher->publish($update);
} catch (\Exception $exception) {
$io->error($exception->getMessage());
return Command::FAILURE;
}
$io->success('Data have been published, see your daemon logs!');
return Command::SUCCESS;
}
}
<?php
namespace App\Model;
class MercureDataRelation
{
public string $iri;
public string $relation;
public string $action;
public ?string $comment = null;
public string $repository;
}
\ No newline at end of file
<?php
namespace App\Model;
class MercurePublication
{
public string $topic;
public string $iri;
/**
* @var array|MercureDataRelation[]
*/
public array $relations;
}
\ No newline at end of file
{
"lcobucci/clock": {
"version": "2.1.0"
},
"lcobucci/jwt": {
"version": "4.1.5"
},
"psr/cache": {
"version": "2.0.0"
},
......@@ -8,6 +14,9 @@
"psr/event-dispatcher": {
"version": "1.0.0"
},
"psr/link": {
"version": "1.1.1"
},
"psr/log": {
"version": "2.0.0"
},
......@@ -87,6 +96,9 @@
"src/Controller/.gitignore"
]
},
"symfony/http-client": {
"version": "v5.3.11"
},
"symfony/http-client-contracts": {
"version": "v2.4.0"
},
......@@ -96,6 +108,21 @@
"symfony/http-kernel": {
"version": "v5.3.11"
},
"symfony/mercure": {
"version": "v0.6.0"
},
"symfony/mercure-bundle": {
"version": "0.3",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "0.3",
"ref": "e0a854b5439186e04b28fb8887b42c54f24a0d32"
},
"files": [
"config/packages/mercure.yaml"
]
},
"symfony/polyfill-intl-grapheme": {
"version": "v1.23.1"
},
......@@ -142,6 +169,9 @@
"symfony/var-exporter": {
"version": "v5.3.11"
},
"symfony/web-link": {
"version": "v5.3.4"
},
"symfony/yaml": {
"version": "v5.3.11"
}
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment