Skip to content

Commit b1cb35d

Browse files
committed
[Notifier] Add mercure bridge
1 parent 31ee43a commit b1cb35d

File tree

14 files changed

+499
-0
lines changed

14 files changed

+499
-0
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory;
110110
use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory;
111111
use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory;
112+
use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory;
112113
use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory;
113114
use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory;
114115
use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory;
@@ -2236,6 +2237,7 @@ private function registerNotifierConfiguration(array $config, ContainerBuilder $
22362237
SendinblueNotifierTransportFactory::class => 'notifier.transport_factory.sendinblue',
22372238
DiscordTransportFactory::class => 'notifier.transport_factory.discord',
22382239
LinkedInTransportFactory::class => 'notifier.transport_factory.linkedin',
2240+
MercureTransportFactory::class => 'notifier.transport_factory.mercure',
22392241
];
22402242

22412243
foreach ($classToServices as $class => $service) {

src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory;
2020
use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory;
2121
use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory;
22+
use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory;
2223
use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory;
2324
use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory;
2425
use Symfony\Component\Notifier\Bridge\OvhCloud\OvhCloudTransportFactory;
@@ -115,6 +116,10 @@
115116
->parent('notifier.transport_factory.abstract')
116117
->tag('chatter.transport_factory')
117118

119+
->set('notifier.transport_factory.mercure', MercureTransportFactory::class)
120+
->parent('notifier.transport_factory.abstract')
121+
->tag('chatter.transport_factory')
122+
118123
->set('notifier.transport_factory.null', NullTransportFactory::class)
119124
->parent('notifier.transport_factory.abstract')
120125
->tag('chatter.transport_factory')
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/.gitattributes export-ignore
2+
/.gitignore export-ignore
3+
/phpunit.xml.dist export-ignore
4+
/Tests export-ignore
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
CHANGELOG
2+
=========
3+
4+
5.3.0
5+
-----
6+
7+
* Added the bridge
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
Copyright (c) 2020 Fabien Potencier
2+
3+
Permission is hereby granted, free of charge, to any person obtaining a copy
4+
of this software and associated documentation files (the "Software"), to deal
5+
in the Software without restriction, including without limitation the rights
6+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7+
copies of the Software, and to permit persons to whom the Software is furnished
8+
to do so, subject to the following conditions:
9+
10+
The above copyright notice and this permission notice shall be included in all
11+
copies or substantial portions of the Software.
12+
13+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
19+
THE SOFTWARE.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Mercure;
13+
14+
use Symfony\Component\Notifier\Message\MessageOptionsInterface;
15+
16+
/**
17+
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
18+
*
19+
* @experimental in 5.3
20+
*/
21+
final class MercureOptions implements MessageOptionsInterface
22+
{
23+
private $topic;
24+
25+
public function __construct(string $topic)
26+
{
27+
$this->topic = $topic;
28+
}
29+
30+
public function toArray(): array
31+
{
32+
return [
33+
'topic' => $this->topic,
34+
];
35+
}
36+
37+
public function getRecipientId(): ?string
38+
{
39+
return $this->topic;
40+
}
41+
}
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Mercure;
13+
14+
use Symfony\Component\Mercure\Jwt\StaticJwtProvider;
15+
use Symfony\Component\Mercure\Publisher;
16+
use Symfony\Component\Mercure\Update;
17+
use Symfony\Component\Notifier\Exception\InvalidArgumentException;
18+
use Symfony\Component\Notifier\Exception\LogicException;
19+
use Symfony\Component\Notifier\Exception\RuntimeException;
20+
use Symfony\Component\Notifier\Exception\TransportException;
21+
use Symfony\Component\Notifier\Message\ChatMessage;
22+
use Symfony\Component\Notifier\Message\MessageInterface;
23+
use Symfony\Component\Notifier\Message\SentMessage;
24+
use Symfony\Component\Notifier\Transport\AbstractTransport;
25+
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
26+
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
27+
use Symfony\Contracts\HttpClient\HttpClientInterface;
28+
29+
/**
30+
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
31+
*
32+
* @internal
33+
*
34+
* @experimental in 5.3
35+
*/
36+
final class MercureTransport extends AbstractTransport
37+
{
38+
private $token;
39+
private $hubPath;
40+
private $topic;
41+
private $secure;
42+
private $scheme;
43+
44+
public function __construct(string $token, string $hubPath, ?string $topic = null, bool $secure = true, HttpClientInterface $client = null, EventDispatcherInterface $dispatcher = null)
45+
{
46+
$this->token = $token;
47+
$this->hubPath = $hubPath;
48+
$this->topic = $topic;
49+
$this->secure = $secure;
50+
$this->scheme = $secure ? 'https' : 'http';
51+
52+
parent::__construct($client, $dispatcher);
53+
}
54+
55+
public function __toString(): string
56+
{
57+
return sprintf('mercure://%s%s?topic=%s&secure=%s', $this->getEndpoint(), $this->hubPath, urlencode($this->topic), $this->secure ? 'true' : 'false');
58+
}
59+
60+
public function supports(MessageInterface $message): bool
61+
{
62+
return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof MercureOptions);
63+
}
64+
65+
/**
66+
* @see https://symfony.com/doc/current/mercure.html#publishing
67+
*/
68+
protected function doSend(MessageInterface $message): SentMessage
69+
{
70+
if (!$message instanceof ChatMessage) {
71+
throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" (instance of "%s" given).', __CLASS__, ChatMessage::class, get_debug_type($message)));
72+
}
73+
74+
$topic = $message->getRecipientId() ?? $this->topic;
75+
if (null === $topic) {
76+
throw new RuntimeException(sprintf('Could not find any topic to publish the message. Please specify one using the "topic" DSN option or by passing a "%s" to the message.', MercureOptions::class));
77+
}
78+
79+
// @see https://www.w3.org/TR/activitystreams-core/#jsonld
80+
$update = new Update($message->getRecipientId() ?? $this->topic, json_encode([
81+
'@context' => 'https://www.w3.org/ns/activitystreams',
82+
'type' => 'Notification',
83+
'summary' => $message->getSubject(),
84+
]));
85+
86+
$publisher = new Publisher(sprintf('%s://%s%s', $this->scheme, $this->getEndpoint(), $this->hubPath), new StaticJwtProvider($this->token), $this->client);
87+
88+
try {
89+
$messageId = $publisher($update);
90+
} catch (HttpExceptionInterface $e) {
91+
throw new TransportException(sprintf('Unable to post the Mercure message: "%s".', $e->getResponse()->getContent(false)), $e->getResponse());
92+
} catch (\InvalidArgumentException $e) {
93+
throw new InvalidArgumentException(sprintf('Unable to post the Mercure message: "%s".', $e->getMessage()));
94+
}
95+
96+
$sentMessage = new SentMessage($message, (string) $this);
97+
$sentMessage->setMessageId($messageId);
98+
99+
return $sentMessage;
100+
}
101+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Mercure;
13+
14+
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
15+
use Symfony\Component\Notifier\Transport\AbstractTransportFactory;
16+
use Symfony\Component\Notifier\Transport\Dsn;
17+
use Symfony\Component\Notifier\Transport\TransportInterface;
18+
19+
/**
20+
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
21+
*
22+
* @experimental in 5.3
23+
*/
24+
final class MercureTransportFactory extends AbstractTransportFactory
25+
{
26+
/**
27+
* @return MercureTransport
28+
*/
29+
public function create(Dsn $dsn): TransportInterface
30+
{
31+
if ('mercure' !== $dsn->getScheme()) {
32+
throw new UnsupportedSchemeException($dsn, 'mercure', $this->getSupportedSchemes());
33+
}
34+
35+
$host = 'default' === $dsn->getHost() ? null : $dsn->getHost();
36+
$hubPath = $dsn->getPath() ?? '';
37+
$token = $this->getUser($dsn);
38+
$topic = $dsn->getOption('topic');
39+
$secure = filter_var($dsn->getOption('secure') ?? 'true', \FILTER_VALIDATE_BOOLEAN);
40+
41+
return (new MercureTransport($token, $hubPath, $topic, $secure, $this->client, $this->dispatcher))->setHost($host)->setPort($dsn->getPort());
42+
}
43+
44+
protected function getSupportedSchemes(): array
45+
{
46+
return ['mercure'];
47+
}
48+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
Mercure Notifier
2+
===================
3+
4+
Provides Mercure integration for Symfony Notifier.
5+
6+
DSN example
7+
-----------
8+
9+
```
10+
// .env file
11+
MERCURE_DSN=mercure://JWT_TOKEN@default/HUB_PATH?topic=TOPIC&secure=SECURE
12+
```
13+
14+
where:
15+
- `JWT_TOKEN` is the Mercure JWT token
16+
- `HUB_PATH` is the path to the Mercure Hub
17+
- `TOPIC` is the default topic IRI (optional, default: `null`)
18+
- `SECURE` is specifying if the HTTP call is using SSL (optional, default: `true`)
19+
20+
Resources
21+
---------
22+
23+
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
24+
* [Report issues](https://github.com/symfony/symfony/issues) and
25+
[send Pull Requests](https://github.com/symfony/symfony/pulls)
26+
in the [main Symfony repository](https://github.com/symfony/symfony)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Notifier\Bridge\Mercure\Tests;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory;
16+
use Symfony\Component\Notifier\Exception\IncompleteDsnException;
17+
use Symfony\Component\Notifier\Exception\UnsupportedSchemeException;
18+
use Symfony\Component\Notifier\Transport\Dsn;
19+
20+
/**
21+
* @author Mathias Arlaud <mathias.arlaud@gmail.com>
22+
*/
23+
final class MercureTransportFactoryTest extends TestCase
24+
{
25+
public function testCreateWithDsn(): void
26+
{
27+
$factory = new MercureTransportFactory();
28+
29+
$host = 'host';
30+
$hubPath = 'path';
31+
$topic = 'topic';
32+
$transport = $factory->create(Dsn::fromString(sprintf('mercure://jwtToken@%s/%s?topic=%s', $host, $hubPath, $topic)));
33+
34+
$this->assertSame(sprintf('mercure://%s/%s?topic=%s&secure=true', $host, $hubPath, $topic), (string) $transport);
35+
}
36+
37+
public function testCreateWithNoPath(): void
38+
{
39+
$factory = new MercureTransportFactory();
40+
41+
$host = 'host';
42+
$topic = 'topic';
43+
$secure = 'false';
44+
$transport = $factory->create(Dsn::fromString(sprintf('mercure://jwtToken@%s?topic=%s&secure=%s', $host, $topic, $secure)));
45+
46+
$this->assertSame(sprintf('mercure://%s?topic=%s&secure=%s', $host, $topic, $secure), (string) $transport);
47+
}
48+
49+
public function testCreateWithNoTokenThrowsMalformed(): void
50+
{
51+
$factory = new MercureTransportFactory();
52+
53+
$this->expectException(IncompleteDsnException::class);
54+
$factory->create(Dsn::fromString(sprintf('mercure://%s/%s/?topic=%s', 'host', 'path', 'topic')));
55+
}
56+
57+
public function testSupportsMercureScheme(): void
58+
{
59+
$factory = new MercureTransportFactory();
60+
61+
$this->assertTrue($factory->supports(Dsn::fromString('mercure://host/path?topic=topic')));
62+
$this->assertFalse($factory->supports(Dsn::fromString('somethingElse://host/path?topic=topic')));
63+
}
64+
65+
public function testNonHttpOrHttpsSchemeThrows(): void
66+
{
67+
$factory = new MercureTransportFactory();
68+
69+
$this->expectException(UnsupportedSchemeException::class);
70+
71+
$factory->create(Dsn::fromString('somethingElse://token@host/path?topic=topic'));
72+
}
73+
}

0 commit comments

Comments
 (0)