Merge pull request #726 from acelaya-forks/feature/mercure-integration

Feature/mercure integration
This commit is contained in:
Alejandro Celaya 2020-04-13 10:03:12 +02:00 committed by GitHub
commit f5e0d0c2b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 774 additions and 19 deletions

View file

@ -4,6 +4,37 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
#### Added
* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server.
Thanks to that, Shlink will be able to publish events that can be consumed in real time.
For now, only one topic (event) is published, identified by `https://shlink.io/new_visit`, which includes a payload with the visit and the shortUrl, every time a new visit occurs.
The updates are only published when serving Shlink with swoole.
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates.
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* *Nothing*
## 2.1.3 - 2020-04-09
#### Added

View file

@ -34,6 +34,7 @@
"laminas/laminas-paginator": "^2.8",
"laminas/laminas-servicemanager": "^3.4",
"laminas/laminas-stdlib": "^3.2",
"lcobucci/jwt": "^4.0@alpha",
"lstrojny/functional-php": "^1.9",
"mezzio/mezzio": "^3.2",
"mezzio/mezzio-fastroute": "^3.0",
@ -49,14 +50,15 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "dev-master#aafa221ec979271713f87e23f17f6a6b5ae5ee67 as 3.0.1",
"shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-installer": "^4.3.2",
"shlinkio/shlink-installer": "dev-master#c1412b9e9a150f443874f05452f7ce8e6f9e0339 as 4.4.0",
"shlinkio/shlink-ip-geolocation": "^1.4",
"symfony/console": "^5.0",
"symfony/filesystem": "^5.0",
"symfony/lock": "^5.0",
"symfony/mercure": "^0.3.0",
"symfony/process": "^5.0"
},
"require-dev": {

View file

@ -31,6 +31,10 @@ return [
Option\WebWorkerNumConfigOption::class,
Option\RedisServersConfigOption::class,
Option\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
Option\Mercure\MercurePublicUrlConfigOption::class,
Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class,
],
'installation_commands' => [

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\PublisherInterface;
return [
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
'jwt_days_duration' => 5,
'jwt_issuer' => 'Shlink',
],
'dependencies' => [
'delegators' => [
LcobucciJwtProvider::class => [
LazyServiceFactory::class,
],
Publisher::class => [
LazyServiceFactory::class,
],
],
'lazy_services' => [
'class_map' => [
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
Publisher::class => PublisherInterface::class,
],
],
],
];

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'mercure' => [
'public_hub_url' => 'http://localhost:3080',
'internal_hub_url' => 'http://shlink_mercure',
'jwt_secret' => 'mercure_jwt_key',
],
];

