Skip to content

Commit

Permalink
Merge pull request #587 from acelaya-forks/feature/visit-webhook
Browse files Browse the repository at this point in the history
Feature/visit webhook
  • Loading branch information
acelaya authored Dec 29, 2019
2 parents 34d8b39 + b4e3dd7 commit fd61510
Show file tree
Hide file tree
Showing 23 changed files with 445 additions and 55 deletions.
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
9 changes: 3 additions & 6 deletions bin/cli
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);

use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as CliApp;
declare(strict_types=1);

/** @var ContainerInterface $container */
$container = include __DIR__ . '/../config/container.php';
$container->get(CliApp::class)->run();
$run = require __DIR__ . '/../config/run.php';
$run(true);
10 changes: 5 additions & 5 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions config/autoload/installer.global.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
Plugin\UrlShortenerConfigCustomizer::SCHEMA,
Plugin\UrlShortenerConfigCustomizer::HOSTNAME,
Plugin\UrlShortenerConfigCustomizer::VALIDATE_URL,
Plugin\UrlShortenerConfigCustomizer::NOTIFY_VISITS_WEBHOOKS,
Plugin\UrlShortenerConfigCustomizer::VISITS_WEBHOOKS,
],

Plugin\ApplicationConfigCustomizer::class => [
Expand Down
1 change: 1 addition & 0 deletions config/autoload/url-shortener.global.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
'hostname' => '',
],
'validate_url' => true,
'visits_webhooks' => [],
],

];
15 changes: 10 additions & 5 deletions config/container.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@
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 changes: 15 additions & 0 deletions config/run.php
Original file line number Diff line number Diff line change
@@ -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();
};
6 changes: 6 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions docker/config/shlink_in_docker.local.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ public function getNotFoundRedirectsConfig(): array
'base_url' => env('BASE_URL_REDIRECT_TO'),
];
}

public function getVisitsWebhooks(): array
{
$webhooks = env('VISITS_WEBHOOKS');
return $webhooks === null ? [] : explode(',', $webhooks);
}
};

return [
Expand All @@ -125,6 +131,7 @@ public function getNotFoundRedirectsConfig(): array
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'validate_url' => (bool) env('VALIDATE_URLS', true),
'visits_webhooks' => $helper->getVisitsWebhooks(),
],

'not_found_redirects' => $helper->getNotFoundRedirectsConfig(),
Expand Down
17 changes: 16 additions & 1 deletion module/Core/config/event_dispatcher.config.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,19 @@

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;

return [

'events' => [
'regular' => [],
'regular' => [
EventDispatcher\VisitLocated::class => [
EventDispatcher\NotifyVisitToWebHooks::class,
],
],
'async' => [
EventDispatcher\ShortUrlVisited::class => [
EventDispatcher\LocateShortUrlVisit::class,
Expand All @@ -22,6 +27,7 @@
'dependencies' => [
'factories' => [
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
],
],

Expand All @@ -31,6 +37,15 @@
'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,
],
],

Expand Down
1 change: 1 addition & 0 deletions module/Core/src/Config/SimplifiedConfigParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down
5 changes: 5 additions & 0 deletions module/Core/src/Entity/Visit.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ public function hasRemoteAddr(): bool
return ! empty($this->remoteAddr);
}

public function getShortUrl(): ShortUrl
{
return $this->shortUrl;
}

public function getVisitLocation(): VisitLocationInterface
{
return $this->visitLocation ?? new UnknownVisitLocation();
Expand Down
45 changes: 31 additions & 14 deletions module/Core/src/EventDispatcher/LocateShortUrlVisit.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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;
Expand All @@ -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
Expand All @@ -46,42 +51,54 @@ public function __invoke(ShortUrlVisited $shortUrlVisited): void
/** @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'));
});
} 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();
}
}
Loading

0 comments on commit fd61510

Please sign in to comment.