mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge pull request #587 from acelaya-forks/feature/visit-webhook
Feature/visit webhook
This commit is contained in:
commit
fd6151040e
23 changed files with 445 additions and 55 deletions
17
CHANGELOG.md
17
CHANGELOG.md
|
@ -4,7 +4,7 @@ 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]
|
||||
## 1.21.0 - 2019-12-29
|
||||
|
||||
#### Added
|
||||
|
||||
|
@ -22,6 +22,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
|||
* The `GET /short-urls` endpoint now accepts the `startDate` and `endDate` query params.
|
||||
* The `short-urls:list` command now allows `--startDate` and `--endDate` flags to be optionally provided.
|
||||
|
||||
* [#338](https://github.com/shlinkio/shlink/issues/338) Added support to asynchronously notify external services via webhook, only when shlink is served with swoole.
|
||||
|
||||
Configured webhooks will receive a POST request every time a URL receives a visit, including information about the short URL and the visit.
|
||||
|
||||
The payload will look like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"shortUrl": {},
|
||||
"visit": {}
|
||||
}
|
||||
```
|
||||
|
||||
> The `shortUrl` and `visit` props have the same shape as it is defined in the [API spec](https://api-spec.shlink.io).
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#492](https://github.com/shlinkio/shlink/issues/492) Updated to monolog 2, together with other dependencies, like Symfony 5 and infection-php.
|
||||
|
|
9
bin/cli
9
bin/cli
|
@ -1,10 +1,7 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/../config/container.php';
|
||||
$container->get(CliApp::class)->run();
|
||||
$run = require __DIR__ . '/../config/run.php';
|
||||
$run(true);
|
||||
|
|
|
@ -36,7 +36,7 @@
|
|||
"pugx/shortid-php": "^0.5",
|
||||
"shlinkio/shlink-common": "^2.4",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.1",
|
||||
"shlinkio/shlink-installer": "^3.2",
|
||||
"shlinkio/shlink-installer": "^3.3",
|
||||
"shlinkio/shlink-ip-geolocation": "^1.2",
|
||||
"symfony/console": "^5.0",
|
||||
"symfony/filesystem": "^5.0",
|
||||
|
@ -111,7 +111,7 @@
|
|||
"@test:api"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
"test:unit:ci": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml --testdox",
|
||||
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
|
||||
"test:db": [
|
||||
"@test:db:sqlite",
|
||||
"@test:db:mysql",
|
||||
|
@ -123,15 +123,15 @@
|
|||
"@test:db:mysql",
|
||||
"@test:db:postgres"
|
||||
],
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always -c phpunit-db.xml --coverage-php build/coverage-db.cov --testdox",
|
||||
"test:db:sqlite": "APP_ENV=test phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-db.cov --testdox -c phpunit-db.xml",
|
||||
"test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
|
||||
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
|
||||
"test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite",
|
||||
"test:api": "bin/test/run-api-tests.sh",
|
||||
"test:unit:pretty": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-html build/coverage",
|
||||
"infect": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered",
|
||||
"infect:ci": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --coverage=build",
|
||||
"infect:show": "infection --threads=4 --min-msi=80 --log-verbosity=default --only-covered --show-mutations",
|
||||
"infect:ci": "@infect --coverage=build",
|
||||
"infect:show": "@infect --show-mutations",
|
||||
"infect:test": [
|
||||
"@test:unit:ci",
|
||||
"@infect:ci"
|
||||
|
|
|
@ -11,6 +11,8 @@ return [
|
|||
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
|
||||
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
|
||||
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
|
||||
Plugin\UrlShortenerConfigCustomizer::NOTIFY_VISITS_WEBHOOKS,
|
||||
Plugin\UrlShortenerConfigCustomizer::VISITS_WEBHOOKS,
|
||||
],
|
||||
|
||||
Plugin\ApplicationConfigCustomizer::class => [
|
||||
|
|
|
@ -10,6 +10,7 @@ return [
|
|||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => true,
|
||||
'visits_webhooks' => [],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -10,10 +10,15 @@ chdir(dirname(__DIR__));
|
|||
require 'vendor/autoload.php';
|
||||
|
||||
// This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name
|
||||
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
||||
if (! class_exists('Shlinkio\Shlink\LocalLockFactory')) {
|
||||
class_alias(Lock\LockFactory::class, 'Shlinkio\Shlink\LocalLockFactory');
|
||||
}
|
||||
|
||||
// Build container
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$container = new ServiceManager($config['dependencies']);
|
||||
$container->setService('config', $config);
|
||||
return $container;
|
||||
return (function () {
|
||||
$config = require __DIR__ . '/config.php';
|
||||
$container = new ServiceManager($config['dependencies']);
|
||||
$container->setService('config', $config);
|
||||
|
||||
return $container;
|
||||
})();
|
||||
|
|
15
config/run.php
Normal file
15
config/run.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Symfony\Component\Console\Application as CliApp;
|
||||
use Zend\Expressive\Application;
|
||||
|
||||
return function (bool $isCli = false): void {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/container.php';
|
||||
$app = $container->get($isCli ? CliApp::class : Application::class);
|
||||
|
||||
$app->run();
|
||||
};
|
|
@ -110,6 +110,7 @@ This is the complete list of supported env vars:
|
|||
* `BASE_PATH`: The base path from which you plan to serve shlink, in case you don't want to serve it from the root of the domain. Defaults to `''`.
|
||||
* `WEB_WORKER_NUM`: The amount of concurrent http requests this shlink instance will be able to server. Defaults to 16.
|
||||
* `TASK_WORKER_NUM`: The amount of concurrent background tasks this shlink instance will be able to execute. Defaults to 16.
|
||||
* `VISITS_WEBHOOKS`: A comma-separated list of URLs that will receive a `POST` request when a short URL receives a visit.
|
||||
* `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.
|
||||
|
@ -145,6 +146,7 @@ docker run \
|
|||
-e "BASE_PATH=/my-campaign" \
|
||||
-e WEB_WORKER_NUM=64 \
|
||||
-e TASK_WORKER_NUM=32 \
|
||||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
|
@ -173,6 +175,10 @@ The whole configuration should have this format, but it can be split into multip
|
|||
"tcp://172.20.0.1:6379",
|
||||
"tcp://172.20.0.2:6379"
|
||||
],
|
||||
"visits_webhooks": [
|
||||
"http://my-api.com/api/v2.3/notify",
|
||||
"https://third-party.io/foo"
|
||||
],
|
||||
"db_config": {
|
||||
"driver": "pdo_mysql",
|
||||
"dbname": "shlink",
|
||||
|
|
|
@ -99,6 +99,12 @@ $helper = new class {
|
|||
'base_url' => env('BASE_URL_REDIRECT_TO'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getVisitsWebhooks(): array
|
||||
{
|
||||
$webhooks = env('VISITS_WEBHOOKS');
|
||||
return $webhooks === null ? [] : explode(',', $webhooks);
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
|
@ -125,6 +131,7 @@ return [
|
|||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
],
|
||||
|
||||
'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
|
||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface;
|
||||
use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
||||
|
@ -11,7 +12,11 @@ use Zend\ServiceManager\AbstractFactory\ConfigAbstractFactory;
|
|||
return [
|
||||
|
||||
'events' => [
|
||||
'regular' => [],
|
||||
'regular' => [
|
||||
EventDispatcher\VisitLocated::class => [
|
||||
EventDispatcher\NotifyVisitToWebHooks::class,
|
||||
],
|
||||
],
|
||||
'async' => [
|
||||
EventDispatcher\ShortUrlVisited::class => [
|
||||
EventDispatcher\LocateShortUrlVisit::class,
|
||||
|
@ -22,6 +27,7 @@ return [
|
|||
'dependencies' => [
|
||||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -31,6 +37,15 @@ return [
|
|||
'em',
|
||||
'Logger_Shlink',
|
||||
GeolocationDbUpdater::class,
|
||||
EventDispatcherInterface::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => [
|
||||
'httpClient',
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
'config.url_shortener.visits_webhooks',
|
||||
'config.url_shortener.domain',
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ class SimplifiedConfigParser
|
|||
'base_path' => ['router', 'base_path'],
|
||||
'web_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'worker_num'],
|
||||
'task_worker_num' => ['zend-expressive-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
|
||||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
|
|
@ -61,6 +61,11 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
return ! empty($this->remoteAddr);
|
||||
}
|
||||
|
||||
public function getShortUrl(): ShortUrl
|
||||
{
|
||||
return $this->shortUrl;
|
||||
}
|
||||
|
||||
public function getVisitLocation(): VisitLocationInterface
|
||||
{
|
||||
return $this->visitLocation ?? new UnknownVisitLocation();
|
||||
|
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
|
@ -26,17 +27,21 @@ class LocateShortUrlVisit
|
|||
private $logger;
|
||||
/** @var GeolocationDbUpdaterInterface */
|
||||
private $dbUpdater;
|
||||
/** @var EventDispatcherInterface */
|
||||
private $eventDispatcher;
|
||||
|
||||
public function __construct(
|
||||
IpLocationResolverInterface $ipLocationResolver,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger,
|
||||
GeolocationDbUpdaterInterface $dbUpdater
|
||||
GeolocationDbUpdaterInterface $dbUpdater,
|
||||
EventDispatcherInterface $eventDispatcher
|
||||
) {
|
||||
$this->ipLocationResolver = $ipLocationResolver;
|
||||
$this->em = $em;
|
||||
$this->logger = $logger;
|
||||
$this->dbUpdater = $dbUpdater;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
}
|
||||
|
||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||
|
@ -46,10 +51,21 @@ class LocateShortUrlVisit
|
|||
/** @var Visit|null $visit */
|
||||
$visit = $this->em->find(Visit::class, $visitId);
|
||||
if ($visit === null) {
|
||||
$this->logger->warning(sprintf('Tried to locate visit with id "%s", but it does not exist.', $visitId));
|
||||
$this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
||||
'visitId' => $visitId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->downloadOrUpdateGeoLiteDb($visitId)) {
|
||||
$this->locateVisit($visitId, $visit);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||
}
|
||||
|
||||
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
|
||||
{
|
||||
try {
|
||||
$this->dbUpdater->checkDbUpdate(function (bool $olderDbExists) {
|
||||
$this->logger->notice(sprintf('%s GeoLite2 database...', $olderDbExists ? 'Updating' : 'Downloading'));
|
||||
|
@ -57,31 +73,32 @@ class LocateShortUrlVisit
|
|||
} catch (GeolocationDbUpdateFailedException $e) {
|
||||
if (! $e->olderDbExists()) {
|
||||
$this->logger->error(
|
||||
sprintf(
|
||||
'GeoLite2 database download failed. It is not possible to locate visit with id %s. {e}',
|
||||
$visitId
|
||||
),
|
||||
['e' => $e]
|
||||
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
|
||||
['e' => $e, 'visitId' => $visitId]
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->logger->warning('GeoLite2 database update failed. Proceeding with old version. {e}', ['e' => $e]);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function locateVisit(string $visitId, Visit $visit): void
|
||||
{
|
||||
try {
|
||||
$location = $visit->isLocatable()
|
||||
? $this->ipLocationResolver->resolveIpLocation($visit->getRemoteAddr())
|
||||
: Location::emptyInstance();
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$this->em->flush();
|
||||
} catch (WrongIpException $e) {
|
||||
$this->logger->warning(
|
||||
sprintf('Tried to locate visit with id "%s", but its address seems to be wrong. {e}', $visitId),
|
||||
['e' => $e]
|
||||
'Tried to locate visit with id "{visitId}", but its address seems to be wrong. {e}',
|
||||
['e' => $e, 'visitId' => $visitId]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
$visit->locate(new VisitLocation($location));
|
||||
$this->em->flush();
|
||||
}
|
||||
}
|
||||
|
|
113
module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php
Normal file
113
module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php
Normal file
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Closure;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
use Throwable;
|
||||
|
||||
use function Functional\map;
|
||||
use function Functional\partial_left;
|
||||
use function GuzzleHttp\Promise\settle;
|
||||
|
||||
class NotifyVisitToWebHooks
|
||||
{
|
||||
/** @var ClientInterface */
|
||||
private $httpClient;
|
||||
/** @var EntityManagerInterface */
|
||||
private $em;
|
||||
/** @var LoggerInterface */
|
||||
private $logger;
|
||||
/** @var array */
|
||||
private $webhooks;
|
||||
/** @var ShortUrlDataTransformer */
|
||||
private $transformer;
|
||||
/** @var AppOptions */
|
||||
private $appOptions;
|
||||
|
||||
public function __construct(
|
||||
ClientInterface $httpClient,
|
||||
EntityManagerInterface $em,
|
||||
LoggerInterface $logger,
|
||||
array $webhooks,
|
||||
array $domainConfig,
|
||||
AppOptions $appOptions
|
||||
) {
|
||||
$this->httpClient = $httpClient;
|
||||
$this->em = $em;
|
||||
$this->logger = $logger;
|
||||
$this->webhooks = $webhooks;
|
||||
$this->transformer = new ShortUrlDataTransformer($domainConfig);
|
||||
$this->appOptions = $appOptions;
|
||||
}
|
||||
|
||||
public function __invoke(VisitLocated $shortUrlLocated): void
|
||||
{
|
||||
if (empty($this->webhooks)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$visitId = $shortUrlLocated->visitId();
|
||||
|
||||
/** @var Visit|null $visit */
|
||||
$visit = $this->em->find(Visit::class, $visitId);
|
||||
if ($visit === null) {
|
||||
$this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [
|
||||
'visitId' => $visitId,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
$requestOptions = $this->buildRequestOptions($visit);
|
||||
$requestPromises = $this->performRequests($requestOptions, $visitId);
|
||||
|
||||
// Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error.
|
||||
settle($requestPromises)->wait();
|
||||
}
|
||||
|
||||
private function buildRequestOptions(Visit $visit): array
|
||||
{
|
||||
return [
|
||||
RequestOptions::TIMEOUT => 10,
|
||||
RequestOptions::HEADERS => [
|
||||
'User-Agent' => (string) $this->appOptions,
|
||||
],
|
||||
RequestOptions::JSON => [
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl(), false),
|
||||
'visit' => $visit->jsonSerialize(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Promise[] $requestOptions
|
||||
*/
|
||||
private function performRequests(array $requestOptions, string $visitId): array
|
||||
{
|
||||
return map($this->webhooks, function (string $webhook) use ($requestOptions, $visitId) {
|
||||
$promise = $this->httpClient->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions);
|
||||
return $promise->otherwise(
|
||||
partial_left(Closure::fromCallable([$this, 'logWebhookFailure']), $webhook, $visitId)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
|
||||
{
|
||||
$this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [
|
||||
'visitId' => $visitId,
|
||||
'webhook' => $webhook,
|
||||
'e' => $e,
|
||||
]);
|
||||
}
|
||||
}
|
28
module/Core/src/EventDispatcher/VisitLocated.php
Normal file
28
module/Core/src/EventDispatcher/VisitLocated.php
Normal file
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class VisitLocated implements JsonSerializable
|
||||
{
|
||||
/** @var string */
|
||||
private $visitId;
|
||||
|
||||
public function __construct(string $visitId)
|
||||
{
|
||||
$this->visitId = $visitId;
|
||||
}
|
||||
|
||||
public function visitId(): string
|
||||
{
|
||||
return $this->visitId;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return ['visitId' => $this->visitId];
|
||||
}
|
||||
}
|
|
@ -23,11 +23,11 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
|||
/**
|
||||
* @param ShortUrl $shortUrl
|
||||
*/
|
||||
public function transform($shortUrl): array
|
||||
public function transform($shortUrl, bool $includeDeprecated = true): array
|
||||
{
|
||||
$longUrl = $shortUrl->getLongUrl();
|
||||
|
||||
return [
|
||||
$rawData = [
|
||||
'shortCode' => $shortUrl->getShortCode(),
|
||||
'shortUrl' => $shortUrl->toString($this->domainConfig),
|
||||
'longUrl' => $longUrl,
|
||||
|
@ -35,10 +35,13 @@ class ShortUrlDataTransformer implements DataTransformerInterface
|
|||
'visitsCount' => $shortUrl->getVisitsCount(),
|
||||
'tags' => invoke($shortUrl->getTags(), '__toString'),
|
||||
'meta' => $this->buildMeta($shortUrl),
|
||||
|
||||
// Deprecated
|
||||
'originalUrl' => $longUrl,
|
||||
];
|
||||
|
||||
if ($includeDeprecated) {
|
||||
$rawData['originalUrl'] = $longUrl;
|
||||
}
|
||||
|
||||
return $rawData;
|
||||
}
|
||||
|
||||
private function buildMeta(ShortUrl $shortUrl): array
|
||||
|
|
|
@ -53,6 +53,10 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
],
|
||||
'base_path' => '/foo/bar',
|
||||
'task_worker_num' => 50,
|
||||
'visits_webhooks' => [
|
||||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
|
@ -76,6 +80,10 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'hostname' => 'doma.in',
|
||||
],
|
||||
'validate_url' => false,
|
||||
'visits_webhooks' => [
|
||||
'http://my-api.com/api/v2.3/notify',
|
||||
'https://third-party.io/foo',
|
||||
],
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
|
|
|
@ -8,6 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
|
@ -17,6 +18,7 @@ use Shlinkio\Shlink\Core\Entity\Visit;
|
|||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\LocateShortUrlVisit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\UnknownVisitLocation;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
|
@ -35,6 +37,8 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
private $logger;
|
||||
/** @var ObjectProphecy */
|
||||
private $dbUpdater;
|
||||
/** @var ObjectProphecy */
|
||||
private $eventDispatcher;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
|
@ -42,12 +46,14 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->locateVisit = new LocateShortUrlVisit(
|
||||
$this->ipLocationResolver->reveal(),
|
||||
$this->em->reveal(),
|
||||
$this->logger->reveal(),
|
||||
$this->dbUpdater->reveal()
|
||||
$this->dbUpdater->reveal(),
|
||||
$this->eventDispatcher->reveal()
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -56,7 +62,11 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
{
|
||||
$event = new ShortUrlVisited('123');
|
||||
$findVisit = $this->em->find(Visit::class, '123')->willReturn(null);
|
||||
$logWarning = $this->logger->warning('Tried to locate visit with id "123", but it does not exist.');
|
||||
$logWarning = $this->logger->warning('Tried to locate visit with id "{visitId}", but it does not exist.', [
|
||||
'visitId' => 123,
|
||||
]);
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -64,6 +74,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$this->em->flush()->shouldNotHaveBeenCalled();
|
||||
$this->ipLocationResolver->resolveIpLocation(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$logWarning->shouldHaveBeenCalled();
|
||||
$dispatch->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -77,9 +88,11 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
WrongIpException::class
|
||||
);
|
||||
$logWarning = $this->logger->warning(
|
||||
Argument::containingString('Tried to locate visit with id "123", but its address seems to be wrong.'),
|
||||
Argument::containingString('Tried to locate visit with id "{visitId}", but its address seems to be wrong.'),
|
||||
Argument::type('array')
|
||||
);
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -87,6 +100,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$resolveLocation->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldHaveBeenCalled();
|
||||
$this->em->flush()->shouldNotHaveBeenCalled();
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -100,6 +114,8 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$flush = $this->em->flush()->will(function () {
|
||||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation(Argument::any());
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -108,6 +124,7 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideNonLocatableVisits(): iterable
|
||||
|
@ -131,6 +148,8 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$flush = $this->em->flush()->will(function () {
|
||||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -139,10 +158,11 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$flush->shouldHaveBeenCalledOnce();
|
||||
$resolveIp->shouldHaveBeenCalledOnce();
|
||||
$this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled();
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenUpdatingGeoliteWithExistingCopyLogsWarning(): void
|
||||
public function errorWhenUpdatingGeoLiteWithExistingCopyLogsWarning(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create(true);
|
||||
$ipAddr = '1.2.3.0';
|
||||
|
@ -155,6 +175,8 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
});
|
||||
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
|
||||
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -167,10 +189,11 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
'GeoLite2 database update failed. Proceeding with old version. {e}',
|
||||
['e' => $e]
|
||||
)->shouldHaveBeenCalledOnce();
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function errorWhenDownloadingGeoliteCancelsLocation(): void
|
||||
public function errorWhenDownloadingGeoLiteCancelsLocation(): void
|
||||
{
|
||||
$e = GeolocationDbUpdateFailedException::create(false);
|
||||
$ipAddr = '1.2.3.0';
|
||||
|
@ -184,9 +207,11 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$resolveIp = $this->ipLocationResolver->resolveIpLocation($ipAddr)->willReturn($location);
|
||||
$checkUpdateDb = $this->dbUpdater->checkDbUpdate(Argument::cetera())->willThrow($e);
|
||||
$logError = $this->logger->error(
|
||||
'GeoLite2 database download failed. It is not possible to locate visit with id 123. {e}',
|
||||
['e' => $e]
|
||||
'GeoLite2 database download failed. It is not possible to locate visit with id {visitId}. {e}',
|
||||
['e' => $e, 'visitId' => 123]
|
||||
);
|
||||
$dispatch = $this->eventDispatcher->dispatch(new VisitLocated('123'))->will(function () {
|
||||
});
|
||||
|
||||
($this->locateVisit)($event);
|
||||
|
||||
|
@ -196,5 +221,6 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
$resolveIp->shouldNotHaveBeenCalled();
|
||||
$checkUpdateDb->shouldHaveBeenCalledOnce();
|
||||
$logError->shouldHaveBeenCalledOnce();
|
||||
$dispatch->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
132
module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php
Normal file
132
module/Rest/test/EventDispatcher/NotifyVisitToWebHooksTest.php
Normal file
|
@ -0,0 +1,132 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
||||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\FulfilledPromise;
|
||||
use GuzzleHttp\Promise\RejectedPromise;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\VisitLocated;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
|
||||
use function count;
|
||||
use function Functional\contains;
|
||||
|
||||
class NotifyVisitToWebHooksTest extends TestCase
|
||||
{
|
||||
/** @var ObjectProphecy */
|
||||
private $httpClient;
|
||||
/** @var ObjectProphecy */
|
||||
private $em;
|
||||
/** @var ObjectProphecy */
|
||||
private $logger;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->httpClient = $this->prophesize(ClientInterface::class);
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function emptyWebhooksMakeNoFurtherActions(): void
|
||||
{
|
||||
$find = $this->em->find(Visit::class, '1')->willReturn(null);
|
||||
|
||||
$this->createListener([])(new VisitLocated('1'));
|
||||
|
||||
$find->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function invalidVisitDoesNotPerformAnyRequest(): void
|
||||
{
|
||||
$find = $this->em->find(Visit::class, '1')->willReturn(null);
|
||||
$requestAsync = $this->httpClient->requestAsync(
|
||||
RequestMethodInterface::METHOD_POST,
|
||||
Argument::type('string'),
|
||||
Argument::type('array')
|
||||
)->willReturn(new FulfilledPromise(''));
|
||||
$logWarning = $this->logger->warning(
|
||||
'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.',
|
||||
['visitId' => '1']
|
||||
);
|
||||
|
||||
$this->createListener(['foo', 'bar'])(new VisitLocated('1'));
|
||||
|
||||
$find->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldHaveBeenCalledOnce();
|
||||
$requestAsync->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function expectedRequestsArePerformedToWebhooks(): void
|
||||
{
|
||||
$webhooks = ['foo', 'invalid', 'bar', 'baz'];
|
||||
$invalidWebhooks = ['invalid', 'baz'];
|
||||
|
||||
$find = $this->em->find(Visit::class, '1')->willReturn(new Visit(new ShortUrl(''), Visitor::emptyInstance()));
|
||||
$requestAsync = $this->httpClient->requestAsync(
|
||||
RequestMethodInterface::METHOD_POST,
|
||||
Argument::type('string'),
|
||||
Argument::that(function (array $requestOptions) {
|
||||
Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions);
|
||||
Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions);
|
||||
Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions);
|
||||
Assert::assertEquals($requestOptions[RequestOptions::TIMEOUT], 10);
|
||||
Assert::assertEquals($requestOptions[RequestOptions::HEADERS], ['User-Agent' => 'Shlink:v1.2.3']);
|
||||
Assert::assertArrayHasKey('shortUrl', $requestOptions[RequestOptions::JSON]);
|
||||
Assert::assertArrayHasKey('visit', $requestOptions[RequestOptions::JSON]);
|
||||
|
||||
return $requestOptions;
|
||||
})
|
||||
)->will(function (array $args) use ($invalidWebhooks) {
|
||||
[, $webhook] = $args;
|
||||
$e = new Exception('');
|
||||
|
||||
return contains($invalidWebhooks, $webhook) ? new RejectedPromise($e) : new FulfilledPromise('');
|
||||
});
|
||||
$logWarning = $this->logger->warning(
|
||||
'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}',
|
||||
Argument::that(function (array $extra) {
|
||||
Assert::assertArrayHasKey('webhook', $extra);
|
||||
Assert::assertArrayHasKey('visitId', $extra);
|
||||
Assert::assertArrayHasKey('e', $extra);
|
||||
|
||||
return $extra;
|
||||
})
|
||||
);
|
||||
|
||||
$this->createListener($webhooks)(new VisitLocated('1'));
|
||||
|
||||
$find->shouldHaveBeenCalledOnce();
|
||||
$requestAsync->shouldHaveBeenCalledTimes(count($webhooks));
|
||||
$logWarning->shouldHaveBeenCalledTimes(count($invalidWebhooks));
|
||||
}
|
||||
|
||||
private function createListener(array $webhooks): NotifyVisitToWebHooks
|
||||
{
|
||||
return new NotifyVisitToWebHooks(
|
||||
$this->httpClient->reveal(),
|
||||
$this->em->reveal(),
|
||||
$this->logger->reveal(),
|
||||
$webhooks,
|
||||
[],
|
||||
new AppOptions(['name' => 'Shlink', 'version' => '1.2.3'])
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
|
||||
bootstrap="./config/test/bootstrap_api_tests.php"
|
||||
colors="true"
|
||||
>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
|
||||
bootstrap="./config/test/bootstrap_db_tests.php"
|
||||
colors="true"
|
||||
>
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0"?>
|
||||
<phpunit
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/6.5/phpunit.xsd"
|
||||
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/8.5/phpunit.xsd"
|
||||
bootstrap="./vendor/autoload.php"
|
||||
colors="true"
|
||||
>
|
||||
|
|
|
@ -2,11 +2,5 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Zend\Expressive\Application;
|
||||
|
||||
(function () {
|
||||
/** @var ContainerInterface $container */
|
||||
$container = include __DIR__ . '/../config/container.php';
|
||||
$container->get(Application::class)->run();
|
||||
})();
|
||||
$run = require __DIR__ . '/../config/run.php';
|
||||
$run();
|
||||
|
|
Loading…
Add table
Reference in a new issue