View file

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
use Laminas\ConfigAggregator;
use Laminas\ZendFrameworkBridge;
use Mezzio;
use Mezzio\ProblemDetails;
@ -30,7 +29,6 @@ return (new ConfigAggregator\ConfigAggregator([
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
: new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
], 'data/cache/app_config.php', [
ZendFrameworkBridge\ConfigPostProcessor::class,
Core\Config\SimplifiedConfigParser::class,
Core\Config\BasePathPrefixer::class,
Core\Config\DeprecatedConfigParser::class,

View file

@ -79,13 +79,17 @@ return [
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
'worker_num' => 1,
'task_worker_num' => 1,
'enable_coroutine' => false,
],
],
],
'mercure' => [
'public_hub_url' => null,
'internal_hub_url' => null,
'jwt_secret' => null,
],
'dependencies' => [
'services' => [
'shlink_test_api_client' => new Client([

View file

@ -27,6 +27,7 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
environment:
LC_ALL: C
@ -47,6 +48,7 @@ services:
- shlink_db_maria
- shlink_db_ms
- shlink_redis
- shlink_mercure
environment:
LC_ALL: C
@ -102,3 +104,12 @@ services:
image: redis:5.0-alpine
ports:
- "6380:6379"
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.8
ports:
- "3080:80"
environment:
CORS_ALLOWED_ORIGINS: "*"
JWT_KEY: "mercure_jwt_key"

View file

@ -73,18 +73,73 @@ It is possible to use a set of env vars to make this shlink instance interact wi
Taking this into account, you could run shlink on a local docker service like this:
```bash
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink:stable
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e DB_DRIVER=mysql \
-e DB_USER=root \
-e DB_PASSWORD=123abc \
-e DB_HOST=something.rds.amazonaws.com \
shlinkio/shlink:stable
```
You could even link to a local database running on a different container:
```bash
docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink:stable
docker run \
--name shlink \
-p 8080:8080 \
[...] \
-e DB_HOST=some_mysql_container \
--link some_mysql_container \
shlinkio/shlink:stable
```
> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
## Supported env vars
## Other integrations
### Use an external redis server
If you plan to run more than one Shlink instance, there are some resources that should be shared ([Multi instance considerations](#multi-instance-considerations)).
One of those resources are the locks Shlink generates to prevent some operations to be run more than once in parallel (in the future, these redis servers could be used for other caching operations).
In order to share those locks, you should use an external redis server (or a cluster of redis servers), by providing the `REDIS_SERVERS` env var.
It can be either one server name or a comma-separated list of servers.
> If more than one redis server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
### Integrate with a mercure hub server
One way to get real time updates when certain events happen in Shlink is by integrating it with a [mercure hub](https://mercure.rocks/) server.
If you do that, Shlink will publish updates and other clients can subscribe to those.
There are three env vars you need to provide if you want to enable this:
* `MERCURE_PUBLIC_HUB_URL`: **[Mandatory]**. The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
* `MERCURE_INTERNAL_HUB_URL`: **[Optional]**. An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided, the `MERCURE_PUBLIC_HUB_URL` one will be used to publish updates.
* `MERCURE_JWT_SECRET`: **[Mandatory]**. The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
So in order to run shlink with mercure integration, you would do it like this:
```bash
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e "MERCURE_PUBLIC_HUB_URL=https://example.com"
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local"
-e MERCURE_JWT_SECRET=super_secret_key
shlinkio/shlink:stable
```
## All supported env vars
A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior.
@ -114,12 +169,9 @@ This is the complete list of supported env vars:
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
* `DEFAULT_SHORT_CODES_LENGTH`: The length you want generated short codes to have. It defaults to 5 and has to be at least 4, so any value smaller than that will fall back to 4.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
If more than one server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
In the future, these redis servers could be used for other caching operations performed by shlink.
* `MERCURE_PUBLIC_HUB_URL`: The public URL of a mercure hub server to which Shlink will sent updates. This URL will also be served to consumers that want to subscribe to those updates.
* `MERCURE_INTERNAL_HUB_URL`: An internal URL for a mercure hub. Will be used only when publishing updates to mercure, and does not need to be public. If this is not provided but `MERCURE_PUBLIC_HUB_URL` was, the former one will be used to publish updates.
* `MERCURE_JWT_SECRET`: The secret key that was provided to the mercure hub server, in order to be able to generate valid JWTs for publishing/subscribing to that server.
An example using all env vars could look like this:
@ -147,6 +199,9 @@ docker run \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
-e "MERCURE_PUBLIC_HUB_URL=https://example.com"
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local"
-e MERCURE_JWT_SECRET=super_secret_key
shlinkio/shlink:stable
```
@ -187,7 +242,10 @@ The whole configuration should have this format, but it can be split into multip
"password": "123abc",
"host": "something.rds.amazonaws.com",
"port": "3306"
}
},
"mercure_public_hub_url": "https://example.com",
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local",
"mercure_jwt_secret": "super_secret_key"
}
```

View file

@ -79,6 +79,17 @@ $helper = new class {
$value = (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH);
return $value < MIN_SHORT_CODES_LENGTH ? MIN_SHORT_CODES_LENGTH : $value;
}
public function getMercureConfig(): array
{
$publicUrl = env('MERCURE_PUBLIC_HUB_URL');
return [
'public_hub_url' => $publicUrl,
'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
'jwt_secret' => env('MERCURE_JWT_SECRET'),
];
}
};
return [
@ -147,4 +158,6 @@ return [
],
],
'mercure' => $helper->getMercureConfig(),
];

View file

@ -0,0 +1,18 @@
{
"type": "object",
"required": ["mercureHubUrl", "jwt", "jwtExpiration"],
"properties": {
"mercureHubUrl": {
"type": "string",
"description": "The public URL of the mercure hub that can be used to get real-time updates published by Shlink"
},
"jwt": {
"type": "string",
"description": "A JWT with subscribe permissions which is valid with the mercure hub"
},
"jwtExpiration": {
"type": "string",
"description": "The date (in ISO-8601 format) in which the JWT will expire"
}
}
}

View file

@ -0,0 +1,67 @@
{
"get": {
"operationId": "mercureInfo",
"tags": [
"Integrations"
],
"summary": "Get mercure integration info",
"description": "Returns information to consume updates published by Shlink on a mercure hub. https://mercure.rocks/",
"parameters": [
{
"$ref": "../parameters/version.json"
}
],
"security": [
{
"ApiKey": []
}
],
"responses": {
"200": {
"description": "The mercure integration info",
"content": {
"application/json": {
"schema": {
"$ref": "../definitions/MercureInfo.json"
}
}
},
"examples": {
"application/json": {
"mercureHubUrl": "https://example.com/.well-known/mercure",
"jwt": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTaGxpbmsiLCJpYXQiOjE1ODY2ODY3MzIsImV4cCI6MTU4Njk0NTkzMiwibWVyY3VyZSI6eyJzdWJzY3JpYmUiOltdfX0.P-519lgU7dFz0bbNlRG1CXyqugGbaHon4kw6fu4QBdQ",
"jwtExpiration": "2020-04-15T12:18:52+02:00"
}
}
},
"501": {
"description": "This Shlink instance is not integrated with a mercure hub",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
},
"examples": {
"application/json": {
"title": "Mercure integration not configured",
"type": "MERCURE_NOT_CONFIGURED",
"detail": "This Shlink instance is not integrated with a mercure hub.",
"status": 501
}
}
},
"500": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
}
}

View file

@ -82,6 +82,10 @@
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
},
"/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json"
},
"/rest/health": {
"$ref": "paths/health.json"
},

View file

@ -38,6 +38,8 @@ return [
Action\QrCodeAction::class => ConfigAbstractFactory::class,
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
],
],
@ -83,6 +85,8 @@ return [
],
Resolver\PersistenceDomainResolver::class => ['em'],
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
],
];

View file

@ -8,12 +8,14 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
use Symfony\Component\Mercure\Publisher;
return [
'events' => [
'regular' => [
EventDispatcher\VisitLocated::class => [
EventDispatcher\NotifyVisitToMercure::class,
EventDispatcher\NotifyVisitToWebHooks::class,
],
],
@ -28,6 +30,7 @@ return [
'factories' => [
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
],
'delegators' => [
@ -53,6 +56,12 @@ return [
'config.url_shortener.domain',
Options\AppOptions::class,
],
EventDispatcher\NotifyVisitToMercure::class => [
Publisher::class,
Mercure\MercureUpdatesGenerator::class,
'em',
'Logger_Shlink',
],
],
];

View file

@ -33,6 +33,9 @@ class SimplifiedConfigParser
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [

View file

@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
use Symfony\Component\Mercure\PublisherInterface;
use Throwable;
class NotifyVisitToMercure
{
private PublisherInterface $publisher;
private MercureUpdatesGeneratorInterface $updatesGenerator;
private EntityManagerInterface $em;
private LoggerInterface $logger;
public function __construct(
PublisherInterface $publisher,
MercureUpdatesGeneratorInterface $updatesGenerator,
EntityManagerInterface $em,
LoggerInterface $logger
) {
$this->publisher = $publisher;
$this->em = $em;
$this->logger = $logger;
$this->updatesGenerator = $updatesGenerator;
}
public function __invoke(VisitLocated $shortUrlLocated): void
{
$visitId = $shortUrlLocated->visitId();
/** @var Visit|null $visit */
$visit = $this->em->find(Visit::class, $visitId);
if ($visit === null) {
$this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [
'visitId' => $visitId,
]);
return;
}
try {
($this->publisher)($this->updatesGenerator->newVisitUpdate($visit));
} catch (Throwable $e) {
$this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
'e' => $e,
]);
}
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Mercure;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
use Symfony\Component\Mercure\Update;
use function json_encode;
use const JSON_THROW_ON_ERROR;
final class MercureUpdatesGenerator implements MercureUpdatesGeneratorInterface
{
private const NEW_VISIT_TOPIC = 'https://shlink.io/new_visit';
private ShortUrlDataTransformer $transformer;
public function __construct(array $domainConfig)
{
$this->transformer = new ShortUrlDataTransformer($domainConfig);
}
public function newVisitUpdate(Visit $visit): Update
{
return new Update(self::NEW_VISIT_TOPIC, json_encode([
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
'visit' => $visit->jsonSerialize(),
], JSON_THROW_ON_ERROR));
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Mercure;
use Shlinkio\Shlink\Core\Entity\Visit;
use Symfony\Component\Mercure\Update;
interface MercureUpdatesGeneratorInterface
{
public function newVisitUpdate(Visit $visit): Update;
}

View file

@ -60,6 +60,9 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
'mercure_public_hub_url' => 'public_url',
'mercure_internal_hub_url' => 'internal_url',
'mercure_jwt_secret' => 'super_secret_value',
];
$expected = [
'app_options' => [
@ -127,6 +130,12 @@ class SimplifiedConfigParserTest extends TestCase
],
],
],
'mercure' => [
'public_hub_url' => 'public_url',
'internal_hub_url' => 'internal_url',
'jwt_secret' => 'super_secret_value',
],
];
$result = ($this->postProcessor)(array_merge($config, $simplified));

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\EventDispatcher;
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\EventDispatcher;
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\DBAL\Connection;
use PHPUnit\Framework\TestCase;

View file

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure;
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface;
use Shlinkio\Shlink\Core\Model\Visitor;
use Symfony\Component\Mercure\PublisherInterface;
use Symfony\Component\Mercure\Update;
class NotifyVisitToMercureTest extends TestCase
{
private NotifyVisitToMercure $listener;
private ObjectProphecy $publisher;
private ObjectProphecy $updatesGenerator;
private ObjectProphecy $em;
private ObjectProphecy $logger;
public function setUp(): void
{
$this->publisher = $this->prophesize(PublisherInterface::class);
$this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class);
$this->em = $this->prophesize(EntityManagerInterface::class);
$this->logger = $this->prophesize(LoggerInterface::class);
$this->listener = new NotifyVisitToMercure(
$this->publisher->reveal(),
$this->updatesGenerator->reveal(),
$this->em->reveal(),
$this->logger->reveal(),
);
}
/** @test */
public function notificationIsNotSentWhenVisitCannotBeFound(): void
{
$visitId = '123';
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null);
$logWarning = $this->logger->warning(
'Tried to notify mercure for visit with id "{visitId}", but it does not exist.',
['visitId' => $visitId],
);
$logDebug = $this->logger->debug(Argument::cetera());
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class))->willReturn(
new Update('', ''),
);
$publish = $this->publisher->__invoke(Argument::type(Update::class));
($this->listener)(new VisitLocated($visitId));
$findVisit->shouldHaveBeenCalledOnce();
$logWarning->shouldHaveBeenCalledOnce();
$logDebug->shouldNotHaveBeenCalled();
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
$publish->shouldNotHaveBeenCalled();
}
/** @test */
public function notificationIsSentWhenVisitIsFound(): void
{
$visitId = '123';
$visit = new Visit(new ShortUrl(''), Visitor::emptyInstance());
$update = new Update('', '');
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
$logWarning = $this->logger->warning(Argument::cetera());
$logDebug = $this->logger->debug(Argument::cetera());
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
$publish = $this->publisher->__invoke($update);
($this->listener)(new VisitLocated($visitId));
$findVisit->shouldHaveBeenCalledOnce();
$logWarning->shouldNotHaveBeenCalled();
$logDebug->shouldNotHaveBeenCalled();
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
$publish->shouldHaveBeenCalledOnce();
}
/** @test */
public function debugIsLoggedWhenExceptionIsThrown(): void
{
$visitId = '123';
$visit = new Visit(new ShortUrl(''), Visitor::emptyInstance());
$update = new Update('', '');
$e = new RuntimeException('Error');
$findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit);
$logWarning = $this->logger->warning(Argument::cetera());
$logDebug = $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [
'e' => $e,
]);
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
$publish = $this->publisher->__invoke($update)->willThrow($e);
($this->listener)(new VisitLocated($visitId));
$findVisit->shouldHaveBeenCalledOnce();
$logWarning->shouldNotHaveBeenCalled();
$logDebug->shouldHaveBeenCalledOnce();
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
$publish->shouldHaveBeenCalledOnce();
}
}

