mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-28 09:03:07 +03:00
commit
cb6756d801
122 changed files with 2911 additions and 402 deletions
|
@ -8,8 +8,6 @@ php:
|
|||
- '7.4'
|
||||
|
||||
services:
|
||||
- mysql
|
||||
- postgresql
|
||||
- docker
|
||||
|
||||
cache:
|
||||
|
@ -17,8 +15,10 @@ cache:
|
|||
- $HOME/.composer/cache/files
|
||||
|
||||
before_install:
|
||||
- sudo ./data/infra/ci/install-ms-odbc.sh
|
||||
- docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria
|
||||
- yes | pecl install pdo_sqlsrv swoole-4.4.18
|
||||
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
|
||||
- yes | pecl install swoole-4.4.15
|
||||
- phpenv config-rm xdebug.ini || return 0
|
||||
|
||||
install:
|
||||
|
@ -26,8 +26,7 @@ install:
|
|||
- composer install --no-interaction --prefer-dist
|
||||
|
||||
before_script:
|
||||
- mysql -e 'CREATE DATABASE shlink_test;'
|
||||
- psql -c 'create database shlink_test;' -U postgres
|
||||
- docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;"
|
||||
- mkdir build
|
||||
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
|
||||
|
||||
|
|
52
CHANGELOG.md
52
CHANGELOG.md
|
@ -4,6 +4,58 @@ 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).
|
||||
|
||||
## 2.2.0 - 2020-05-09
|
||||
|
||||
#### 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, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl:
|
||||
|
||||
* A visit occurs on any short URL: `https://shlink.io/new-visit`.
|
||||
* A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`.
|
||||
|
||||
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 subscribe to updates.
|
||||
|
||||
* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats.
|
||||
* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag.
|
||||
|
||||
It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag.
|
||||
|
||||
* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag.
|
||||
|
||||
Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag.
|
||||
|
||||
Also, the `tag:list` CLI command has been changed and it always behaves like this.
|
||||
|
||||
* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation.
|
||||
|
||||
#### Changed
|
||||
|
||||
* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets.
|
||||
* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered.
|
||||
* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed.
|
||||
|
||||
#### Deprecated
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Removed
|
||||
|
||||
* *Nothing*
|
||||
|
||||
#### Fixed
|
||||
|
||||
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql.
|
||||
* [#735](https://github.com/shlinkio/shlink/issues/735) Fixed error when cleaning metadata cache during installation when APCu is enabled.
|
||||
* [#677](https://github.com/shlinkio/shlink/issues/677) Fixed `/health` endpoint returning `503` fail responses when the database connection has expired.
|
||||
* [#732](https://github.com/shlinkio/shlink/issues/732) Fixed wrong client IP in access logs when serving app with swoole behind load balancer.
|
||||
|
||||
|
||||
## 2.1.4 - 2020-04-30
|
||||
|
||||
#### Added
|
||||
|
|
11
Dockerfile
11
Dockerfile
|
@ -1,8 +1,8 @@
|
|||
FROM php:7.4.2-alpine3.11 as base
|
||||
FROM php:7.4.5-alpine3.11 as base
|
||||
|
||||
ARG SHLINK_VERSION=2.0.5
|
||||
ARG SHLINK_VERSION=2.1.4
|
||||
ENV SHLINK_VERSION ${SHLINK_VERSION}
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
ENV LC_ALL "C"
|
||||
|
||||
WORKDIR /etc/shlink
|
||||
|
@ -25,15 +25,12 @@ RUN \
|
|||
|
||||
# Install swoole and sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
|
||||
# Install shlink
|
||||
|
|
|
@ -17,13 +17,11 @@
|
|||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^1.0",
|
||||
"cakephp/chronos": "^1.2",
|
||||
"cocur/slugify": "^3.0",
|
||||
"doctrine/cache": "^1.9",
|
||||
"doctrine/dbal": "^2.10",
|
||||
"doctrine/migrations": "^2.2",
|
||||
"doctrine/orm": "^2.7",
|
||||
"endroid/qr-code": "^3.6",
|
||||
"firebase/php-jwt": "^4.0",
|
||||
"geoip2/geoip2": "^2.9",
|
||||
"guzzlehttp/guzzle": "^6.5.1",
|
||||
"laminas/laminas-config": "^3.3",
|
||||
|
@ -34,13 +32,14 @@
|
|||
"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",
|
||||
"mezzio/mezzio-helpers": "^5.3",
|
||||
"mezzio/mezzio-platesrenderer": "^2.1",
|
||||
"mezzio/mezzio-problem-details": "^1.1",
|
||||
"mezzio/mezzio-swoole": "^2.6",
|
||||
"mezzio/mezzio-swoole": "^2.6.4",
|
||||
"monolog/monolog": "^2.0",
|
||||
"nikolaposa/monolog-factory": "^3.0",
|
||||
"ocramius/proxy-manager": "^2.7.0",
|
||||
|
@ -49,14 +48,15 @@
|
|||
"predis/predis": "^1.1",
|
||||
"pugx/shortid-php": "^0.5",
|
||||
"ramsey/uuid": "^3.9",
|
||||
"shlinkio/shlink-common": "^3.0",
|
||||
"shlinkio/shlink-common": "^3.1.0",
|
||||
"shlinkio/shlink-config": "^1.0",
|
||||
"shlinkio/shlink-event-dispatcher": "^1.4",
|
||||
"shlinkio/shlink-installer": "^4.4.0",
|
||||
"shlinkio/shlink-installer": "^5.0.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": {
|
||||
|
@ -109,7 +109,7 @@
|
|||
],
|
||||
"test:ci": [
|
||||
"@test:unit:ci",
|
||||
"@test:db:ci",
|
||||
"@test:db",
|
||||
"@test:api:ci"
|
||||
],
|
||||
"test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
|
||||
|
@ -121,11 +121,6 @@
|
|||
"@test:db:postgres",
|
||||
"@test:db:ms"
|
||||
],
|
||||
"test:db:ci": [
|
||||
"@test:db:sqlite",
|
||||
"@test:db:mysql",
|
||||
"@test:db:postgres"
|
||||
],
|
||||
"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",
|
||||
|
@ -152,8 +147,7 @@
|
|||
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:unit": "<fg=blue;options=bold>Runs unit test suites</>",
|
||||
"test:unit:ci": "<fg=blue;options=bold>Runs unit test suites, generating all needed reports and logs for CI envs</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB and PostgreSQL</>",
|
||||
"test:db:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL and PostgreSQL</>",
|
||||
"test:db": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
|
||||
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>",
|
||||
"test:db:mysql": "<fg=blue;options=bold>Runs database test suites on a MySQL database</>",
|
||||
"test:db:maria": "<fg=blue;options=bold>Runs database test suites on a MariaDB database</>",
|
||||
|
|
|
@ -2,6 +2,14 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
use function Shlinkio\Shlink\Common\env;
|
||||
|
||||
// When running tests, any mysql-specific option can interfere with other drivers
|
||||
$driverOptions = env('APP_ENV') === 'test' ? [] : [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
];
|
||||
|
||||
return [
|
||||
|
||||
'entity_manager' => [
|
||||
|
@ -10,9 +18,7 @@ return [
|
|||
'password' => 'root',
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => 'shlink_db',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
],
|
||||
'driverOptions' => $driverOptions,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -8,30 +8,35 @@ return [
|
|||
|
||||
'installer' => [
|
||||
'enabled_options' => [
|
||||
Option\DatabaseDriverConfigOption::class,
|
||||
Option\DatabaseNameConfigOption::class,
|
||||
Option\DatabaseHostConfigOption::class,
|
||||
Option\DatabasePortConfigOption::class,
|
||||
Option\DatabaseUserConfigOption::class,
|
||||
Option\DatabasePasswordConfigOption::class,
|
||||
Option\DatabaseSqlitePathConfigOption::class,
|
||||
Option\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\ShortDomainHostConfigOption::class,
|
||||
Option\ShortDomainSchemaConfigOption::class,
|
||||
Option\ValidateUrlConfigOption::class,
|
||||
Option\VisitsWebhooksConfigOption::class,
|
||||
Option\BaseUrlRedirectConfigOption::class,
|
||||
Option\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Regular404RedirectConfigOption::class,
|
||||
Option\Database\DatabaseDriverConfigOption::class,
|
||||
Option\Database\DatabaseNameConfigOption::class,
|
||||
Option\Database\DatabaseHostConfigOption::class,
|
||||
Option\Database\DatabasePortConfigOption::class,
|
||||
Option\Database\DatabaseUserConfigOption::class,
|
||||
Option\Database\DatabasePasswordConfigOption::class,
|
||||
Option\Database\DatabaseSqlitePathConfigOption::class,
|
||||
Option\Database\DatabaseMySqlOptionsConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainHostConfigOption::class,
|
||||
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
|
||||
Option\UrlShortener\ValidateUrlConfigOption::class,
|
||||
Option\Visit\VisitsWebhooksConfigOption::class,
|
||||
Option\Redirect\BaseUrlRedirectConfigOption::class,
|
||||
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
|
||||
Option\Redirect\Regular404RedirectConfigOption::class,
|
||||
Option\DisableTrackParamConfigOption::class,
|
||||
Option\CheckVisitsThresholdConfigOption::class,
|
||||
Option\VisitsThresholdConfigOption::class,
|
||||
Option\Visit\CheckVisitsThresholdConfigOption::class,
|
||||
Option\Visit\VisitsThresholdConfigOption::class,
|
||||
Option\BasePathConfigOption::class,
|
||||
Option\TaskWorkerNumConfigOption::class,
|
||||
Option\WebWorkerNumConfigOption::class,
|
||||
Option\Worker\TaskWorkerNumConfigOption::class,
|
||||
Option\Worker\WebWorkerNumConfigOption::class,
|
||||
Option\RedisServersConfigOption::class,
|
||||
Option\ShortCodeLengthOption::class,
|
||||
Option\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\ShortCodeLengthOption::class,
|
||||
Option\Mercure\EnableMercureConfigOption::class,
|
||||
Option\Mercure\MercurePublicUrlConfigOption::class,
|
||||
Option\Mercure\MercureInternalUrlConfigOption::class,
|
||||
Option\Mercure\MercureJwtSecretConfigOption::class,
|
||||
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
|
||||
Option\UrlShortener\IpAnonymizationConfigOption::class,
|
||||
],
|
||||
|
||||
'installation_commands' => [
|
||||
|
|
36
config/autoload/mercure.global.php
Normal file
36
config/autoload/mercure.global.php
Normal file
|
@ -0,0 +1,36 @@
|
|||
<?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_issuer' => 'Shlink',
|
||||
],
|
||||
|
||||
'dependencies' => [
|
||||
'delegators' => [
|
||||
LcobucciJwtProvider::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
Publisher::class => [
|
||||
LazyServiceFactory::class,
|
||||
],
|
||||
],
|
||||
'lazy_services' => [
|
||||
'class_map' => [
|
||||
LcobucciJwtProvider::class => LcobucciJwtProvider::class,
|
||||
Publisher::class => PublisherInterface::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
];
|
13
config/autoload/mercure.local.php.dist
Normal file
13
config/autoload/mercure.local.php.dist
Normal file
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
return [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'http://localhost:8001',
|
||||
'internal_hub_url' => 'http://shlink_mercure_proxy',
|
||||
'jwt_secret' => 'mercure_jwt_key',
|
||||
],
|
||||
|
||||
];
|
|
@ -5,7 +5,8 @@ declare(strict_types=1);
|
|||
return [
|
||||
|
||||
'mezzio-swoole' => [
|
||||
'enable_coroutine' => true,
|
||||
// Setting this to true can have unexpected behaviors when running several concurrent slow DB queries
|
||||
'enable_coroutine' => false,
|
||||
|
||||
'swoole-http-server' => [
|
||||
'host' => '0.0.0.0',
|
||||
|
|
|
@ -12,6 +12,7 @@ return [
|
|||
'hostname' => '',
|
||||
],
|
||||
'validate_url' => false,
|
||||
'anonymize_remote_addr' => true,
|
||||
'visits_webhooks' => [],
|
||||
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
|
||||
],
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -20,6 +20,7 @@ $buildDbConnection = function (): array {
|
|||
$driver = env('DB_DRIVER', 'sqlite');
|
||||
$isCi = env('TRAVIS', false);
|
||||
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
|
||||
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
|
||||
|
||||
$driverConfigMap = [
|
||||
'sqlite' => [
|
||||
|
@ -29,19 +30,22 @@ $buildDbConnection = function (): array {
|
|||
'mysql' => [
|
||||
'driver' => 'pdo_mysql',
|
||||
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
|
||||
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
|
||||
'user' => 'root',
|
||||
'password' => $isCi ? '' : 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
'driverOptions' => [
|
||||
PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
|
||||
PDO::MYSQL_ATTR_USE_BUFFERED_QUERY => true,
|
||||
],
|
||||
],
|
||||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
'port' => $isCi ? '5433' : '5432',
|
||||
'user' => 'postgres',
|
||||
'password' => $isCi ? '' : 'root',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
'charset' => 'utf8',
|
||||
],
|
||||
|
@ -49,7 +53,7 @@ $buildDbConnection = function (): array {
|
|||
'driver' => 'pdo_sqlsrv',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
|
||||
'user' => 'sa',
|
||||
'password' => $isCi ? '' : 'Passw0rd!',
|
||||
'password' => 'Passw0rd!',
|
||||
'dbname' => 'shlink_test',
|
||||
],
|
||||
];
|
||||
|
@ -79,13 +83,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([
|
||||
|
|
9
data/infra/ci/install-ms-odbc.sh
Executable file
9
data/infra/ci/install-ms-odbc.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
set -ex
|
||||
|
||||
curl https://packages.microsoft.com/keys/microsoft.asc | apt-key add -
|
||||
curl https://packages.microsoft.com/config/ubuntu/16.04/prod.list > /etc/apt/sources.list.d/mssql-release.list
|
||||
apt-get update
|
||||
ACCEPT_EULA=Y apt-get install msodbcsql17
|
||||
apt-get install unixodbc-dev
|
17
data/infra/mercure_proxy_vhost.conf
Normal file
17
data/infra/mercure_proxy_vhost.conf
Normal file
|
@ -0,0 +1,17 @@
|
|||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
error_log /home/shlink/www/data/infra/nginx/mercure_proxy.error.log;
|
||||
|
||||
location / {
|
||||
proxy_pass http://shlink_mercure;
|
||||
proxy_read_timeout 24h;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
## Be sure to set USE_FORWARDED_HEADERS=1 to allow the hub to use those headers ##
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
FROM php:7.4.2-fpm-alpine3.11
|
||||
FROM php:7.4.5-fpm-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
|
@ -67,15 +67,12 @@ RUN rm /tmp/xdebug.tar.gz
|
|||
|
||||
# Install sqlsrv driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install pdo_sqlsrv && \
|
||||
docker-php-ext-enable pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
FROM php:7.4.2-alpine3.11
|
||||
FROM php:7.4.5-alpine3.11
|
||||
MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
|
||||
|
||||
ENV APCU_VERSION 5.1.18
|
||||
ENV APCU_BC_VERSION 1.0.5
|
||||
ENV INOTIFY_VERSION 2.0.0
|
||||
ENV SWOOLE_VERSION 4.4.15
|
||||
ENV SWOOLE_VERSION 4.4.18
|
||||
|
||||
RUN apk update
|
||||
|
||||
|
@ -68,15 +68,12 @@ RUN rm /tmp/inotify.tar.gz
|
|||
|
||||
# Install swoole and mssql driver
|
||||
RUN wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
apk add --allow-untrusted mssql-tools_17.5.1.1-1_amd64.apk && \
|
||||
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
|
||||
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
|
||||
docker-php-ext-enable swoole pdo_sqlsrv && \
|
||||
apk del .phpize-deps && \
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk && \
|
||||
rm mssql-tools_17.5.1.1-1_amd64.apk
|
||||
rm msodbcsql17_17.5.1.1-1_amd64.apk
|
||||
|
||||
# Install composer
|
||||
RUN php -r "readfile('https://getcomposer.org/installer');" | php
|
||||
|
|
14
data/infra/swoole_proxy_vhost.conf
Normal file
14
data/infra/swoole_proxy_vhost.conf
Normal file
|
@ -0,0 +1,14 @@
|
|||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log;
|
||||
|
||||
location / {
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_pass http://shlink_swoole:8080;
|
||||
proxy_read_timeout 90s;
|
||||
}
|
||||
}
|
27
data/migrations/Version20200503170404.php
Normal file
27
data/migrations/Version20200503170404.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20200503170404 extends AbstractMigration
|
||||
{
|
||||
private const INDEX_NAME = 'IDX_visits_date';
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf($visits->hasIndex(self::INDEX_NAME));
|
||||
$visits->addIndex(['date'], self::INDEX_NAME);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$visits = $schema->getTable('visits');
|
||||
$this->skipIf(! $visits->hasIndex(self::INDEX_NAME));
|
||||
$visits->dropIndex(self::INDEX_NAME);
|
||||
}
|
||||
}
|
14
docker-compose.ci.yml
Normal file
14
docker-compose.ci.yml
Normal file
|
@ -0,0 +1,14 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
shlink_db:
|
||||
environment:
|
||||
MYSQL_DATABASE: shlink_test
|
||||
|
||||
shlink_db_postgres:
|
||||
environment:
|
||||
POSTGRES_DB: shlink_test
|
||||
|
||||
shlink_db_maria:
|
||||
environment:
|
||||
MYSQL_DATABASE: shlink_test
|
|
@ -3,7 +3,7 @@ version: '3'
|
|||
services:
|
||||
shlink_nginx:
|
||||
container_name: shlink_nginx
|
||||
image: nginx:1.17.6-alpine
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8000:80"
|
||||
volumes:
|
||||
|
@ -27,9 +27,22 @@ services:
|
|||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
shlink_swoole_proxy:
|
||||
container_name: shlink_swoole_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8002:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
links:
|
||||
- shlink_swoole
|
||||
|
||||
shlink_swoole:
|
||||
container_name: shlink_swoole
|
||||
build:
|
||||
|
@ -47,6 +60,8 @@ services:
|
|||
- shlink_db_maria
|
||||
- shlink_db_ms
|
||||
- shlink_redis
|
||||
- shlink_mercure
|
||||
- shlink_mercure_proxy
|
||||
environment:
|
||||
LC_ALL: C
|
||||
|
||||
|
@ -64,7 +79,7 @@ services:
|
|||
|
||||
shlink_db_postgres:
|
||||
container_name: shlink_db_postgres
|
||||
image: postgres:10.7-alpine
|
||||
image: postgres:12.2-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
volumes:
|
||||
|
@ -77,7 +92,7 @@ services:
|
|||
|
||||
shlink_db_maria:
|
||||
container_name: shlink_db_maria
|
||||
image: mariadb:10.2
|
||||
image: mariadb:10.5
|
||||
ports:
|
||||
- "3308:3306"
|
||||
volumes:
|
||||
|
@ -99,6 +114,27 @@ services:
|
|||
|
||||
shlink_redis:
|
||||
container_name: shlink_redis
|
||||
image: redis:5.0-alpine
|
||||
image: redis:6.0-alpine
|
||||
ports:
|
||||
- "6380:6379"
|
||||
|
||||
shlink_mercure_proxy:
|
||||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.17.10-alpine
|
||||
ports:
|
||||
- "8001:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
links:
|
||||
- shlink_mercure
|
||||
|
||||
shlink_mercure:
|
||||
container_name: shlink_mercure
|
||||
image: dunglas/mercure:v0.9
|
||||
ports:
|
||||
- "3080:80"
|
||||
environment:
|
||||
CORS_ALLOWED_ORIGINS: "*"
|
||||
JWT_KEY: "mercure_jwt_key"
|
||||
USE_FORWARDED_HEADERS: "1"
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
@ -113,15 +168,12 @@ This is the complete list of supported env vars:
|
|||
* `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.
|
||||
* `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.
|
||||
|
||||
* `GEOLITE_LICENSE_KEY`: The license key used to download new GeoLite2 database files. This is not mandatory, as a default license key is provided, but it is **strongly recommended** that you provide your own. Go to [https://shlink.io/documentation/geolite-license-key](https://shlink.io/documentation/geolite-license-key) to know how to generate it.
|
||||
* `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).
|
||||
* `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.
|
||||
* `ANONYMIZE_REMOTE_ADDR`: Tells if IP addresses from visitors should be obfuscated before storing them in the database. Default value is `true`. **Careful!** Setting this to `false` will make your Shlink instance no longer be in compliance with the GDPR and other similar data protection regulations.
|
||||
|
||||
An example using all env vars could look like this:
|
||||
|
||||
|
@ -150,6 +202,10 @@ docker run \
|
|||
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
|
||||
-e DEFAULT_SHORT_CODES_LENGTH=6 \
|
||||
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
|
||||
-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 \
|
||||
-e ANONYMIZE_REMOTE_ADDR=false \
|
||||
shlinkio/shlink:stable
|
||||
```
|
||||
|
||||
|
@ -191,7 +247,11 @@ The whole configuration should have this format, but it can be split into multip
|
|||
"host": "something.rds.amazonaws.com",
|
||||
"port": "3306"
|
||||
},
|
||||
"geolite_license_key": "kjh23ljkbndskj345"
|
||||
"geolite_license_key": "kjh23ljkbndskj345",
|
||||
"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",
|
||||
"anonymize_remote_addr": false
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
@ -7,7 +7,9 @@ echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
|
|||
if [[ ! -z $TRAVIS_TAG ]]; then
|
||||
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable .
|
||||
docker push shlinkio/shlink:${TRAVIS_TAG#?}
|
||||
docker push shlinkio/shlink:stable
|
||||
|
||||
# Push stable tag only if this is not an alpha or beta tag
|
||||
[[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && docker push shlinkio/shlink:stable
|
||||
# If build branch is develop, build latest (on master, when there's no tag, do not build anything)
|
||||
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
|
||||
docker build -t shlinkio/shlink:latest .
|
||||
|
|
|
@ -41,6 +41,8 @@ $helper = new class {
|
|||
$driverOptions = ! contains(['maria', 'mysql'], $driver) ? [] : [
|
||||
// 1002 -> PDO::MYSQL_ATTR_INIT_COMMAND
|
||||
1002 => 'SET NAMES utf8',
|
||||
// 1000 -> PDO::MYSQL_ATTR_USE_BUFFERED_QUERY
|
||||
1000 => true,
|
||||
];
|
||||
return [
|
||||
'driver' => self::DB_DRIVERS_MAP[$driver],
|
||||
|
@ -79,6 +81,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 [
|
||||
|
@ -104,6 +117,7 @@ return [
|
|||
'hostname' => env('SHORT_DOMAIN_HOST', ''),
|
||||
],
|
||||
'validate_url' => (bool) env('VALIDATE_URLS', false),
|
||||
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
|
||||
'visits_webhooks' => $helper->getVisitsWebhooks(),
|
||||
'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
|
||||
],
|
||||
|
@ -151,4 +165,6 @@ return [
|
|||
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
|
||||
],
|
||||
|
||||
'mercure' => $helper->getMercureConfig(),
|
||||
|
||||
];
|
||||
|
|
210
docs/async-api/async-api.json
Normal file
210
docs/async-api/async-api.json
Normal file
|
@ -0,0 +1,210 @@
|
|||
{
|
||||
"asyncapi": "2.0.0",
|
||||
"info": {
|
||||
"title": "Shlink",
|
||||
"version": "2.0.0",
|
||||
"description": "Shlink, the self-hosted URL shortener",
|
||||
"license": {
|
||||
"name": "MIT",
|
||||
"url": "https://github.com/shlinkio/shlink/blob/develop/LICENSE"
|
||||
}
|
||||
},
|
||||
"defaultContentType": "application/json",
|
||||
"channels": {
|
||||
"http://shlink.io/new-visit": {
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on any short URL.",
|
||||
"operationId": "newVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http://shlink.io/new-visit/{shortCode}": {
|
||||
"parameters": {
|
||||
"shortCode": {
|
||||
"description": "The short code of the short URL",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"summary": "Receive information about any new visit occurring on a specific short URL.",
|
||||
"operationId": "newShortUrlVisit",
|
||||
"message": {
|
||||
"payload": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"shortUrl": {
|
||||
"$ref": "#/components/schemas/ShortUrl"
|
||||
},
|
||||
"visit": {
|
||||
"$ref": "#/components/schemas/Visit"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"ShortUrl": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"shortCode": {
|
||||
"type": "string",
|
||||
"description": "The short code for this short URL."
|
||||
},
|
||||
"shortUrl": {
|
||||
"type": "string",
|
||||
"description": "The short URL."
|
||||
},
|
||||
"longUrl": {
|
||||
"type": "string",
|
||||
"description": "The original long URL."
|
||||
},
|
||||
"dateCreated": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the short URL was created in ISO format."
|
||||
},
|
||||
"visitsCount": {
|
||||
"type": "integer",
|
||||
"description": "The number of visits that this short URL has recieved."
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "A list of tags applied to this short URL"
|
||||
},
|
||||
"meta": {
|
||||
"$ref": "#/components/schemas/ShortUrlMeta"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string",
|
||||
"description": "The domain in which the short URL was created. Null if it belongs to default domain."
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"shortCode": "12C18",
|
||||
"shortUrl": "https://doma.in/12C18",
|
||||
"longUrl": "https://store.steampowered.com",
|
||||
"dateCreated": "2016-08-21T20:34:16+02:00",
|
||||
"visitsCount": 328,
|
||||
"tags": [
|
||||
"games",
|
||||
"tech"
|
||||
],
|
||||
"meta": {
|
||||
"validSince": "2017-01-21T00:00:00+02:00",
|
||||
"validUntil": null,
|
||||
"maxVisits": 100
|
||||
},
|
||||
"domain": "example.com"
|
||||
}
|
||||
},
|
||||
"ShortUrlMeta": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"validSince",
|
||||
"validUntil",
|
||||
"maxVisits"
|
||||
],
|
||||
"properties": {
|
||||
"validSince": {
|
||||
"description": "The date (in ISO-8601 format) from which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"validUntil": {
|
||||
"description": "The date (in ISO-8601 format) until which this short code will be valid",
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"maxVisits": {
|
||||
"description": "The maximum number of allowed visits for this short code",
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"Visit": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"referer": {
|
||||
"type": "string",
|
||||
"description": "The origin from which the visit was performed"
|
||||
},
|
||||
"date": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "The date in which the visit was performed"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "string",
|
||||
"description": "The user agent from which the visit was performed"
|
||||
},
|
||||
"visitLocation": {
|
||||
"$ref": "#/components/schemas/VisitLocation"
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VisitLocation": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cityName": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryCode": {
|
||||
"type": "string"
|
||||
},
|
||||
"countryName": {
|
||||
"type": "string"
|
||||
},
|
||||
"latitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"type": "number"
|
||||
},
|
||||
"regionName": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
18
docs/swagger/definitions/MercureInfo.json
Normal file
18
docs/swagger/definitions/MercureInfo.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
17
docs/swagger/definitions/TagInfo.json
Normal file
17
docs/swagger/definitions/TagInfo.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag": {
|
||||
"type": "string",
|
||||
"description": "The unique tag name"
|
||||
},
|
||||
"shortUrlsCount": {
|
||||
"type": "number",
|
||||
"description": "The amount of short URLs using this tag"
|
||||
},
|
||||
"userAgent": {
|
||||
"type": "number",
|
||||
"description": "The combined amount of visits received by short URLs with this tag"
|
||||
}
|
||||
}
|
||||
}
|
10
docs/swagger/definitions/VisitStats.json
Normal file
10
docs/swagger/definitions/VisitStats.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"type": "object",
|
||||
"required": ["visitsCount"],
|
||||
"properties": {
|
||||
"visitsCount": {
|
||||
"type": "number",
|
||||
"description": "The total amount of visits received."
|
||||
}
|
||||
}
|
||||
}
|
|
@ -14,6 +14,19 @@
|
|||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "withStats",
|
||||
"description": "Whether you want to include also a list with general stats by tag or not.",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
@ -26,12 +39,20 @@
|
|||
"properties": {
|
||||
"tags": {
|
||||
"type": "object",
|
||||
"required": ["data"],
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"stats": {
|
||||
"description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/TagInfo.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
67
docs/swagger/paths/v2_mercure-info.json
Normal file
67
docs/swagger/paths/v2_mercure-info.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
154
docs/swagger/paths/v2_tags_{tag}_visits.json
Normal file
154
docs/swagger/paths/v2_tags_{tag}_visits.json
Normal file
|
@ -0,0 +1,154 @@
|
|||
{
|
||||
"get": {
|
||||
"operationId": "getTagVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "List visits for tag",
|
||||
"description": "Get the list of visits on any short URL which is tagged with provided tag.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
},
|
||||
{
|
||||
"name": "tag",
|
||||
"in": "path",
|
||||
"description": "The tag from which we want to get the visits.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "startDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) from which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "endDate",
|
||||
"in": "query",
|
||||
"description": "The date (in ISO-8601 format) until which we want to get visits.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"description": "The page to display. Defaults to 1",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "itemsPerPage",
|
||||
"in": "query",
|
||||
"description": "The amount of items to return on every page. Defaults to all the items",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "List of visits.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "../definitions/Visit.json"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "../definitions/Pagination.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"data": [
|
||||
{
|
||||
"referer": "https://twitter.com",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0 Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/42.0",
|
||||
"visitLocation": null
|
||||
},
|
||||
{
|
||||
"referer": "https://t.co",
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36",
|
||||
"visitLocation": {
|
||||
"cityName": "Cupertino",
|
||||
"countryCode": "US",
|
||||
"countryName": "United States",
|
||||
"latitude": 37.3042,
|
||||
"longitude": -122.0946,
|
||||
"regionName": "California",
|
||||
"timezone": "America/Los_Angeles"
|
||||
}
|
||||
},
|
||||
{
|
||||
"referer": null,
|
||||
"date": "2015-08-20T05:05:03+04:00",
|
||||
"userAgent": "some_web_crawler/1.4",
|
||||
"visitLocation": null
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"currentPage": 5,
|
||||
"pagesCount": 12,
|
||||
"itemsPerPage": 10,
|
||||
"itemsInCurrentPage": 10,
|
||||
"totalItems": 115
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "The tag does not exist.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
54
docs/swagger/paths/v2_visits.json
Normal file
54
docs/swagger/paths/v2_visits.json
Normal file
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"get": {
|
||||
"operationId": "getGlobalVisits",
|
||||
"tags": [
|
||||
"Visits"
|
||||
],
|
||||
"summary": "Get general visits stats",
|
||||
"description": "Get general visits stats not linked to one specific short URL.",
|
||||
"parameters": [
|
||||
{
|
||||
"$ref": "../parameters/version.json"
|
||||
}
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"ApiKey": []
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Visits stats.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"visits": {
|
||||
"$ref": "../definitions/VisitStats.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"examples": {
|
||||
"application/json": {
|
||||
"visits": {
|
||||
"visitsCount": 1569874
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Unexpected error.",
|
||||
"content": {
|
||||
"application/problem+json": {
|
||||
"schema": {
|
||||
"$ref": "../definitions/Error.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -78,9 +78,19 @@
|
|||
"$ref": "paths/v1_tags.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/visits": {
|
||||
"$ref": "paths/v2_visits.json"
|
||||
},
|
||||
"/rest/v{version}/short-urls/{shortCode}/visits": {
|
||||
"$ref": "paths/v1_short-urls_{shortCode}_visits.json"
|
||||
},
|
||||
"/rest/v{version}/tags/{tag}/visits": {
|
||||
"$ref": "paths/v2_tags_{tag}_visits.json"
|
||||
},
|
||||
|
||||
"/rest/v{version}/mercure-info": {
|
||||
"$ref": "paths/v2_mercure-info.json"
|
||||
},
|
||||
|
||||
"/rest/health": {
|
||||
"$ref": "paths/health.json"
|
||||
|
|
|
@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory;
|
|||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater;
|
||||
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
|
||||
use Shlinkio\Shlink\Core\Service;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
|
||||
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
|
||||
|
@ -78,10 +79,10 @@ return [
|
|||
Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
|
||||
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
|
||||
|
||||
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class],
|
||||
Command\Tag\ListTagsCommand::class => [TagService::class],
|
||||
Command\Tag\CreateTagCommand::class => [TagService::class],
|
||||
Command\Tag\RenameTagCommand::class => [TagService::class],
|
||||
Command\Tag\DeleteTagsCommand::class => [TagService::class],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
|
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
|
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Command\Tag;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
|
|
@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
|||
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
|
@ -35,17 +35,20 @@ class ListTagsCommand extends Command
|
|||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): ?int
|
||||
{
|
||||
ShlinkTable::fromOutput($output)->render(['Name'], $this->getTagsRows());
|
||||
ShlinkTable::fromOutput($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows());
|
||||
return ExitCodes::EXIT_SUCCESS;
|
||||
}
|
||||
|
||||
private function getTagsRows(): array
|
||||
{
|
||||
$tags = $this->tagService->listTags();
|
||||
$tags = $this->tagService->tagsInfo();
|
||||
if (empty($tags)) {
|
||||
return [['No tags yet']];
|
||||
return [['No tags found', '-', '-']];
|
||||
}
|
||||
|
||||
return map($tags, fn (Tag $tag) => [(string) $tag]);
|
||||
return map(
|
||||
$tags,
|
||||
fn (TagInfo $tagInfo) => [(string) $tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
|
|||
use Shlinkio\Shlink\CLI\Util\ExitCodes;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
|
|
@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
|
|
@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
|
|
@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase;
|
|||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
@ -31,28 +32,32 @@ class ListTagsCommandTest extends TestCase
|
|||
/** @test */
|
||||
public function noTagsPrintsEmptyMessage(): void
|
||||
{
|
||||
$listTags = $this->tagService->listTags()->willReturn([]);
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('No tags yet', $output);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
$this->assertStringContainsString('No tags found', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function listOfTagsIsPrinted(): void
|
||||
{
|
||||
$listTags = $this->tagService->listTags()->willReturn([
|
||||
new Tag('foo'),
|
||||
new Tag('bar'),
|
||||
$tagsInfo = $this->tagService->tagsInfo()->willReturn([
|
||||
new TagInfo(new Tag('foo'), 10, 2),
|
||||
new TagInfo(new Tag('bar'), 7, 32),
|
||||
]);
|
||||
|
||||
$this->commandTester->execute([]);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
$this->assertStringContainsString('foo', $output);
|
||||
$this->assertStringContainsString('bar', $output);
|
||||
$listTags->shouldHaveBeenCalled();
|
||||
$this->assertStringContainsString('| foo', $output);
|
||||
$this->assertStringContainsString('| bar', $output);
|
||||
$this->assertStringContainsString('| 10 ', $output);
|
||||
$this->assertStringContainsString('| 2 ', $output);
|
||||
$this->assertStringContainsString('| 7 ', $output);
|
||||
$this->assertStringContainsString('| 32 ', $output);
|
||||
$tagsInfo->shouldHaveBeenCalled();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ use Prophecy\Prophecy\ObjectProphecy;
|
|||
use Shlinkio\Shlink\CLI\Command\Tag\RenameTagCommand;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
|
|
|
@ -27,7 +27,8 @@ return [
|
|||
Service\VisitsTracker::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitLocator::class => ConfigAbstractFactory::class,
|
||||
Service\Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Visit\VisitsStatsHelper::class => ConfigAbstractFactory::class,
|
||||
Tag\TagService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
|
||||
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
|
@ -38,6 +39,8 @@ return [
|
|||
Action\QrCodeAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ConfigAbstractFactory::class,
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -51,10 +54,15 @@ return [
|
|||
Options\UrlShortenerOptions::class => ['config.url_shortener'],
|
||||
|
||||
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class],
|
||||
Service\VisitsTracker::class => ['em', EventDispatcherInterface::class],
|
||||
Service\VisitsTracker::class => [
|
||||
'em',
|
||||
EventDispatcherInterface::class,
|
||||
'config.url_shortener.anonymize_remote_addr',
|
||||
],
|
||||
Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
|
||||
Visit\VisitLocator::class => ['em'],
|
||||
Service\Tag\TagService::class => ['em'],
|
||||
Visit\VisitsStatsHelper::class => ['em'],
|
||||
Tag\TagService::class => ['em'],
|
||||
Service\ShortUrl\DeleteShortUrlService::class => [
|
||||
'em',
|
||||
Options\DeleteShortUrlsOptions::class,
|
||||
|
@ -83,6 +91,8 @@ return [
|
|||
],
|
||||
|
||||
Resolver\PersistenceDomainResolver::class => ['em'],
|
||||
|
||||
Mercure\MercureUpdatesGenerator::class => ['config.url_shortener.domain'],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
|
||||
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
|
||||
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
|
||||
->setOrderBy(['name' => 'ASC'])
|
||||
->build();
|
||||
|
||||
$builder->createManyToOne('domain', Entity\Domain::class)
|
||||
|
|
|
@ -24,4 +24,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
$builder->createField('name', Types::STRING)
|
||||
->unique()
|
||||
->build();
|
||||
|
||||
$builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags');
|
||||
};
|
||||
|
|
|
@ -32,6 +32,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
|
|||
->columnName('`date`')
|
||||
->build();
|
||||
|
||||
$builder->addIndex(['date'], 'IDX_visits_date');
|
||||
|
||||
$builder->createField('remoteAddr', Types::STRING)
|
||||
->columnName('remote_addr')
|
||||
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH)
|
||||
|
|
|
@ -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,13 @@ return [
|
|||
'factories' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class,
|
||||
EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
|
||||
'delegators' => [
|
||||
EventDispatcher\LocateShortUrlVisit::class => [
|
||||
EventDispatcher\CloseDbConnectionEventListenerDelegator::class,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -47,6 +56,12 @@ return [
|
|||
'config.url_shortener.domain',
|
||||
Options\AppOptions::class,
|
||||
],
|
||||
EventDispatcher\NotifyVisitToMercure::class => [
|
||||
Publisher::class,
|
||||
Mercure\MercureUpdatesGenerator::class,
|
||||
'em',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -34,6 +34,10 @@ class SimplifiedConfigParser
|
|||
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
|
||||
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
|
||||
'geolite_license_key' => ['geolite2', 'license_key'],
|
||||
'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
|
||||
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
|
||||
'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
|
||||
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
|
||||
];
|
||||
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
|
||||
'delete_short_url_threshold' => [
|
||||
|
|
|
@ -4,16 +4,19 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Entity;
|
||||
|
||||
use Doctrine\Common\Collections;
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
|
||||
class Tag extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
private string $name;
|
||||
private Collections\Collection $shortUrls;
|
||||
|
||||
public function __construct(string $name)
|
||||
{
|
||||
$this->name = $name;
|
||||
$this->shortUrls = new Collections\ArrayCollection();
|
||||
}
|
||||
|
||||
public function rename(string $name): void
|
||||
|
|
|
@ -21,24 +21,24 @@ class Visit extends AbstractEntity implements JsonSerializable
|
|||
private ShortUrl $shortUrl;
|
||||
private ?VisitLocation $visitLocation = null;
|
||||
|
||||
public function __construct(ShortUrl $shortUrl, Visitor $visitor, ?Chronos $date = null)
|
||||
public function __construct(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true, ?Chronos $date = null)
|
||||
{
|
||||
$this->shortUrl = $shortUrl;
|
||||
$this->date = $date ?? Chronos::now();
|
||||
$this->userAgent = $visitor->getUserAgent();
|
||||
$this->referer = $visitor->getReferer();
|
||||
$this->remoteAddr = $this->obfuscateAddress($visitor->getRemoteAddress());
|
||||
$this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress());
|
||||
}
|
||||
|
||||
private function obfuscateAddress(?string $address): ?string
|
||||
private function processAddress(bool $anonymize, ?string $address): ?string
|
||||
{
|
||||
// Localhost addresses do not need to be obfuscated
|
||||
if ($address === null || $address === IpAddress::LOCALHOST) {
|
||||
// Localhost addresses do not need to be anonymized
|
||||
if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) {
|
||||
return $address;
|
||||
}
|
||||
|
||||
try {
|
||||
return (string) IpAddress::fromString($address)->getObfuscatedCopy();
|
||||
return (string) IpAddress::fromString($address)->getAnonymizedCopy();
|
||||
} catch (InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListener
|
||||
{
|
||||
private ReopeningEntityManagerInterface $em;
|
||||
/** @var callable */
|
||||
private $wrapped;
|
||||
|
||||
public function __construct(ReopeningEntityManagerInterface $em, callable $wrapped)
|
||||
{
|
||||
$this->em = $em;
|
||||
$this->wrapped = $wrapped;
|
||||
}
|
||||
|
||||
public function __invoke(object $event): void
|
||||
{
|
||||
$this->em->open();
|
||||
|
||||
try {
|
||||
($this->wrapped)($event);
|
||||
} finally {
|
||||
$this->em->getConnection()->close();
|
||||
$this->em->clear();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegator
|
||||
{
|
||||
public function __invoke(
|
||||
ContainerInterface $container,
|
||||
string $name,
|
||||
callable $callback
|
||||
): CloseDbConnectionEventListener {
|
||||
/** @var callable $wrapped */
|
||||
$wrapped = $callback();
|
||||
/** @var ReopeningEntityManagerInterface $em */
|
||||
$em = $container->get('em');
|
||||
|
||||
return new CloseDbConnectionEventListener($em, $wrapped);
|
||||
}
|
||||
}
|
|
@ -9,7 +9,6 @@ use Psr\EventDispatcher\EventDispatcherInterface;
|
|||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\CLI\Exception\GeolocationDbUpdateFailedException;
|
||||
use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdaterInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManager;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException;
|
||||
|
@ -42,35 +41,22 @@ class LocateShortUrlVisit
|
|||
|
||||
public function __invoke(ShortUrlVisited $shortUrlVisited): void
|
||||
{
|
||||
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
|
||||
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
|
||||
if ($this->em instanceof ReopeningEntityManager) {
|
||||
$this->em->open();
|
||||
}
|
||||
|
||||
$visitId = $shortUrlVisited->visitId();
|
||||
|
||||
try {
|
||||
/** @var Visit|null $visit */
|
||||
$visit = $this->em->find(Visit::class, $visitId);
|
||||
if ($visit === null) {
|
||||
$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, $shortUrlVisited->originalIpAddress(), $visit);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||
} finally {
|
||||
// FIXME Temporarily handling DB connection reset here to fix https://github.com/shlinkio/shlink/issues/717
|
||||
// Remove when https://github.com/shlinkio/shlink-event-dispatcher/issues/23 is implemented
|
||||
$this->em->getConnection()->close();
|
||||
$this->em->clear();
|
||||
/** @var Visit|null $visit */
|
||||
$visit = $this->em->find(Visit::class, $visitId);
|
||||
if ($visit === null) {
|
||||
$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, $shortUrlVisited->originalIpAddress(), $visit);
|
||||
}
|
||||
|
||||
$this->eventDispatcher->dispatch(new VisitLocated($visitId));
|
||||
}
|
||||
|
||||
private function downloadOrUpdateGeoLiteDb(string $visitId): bool
|
||||
|
|
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
55
module/Core/src/EventDispatcher/NotifyVisitToMercure.php
Normal file
|
@ -0,0 +1,55 @@
|
|||
<?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->newShortUrlVisitUpdate($visit));
|
||||
($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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -9,6 +9,7 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use GuzzleHttp\ClientInterface;
|
||||
use GuzzleHttp\Promise\Promise;
|
||||
use GuzzleHttp\Promise\PromiseInterface;
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
|
@ -89,12 +90,14 @@ class NotifyVisitToWebHooks
|
|||
*/
|
||||
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),
|
||||
);
|
||||
});
|
||||
$logWebhookFailure = Closure::fromCallable([$this, 'logWebhookFailure']);
|
||||
|
||||
return map(
|
||||
$this->webhooks,
|
||||
fn (string $webhook): PromiseInterface => $this->httpClient
|
||||
->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions)
|
||||
->otherwise(partial_left($logWebhookFailure, $webhook, $visitId)),
|
||||
);
|
||||
}
|
||||
|
||||
private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void
|
||||
|
|
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
50
module/Core/src/Mercure/MercureUpdatesGenerator.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?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 function sprintf;
|
||||
|
||||
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, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update
|
||||
{
|
||||
$shortUrl = $visit->getShortUrl();
|
||||
$topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl->getShortCode());
|
||||
|
||||
return new Update($topic, $this->serialize([
|
||||
'shortUrl' => $this->transformer->transform($visit->getShortUrl()),
|
||||
'visit' => $visit,
|
||||
]));
|
||||
}
|
||||
|
||||
private function serialize(array $data): string
|
||||
{
|
||||
return json_encode($data, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
15
module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php
Normal file
|
@ -0,0 +1,15 @@
|
|||
<?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;
|
||||
|
||||
public function newShortUrlVisitUpdate(Visit $visit): Update;
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
|
||||
abstract class AbstractCacheableCountPaginatorAdapter implements AdapterInterface
|
||||
{
|
||||
private ?int $count = null;
|
||||
|
||||
final public function count(): int
|
||||
{
|
||||
// Since a new adapter instance is created every time visits are fetched, it is reasonably safe to internally
|
||||
// cache the count value.
|
||||
// The reason it is cached is because the Paginator is actually calling the method twice.
|
||||
// An inconsistent value could be returned if between the first call and the second one, a new visit is created.
|
||||
// However, it's almost instant, and then the adapter instance is discarded immediately after.
|
||||
|
||||
if ($this->count !== null) {
|
||||
return $this->count;
|
||||
}
|
||||
|
||||
return $this->count = $this->doCount();
|
||||
}
|
||||
|
||||
abstract protected function doCount(): int;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private string $tag;
|
||||
private VisitsParams $params;
|
||||
|
||||
public function __construct(VisitRepositoryInterface $visitRepository, string $tag, VisitsParams $params)
|
||||
{
|
||||
$this->visitRepository = $visitRepository;
|
||||
$this->params = $params;
|
||||
$this->tag = $tag;
|
||||
}
|
||||
|
||||
public function getItems($offset, $itemCountPerPage): array // phpcs:ignore
|
||||
{
|
||||
return $this->visitRepository->findVisitsByTag(
|
||||
$this->tag,
|
||||
$this->params->getDateRange(),
|
||||
$itemCountPerPage,
|
||||
$offset,
|
||||
);
|
||||
}
|
||||
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByTag($this->tag, $this->params->getDateRange());
|
||||
}
|
||||
}
|
|
@ -4,12 +4,11 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use Laminas\Paginator\Adapter\AdapterInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsPaginatorAdapter implements AdapterInterface
|
||||
class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
|
||||
{
|
||||
private VisitRepositoryInterface $visitRepository;
|
||||
private ShortUrlIdentifier $identifier;
|
||||
|
@ -36,7 +35,7 @@ class VisitsPaginatorAdapter implements AdapterInterface
|
|||
);
|
||||
}
|
||||
|
||||
public function count(): int
|
||||
protected function doCount(): int
|
||||
{
|
||||
return $this->visitRepository->countVisitsByShortCode(
|
||||
$this->identifier->shortCode(),
|
||||
|
|
|
@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository;
|
|||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
use function Functional\map;
|
||||
|
||||
class TagRepository extends EntityRepository implements TagRepositoryInterface
|
||||
{
|
||||
|
@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
|
|||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array
|
||||
{
|
||||
$dql = <<<DQL
|
||||
SELECT t AS tag, COUNT(DISTINCT s.id) AS shortUrlsCount, COUNT(DISTINCT v.id) AS visitsCount
|
||||
FROM Shlinkio\Shlink\Core\Entity\Tag t
|
||||
LEFT JOIN t.shortUrls s
|
||||
LEFT JOIN s.visits v
|
||||
GROUP BY t
|
||||
ORDER BY t.name ASC
|
||||
DQL;
|
||||
$query = $this->getEntityManager()->createQuery($dql);
|
||||
|
||||
return map(
|
||||
$query->getResult(),
|
||||
fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,14 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Persistence\ObjectRepository;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
interface TagRepositoryInterface extends ObjectRepository
|
||||
{
|
||||
public function deleteByName(array $names): int;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function findTagsWithInfo(): array;
|
||||
}
|
||||
|
|
|
@ -5,9 +5,16 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\Query\ResultSetMappingBuilder;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
|
||||
use function array_column;
|
||||
|
||||
use const PHP_INT_MAX;
|
||||
|
||||
class VisitRepository extends EntityRepository implements VisitRepositoryInterface
|
||||
{
|
||||
|
@ -21,7 +28,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
->from(Visit::class, 'v')
|
||||
->where($qb->expr()->isNull('v.visitLocation'));
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -37,7 +44,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
|
||||
->setParameter('isEmpty', true);
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
|
||||
|
@ -46,10 +53,10 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
$qb->select('v')
|
||||
->from(Visit::class, 'v');
|
||||
|
||||
return $this->findVisitsForQuery($qb, $blockSize);
|
||||
return $this->visitsIterableForQuery($qb, $blockSize);
|
||||
}
|
||||
|
||||
private function findVisitsForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
private function visitsIterableForQuery(QueryBuilder $qb, int $blockSize): iterable
|
||||
{
|
||||
$originalQueryBuilder = $qb->setMaxResults($blockSize)
|
||||
->orderBy('v.id', 'ASC');
|
||||
|
@ -82,23 +89,13 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('v')
|
||||
->orderBy('v.date', 'DESC');
|
||||
|
||||
if ($limit !== null) {
|
||||
$qb->setMaxResults($limit);
|
||||
}
|
||||
if ($offset !== null) {
|
||||
$qb->setFirstResult($offset);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
|
||||
$qb->select('COUNT(DISTINCT v.id)');
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
@ -108,31 +105,103 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
|
|||
?string $domain,
|
||||
?DateRange $dateRange
|
||||
): QueryBuilder {
|
||||
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
|
||||
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
|
||||
$shortUrl = $shortUrlRepo->findOne($shortCode, $domain);
|
||||
$shortUrlId = $shortUrl !== null ? $shortUrl->getId() : -1;
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->from(Visit::class, 'v')
|
||||
->join('v.shortUrl', 'su')
|
||||
->where($qb->expr()->eq('su.shortCode', ':shortCode'))
|
||||
->setParameter('shortCode', $shortCode);
|
||||
|
||||
// Apply domain filtering
|
||||
if ($domain !== null) {
|
||||
$qb->join('su.domain', 'd')
|
||||
->andWhere($qb->expr()->eq('d.authority', ':domain'))
|
||||
->setParameter('domain', $domain);
|
||||
} else {
|
||||
$qb->andWhere($qb->expr()->isNull('su.domain'));
|
||||
}
|
||||
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
|
||||
|
||||
// Apply date range filtering
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', ':startDate'))
|
||||
->setParameter('startDate', $dateRange->getStartDate());
|
||||
}
|
||||
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('v.date', ':endDate'))
|
||||
->setParameter('endDate', $dateRange->getEndDate());
|
||||
}
|
||||
$this->applyDatesInline($qb, $dateRange);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function findVisitsByTag(
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array {
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
|
||||
}
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int
|
||||
{
|
||||
$qb = $this->createVisitsByTagQueryBuilder($tag, $dateRange);
|
||||
$qb->select('COUNT(v.id)');
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
private function createVisitsByTagQueryBuilder(string $tag, ?DateRange $dateRange = null): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('s.id')
|
||||
->from(ShortUrl::class, 's')
|
||||
->join('s.tags', 't')
|
||||
->where($qb->expr()->eq('t.name', ':tag'))
|
||||
->setParameter('tag', $tag);
|
||||
|
||||
$shortUrlIds = array_column($qb->getQuery()->getArrayResult(), 'id');
|
||||
$shortUrlIds[] = '-1'; // Add an invalid ID, in case the list is empty
|
||||
|
||||
// Parameters in this query need to be part of the query itself, as we need to use it a sub-query later
|
||||
// Since they are not strictly provided by the caller, it's reasonably safe
|
||||
$qb2 = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb2->from(Visit::class, 'v')
|
||||
->where($qb2->expr()->in('v.shortUrl', $shortUrlIds));
|
||||
|
||||
// Apply date range filtering
|
||||
$this->applyDatesInline($qb2, $dateRange);
|
||||
|
||||
return $qb2;
|
||||
}
|
||||
|
||||
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
|
||||
{
|
||||
if ($dateRange !== null && $dateRange->getStartDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->getStartDate()->toDateTimeString() . '\''));
|
||||
}
|
||||
if ($dateRange !== null && $dateRange->getEndDate() !== null) {
|
||||
$qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->getEndDate()->toDateTimeString() . '\''));
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveVisitsWithNativeQuery(QueryBuilder $qb, ?int $limit, ?int $offset): array
|
||||
{
|
||||
$qb->select('v.id')
|
||||
->orderBy('v.id', 'DESC')
|
||||
// Falling back to values that will behave as no limit/offset, but will workaround MS SQL not allowing
|
||||
// order on sub-queries without offset
|
||||
->setMaxResults($limit ?? PHP_INT_MAX)
|
||||
->setFirstResult($offset ?? 0);
|
||||
$subQuery = $qb->getQuery()->getSQL();
|
||||
|
||||
// A native query builder needs to be used here because DQL and ORM query builders do not accept
|
||||
// sub-queries at "from" and "join" level.
|
||||
// If no sub-query is used, then performance drops dramatically while the "offset" grows.
|
||||
$nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder();
|
||||
$nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*')
|
||||
->from('visits', 'v')
|
||||
->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id'))
|
||||
->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id'))
|
||||
->orderBy('v.id', 'DESC');
|
||||
|
||||
$rsm = new ResultSetMappingBuilder($this->getEntityManager());
|
||||
$rsm->addRootEntityFromClassMetadata(Visit::class, 'v', ['id' => 'visit_id']);
|
||||
$rsm->addJoinedEntityFromClassMetadata(VisitLocation::class, 'vl', 'v', 'visitLocation', [
|
||||
'id' => 'visit_location_id',
|
||||
]);
|
||||
|
||||
$query = $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm);
|
||||
|
||||
return $query->getResult();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,4 +43,16 @@ interface VisitRepositoryInterface extends ObjectRepository
|
|||
?string $domain = null,
|
||||
?DateRange $dateRange = null
|
||||
): int;
|
||||
|
||||
/**
|
||||
* @return Visit[]
|
||||
*/
|
||||
public function findVisitsByTag(
|
||||
string $tag,
|
||||
?DateRange $dateRange = null,
|
||||
?int $limit = null,
|
||||
?int $offset = null
|
||||
): array;
|
||||
|
||||
public function countVisitsByTag(string $tag, ?DateRange $dateRange = null): int;
|
||||
}
|
||||
|
|
|
@ -8,33 +8,39 @@ use Doctrine\ORM;
|
|||
use Laminas\Paginator\Paginator;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsTracker implements VisitsTrackerInterface
|
||||
{
|
||||
private ORM\EntityManagerInterface $em;
|
||||
private EventDispatcherInterface $eventDispatcher;
|
||||
private bool $anonymizeRemoteAddr;
|
||||
|
||||
public function __construct(ORM\EntityManagerInterface $em, EventDispatcherInterface $eventDispatcher)
|
||||
{
|
||||
public function __construct(
|
||||
ORM\EntityManagerInterface $em,
|
||||
EventDispatcherInterface $eventDispatcher,
|
||||
bool $anonymizeRemoteAddr
|
||||
) {
|
||||
$this->em = $em;
|
||||
$this->eventDispatcher = $eventDispatcher;
|
||||
$this->anonymizeRemoteAddr = $anonymizeRemoteAddr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void
|
||||
{
|
||||
$visit = new Visit($shortUrl, $visitor);
|
||||
$visit = new Visit($shortUrl, $visitor, $this->anonymizeRemoteAddr);
|
||||
|
||||
$this->em->persist($visit);
|
||||
$this->em->flush();
|
||||
|
@ -43,8 +49,6 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
|
@ -56,7 +60,7 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
throw ShortUrlNotFoundException::fromNotFound($identifier);
|
||||
}
|
||||
|
||||
/** @var VisitRepository $repo */
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
|
@ -64,4 +68,26 @@ class VisitsTracker implements VisitsTrackerInterface
|
|||
|
||||
return $paginator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator
|
||||
{
|
||||
/** @var TagRepository $tagRepo */
|
||||
$tagRepo = $this->em->getRepository(Tag::class);
|
||||
$count = $tagRepo->count(['name' => $tag]);
|
||||
if ($count === 0) {
|
||||
throw TagNotFoundException::fromTag($tag);
|
||||
}
|
||||
|
||||
/** @var VisitRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Visit::class);
|
||||
$paginator = new Paginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params));
|
||||
$paginator->setItemCountPerPage($params->getItemsPerPage())
|
||||
->setCurrentPageNumber($params->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,22 +8,24 @@ use Laminas\Paginator\Paginator;
|
|||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
|
||||
interface VisitsTrackerInterface
|
||||
{
|
||||
/**
|
||||
* Tracks a new visit to provided short code from provided visitor
|
||||
*/
|
||||
public function track(ShortUrl $shortUrl, Visitor $visitor): void;
|
||||
|
||||
/**
|
||||
* Returns the visits on certain short code
|
||||
*
|
||||
* @return Visit[]|Paginator
|
||||
* @throws ShortUrlNotFoundException
|
||||
*/
|
||||
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator;
|
||||
|
||||
/**
|
||||
* @return Visit[]|Paginator
|
||||
* @throws TagNotFoundException
|
||||
*/
|
||||
public function visitsForTag(string $tag, VisitsParams $params): Paginator;
|
||||
}
|
||||
|
|
46
module/Core/src/Tag/Model/TagInfo.php
Normal file
46
module/Core/src/Tag/Model/TagInfo.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Tag\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
|
||||
final class TagInfo implements JsonSerializable
|
||||
{
|
||||
private Tag $tag;
|
||||
private int $shortUrlsCount;
|
||||
private int $visitsCount;
|
||||
|
||||
public function __construct(Tag $tag, int $shortUrlsCount, int $visitsCount)
|
||||
{
|
||||
$this->tag = $tag;
|
||||
$this->shortUrlsCount = $shortUrlsCount;
|
||||
$this->visitsCount = $visitsCount;
|
||||
}
|
||||
|
||||
public function tag(): Tag
|
||||
{
|
||||
return $this->tag;
|
||||
}
|
||||
|
||||
public function shortUrlsCount(): int
|
||||
{
|
||||
return $this->shortUrlsCount;
|
||||
}
|
||||
|
||||
public function visitsCount(): int
|
||||
{
|
||||
return $this->visitsCount;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'tag' => $this->tag,
|
||||
'shortUrlsCount' => $this->shortUrlsCount,
|
||||
'visitsCount' => $this->visitsCount,
|
||||
];
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\Tag;
|
||||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM;
|
||||
|
@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag;
|
|||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Util\TagManagerTrait;
|
||||
|
||||
class TagService implements TagServiceInterface
|
||||
|
@ -25,7 +27,6 @@ class TagService implements TagServiceInterface
|
|||
|
||||
/**
|
||||
* @return Tag[]
|
||||
* @throws \UnexpectedValueException
|
||||
*/
|
||||
public function listTags(): array
|
||||
{
|
||||
|
@ -34,6 +35,16 @@ class TagService implements TagServiceInterface
|
|||
return $tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array
|
||||
{
|
||||
/** @var TagRepositoryInterface $repo */
|
||||
$repo = $this->em->getRepository(Tag::class);
|
||||
return $repo->findTagsWithInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
|
@ -2,12 +2,13 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Service\Tag;
|
||||
namespace Shlinkio\Shlink\Core\Tag;
|
||||
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
|
||||
interface TagServiceInterface
|
||||
{
|
||||
|
@ -16,6 +17,11 @@ interface TagServiceInterface
|
|||
*/
|
||||
public function listTags(): array;
|
||||
|
||||
/**
|
||||
* @return TagInfo[]
|
||||
*/
|
||||
public function tagsInfo(): array;
|
||||
|
||||
/**
|
||||
* @param string[] $tagNames
|
||||
*/
|
24
module/Core/src/Visit/Model/VisitsStats.php
Normal file
24
module/Core/src/Visit/Model/VisitsStats.php
Normal file
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit\Model;
|
||||
|
||||
use JsonSerializable;
|
||||
|
||||
final class VisitsStats implements JsonSerializable
|
||||
{
|
||||
private int $visitsCount;
|
||||
|
||||
public function __construct(int $visitsCount)
|
||||
{
|
||||
$this->visitsCount = $visitsCount;
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
'visitsCount' => $this->visitsCount,
|
||||
];
|
||||
}
|
||||
}
|
32
module/Core/src/Visit/VisitsStatsHelper.php
Normal file
32
module/Core/src/Visit/VisitsStatsHelper.php
Normal file
|
@ -0,0 +1,32 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
|
||||
class VisitsStatsHelper implements VisitsStatsHelperInterface
|
||||
{
|
||||
private EntityManagerInterface $em;
|
||||
|
||||
public function __construct(EntityManagerInterface $em)
|
||||
{
|
||||
$this->em = $em;
|
||||
}
|
||||
|
||||
public function getVisitsStats(): VisitsStats
|
||||
{
|
||||
return new VisitsStats($this->getVisitsCount());
|
||||
}
|
||||
|
||||
private function getVisitsCount(): int
|
||||
{
|
||||
/** @var VisitRepository $visitsRepo */
|
||||
$visitsRepo = $this->em->getRepository(Visit::class);
|
||||
return $visitsRepo->count([]);
|
||||
}
|
||||
}
|
12
module/Core/src/Visit/VisitsStatsHelperInterface.php
Normal file
12
module/Core/src/Visit/VisitsStatsHelperInterface.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\Visit;
|
||||
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
|
||||
interface VisitsStatsHelperInterface
|
||||
{
|
||||
public function getVisitsStats(): VisitsStats;
|
||||
}
|
|
@ -4,13 +4,21 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
|
||||
|
||||
use function array_chunk;
|
||||
|
||||
class TagRepositoryTest extends DatabaseTestCase
|
||||
{
|
||||
protected const ENTITIES_TO_EMPTY = [
|
||||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Tag::class,
|
||||
];
|
||||
|
||||
|
@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase
|
|||
|
||||
$this->assertEquals(2, $this->repo->deleteByName($toDelete));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properTagsInfoIsReturned(): void
|
||||
{
|
||||
$names = ['foo', 'bar', 'baz', 'another'];
|
||||
$tags = [];
|
||||
foreach ($names as $name) {
|
||||
$tag = new Tag($name);
|
||||
$tags[] = $tag;
|
||||
$this->getEntityManager()->persist($tag);
|
||||
}
|
||||
|
||||
[$firstUrlTags] = array_chunk($tags, 3);
|
||||
$secondUrlTags = [$tags[0]];
|
||||
|
||||
$shortUrl = new ShortUrl('');
|
||||
$shortUrl->setTags(new ArrayCollection($firstUrlTags));
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl, Visitor::emptyInstance()));
|
||||
|
||||
$shortUrl2 = new ShortUrl('');
|
||||
$shortUrl2->setTags(new ArrayCollection($secondUrlTags));
|
||||
$this->getEntityManager()->persist($shortUrl2);
|
||||
$this->getEntityManager()->persist(new Visit($shortUrl2, Visitor::emptyInstance()));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$result = $this->repo->findTagsWithInfo();
|
||||
|
||||
$this->assertCount(4, $result);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[3], 'shortUrlsCount' => 0, 'visitsCount' => 0],
|
||||
$result[0]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[1], 'shortUrlsCount' => 1, 'visitsCount' => 3],
|
||||
$result[1]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[2], 'shortUrlsCount' => 1, 'visitsCount' => 3],
|
||||
$result[2]->jsonSerialize(),
|
||||
);
|
||||
$this->assertEquals(
|
||||
['tag' => $tags[0], 'shortUrlsCount' => 2, 'visitsCount' => 4],
|
||||
$result[3]->jsonSerialize(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,9 +5,11 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Core\Repository;
|
||||
|
||||
use Cake\Chronos\Chronos;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\Domain;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Entity\VisitLocation;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
|
||||
|
@ -27,6 +29,7 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
Visit::class,
|
||||
ShortUrl::class,
|
||||
Domain::class,
|
||||
Tag::class,
|
||||
];
|
||||
|
||||
private VisitRepository $repo;
|
||||
|
@ -125,33 +128,99 @@ class VisitRepositoryTest extends DatabaseTestCase
|
|||
)));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(): array
|
||||
/** @test */
|
||||
public function findVisitsByTagReturnsProperData(): void
|
||||
{
|
||||
$foo = new Tag('foo');
|
||||
$this->getEntityManager()->persist($foo);
|
||||
|
||||
/** @var ShortUrl $shortUrl */
|
||||
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl2 */
|
||||
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl3 */
|
||||
[,, $shortUrl3] = $this->createShortUrlsAndVisits(false);
|
||||
|
||||
$shortUrl->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl2->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl3->setTags(new ArrayCollection([$foo]));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertCount(0, $this->repo->findVisitsByTag('invalid'));
|
||||
$this->assertCount(18, $this->repo->findVisitsByTag((string) $foo));
|
||||
$this->assertCount(6, $this->repo->findVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertCount(12, $this->repo->findVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function countVisitsByTagReturnsProperData(): void
|
||||
{
|
||||
$foo = new Tag('foo');
|
||||
$this->getEntityManager()->persist($foo);
|
||||
|
||||
/** @var ShortUrl $shortUrl */
|
||||
[,, $shortUrl] = $this->createShortUrlsAndVisits(false);
|
||||
/** @var ShortUrl $shortUrl2 */
|
||||
[,, $shortUrl2] = $this->createShortUrlsAndVisits(false);
|
||||
|
||||
$shortUrl->setTags(new ArrayCollection([$foo]));
|
||||
$shortUrl2->setTags(new ArrayCollection([$foo]));
|
||||
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
$this->assertEquals(0, $this->repo->countVisitsByTag('invalid'));
|
||||
$this->assertEquals(12, $this->repo->countVisitsByTag((string) $foo));
|
||||
$this->assertEquals(4, $this->repo->countVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-02'),
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
$this->assertEquals(8, $this->repo->countVisitsByTag((string) $foo, new DateRange(
|
||||
Chronos::parse('2016-01-03'),
|
||||
)));
|
||||
}
|
||||
|
||||
private function createShortUrlsAndVisits(bool $withDomain = true): array
|
||||
{
|
||||
$shortUrl = new ShortUrl('');
|
||||
$domain = 'example.com';
|
||||
$shortCode = $shortUrl->getShortCode();
|
||||
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'customSlug' => $shortCode,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
|
||||
$this->getEntityManager()->persist($shortUrl);
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance(), Chronos::parse(sprintf('2016-01-0%s', $i + 1)));
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrlWithDomain,
|
||||
$shortUrl,
|
||||
Visitor::emptyInstance(),
|
||||
true,
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
|
||||
return [$shortCode, $domain];
|
||||
if ($withDomain) {
|
||||
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
|
||||
'customSlug' => $shortCode,
|
||||
'domain' => $domain,
|
||||
]));
|
||||
$this->getEntityManager()->persist($shortUrlWithDomain);
|
||||
|
||||
for ($i = 0; $i < 3; $i++) {
|
||||
$visit = new Visit(
|
||||
$shortUrlWithDomain,
|
||||
Visitor::emptyInstance(),
|
||||
true,
|
||||
Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
|
||||
);
|
||||
$this->getEntityManager()->persist($visit);
|
||||
}
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
return [$shortCode, $domain, $shortUrl];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -61,6 +61,10 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'geolite_license_key' => 'kjh23ljkbndskj345',
|
||||
'mercure_public_hub_url' => 'public_url',
|
||||
'mercure_internal_hub_url' => 'internal_url',
|
||||
'mercure_jwt_secret' => 'super_secret_value',
|
||||
'anonymize_remote_addr' => false,
|
||||
];
|
||||
$expected = [
|
||||
'app_options' => [
|
||||
|
@ -89,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'https://third-party.io/foo',
|
||||
],
|
||||
'default_short_codes_length' => 8,
|
||||
'anonymize_remote_addr' => false,
|
||||
],
|
||||
|
||||
'delete_short_urls' => [
|
||||
|
@ -132,6 +137,12 @@ class SimplifiedConfigParserTest extends TestCase
|
|||
'geolite2' => [
|
||||
'license_key' => 'kjh23ljkbndskj345',
|
||||
],
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'public_url',
|
||||
'internal_hub_url' => 'internal_url',
|
||||
'jwt_secret' => 'super_secret_value',
|
||||
],
|
||||
];
|
||||
|
||||
$result = ($this->postProcessor)(array_merge($config, $simplified));
|
||||
|
|
|
@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Entity;
|
|||
|
||||
use Cake\Chronos\Chronos;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Common\Util\IpAddress;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
|
@ -18,7 +19,7 @@ class VisitTest extends TestCase
|
|||
*/
|
||||
public function isProperlyJsonSerialized(?Chronos $date): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), $date);
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', '1.2.3.4'), true, $date);
|
||||
|
||||
$this->assertEquals([
|
||||
'referer' => 'some site',
|
||||
|
@ -33,4 +34,25 @@ class VisitTest extends TestCase
|
|||
yield 'null date' => [null];
|
||||
yield 'not null date' => [Chronos::now()->subDays(10)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideAddresses
|
||||
*/
|
||||
public function addressIsAnonymizedWhenRequested(bool $anonymize, ?string $address, ?string $expectedAddress): void
|
||||
{
|
||||
$visit = new Visit(new ShortUrl(''), new Visitor('Chrome', 'some site', $address), $anonymize);
|
||||
|
||||
$this->assertEquals($expectedAddress, $visit->getRemoteAddr());
|
||||
}
|
||||
|
||||
public function provideAddresses(): iterable
|
||||
{
|
||||
yield 'anonymized null address' => [true, null, null];
|
||||
yield 'non-anonymized null address' => [false, null, null];
|
||||
yield 'anonymized localhost' => [true, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
|
||||
yield 'non-anonymized localhost' => [false, IpAddress::LOCALHOST, IpAddress::LOCALHOST];
|
||||
yield 'anonymized regular address' => [true, '1.2.3.4', '1.2.3.0'];
|
||||
yield 'non-anonymized regular address' => [false, '1.2.3.4', '1.2.3.4'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\Container\ContainerInterface;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListenerDelegator;
|
||||
|
||||
class CloseDbConnectionEventListenerDelegatorTest extends TestCase
|
||||
{
|
||||
private CloseDbConnectionEventListenerDelegator $delegator;
|
||||
private ObjectProphecy $container;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->container = $this->prophesize(ContainerInterface::class);
|
||||
$this->delegator = new CloseDbConnectionEventListenerDelegator();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function properDependenciesArePassed(): void
|
||||
{
|
||||
$callbackInvoked = false;
|
||||
$callback = function () use (&$callbackInvoked): callable {
|
||||
$callbackInvoked = true;
|
||||
|
||||
return function (): void {
|
||||
};
|
||||
};
|
||||
|
||||
$em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
$getEm = $this->container->get('em')->willReturn($em->reveal());
|
||||
|
||||
($this->delegator)($this->container->reveal(), '', $callback);
|
||||
|
||||
$this->assertTrue($callbackInvoked);
|
||||
$getEm->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use RuntimeException;
|
||||
use Shlinkio\Shlink\Common\Doctrine\ReopeningEntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\CloseDbConnectionEventListener;
|
||||
use stdClass;
|
||||
use Throwable;
|
||||
|
||||
class CloseDbConnectionEventListenerTest extends TestCase
|
||||
{
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(ReopeningEntityManagerInterface::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideWrapped
|
||||
*/
|
||||
public function connectionIsOpenedBeforeAndClosedAfter(callable $wrapped, bool &$wrappedWasCalled): void
|
||||
{
|
||||
$conn = $this->prophesize(Connection::class);
|
||||
$close = $conn->close()->will(function (): void {
|
||||
});
|
||||
$getConn = $this->em->getConnection()->willReturn($conn->reveal());
|
||||
$clear = $this->em->clear()->will(function (): void {
|
||||
});
|
||||
$open = $this->em->open()->will(function (): void {
|
||||
});
|
||||
|
||||
$eventListener = new CloseDbConnectionEventListener($this->em->reveal(), $wrapped);
|
||||
|
||||
try {
|
||||
($eventListener)(new stdClass());
|
||||
} catch (Throwable $e) {
|
||||
// Ignore exceptions
|
||||
}
|
||||
|
||||
$this->assertTrue($wrappedWasCalled);
|
||||
$close->shouldHaveBeenCalledOnce();
|
||||
$getConn->shouldHaveBeenCalledOnce();
|
||||
$clear->shouldHaveBeenCalledOnce();
|
||||
$open->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideWrapped(): iterable
|
||||
{
|
||||
yield 'does not throw exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
yield 'throws exception' => (function (): array {
|
||||
$wrappedWasCalled = false;
|
||||
$wrapped = function () use (&$wrappedWasCalled): void {
|
||||
$wrappedWasCalled = true;
|
||||
throw new RuntimeException('Some error');
|
||||
};
|
||||
|
||||
return [$wrapped, &$wrappedWasCalled];
|
||||
})();
|
||||
}
|
||||
}
|
|
@ -4,7 +4,6 @@ declare(strict_types=1);
|
|||
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
|
@ -38,10 +37,6 @@ class LocateShortUrlVisitTest extends TestCase
|
|||
{
|
||||
$this->ipLocationResolver = $this->prophesize(IpLocationResolverInterface::class);
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$conn = $this->prophesize(Connection::class);
|
||||
$this->em->getConnection()->willReturn($conn->reveal());
|
||||
$this->em->clear()->will(function (): void {
|
||||
});
|
||||
$this->logger = $this->prophesize(LoggerInterface::class);
|
||||
$this->dbUpdater = $this->prophesize(GeolocationDbUpdaterInterface::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
|
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
123
module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php
Normal file
|
@ -0,0 +1,123 @@
|
|||
<?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 notificationsAreNotSentWhenVisitCannotBeFound(): 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());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate(
|
||||
Argument::type(Visit::class),
|
||||
)->willReturn(new Update('', ''));
|
||||
$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();
|
||||
$buildNewShortUrlVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldNotHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function notificationsAreSentWhenVisitIsFound(): 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());
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update);
|
||||
$publish = $this->publisher->__invoke($update);
|
||||
|
||||
($this->listener)(new VisitLocated($visitId));
|
||||
|
||||
$findVisit->shouldHaveBeenCalledOnce();
|
||||
$logWarning->shouldNotHaveBeenCalled();
|
||||
$logDebug->shouldNotHaveBeenCalled();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$publish->shouldHaveBeenCalledTimes(2);
|
||||
}
|
||||
|
||||
/** @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,
|
||||
]);
|
||||
$buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update);
|
||||
$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();
|
||||
$buildNewShortUrlVisitUpdate->shouldHaveBeenCalledOnce();
|
||||
$buildNewVisitUpdate->shouldNotHaveBeenCalled();
|
||||
$publish->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\EventDispatcher;
|
||||
namespace ShlinkioTest\Shlink\Core\EventDispatcher;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Exception;
|
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
66
module/Core/test/Mercure/MercureUpdatesGeneratorTest.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?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\ShortUrlMeta;
|
||||
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
|
||||
* @dataProvider provideMethod
|
||||
*/
|
||||
public function visitIsProperlySerializedIntoUpdate(string $method, string $expectedTopic): void
|
||||
{
|
||||
$shortUrl = new ShortUrl('', ShortUrlMeta::fromRawData(['customSlug' => 'foo']));
|
||||
$visit = new Visit($shortUrl, Visitor::emptyInstance());
|
||||
|
||||
$update = $this->generator->{$method}($visit);
|
||||
|
||||
$this->assertEquals([$expectedTopic], $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()));
|
||||
}
|
||||
|
||||
public function provideMethod(): iterable
|
||||
{
|
||||
yield 'newVisitUpdate' => ['newVisitUpdate', 'https://shlink.io/new-visit'];
|
||||
yield 'newShortUrlVisitUpdate' => ['newShortUrlVisitUpdate', 'https://shlink.io/new-visit/foo'];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsForTagPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
private VisitsForTagPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->adapter = new VisitsForTagPaginatorAdapter($this->repo->reveal(), 'foo', VisitsParams::fromRawData([]));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledEveryTimeItemsAreFetched(): void
|
||||
{
|
||||
$count = 3;
|
||||
$limit = 1;
|
||||
$offset = 5;
|
||||
$findVisits = $this->repo->findVisitsByTag('foo', new DateRange(), $limit, $offset)->willReturn([]);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->getItems($offset, $limit);
|
||||
}
|
||||
|
||||
$findVisits->shouldHaveBeenCalledTimes($count);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledOnlyOnceForCount(): void
|
||||
{
|
||||
$count = 3;
|
||||
$countVisits = $this->repo->countVisitsByTag('foo', new DateRange())->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->count();
|
||||
}
|
||||
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
|
||||
|
||||
class VisitsPaginatorAdapterTest extends TestCase
|
||||
{
|
||||
private VisitsPaginatorAdapter $adapter;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->prophesize(VisitRepositoryInterface::class);
|
||||
$this->adapter = new VisitsPaginatorAdapter(
|
||||
$this->repo->reveal(),
|
||||
new ShortUrlIdentifier(''),
|
||||
VisitsParams::fromRawData([]),
|
||||
);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledEveryTimeItemsAreFetched(): void
|
||||
{
|
||||
$count = 3;
|
||||
$limit = 1;
|
||||
$offset = 5;
|
||||
$findVisits = $this->repo->findVisitsByShortCode('', null, new DateRange(), $limit, $offset)->willReturn([]);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->getItems($offset, $limit);
|
||||
}
|
||||
|
||||
$findVisits->shouldHaveBeenCalledTimes($count);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function repoIsCalledOnlyOnceForCount(): void
|
||||
{
|
||||
$count = 3;
|
||||
$countVisits = $this->repo->countVisitsByShortCode('', null, new DateRange())->willReturn(3);
|
||||
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->adapter->count();
|
||||
}
|
||||
|
||||
$countVisits->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
|
@ -5,7 +5,6 @@ declare(strict_types=1);
|
|||
namespace ShlinkioTest\Shlink\Core\Service\Tag;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
|
@ -13,16 +12,21 @@ use Shlinkio\Shlink\Core\Entity\Tag;
|
|||
use Shlinkio\Shlink\Core\Exception\TagConflictException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
|
||||
class TagServiceTest extends TestCase
|
||||
{
|
||||
private TagService $service;
|
||||
private ObjectProphecy $em;
|
||||
private ObjectProphecy $repo;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->repo = $this->prophesize(TagRepository::class);
|
||||
$this->em->getRepository(Tag::class)->willReturn($this->repo->reveal())->shouldBeCalled();
|
||||
|
||||
$this->service = new TagService($this->em->reveal());
|
||||
}
|
||||
|
||||
|
@ -31,36 +35,41 @@ class TagServiceTest extends TestCase
|
|||
{
|
||||
$expected = [new Tag('foo'), new Tag('bar')];
|
||||
|
||||
$repo = $this->prophesize(EntityRepository::class);
|
||||
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
|
||||
|
||||
$result = $this->service->listTags();
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function tagsInfoDelegatesOnRepository(): void
|
||||
{
|
||||
$expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
|
||||
|
||||
$find = $this->repo->findTagsWithInfo()->willReturn($expected);
|
||||
|
||||
$result = $this->service->tagsInfo();
|
||||
|
||||
$this->assertEquals($expected, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function deleteTagsDelegatesOnRepository(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
|
||||
|
||||
$this->service->deleteTags(['foo', 'bar']);
|
||||
|
||||
$delete->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function createTagsPersistsEntities(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
|
@ -68,7 +77,6 @@ class TagServiceTest extends TestCase
|
|||
|
||||
$this->assertCount(2, $result);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
$persist->shouldHaveBeenCalledTimes(2);
|
||||
$flush->shouldHaveBeenCalled();
|
||||
}
|
||||
|
@ -76,12 +84,9 @@ class TagServiceTest extends TestCase
|
|||
/** @test */
|
||||
public function renameInvalidTagThrowsException(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
|
||||
|
||||
$find->shouldBeCalled();
|
||||
$getRepo->shouldBeCalled();
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
|
||||
$this->service->renameTag('foo', 'bar');
|
||||
|
@ -95,10 +100,8 @@ class TagServiceTest extends TestCase
|
|||
{
|
||||
$expected = new Tag('foo');
|
||||
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected);
|
||||
$countTags = $repo->count(Argument::cetera())->willReturn($count);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected);
|
||||
$countTags = $this->repo->count(Argument::cetera())->willReturn($count);
|
||||
$flush = $this->em->flush()->willReturn(null);
|
||||
|
||||
$tag = $this->service->renameTag($oldName, $newName);
|
||||
|
@ -106,7 +109,6 @@ class TagServiceTest extends TestCase
|
|||
$this->assertSame($expected, $tag);
|
||||
$this->assertEquals($newName, (string) $tag);
|
||||
$find->shouldHaveBeenCalled();
|
||||
$getRepo->shouldHaveBeenCalled();
|
||||
$flush->shouldHaveBeenCalled();
|
||||
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
|
||||
}
|
||||
|
@ -120,14 +122,11 @@ class TagServiceTest extends TestCase
|
|||
/** @test */
|
||||
public function renameTagToAnExistingNameThrowsException(): void
|
||||
{
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$countTags = $repo->count(Argument::cetera())->willReturn(1);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
$find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
|
||||
$countTags = $this->repo->count(Argument::cetera())->willReturn(1);
|
||||
$flush = $this->em->flush(Argument::any())->willReturn(null);
|
||||
|
||||
$find->shouldBeCalled();
|
||||
$getRepo->shouldBeCalled();
|
||||
$countTags->shouldBeCalled();
|
||||
$flush->shouldNotBeCalled();
|
||||
$this->expectException(TagConflictException::class);
|
||||
|
|
|
@ -6,20 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Service;
|
|||
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Laminas\Stdlib\ArrayUtils;
|
||||
use PHPUnit\Framework\Assert;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\Entity\Tag;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Model\Visitor;
|
||||
use Shlinkio\Shlink\Core\Model\VisitsParams;
|
||||
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
|
||||
use Shlinkio\Shlink\Core\Repository\TagRepository;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Service\VisitsTracker;
|
||||
|
||||
|
@ -37,7 +39,7 @@ class VisitsTrackerTest extends TestCase
|
|||
$this->em = $this->prophesize(EntityManager::class);
|
||||
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class);
|
||||
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal());
|
||||
$this->visitsTracker = new VisitsTracker($this->em->reveal(), $this->eventDispatcher->reveal(), true);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
@ -53,25 +55,6 @@ class VisitsTrackerTest extends TestCase
|
|||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function trackedIpAddressGetsObfuscated(): void
|
||||
{
|
||||
$shortCode = '123ABC';
|
||||
|
||||
$this->em->persist(Argument::any())->will(function ($args) {
|
||||
/** @var Visit $visit */
|
||||
$visit = $args[0];
|
||||
Assert::assertEquals('4.3.2.0', $visit->getRemoteAddr());
|
||||
$visit->setId('1');
|
||||
return $visit;
|
||||
})->shouldBeCalledOnce();
|
||||
$this->em->flush()->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->track(new ShortUrl($shortCode), new Visitor('', '', '4.3.2.1'));
|
||||
|
||||
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function infoReturnsVisitsForCertainShortCode(): void
|
||||
{
|
||||
|
@ -105,4 +88,40 @@ class VisitsTrackerTest extends TestCase
|
|||
|
||||
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function throwsExceptionWhenRequestingVisitsForInvalidTag(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$count = $repo->count(['name' => $tag])->willReturn(0);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$this->expectException(TagNotFoundException::class);
|
||||
$count->shouldBeCalledOnce();
|
||||
$getRepo->shouldBeCalledOnce();
|
||||
|
||||
$this->visitsTracker->visitsForTag($tag, new VisitsParams());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function visitsForTagAreReturnedAsExpected(): void
|
||||
{
|
||||
$tag = 'foo';
|
||||
$repo = $this->prophesize(TagRepository::class);
|
||||
$count = $repo->count(['name' => $tag])->willReturn(1);
|
||||
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
|
||||
|
||||
$list = map(range(0, 1), fn () => new Visit(new ShortUrl(''), Visitor::emptyInstance()));
|
||||
$repo2 = $this->prophesize(VisitRepository::class);
|
||||
$repo2->findVisitsByTag($tag, Argument::type(DateRange::class), 1, 0)->willReturn($list);
|
||||
$repo2->countVisitsByTag($tag, Argument::type(DateRange::class))->willReturn(1);
|
||||
$this->em->getRepository(Visit::class)->willReturn($repo2->reveal())->shouldBeCalledOnce();
|
||||
|
||||
$paginator = $this->visitsTracker->visitsForTag($tag, new VisitsParams());
|
||||
|
||||
$this->assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentItems()));
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
}
|
||||
|
|
50
module/Core/test/Visit/VisitsStatsHelperTest.php
Normal file
50
module/Core/test/Visit/VisitsStatsHelperTest.php
Normal file
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\Visit;
|
||||
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Shlinkio\Shlink\Core\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Repository\VisitRepository;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
|
||||
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelper;
|
||||
|
||||
use function Functional\map;
|
||||
use function range;
|
||||
|
||||
class VisitsStatsHelperTest extends TestCase
|
||||
{
|
||||
private VisitsStatsHelper $helper;
|
||||
private ObjectProphecy $em;
|
||||
|
||||
public function setUp(): void
|
||||
{
|
||||
$this->em = $this->prophesize(EntityManagerInterface::class);
|
||||
$this->helper = new VisitsStatsHelper($this->em->reveal());
|
||||
}
|
||||
|
||||
/**
|
||||
* @test
|
||||
* @dataProvider provideCounts
|
||||
*/
|
||||
public function returnsExpectedVisitsStats(int $expectedCount): void
|
||||
{
|
||||
$repo = $this->prophesize(VisitRepository::class);
|
||||
$count = $repo->count([])->willReturn($expectedCount);
|
||||
$getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
|
||||
|
||||
$stats = $this->helper->getVisitsStats();
|
||||
|
||||
$this->assertEquals(new VisitsStats($expectedCount), $stats);
|
||||
$count->shouldHaveBeenCalledOnce();
|
||||
$getRepo->shouldHaveBeenCalledOnce();
|
||||
}
|
||||
|
||||
public function provideCounts(): iterable
|
||||
{
|
||||
return map(range(0, 50, 5), fn (int $value) => [$value]);
|
||||
}
|
||||
}
|
|
@ -4,13 +4,14 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Rest;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
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\Core\Tag\TagService;
|
||||
use Shlinkio\Shlink\Core\Visit;
|
||||
use Shlinkio\Shlink\Rest\Service\ApiKeyService;
|
||||
|
||||
return [
|
||||
|
@ -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,
|
||||
|
@ -27,7 +29,9 @@ return [
|
|||
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GetVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
|
||||
|
@ -45,35 +49,29 @@ return [
|
|||
ConfigAbstractFactory::class => [
|
||||
ApiKeyService::class => ['em'],
|
||||
|
||||
Action\HealthAction::class => [Connection::class, AppOptions::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\CreateShortUrlAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\HealthAction::class => ['em', AppOptions::class],
|
||||
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'],
|
||||
Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
|
||||
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
|
||||
Service\UrlShortener::class,
|
||||
ApiKeyService::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class],
|
||||
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class],
|
||||
Action\ShortUrl\ResolveShortUrlAction::class => [
|
||||
Service\ShortUrl\ShortUrlResolver::class,
|
||||
'config.url_shortener.domain',
|
||||
],
|
||||
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [
|
||||
Service\ShortUrlService::class,
|
||||
'config.url_shortener.domain',
|
||||
'Logger_Shlink',
|
||||
],
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'],
|
||||
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
|
||||
Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class],
|
||||
Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
|
||||
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
|
||||
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
|
||||
Action\Tag\ListTagsAction::class => [TagService::class],
|
||||
Action\Tag\DeleteTagsAction::class => [TagService::class],
|
||||
Action\Tag\CreateTagsAction::class => [TagService::class],
|
||||
Action\Tag\UpdateTagAction::class => [TagService::class],
|
||||
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [
|
||||
|
|
|
@ -26,13 +26,17 @@ return [
|
|||
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
||||
// Visits
|
||||
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\Visit\TagVisitsAction::getRouteDef(),
|
||||
Action\Visit\GlobalVisitsAction::getRouteDef(),
|
||||
|
||||
// Tags
|
||||
Action\Tag\ListTagsAction::getRouteDef(),
|
||||
Action\Tag\DeleteTagsAction::getRouteDef(),
|
||||
Action\Tag\CreateTagsAction::getRouteDef(),
|
||||
Action\Tag\UpdateTagAction::getRouteDef(),
|
||||
|
||||
Action\MercureInfoAction::getRouteDef(),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action;
|
|||
use Fig\Http\Message\RequestMethodInterface;
|
||||
use Fig\Http\Message\StatusCodeInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
|
||||
use function array_merge;
|
||||
|
||||
|
@ -17,13 +15,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet
|
|||
protected const ROUTE_PATH = '';
|
||||
protected const ROUTE_ALLOWED_METHODS = [];
|
||||
|
||||
protected LoggerInterface $logger;
|
||||
|
||||
public function __construct(?LoggerInterface $logger = null)
|
||||
{
|
||||
$this->logger = $logger ?: new NullLogger();
|
||||
}
|
||||
|
||||
public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array
|
||||
{
|
||||
return [
|
||||
|
|
|
@ -4,11 +4,10 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\Rest\Action;
|
||||
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Options\AppOptions;
|
||||
use Throwable;
|
||||
|
||||
|
@ -21,13 +20,12 @@ class HealthAction extends AbstractRestAction
|
|||
protected const ROUTE_PATH = '/health';
|
||||
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
|
||||
|
||||
private EntityManagerInterface $em;
|
||||
private AppOptions $options;
|
||||
private Connection $conn;
|
||||
|
||||
public function __construct(Connection $conn, AppOptions $options, ?LoggerInterface $logger = null)
|
||||
public function __construct(EntityManagerInterface $em, AppOptions $options)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->conn = $conn;
|
||||
$this->em = $em;
|
||||
$this->options = $options;
|
||||
}
|
||||
|
||||
|
@ -39,7 +37,7 @@ class HealthAction extends AbstractRestAction
|
|||
public function handle(ServerRequestInterface $request): ResponseInterface
|
||||
{
|
||||
try {
|
||||
$connected = $this->conn->ping();
|
||||
$connected = $this->em->getConnection()->ping();
|
||||
} catch (Throwable $e) {
|
||||
$connected = false;
|
||||
}
|
||||
|
|
53
module/Rest/src/Action/MercureInfoAction.php
Normal file
53
module/Rest/src/Action/MercureInfoAction.php
Normal file
|
@ -0,0 +1,53 @@
|
|||
<?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 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)
|
||||
{
|
||||
$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'] ?? 1;
|
||||
$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(),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
|
@ -19,12 +18,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
|
|||
private UrlShortenerInterface $urlShortener;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
|
||||
{
|
||||
$this->urlShortener = $urlShortener;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
@ -19,9 +18,8 @@ class DeleteShortUrlAction extends AbstractRestAction
|
|||
|
||||
private DeleteShortUrlServiceInterface $deleteShortUrlService;
|
||||
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->deleteShortUrlService = $deleteShortUrlService;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
|
@ -20,9 +19,8 @@ class EditShortUrlAction extends AbstractRestAction
|
|||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
|
@ -20,9 +19,8 @@ class EditShortUrlTagsAction extends AbstractRestAction
|
|||
|
||||
private ShortUrlServiceInterface $shortUrlService;
|
||||
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null)
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
|
||||
|
@ -24,12 +23,8 @@ class ListShortUrlsAction extends AbstractRestAction
|
|||
private ShortUrlServiceInterface $shortUrlService;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlServiceInterface $shortUrlService,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
|
||||
{
|
||||
$this->shortUrlService = $shortUrlService;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface as Response;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
|
||||
|
@ -21,12 +20,8 @@ class ResolveShortUrlAction extends AbstractRestAction
|
|||
private ShortUrlResolverInterface $urlResolver;
|
||||
private array $domainConfig;
|
||||
|
||||
public function __construct(
|
||||
ShortUrlResolverInterface $urlResolver,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
) {
|
||||
parent::__construct($logger);
|
||||
public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig)
|
||||
{
|
||||
$this->urlResolver = $urlResolver;
|
||||
$this->domainConfig = $domainConfig;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
|
|||
|
||||
use Laminas\Diactoros\Uri;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
|
||||
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
|
||||
|
@ -22,10 +21,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
|
|||
public function __construct(
|
||||
UrlShortenerInterface $urlShortener,
|
||||
ApiKeyServiceInterface $apiKeyService,
|
||||
array $domainConfig,
|
||||
?LoggerInterface $logger = null
|
||||
array $domainConfig
|
||||
) {
|
||||
parent::__construct($urlShortener, $domainConfig, $logger);
|
||||
parent::__construct($urlShortener, $domainConfig);
|
||||
$this->apiKeyService = $apiKeyService;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
|||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class CreateTagsAction extends AbstractRestAction
|
||||
|
@ -18,9 +17,8 @@ class CreateTagsAction extends AbstractRestAction
|
|||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
|
|
|
@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
|
|||
use Laminas\Diactoros\Response\EmptyResponse;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
|
||||
|
||||
class DeleteTagsAction extends AbstractRestAction
|
||||
|
@ -18,9 +17,8 @@ class DeleteTagsAction extends AbstractRestAction
|
|||
|
||||
private TagServiceInterface $tagService;
|
||||
|
||||
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
|
||||
public function __construct(TagServiceInterface $tagService)
|
||||
{
|
||||
parent::__construct($logger);
|
||||
$this->tagService = $tagService;
|
||||
}
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue