diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e0e0625..de782f25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Now you can run `tag:visits`, `domain:visits`, `visit:orphan` or `visit:non-orphan` to get the corresponding list of visits from the command line. +* [#962](https://github.com/shlinkio/shlink/issues/962) Added new real-time update for new short URLs. + + You can now subscribe to the `https://shlink.io/new-short-url` topic on any of the supported async updates technologies in order to get notified when a short URL is created. + ### Changed * [#1452](https://github.com/shlinkio/shlink/issues/1452) Updated to monolog 3 diff --git a/composer.json b/composer.json index 02a22264..f85c1a83 100644 --- a/composer.json +++ b/composer.json @@ -40,12 +40,11 @@ "mlocati/ip-lib": "^1.17", "ocramius/proxy-manager": "^2.11", "pagerfanta/core": "^3.5", - "php-amqplib/php-amqplib": "^3.1", "php-middleware/request-id": "^4.1", "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "dev-main#3244088 as 4.5", + "shlinkio/shlink-common": "dev-main#0396706 as 4.5", "shlinkio/shlink-config": "^1.6", "shlinkio/shlink-event-dispatcher": "^2.4", "shlinkio/shlink-importer": "^3.0", diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index a9764c8c..6f63eca6 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -2,9 +2,6 @@ declare(strict_types=1); -use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Laminas\ServiceManager\Proxy\LazyServiceFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Shlinkio\Shlink\Core\Config\EnvVars; return [ @@ -18,30 +15,4 @@ return [ 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), ], - 'dependencies' => [ - 'factories' => [ - AMQPStreamConnection::class => ConfigAbstractFactory::class, - ], - 'delegators' => [ - AMQPStreamConnection::class => [ - LazyServiceFactory::class, - ], - ], - 'lazy_services' => [ - 'class_map' => [ - AMQPStreamConnection::class => AMQPStreamConnection::class, - ], - ], - ], - - ConfigAbstractFactory::class => [ - AMQPStreamConnection::class => [ - 'config.rabbitmq.host', - 'config.rabbitmq.port', - 'config.rabbitmq.user', - 'config.rabbitmq.password', - 'config.rabbitmq.vhost', - ], - ], - ]; diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index f2b964b9..3b59e8e5 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -1,8 +1,8 @@ { - "asyncapi": "2.0.0", + "asyncapi": "2.4.0", "info": { "title": "Shlink", - "version": "2.0.0", + "version": "3.0.0", "description": "Shlink, the self-hosted URL shortener", "license": { "name": "MIT", @@ -75,6 +75,23 @@ } } } + }, + "https://shlink.io/new-short-url": { + "subscribe": { + "summary": "Receive information about any new short URL.", + "operationId": "newshortUrl", + "message": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortUrl": { + "$ref": "#/components/schemas/ShortUrl" + } + } + } + } + } } }, "components": { diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 06f57c41..840ac84e 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", - "version": "1.0" + "version": "2.0" }, "externalDocs": { diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 516ad8a1..4afd4805 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -98,6 +98,7 @@ return [ 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, Service\ShortUrl\ShortCodeUniquenessHelper::class, + EventDispatcherInterface::class, ], Visit\VisitsTracker::class => [ 'em', diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index d47cc128..9ae99e08 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -5,9 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Mercure\Hub; @@ -22,11 +22,15 @@ return [ ], 'async' => [ EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\NotifyVisitToMercure::class, - EventDispatcher\NotifyVisitToRabbitMq::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, EventDispatcher\NotifyVisitToWebHooks::class, EventDispatcher\UpdateGeoLiteDb::class, ], + EventDispatcher\Event\ShortUrlCreated::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, + ], ], ], @@ -34,16 +38,24 @@ return [ 'factories' => [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, ], 'delegators' => [ - EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], - EventDispatcher\NotifyVisitToRabbitMq::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], EventDispatcher\NotifyVisitToWebHooks::class => [ @@ -68,17 +80,31 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class, Options\AppOptions::class, ], - EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ Hub::class, Mercure\MercureUpdatesGenerator::class, 'em', 'Logger_Shlink', ], - EventDispatcher\NotifyVisitToRabbitMq::class => [ - AMQPStreamConnection::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + Hub::class, + Mercure\MercureUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + RabbitMqPublishingHelper::class, 'em', 'Logger_Shlink', Visit\Transformer\OrphanVisitDataTransformer::class, + ShortUrl\Transformer\ShortUrlDataTransformer::class, + 'config.rabbitmq.enabled', + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + RabbitMqPublishingHelper::class, + 'em', + 'Logger_Shlink', + ShortUrl\Transformer\ShortUrlDataTransformer::class, 'config.rabbitmq.enabled', ], EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php new file mode 100644 index 00000000..9786808f --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -0,0 +1,21 @@ + $this->shortUrlId, + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php new file mode 100644 index 00000000..8e93d88b --- /dev/null +++ b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php @@ -0,0 +1,44 @@ +shortUrlId; + $shortUrl = $this->em->find(ShortUrl::class, $shortUrlId); + + if ($shortUrl === null) { + $this->logger->warning( + 'Tried to notify Mercure for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId], + ); + return; + } + + try { + $this->hub->publish($this->updatesGenerator->newShortUrlUpdate($shortUrl)); + } catch (Throwable $e) { + $this->logger->debug('Error while trying to notify mercure hub with new short URL. {e}', ['e' => $e]); + } + } +} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php b/module/Core/src/EventDispatcher/Mercure/NotifyVisitToMercure.php similarity index 85% rename from module/Core/src/EventDispatcher/NotifyVisitToMercure.php rename to module/Core/src/EventDispatcher/Mercure/NotifyVisitToMercure.php index 096e7e65..11d3a8e4 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToMercure.php +++ b/module/Core/src/EventDispatcher/Mercure/NotifyVisitToMercure.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\EventDispatcher; +namespace Shlinkio\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; @@ -18,10 +18,10 @@ use function Functional\each; class NotifyVisitToMercure { public function __construct( - private HubInterface $hub, - private MercureUpdatesGeneratorInterface $updatesGenerator, - private EntityManagerInterface $em, - private LoggerInterface $logger, + private readonly HubInterface $hub, + private readonly MercureUpdatesGeneratorInterface $updatesGenerator, + private readonly EntityManagerInterface $em, + private readonly LoggerInterface $logger, ) { } diff --git a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php deleted file mode 100644 index a81f2cab..00000000 --- a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php +++ /dev/null @@ -1,102 +0,0 @@ -isEnabled) { - return; - } - - $visitId = $shortUrlLocated->visitId; - $visit = $this->em->find(Visit::class, $visitId); - - if ($visit === null) { - $this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if (! $this->connection->isConnected()) { - $this->connection->reconnect(); - } - - $queues = $this->determineQueuesToPublishTo($visit); - $message = $this->visitToMessage($visit); - - try { - $channel = $this->connection->channel(); - - foreach ($queues as $queue) { - // Declare an exchange and a queue that will persist server restarts - $exchange = $queue; // We use the same name for the exchange and the queue - $channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false); - $channel->queue_declare($queue, false, true, false, false); - - // Bind the exchange and the queue together, and publish the message - $channel->queue_bind($queue, $exchange); - $channel->basic_publish($message, $exchange); - } - - $channel->close(); - } catch (Throwable $e) { - $this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]); - } finally { - $this->connection->close(); - } - } - - /** - * @return string[] - */ - private function determineQueuesToPublishTo(Visit $visit): array - { - if ($visit->isOrphan()) { - return [self::NEW_ORPHAN_VISIT_QUEUE]; - } - - return [ - self::NEW_VISIT_QUEUE, - sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()), - ]; - } - - private function visitToMessage(Visit $visit): AMQPMessage - { - $messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit)); - return new AMQPMessage($messageBody, [ - 'content_type' => 'application/json', - 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, - ]); - } -} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php new file mode 100644 index 00000000..583f9420 --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php @@ -0,0 +1,53 @@ +isEnabled) { + return; + } + + $shortUrlId = $shortUrlCreated->shortUrlId; + $shortUrl = $this->em->find(ShortUrl::class, $shortUrlId); + + if ($shortUrl === null) { + $this->logger->warning( + 'Tried to notify RabbitMQ for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId], + ); + return; + } + + try { + $this->rabbitMqHelper->publishPayloadInQueue( + ['shortUrl' => $this->shortUrlTransformer->transform($shortUrl)], + Topic::NEW_SHORT_URL->value, + ); + } catch (Throwable $e) { + $this->logger->debug('Error while trying to notify RabbitMQ with new short URL. {e}', ['e' => $e]); + } + } +} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php new file mode 100644 index 00000000..897bff29 --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -0,0 +1,89 @@ +isEnabled) { + return; + } + + $visitId = $shortUrlLocated->visitId; + $visit = $this->em->find(Visit::class, $visitId); + + if ($visit === null) { + $this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [ + 'visitId' => $visitId, + ]); + return; + } + + $queues = $this->determineQueuesToPublishTo($visit); + $payload = $this->visitToPayload($visit); + + try { + foreach ($queues as $queue) { + $this->rabbitMqHelper->publishPayloadInQueue($payload, $queue); + } + } catch (Throwable $e) { + $this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]); + } + } + + /** + * @return string[] + */ + private function determineQueuesToPublishTo(Visit $visit): array + { + if ($visit->isOrphan()) { + return [Topic::NEW_ORPHAN_VISIT->value]; + } + + return [ + Topic::NEW_VISIT->value, + Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()), + ]; + } + + private function visitToPayload(Visit $visit): array + { + // FIXME This was defined incorrectly. + // According to the spec, both the visit and the short URL it belongs to, should be published. + // The shape should be ['visit' => [...], 'shortUrl' => ?[...]] + // However, this would be a breaking change, so we need a flag that determines the shape of the payload. + + return ! $visit->isOrphan() ? $visit->jsonSerialize() : $this->orphanVisitTransformer->transform($visit); + + if ($visit->isOrphan()) { // @phpstan-ignore-line + return ['visit' => $this->orphanVisitTransformer->transform($visit)]; + } + + return [ + 'visit' => $visit->jsonSerialize(), + 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Topic.php b/module/Core/src/EventDispatcher/Topic.php new file mode 100644 index 00000000..0cba5a09 --- /dev/null +++ b/module/Core/src/EventDispatcher/Topic.php @@ -0,0 +1,19 @@ +value, $shortCode ?? ''); + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php index 74b85388..0f01faa2 100644 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ b/module/Core/src/Mercure/MercureUpdatesGenerator.php @@ -5,26 +5,24 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Mercure; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Symfony\Component\Mercure\Update; use function Shlinkio\Shlink\Common\json_encode; -use function sprintf; final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface { - private const NEW_VISIT_TOPIC = 'https://shlink.io/new-visit'; - private const NEW_ORPHAN_VISIT_TOPIC = 'https://shlink.io/new-orphan-visit'; - public function __construct( - private DataTransformerInterface $shortUrlTransformer, - private DataTransformerInterface $orphanVisitTransformer, + private readonly DataTransformerInterface $shortUrlTransformer, + private readonly DataTransformerInterface $orphanVisitTransformer, ) { } public function newVisitUpdate(Visit $visit): Update { - return new Update(self::NEW_VISIT_TOPIC, json_encode([ + return new Update(Topic::NEW_VISIT->value, json_encode([ 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), 'visit' => $visit, ])); @@ -32,7 +30,7 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newOrphanVisitUpdate(Visit $visit): Update { - return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([ + return new Update(Topic::NEW_ORPHAN_VISIT->value, json_encode([ 'visit' => $this->orphanVisitTransformer->transform($visit), ])); } @@ -40,11 +38,18 @@ final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface public function newShortUrlVisitUpdate(Visit $visit): Update { $shortUrl = $visit->getShortUrl(); - $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); + $topic = Topic::newShortUrlVisit($shortUrl?->getShortCode()); return new Update($topic, json_encode([ 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), 'visit' => $visit, ])); } + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update + { + return new Update(Topic::NEW_SHORT_URL->value, json_encode([ + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), + ])); + } } diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php index 951e805c..ee0cd593 100644 --- a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php +++ b/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Mercure; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Symfony\Component\Mercure\Update; @@ -14,4 +15,6 @@ interface MercureUpdatesGeneratorInterface public function newOrphanVisitUpdate(Visit $visit): Update; public function newShortUrlVisitUpdate(Visit $visit): Update; + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update; } diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 8fa54493..21afb6b0 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -17,10 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; class UrlShortener implements UrlShortenerInterface { public function __construct( - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private EntityManagerInterface $em, - private ShortUrlRelationResolverInterface $relationResolver, - private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private readonly EntityManagerInterface $em, + private readonly ShortUrlRelationResolverInterface $relationResolver, + private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly EventDispatcherInterface $eventDispatcher, ) { } @@ -39,7 +42,8 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrlMeta $meta */ $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); - return $this->em->transactional(function () use ($meta) { + /** @var ShortUrl $newShortUrl */ + $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); $this->verifyShortCodeUniqueness($meta, $shortUrl); @@ -47,6 +51,10 @@ class UrlShortener implements UrlShortenerInterface return $shortUrl; }); + + $this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId())); + + return $newShortUrl; } private function findExistingShortUrlIfExists(ShortUrlMeta $meta): ?ShortUrl diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php new file mode 100644 index 00000000..6bc2d527 --- /dev/null +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -0,0 +1,104 @@ +hub = $this->prophesize(HubInterface::class); + $this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyNewShortUrlToMercure( + $this->hub->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + ); + } + + /** @test */ + public function messageIsLoggedWhenShortUrlIsNotFound(): void + { + $find = $this->em->find(ShortUrl::class, '123')->willReturn(null); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $this->logger->warning( + 'Tried to notify Mercure for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => '123'], + )->shouldHaveBeenCalledOnce(); + $this->hub->publish(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->updatesGenerator->newShortUrlUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedNotificationIsPublished(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = new Update([]); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + $publish = $this->hub->publish($update)->willReturn(''); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function messageIsPrintedIfPublishingFails(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = new Update([]); + $e = new Exception('Error'); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + $publish = $this->hub->publish($update)->willThrow($e); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug( + 'Error while trying to notify mercure hub with new short URL. {e}', + ['e' => $e], + )->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php similarity index 98% rename from module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php rename to module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index a06eaaa1..bdcb72a8 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -14,7 +14,7 @@ use RuntimeException; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure; +use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitType; diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php new file mode 100644 index 00000000..51b557af --- /dev/null +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -0,0 +1,128 @@ +helper = $this->prophesize(RabbitMqPublishingHelperInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyNewShortUrlToRabbitMq( + $this->helper->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new ShortUrlDataTransformer(new ShortUrlStringifier([])), + true, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $listener = new NotifyNewShortUrlToRabbitMq( + $this->helper->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new ShortUrlDataTransformer(new ShortUrlStringifier([])), + false, + ); + + $listener(new ShortUrlCreated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishPayloadInQueue(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenShortUrlCannotBeFound(): void + { + $shortUrlId = '123'; + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify RabbitMQ for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId], + ); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishPayloadInQueue(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedChannelIsNotified(): void + { + $shortUrlId = '123'; + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $this->helper->publishPayloadInQueue( + Argument::type('array'), + Topic::NEW_SHORT_URL->value, + )->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $shortUrlId = '123'; + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $publish = $this->helper->publishPayloadInQueue(Argument::cetera())->willThrow($e); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $this->logger->debug( + 'Error while trying to notify RabbitMQ with new short URL. {e}', + ['e' => $e], + )->shouldHaveBeenCalledOnce(); + $find->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } +} diff --git a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php similarity index 66% rename from module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php rename to module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index 778da889..2558dfc3 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -2,25 +2,26 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; -use PhpAmqpLib\Channel\AMQPChannel; -use PhpAmqpLib\Connection\AMQPStreamConnection; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; +use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelperInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToRabbitMq; +use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Throwable; @@ -32,28 +33,22 @@ class NotifyVisitToRabbitMqTest extends TestCase use ProphecyTrait; private NotifyVisitToRabbitMq $listener; - private ObjectProphecy $connection; + private ObjectProphecy $helper; private ObjectProphecy $em; private ObjectProphecy $logger; - private ObjectProphecy $orphanVisitTransformer; - private ObjectProphecy $channel; protected function setUp(): void { - $this->channel = $this->prophesize(AMQPChannel::class); - - $this->connection = $this->prophesize(AMQPStreamConnection::class); - $this->connection->isConnected()->willReturn(false); - $this->connection->channel()->willReturn($this->channel->reveal()); - + $this->helper = $this->prophesize(RabbitMqPublishingHelperInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), + $this->helper->reveal(), $this->em->reveal(), $this->logger->reveal(), new OrphanVisitDataTransformer(), + new ShortUrlDataTransformer(new ShortUrlStringifier([])), true, ); } @@ -62,10 +57,11 @@ class NotifyVisitToRabbitMqTest extends TestCase public function doesNothingWhenTheFeatureIsNotEnabled(): void { $listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), + $this->helper->reveal(), $this->em->reveal(), $this->logger->reveal(), new OrphanVisitDataTransformer(), + new ShortUrlDataTransformer(new ShortUrlStringifier([])), false, ); @@ -74,8 +70,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); + $this->helper->publishPayloadInQueue(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @test */ @@ -93,8 +88,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $findVisit->shouldHaveBeenCalledOnce(); $logWarning->shouldHaveBeenCalledOnce(); $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); + $this->helper->publishPayloadInQueue(Argument::cetera())->shouldNotHaveBeenCalled(); } /** @@ -105,27 +99,17 @@ class NotifyVisitToRabbitMqTest extends TestCase { $visitId = '123'; $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel)); + $argumentWithExpectedChannels = Argument::that( + static fn (string $channel) => contains($expectedChannels, $channel), + ); ($this->listener)(new VisitLocated($visitId)); $findVisit->shouldHaveBeenCalledOnce(); - $this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_bind( - $argumentWithExpectedChannel, - $argumentWithExpectedChannel, + $this->helper->publishPayloadInQueue( + Argument::type('array'), + $argumentWithExpectedChannels, )->shouldHaveBeenCalledTimes(count($expectedChannels)); - $this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); } @@ -154,7 +138,7 @@ class NotifyVisitToRabbitMqTest extends TestCase { $visitId = '123'; $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $channel = $this->connection->channel()->willThrow($e); + $publish = $this->helper->publishPayloadInQueue(Argument::cetera())->willThrow($e); ($this->listener)(new VisitLocated($visitId)); @@ -162,11 +146,8 @@ class NotifyVisitToRabbitMqTest extends TestCase 'Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e], )->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); $findVisit->shouldHaveBeenCalledOnce(); - $channel->shouldHaveBeenCalledOnce(); - $this->channel->close()->shouldNotHaveBeenCalled(); + $publish->shouldHaveBeenCalledOnce(); } public function provideExceptions(): iterable diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php index 779ec351..d3521f10 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\Mercure; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; @@ -109,4 +110,35 @@ class MercureUpdatesGeneratorTest extends TestCase yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; yield VisitType::BASE_URL->value => [Visit::forBasePath($visitor)]; } + + /** @test */ + public function shortUrlIsProperlySerializedIntoUpdate(): void + { + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'customSlug' => 'foo', + 'longUrl' => '', + 'title' => 'The title', + ])); + + $update = $this->generator->newShortUrlUpdate($shortUrl); + + self::assertEquals([Topic::NEW_SHORT_URL->value], $update->getTopics()); + self::assertEquals(['shortUrl' => [ + 'shortCode' => $shortUrl->getShortCode(), + 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), + 'longUrl' => '', + 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'domain' => null, + 'title' => $shortUrl->title(), + 'crawlable' => false, + 'forwardQuery' => true, + ],], json_decode($update->getData())); + } } diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index bdd508b4..fbe9b1c4 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -27,6 +28,7 @@ class UrlShortenerTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $titleResolutionHelper; private ObjectProphecy $shortCodeHelper; + private ObjectProphecy $eventDispatcher; public function setUp(): void { @@ -39,7 +41,7 @@ class UrlShortenerTest extends TestCase [$shortUrl] = $arguments; $shortUrl->setId('10'); }); - $this->em->transactional(Argument::type('callable'))->will(function (array $args) { + $this->em->wrapInTransaction(Argument::type('callable'))->will(function (array $args) { /** @var callable $callback */ [$callback] = $args; @@ -51,11 +53,14 @@ class UrlShortenerTest extends TestCase $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class); $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->urlShortener = new UrlShortener( $this->titleResolutionHelper->reveal(), $this->em->reveal(), new SimpleShortUrlRelationResolver(), $this->shortCodeHelper->reveal(), + $this->eventDispatcher->reveal(), ); }