View file

@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
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\Mercure\MercureUpdatesGenerator;
use Shlinkio\Shlink\Core\Model\Visitor;
use function Shlinkio\Shlink\Common\json_decode;
class MercureUpdatesGeneratorTest extends TestCase
{
private MercureUpdatesGenerator $generator;
public function setUp(): void
{
$this->generator = new MercureUpdatesGenerator([]);
}
/** @test */
public function visitIsProperlySerializedIntoUpdate(): void
{
$shortUrl = new ShortUrl('');
$visit = new Visit($shortUrl, Visitor::emptyInstance());
$update = $this->generator->newVisitUpdate($visit);
$this->assertEquals(['https://shlink.io/new_visit'], $update->getTopics());
$this->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,
],
'visit' => [
'referer' => '',
'userAgent' => '',
'visitLocation' => null,
'date' => $visit->getDate()->toAtomString(),
],
], json_decode($update->getData()));
}
}

View file

@ -9,6 +9,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
@ -20,6 +21,7 @@ return [
ApiKeyService::class => ConfigAbstractFactory::class,
Action\HealthAction::class => ConfigAbstractFactory::class,
Action\MercureInfoAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\CreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\SingleStepCreateShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlAction::class => ConfigAbstractFactory::class,
@ -46,6 +48,7 @@ return [
ApiKeyService::class => ['em'],
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'],
Action\ShortUrl\CreateShortUrlAction::class => [
Service\UrlShortener::class,
'config.url_shortener.domain',

View file

@ -33,6 +33,8 @@ return [
Action\Tag\DeleteTagsAction::getRouteDef(),
Action\Tag\CreateTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
Action\MercureInfoAction::getRouteDef(),
],
];

View file

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Throwable;
use function sprintf;
class MercureInfoAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/mercure-info';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private JwtProviderInterface $jwtProvider;
private array $mercureConfig;
public function __construct(
JwtProviderInterface $jwtProvider,
array $mercureConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->jwtProvider = $jwtProvider;
$this->mercureConfig = $mercureConfig;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$hubUrl = $this->mercureConfig['public_hub_url'] ?? null;
if ($hubUrl === null) {
throw MercureException::mercureNotConfigured();
}
$days = $this->mercureConfig['jwt_days_duration'] ?? 3;
$expiresAt = Chronos::now()->addDays($days);
try {
$jwt = $this->jwtProvider->buildSubscriptionToken($expiresAt);
} catch (Throwable $e) {
throw MercureException::mercureNotConfigured($e);
}
return new JsonResponse([
'mercureHubUrl' => sprintf('%s/.well-known/mercure', $hubUrl),
'token' => $jwt,
'jwtExpiration' => $expiresAt->toAtomString(),
]);
}
}

View file

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Exception;
use Fig\Http\Message\StatusCodeInterface;
use Mezzio\ProblemDetails\Exception\CommonProblemDetailsExceptionTrait;
use Mezzio\ProblemDetails\Exception\ProblemDetailsExceptionInterface;
use Throwable;
class MercureException extends RuntimeException implements ProblemDetailsExceptionInterface
{
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Mercure integration not configured';
private const TYPE = 'MERCURE_NOT_CONFIGURED';
public static function mercureNotConfigured(?Throwable $prev = null): self
{
$e = new self('This Shlink instance is not integrated with a mercure hub.', 1, $prev);
$e->detail = $e->getMessage();
$e->title = self::TITLE;
$e->type = self::TYPE;
$e->status = StatusCodeInterface::STATUS_NOT_IMPLEMENTED;
return $e;
}
}

View file

@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action;
use Cake\Chronos\Chronos;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use RuntimeException;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Action\MercureInfoAction;
use Shlinkio\Shlink\Rest\Exception\MercureException;
class MercureInfoActionTest extends TestCase
{
private ObjectProphecy $provider;
public function setUp(): void
{
$this->provider = $this->prophesize(JwtProviderInterface::class);
}
/**
* @test
* @dataProvider provideNoHostConfigs
*/
public function throwsExceptionWhenConfigDoesNotHavePublicHost(array $mercureConfig): void
{
$buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123');
$action = new MercureInfoAction($this->provider->reveal(), $mercureConfig);
$this->expectException(MercureException::class);
$buildToken->shouldNotBeCalled();
$action->handle(ServerRequestFactory::fromGlobals());
}
public function provideNoHostConfigs(): iterable
{
yield 'host not defined' => [[]];
yield 'host is null' => [['public_hub_url' => null]];
}
/**
* @test
* @dataProvider provideValidConfigs
*/
public function throwsExceptionWhenBuildingTokenFails(array $mercureConfig): void
{
$buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willThrow(
new RuntimeException('Error'),
);
$action = new MercureInfoAction($this->provider->reveal(), $mercureConfig);
$this->expectException(MercureException::class);
$buildToken->shouldBeCalledOnce();
$action->handle(ServerRequestFactory::fromGlobals());
}
public function provideValidConfigs(): iterable
{
yield 'days not defined' => [['public_hub_url' => 'http://foobar.com']];
yield 'days defined' => [['public_hub_url' => 'http://foobar.com', 'jwt_days_duration' => 20]];
}
/**
* @test
* @dataProvider provideDays
*/
public function returnsExpectedInfoWhenEverythingIsOk(?int $days): void
{
$buildToken = $this->provider->buildSubscriptionToken(Argument::any())->willReturn('abc.123');
$action = new MercureInfoAction($this->provider->reveal(), [
'public_hub_url' => 'http://foobar.com',
'jwt_days_duration' => $days,
]);
/** @var JsonResponse $resp */
$resp = $action->handle(ServerRequestFactory::fromGlobals());
$payload = $resp->getPayload();
$this->assertArrayHasKey('mercureHubUrl', $payload);
$this->assertEquals('http://foobar.com/.well-known/mercure', $payload['mercureHubUrl']);
$this->assertArrayHasKey('token', $payload);
$this->assertArrayHasKey('jwtExpiration', $payload);
$this->assertEquals(
Chronos::now()->addDays($days ?? 3)->startOfDay(),
Chronos::parse($payload['jwtExpiration'])->startOfDay(),
);
$buildToken->shouldHaveBeenCalledOnce();
}
public function provideDays(): iterable
{
yield 'days not defined' => [null];
yield 'days defined' => [10];
}
}

View file

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Middleware\ShortUrl;
namespace ShlinkioTest\Shlink\Rest\Middleware\ShortUrl;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;