From 19c6544f42232452cf24301e131c6767f587d058 Mon Sep 17 00:00:00 2001 From: Mathias Arlaud Date: Sat, 5 Dec 2020 22:14:52 +0100 Subject: [PATCH] [Notifier] Add mercure bridge --- composer.json | 1 + .../FrameworkExtension.php | 14 ++ .../Resources/config/notifier_transports.php | 5 + .../Notifier/Bridge/Mercure/.gitattributes | 4 + .../Notifier/Bridge/Mercure/CHANGELOG.md | 7 + .../Component/Notifier/Bridge/Mercure/LICENSE | 19 ++ .../Bridge/Mercure/MercureOptions.php | 86 +++++++++ .../Bridge/Mercure/MercureTransport.php | 100 +++++++++++ .../Mercure/MercureTransportFactory.php | 62 +++++++ .../Notifier/Bridge/Mercure/README.md | 23 +++ .../Mercure/Tests/MercureOptionsTest.php | 49 ++++++ .../Tests/MercureTransportFactoryTest.php | 77 +++++++++ .../Mercure/Tests/MercureTransportTest.php | 163 ++++++++++++++++++ .../Notifier/Bridge/Mercure/composer.json | 32 ++++ .../Notifier/Bridge/Mercure/phpunit.xml.dist | 31 ++++ .../Exception/UnsupportedSchemeException.php | 4 + 16 files changed, 677 insertions(+) create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/README.md create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/composer.json create mode 100644 src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist diff --git a/composer.json b/composer.json index f3d9c55ae1..b224ed5b71 100644 --- a/composer.json +++ b/composer.json @@ -130,6 +130,7 @@ "psr/http-client": "^1.0", "psr/simple-cache": "^1.0", "egulias/email-validator": "^2.1.10", + "symfony/mercure-bundle": "^0.2", "symfony/phpunit-bridge": "^5.2", "symfony/security-acl": "~2.8|~3.0", "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 8f6e60737e..4bf5c7153f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,6 +25,7 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; use Symfony\Bundle\FullStack; +use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Component\Asset\PackageInterface; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\Cache\Adapter\AdapterInterface; @@ -43,6 +44,8 @@ use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; use Symfony\Component\DependencyInjection\Alias; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; +use Symfony\Component\DependencyInjection\Argument\ServiceLocatorArgument; +use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -112,6 +115,7 @@ use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; @@ -2242,6 +2246,7 @@ class FrameworkExtension extends Extension LinkedInTransportFactory::class => 'notifier.transport_factory.linkedin', GatewayApiTransportFactory::class => 'notifier.transport_factory.gatewayapi', OctopushTransportFactory::class => 'notifier.transport_factory.octopush', + MercureTransportFactory::class => 'notifier.transport_factory.mercure', ]; foreach ($classToServices as $class => $service) { @@ -2250,6 +2255,15 @@ class FrameworkExtension extends Extension } } + if (class_exists(MercureTransportFactory::class)) { + if (!class_exists(MercureBundle::class)) { + throw new \LogicException('The MercureBundle is not registered in your application. Try running "composer require symfony/mercure-bundle".'); + } + + $container->getDefinition($classToServices[MercureTransportFactory::class]) + ->replaceArgument('$publisherLocator', new ServiceLocatorArgument(new TaggedIteratorArgument('mercure.publisher', null, null, true))); + } + if (isset($config['admin_recipients'])) { $notifier = $container->getDefinition('notifier'); foreach ($config['admin_recipients'] as $i => $recipient) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php index 4a387fa551..4053142a33 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/notifier_transports.php @@ -22,6 +22,7 @@ use Symfony\Component\Notifier\Bridge\Infobip\InfobipTransportFactory; use Symfony\Component\Notifier\Bridge\Iqsms\IqsmsTransportFactory; use Symfony\Component\Notifier\Bridge\LinkedIn\LinkedInTransportFactory; use Symfony\Component\Notifier\Bridge\Mattermost\MattermostTransportFactory; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; use Symfony\Component\Notifier\Bridge\Mobyt\MobytTransportFactory; use Symfony\Component\Notifier\Bridge\Nexmo\NexmoTransportFactory; use Symfony\Component\Notifier\Bridge\Octopush\OctopushTransportFactory; @@ -135,6 +136,10 @@ return static function (ContainerConfigurator $container) { ->parent('notifier.transport_factory.abstract') ->tag('texter.transport_factory') + ->set('notifier.transport_factory.mercure', MercureTransportFactory::class) + ->parent('notifier.transport_factory.abstract') + ->tag('chatter.transport_factory') + ->set('notifier.transport_factory.null', NullTransportFactory::class) ->parent('notifier.transport_factory.abstract') ->tag('chatter.transport_factory') diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes b/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes new file mode 100644 index 0000000000..84c7add058 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/.gitattributes @@ -0,0 +1,4 @@ +/Tests export-ignore +/phpunit.xml.dist export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md b/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md new file mode 100644 index 0000000000..1f2b652ac2 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.3 +--- + + * Add the bridge diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE new file mode 100644 index 0000000000..efb17f98e7 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2021 Fabien Potencier + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished +to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php new file mode 100644 index 0000000000..c6fee89c05 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureOptions.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Component\Notifier\Message\MessageOptionsInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureOptions implements MessageOptionsInterface +{ + private $topics; + private $private; + private $id; + private $type; + private $retry; + + /** + * @param string|string[]|null $topics + */ + public function __construct($topics = null, bool $private = false, ?string $id = null, ?string $type = null, ?int $retry = null) + { + if (null !== $topics && !\is_array($topics) && !\is_string($topics)) { + throw new \TypeError(sprintf('"%s()" expects parameter 1 to be an array of strings, a string or null, "%s" given.', __METHOD__, get_debug_type($topics))); + } + + $this->topics = null !== $topics ? (array) $topics : null; + $this->private = $private; + $this->id = $id; + $this->type = $type; + $this->retry = $retry; + } + + /** + * @return string[]|null + */ + public function getTopics(): ?array + { + return $this->topics; + } + + public function isPrivate(): bool + { + return $this->private; + } + + public function getId(): ?string + { + return $this->id; + } + + public function getType(): ?string + { + return $this->type; + } + + public function getRetry(): ?int + { + return $this->retry; + } + + public function toArray(): array + { + return [ + 'topics' => $this->topics, + 'private' => $this->private, + 'id' => $this->id, + 'type' => $this->type, + 'retry' => $this->retry, + ]; + } + + public function getRecipientId(): ?string + { + return null; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php new file mode 100644 index 0000000000..e822ffc72d --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransport.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Exception\UnsupportedMessageTypeException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\SentMessage; +use Symfony\Component\Notifier\Transport\AbstractTransport; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; +use Symfony\Contracts\HttpClient\Exception\ExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransport extends AbstractTransport +{ + private $publisher; + private $publisherId; + private $topics; + + /** + * @param string|string[]|null $topics + */ + public function __construct(PublisherInterface $publisher, string $publisherId, $topics = null, ?HttpClientInterface $client = null, ?EventDispatcherInterface $dispatcher = null) + { + if (null !== $topics && !\is_array($topics) && !\is_string($topics)) { + throw new \TypeError(sprintf('"%s()" expects parameter 3 to be an array of strings, a string or null, "%s" given.', __METHOD__, get_debug_type($topics))); + } + + $this->publisher = $publisher; + $this->publisherId = $publisherId; + $this->topics = $topics ?? 'https://symfony.com/notifier'; + + parent::__construct($client, $dispatcher); + } + + public function __toString(): string + { + return sprintf('mercure://%s?%s', $this->publisherId, http_build_query(['topic' => $this->topics])); + } + + public function supports(MessageInterface $message): bool + { + return $message instanceof ChatMessage && (null === $message->getOptions() || $message->getOptions() instanceof MercureOptions); + } + + /** + * @see https://symfony.com/doc/current/mercure.html#publishing + */ + protected function doSend(MessageInterface $message): SentMessage + { + if (!$message instanceof ChatMessage) { + throw new UnsupportedMessageTypeException(__CLASS__, ChatMessage::class, $message); + } + + if (($options = $message->getOptions()) && !$options instanceof MercureOptions) { + throw new LogicException(sprintf('The "%s" transport only supports instances of "%s" for options.', __CLASS__, MercureOptions::class)); + } + + if (null === $options) { + $options = new MercureOptions($this->topics); + } + + // @see https://www.w3.org/TR/activitystreams-core/#jsonld + $update = new Update($options->getTopics() ?? $this->topics, json_encode([ + '@context' => 'https://www.w3.org/ns/activitystreams', + 'type' => 'Announce', + 'summary' => $message->getSubject(), + ]), $options->isPrivate(), $options->getId(), $options->getType(), $options->getRetry()); + + try { + $messageId = ($this->publisher)($update); + + $sentMessage = new SentMessage($message, (string) $this); + $sentMessage->setMessageId($messageId); + + return $sentMessage; + } catch (ExceptionInterface $e) { + throw new TransportException(sprintf('Unable to post the Mercure message: "%s".', $e->getResponse()->getContent(false)), $e->getResponse(), $e->getCode(), $e); + } catch (\InvalidArgumentException $e) { + throw new InvalidArgumentException(sprintf('Unable to post the Mercure message: "%s".', $e->getMessage()), $e->getCode(), $e); + } + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php new file mode 100644 index 0000000000..a122bbae66 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/MercureTransportFactory.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure; + +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\UnsupportedSchemeException; +use Symfony\Component\Notifier\Transport\AbstractTransportFactory; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportFactory extends AbstractTransportFactory +{ + private $publisherLocator; + + /** + * @param ServiceProviderInterface $publisherLocator A container that holds {@see PublisherInterface} instances + */ + public function __construct(ServiceProviderInterface $publisherLocator) + { + parent::__construct(); + + $this->publisherLocator = $publisherLocator; + } + + /** + * @return MercureTransport + */ + public function create(Dsn $dsn): TransportInterface + { + if ('mercure' !== $dsn->getScheme()) { + throw new UnsupportedSchemeException($dsn, 'mercure', $this->getSupportedSchemes()); + } + + $publisherId = $dsn->getHost(); + if (!$this->publisherLocator->has($publisherId)) { + throw new LogicException(sprintf('"%s" not found. Did you mean one of: %s?', $publisherId, implode(', ', array_keys($this->publisherLocator->getProvidedServices())))); + } + + $topic = $dsn->getOption('topic'); + + return new MercureTransport($this->publisherLocator->get($publisherId), $publisherId, $topic); + } + + protected function getSupportedSchemes(): array + { + return ['mercure']; + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/README.md b/src/Symfony/Component/Notifier/Bridge/Mercure/README.md new file mode 100644 index 0000000000..7906b15a99 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/README.md @@ -0,0 +1,23 @@ +Mercure Notifier +================ + +Provides [Mercure](https://github.com/symfony/mercure) integration for Symfony Notifier. + +DSN example +----------- + +``` +MERCURE_DSN=mercure://PUBLISHER_SERVICE_ID?topic=TOPIC +``` + +where: + - `PUBLISHER_SERVICE_ID` is the Mercure publisher service id + - `TOPIC` is the topic IRI (optional, default: `https://symfony.com/notifier`. Could be either a single topic: `topic=https://foo` or multiple topics: `topic[]=/foo/1&topic[]=https://bar`) + +Resources +--------- + + * [Contributing](https://symfony.com/doc/current/contributing/index.html) + * [Report issues](https://github.com/symfony/symfony/issues) and + [send Pull Requests](https://github.com/symfony/symfony/pulls) + in the [main Symfony repository](https://github.com/symfony/symfony) diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php new file mode 100644 index 0000000000..184e5e5db8 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureOptionsTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Notifier\Bridge\Mercure\MercureOptions; +use TypeError; + +final class MercureOptionsTest extends TestCase +{ + public function testConstructWithDefaults() + { + $this->assertSame((new MercureOptions())->toArray(), [ + 'topics' => null, + 'private' => false, + 'id' => null, + 'type' => null, + 'retry' => null, + ]); + } + + public function testConstructWithParameters() + { + $options = (new MercureOptions('/topic/1', true, 'id', 'type', 1)); + + $this->assertSame($options->toArray(), [ + 'topics' => ['/topic/1'], + 'private' => true, + 'id' => 'id', + 'type' => 'type', + 'retry' => 1, + ]); + } + + public function testConstructWithWrongTopicsThrows() + { + $this->expectException(TypeError::class); + new MercureOptions(1); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php new file mode 100644 index 0000000000..e8db3f8c9b --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportFactoryTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use LogicException; +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransportFactory; +use Symfony\Component\Notifier\Tests\TransportFactoryTestCase; +use Symfony\Component\Notifier\Transport\Dsn; +use Symfony\Component\Notifier\Transport\TransportFactoryInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportFactoryTest extends TransportFactoryTestCase +{ + public function createFactory(): TransportFactoryInterface + { + $publisherLocator = $this->createMock(ServiceProviderInterface::class); + $publisherLocator->method('has')->willReturn(true); + $publisherLocator->method('get')->willReturn($this->createMock(PublisherInterface::class)); + + return new MercureTransportFactory($publisherLocator); + } + + public function supportsProvider(): iterable + { + yield [true, 'mercure://publisherId?topic=topic']; + yield [false, 'somethingElse://publisherId?topic=topic']; + } + + public function createProvider(): iterable + { + yield [ + 'mercure://publisherId?topic=%2Ftopic%2F1', + 'mercure://publisherId?topic=/topic/1', + ]; + + yield [ + 'mercure://publisherId?topic%5B0%5D=%2Ftopic%2F1&topic%5B1%5D=%2Ftopic%2F2', + 'mercure://publisherId?topic[]=/topic/1&topic[]=/topic/2', + ]; + + yield [ + 'mercure://publisherId?topic=https%3A%2F%2Fsymfony.com%2Fnotifier', + 'mercure://publisherId', + ]; + } + + public function unsupportedSchemeProvider(): iterable + { + yield ['somethingElse://publisherId?topic=topic']; + } + + public function testNotFoundPublisherThrows() + { + $publisherLocator = $this->createMock(ServiceProviderInterface::class); + $publisherLocator->method('has')->willReturn(false); + $publisherLocator->method('getProvidedServices')->willReturn(['fooPublisher' => 'fooFqcn', 'barPublisher' => 'barFqcn']); + + $factory = new MercureTransportFactory($publisherLocator); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('"publisherId" not found. Did you mean one of: fooPublisher, barPublisher?'); + $factory->create(new Dsn('mercure://publisherId')); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php new file mode 100644 index 0000000000..8c2b07d6e5 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/Tests/MercureTransportTest.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Notifier\Bridge\Mercure\Tests; + +use Symfony\Component\Mercure\PublisherInterface; +use Symfony\Component\Mercure\Update; +use Symfony\Component\Notifier\Bridge\Mercure\MercureOptions; +use Symfony\Component\Notifier\Bridge\Mercure\MercureTransport; +use Symfony\Component\Notifier\Exception\InvalidArgumentException; +use Symfony\Component\Notifier\Exception\LogicException; +use Symfony\Component\Notifier\Exception\TransportException; +use Symfony\Component\Notifier\Message\ChatMessage; +use Symfony\Component\Notifier\Message\MessageInterface; +use Symfony\Component\Notifier\Message\MessageOptionsInterface; +use Symfony\Component\Notifier\Message\SmsMessage; +use Symfony\Component\Notifier\Tests\TransportTestCase; +use Symfony\Component\Notifier\Transport\TransportInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; +use TypeError; + +/** + * @author Mathias Arlaud + */ +final class MercureTransportTest extends TransportTestCase +{ + public function createTransport(?HttpClientInterface $client = null, ?PublisherInterface $publisher = null, string $publisherId = 'publisherId', $topics = null): TransportInterface + { + $publisher = $publisher ?? $this->createMock(PublisherInterface::class); + + return new MercureTransport($publisher, $publisherId, $topics); + } + + public function toStringProvider(): iterable + { + yield ['mercure://publisherId?topic=https%3A%2F%2Fsymfony.com%2Fnotifier', $this->createTransport()]; + yield ['mercure://customPublisherId?topic=%2Ftopic', $this->createTransport(null, null, 'customPublisherId', '/topic')]; + yield ['mercure://customPublisherId?topic%5B0%5D=%2Ftopic%2F1&topic%5B1%5D%5B0%5D=%2Ftopic%2F2', $this->createTransport(null, null, 'customPublisherId', ['/topic/1', ['/topic/2']])]; + } + + public function supportedMessagesProvider(): iterable + { + yield [new ChatMessage('Hello!')]; + } + + public function unsupportedMessagesProvider(): iterable + { + yield [new SmsMessage('0611223344', 'Hello!')]; + yield [$this->createMock(MessageInterface::class)]; + } + + public function testCanSetCustomPort() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testCanSetCustomHost() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testCanSetCustomHostAndPort() + { + $this->markTestSkipped("Mercure transport doesn't use a regular HTTP Dsn"); + } + + public function testConstructWithWrongTopicsThrows() + { + $this->expectException(TypeError::class); + $this->createTransport(null, null, 'publisherId', 1); + } + + public function testSendWithNonMercureOptionsThrows() + { + $this->expectException(LogicException::class); + $this->createTransport()->send(new ChatMessage('testMessage', $this->createMock(MessageOptionsInterface::class))); + } + + public function testSendWithWrongResponseThrows() + { + $response = $this->createMock(ResponseInterface::class); + $response->method('getContent')->willReturn('Service Unavailable'); + + $httpException = $this->createMock(ServerExceptionInterface::class); + $httpException->method('getResponse')->willReturn($response); + + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willThrowException($httpException); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage('Unable to post the Mercure message: "Service Unavailable".'); + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendWithWrongTokenThrows() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willThrowException(new \InvalidArgumentException('The provided JWT is not valid')); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unable to post the Mercure message: "The provided JWT is not valid".'); + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendWithMercureOptions() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['/topic/1', '/topic/2'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}', true, 'id', 'type', 1)) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject', new MercureOptions(['/topic/1', '/topic/2'], true, 'id', 'type', 1))); + } + + public function testSendWithMercureOptionsButWithoutOptionTopic() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['https://symfony.com/notifier'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}', true, 'id', 'type', 1)) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject', new MercureOptions(null, true, 'id', 'type', 1))); + } + + public function testSendWithoutMercureOptions() + { + $publisher = $this->createMock(PublisherInterface::class); + $publisher + ->expects($this->once()) + ->method('__invoke') + ->with(new Update(['https://symfony.com/notifier'], '{"@context":"https:\/\/www.w3.org\/ns\/activitystreams","type":"Announce","summary":"subject"}')) + ; + + $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + } + + public function testSendSuccessfully() + { + $messageId = 'urn:uuid:a7045be0-a75d-4d40-8bd2-29fa4e5dd10b'; + + $publisher = $this->createMock(PublisherInterface::class); + $publisher->method('__invoke')->willReturn($messageId); + + $sentMessage = $this->createTransport(null, $publisher)->send(new ChatMessage('subject')); + $this->assertSame($messageId, $sentMessage->getMessageId()); + } +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json b/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json new file mode 100644 index 0000000000..da85334a00 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/composer.json @@ -0,0 +1,32 @@ +{ + "name": "symfony/mercure-notifier", + "type": "symfony-bridge", + "description": "Symfony Mercure Notifier Bridge", + "keywords": ["mercure", "notifier"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Mathias Arlaud", + "email": "mathias.arlaud@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": ">=7.2.5", + "ext-json": "*", + "symfony/mercure": "^0.4", + "symfony/notifier": "^5.3", + "symfony/service-contracts": "^1.10|^2" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Notifier\\Bridge\\Mercure\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev" +} diff --git a/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist b/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist new file mode 100644 index 0000000000..0820c4f905 --- /dev/null +++ b/src/Symfony/Component/Notifier/Bridge/Mercure/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Resources + ./Tests + ./vendor + + + + diff --git a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php index 2bf6a82ec0..0b9bf44b97 100644 --- a/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php +++ b/src/Symfony/Component/Notifier/Exception/UnsupportedSchemeException.php @@ -100,6 +100,10 @@ class UnsupportedSchemeException extends LogicException 'class' => Bridge\Octopush\OctopushTransportFactory::class, 'package' => 'symfony/octopush-notifier', ], + 'mercure' => [ + 'class' => Bridge\Mercure\MercureTransportFactory::class, + 'package' => 'symfony/mercure-notifier', + ], ]; /**