Compare commits

..

79 Commits

Author SHA1 Message Date
Alejandro Celaya
cb6756d801 Merge pull request #763 from shlinkio/develop
Release 2.2.0
2020-05-09 11:10:31 +02:00
Alejandro Celaya
cf605407ad Used definitive dependency versions for shlink-common and shlñink-installer 2020-05-09 10:56:07 +02:00
Alejandro Celaya
1a4eee1c81 Merge pull request #762 from acelaya-forks/feature/visits-by-tag
Feature/visits by tag
2020-05-09 10:52:33 +02:00
Alejandro Celaya
4c5cd88041 Updated changelog 2020-05-09 10:38:18 +02:00
Alejandro Celaya
4d346d1fea Created API test for tags visits endpoint 2020-05-09 10:31:39 +02:00
Alejandro Celaya
7f39e6d768 Created TagVisitsActionTest 2020-05-09 10:22:07 +02:00
Alejandro Celaya
9b9de8e290 Updated VisitsTrackerTest 2020-05-09 10:14:26 +02:00
Alejandro Celaya
e1e3c7f061 Created paginator adapter tests 2020-05-09 10:10:48 +02:00
Alejandro Celaya
3218f8c283 Added Created endpoint to serve visits by tag 2020-05-09 09:53:45 +02:00
Alejandro Celaya
f0acce1be0 Updated to latest common 2020-05-09 09:34:59 +02:00
Alejandro Celaya
dd4b4277c9 Added test for VisitRepository tag methods 2020-05-08 20:11:37 +02:00
Alejandro Celaya
baf77b6ffb Implemented methods to get paginated list of visits by tag, reusing methods used for short code filtering 2020-05-08 19:55:05 +02:00
Alejandro Celaya
5be882a31b Improved parameter definition in some private queries in VisitRepository 2020-05-08 19:41:21 +02:00
Alejandro Celaya
ae060f3b13 Merge pull request #761 from acelaya-forks/feature/optional-obfuscation
Feature/optional obfuscation
2020-05-08 16:03:11 +02:00
Alejandro Celaya
e8ab664561 Updated changelog 2020-05-08 15:54:50 +02:00
Alejandro Celaya
f4bf3551f6 Updated shlink-installer to a version supporting IP anonymization param 2020-05-08 15:50:16 +02:00
Alejandro Celaya
8f06e4b20f Replaced references to obfuscate by anonymize 2020-05-08 15:43:09 +02:00
Alejandro Celaya
bfdd6e0c50 Ensured SimplifiedConfigParser properly handles obfuscate_remote_addr option 2020-05-08 13:21:49 +02:00
Alejandro Celaya
ba13d99a71 Allowed remote addr obfuscation to be configured on docker image by using the OBFUSCATE_REMOTE_ADDR env var 2020-05-08 13:19:40 +02:00
Alejandro Celaya
eac468514b Allow to determine if remote addresses should be obfuscated at configuration level 2020-05-08 13:10:58 +02:00
Alejandro Celaya
7da00fbc8c Updated Visit entity so that the address can be optionally obfuscated 2020-05-08 12:58:49 +02:00
Alejandro Celaya
4b7c54d7a9 Merge pull request #760 from acelaya-forks/feature/list-tags-command
Updated ListTagsCommand so that it displays extended information
2020-05-08 12:57:35 +02:00
Alejandro Celaya
c336bb1901 Updated ListTagsCommand so that it displays extended information 2020-05-08 12:39:02 +02:00
Alejandro Celaya
fbb1c449da Merge pull request #759 from acelaya-forks/feature/improved-tags-endpoint
Feature/improved tags endpoint
2020-05-08 12:17:32 +02:00
Alejandro Celaya
252cc7f49d Updated changelog 2020-05-08 11:53:26 +02:00
Alejandro Celaya
00cac4ba72 Created rest test for list tags action 2020-05-08 11:51:28 +02:00
Alejandro Celaya
91aaffc6db Updated ListTagsActionTest 2020-05-08 11:32:06 +02:00
Alejandro Celaya
2e269bcacd Updated TagServiceTest 2020-05-08 11:14:39 +02:00
Alejandro Celaya
bdd14427d9 Added tests for TagRepository::findTagsWithInfo 2020-05-08 11:09:28 +02:00
Alejandro Celaya
06c59fe2dd Fixed invalid imports after class refactoring 2020-05-08 10:29:24 +02:00
Alejandro Celaya
9a78fd1a26 Fixed definition of inversed many to many entity relationship 2020-05-08 10:25:33 +02:00
Alejandro Celaya
626c92460b Enhanced list tags endpoint so that it can also return stats foir every tag 2020-05-08 10:15:33 +02:00
Alejandro Celaya
7e0a14493e Documented updates on the tags endpoint to return more detailed information 2020-05-08 10:14:38 +02:00
Alejandro Celaya
8d23e60d3a Merge pull request #758 from acelaya-forks/feature/non-stable-alpha
Ensured stable tag is not pushed when building docker image for alpha or beta versions
2020-05-07 10:57:52 +02:00
Alejandro Celaya
5f0293bc21 Ensured stable tag is not pushed when building docker image for alpha or beta versions 2020-05-07 10:45:53 +02:00
Alejandro Celaya
afe7381263 Merge pull request #757 from acelaya-forks/feature/docker-img-impr
Feature/docker img impr
2020-05-07 10:31:32 +02:00
Alejandro Celaya
b75922f1d3 Updated changelog 2020-05-07 10:17:34 +02:00
Alejandro Celaya
d9ae83a92b Updated everything related with dependencies in docker images 2020-05-07 10:16:20 +02:00
Alejandro Celaya
22cc9ace4d Merge pull request #755 from acelaya-forks/feature/fix-logged-remote-ip
Feature/fix logged remote ip
2020-05-05 13:04:02 +02:00
Alejandro Celaya
53a37feafe Updated changelogs 2020-05-05 12:54:08 +02:00
Alejandro Celaya
0cab51b01b Enforced mezzio-swoole 2.6.4 or greater 2020-05-05 12:51:47 +02:00
Alejandro Celaya
5f258b6a28 Merge pull request #752 from acelaya-forks/feature/travis-db-tests
Feature/travis db tests
2020-05-04 22:06:04 +02:00
Alejandro Celaya
cc41c51f77 Removed duplicated pdo_sqlsrv enabling on travis config 2020-05-04 21:55:18 +02:00
Alejandro Celaya
5f42266cf2 Moved ms odbc commands to a script 2020-05-04 21:48:54 +02:00
Alejandro Celaya
522d8ed236 Ensured some commands are run as sudo during travis CI 2020-05-04 21:33:19 +02:00
Alejandro Celaya
78359c28c7 Added MS ODBC package installation to travis 2020-05-04 21:22:41 +02:00
Alejandro Celaya
13bb48d068 Installed pdo_sqlsrv extension in travis 2020-05-04 21:12:49 +02:00
Alejandro Celaya
f6d9a83202 Moved initial ci databases to specific docker-compose file 2020-05-04 21:00:09 +02:00
Alejandro Celaya
dfdae96da5 Added commands to initially create all testing database for all database engines in travis 2020-05-04 20:34:28 +02:00
Alejandro Celaya
9f13063b1f Fixed docker-compose command run in travis 2020-05-04 20:02:48 +02:00
Alejandro Celaya
1e8c36b5f1 Updated changelog 2020-05-04 19:55:52 +02:00
Alejandro Celaya
e747a0b250 Updated how database tests are run in travis, so that all DB engines are covered 2020-05-04 19:55:03 +02:00
Alejandro Celaya
79b8834c61 Merge pull request #748 from acelaya-forks/feature/visits-perf-improvements
Feature/visits perf improvements
2020-05-03 20:11:40 +02:00
Alejandro Celaya
313b6a59b9 Updated changelog 2020-05-03 20:02:50 +02:00
Alejandro Celaya
d5288f756e Fixed entity mapping for visits without a visit location 2020-05-03 19:52:40 +02:00
Alejandro Celaya
867659ea25 Created index on visits.date column 2020-05-03 19:15:26 +02:00
Alejandro Celaya
74ad3553cb Hardcoded types on date fields when filtering visits lists 2020-05-03 19:02:13 +02:00
Alejandro Celaya
8b0ce8e6f3 Improved performance when loading visits chuncks at high offsets 2020-05-03 18:20:01 +02:00
Alejandro Celaya
0e4bccc4bb Cached result of the count query on VisitsPaginatorAdapter 2020-05-03 10:44:01 +02:00
Alejandro Celaya
c4ae89a279 Removed DISTINCT when counting visits for a short URL 2020-05-03 10:22:00 +02:00
Alejandro Celaya
80d41db901 Improved performance on query that returns the list of visits for a short URL 2020-05-02 22:47:59 +02:00
Alejandro Celaya
6c30fc73ee Added swoole reverse proxy container 2020-05-02 12:04:42 +02:00
Alejandro Celaya
56932e4ea6 Disabled swoole coroutines 2020-05-01 18:24:48 +02:00
Alejandro Celaya
84b38c4940 Merge pull request #745 from acelaya-forks/feature/general-visits
Feature/general visits
2020-05-01 12:16:22 +02:00
Alejandro Celaya
aece9e68ba Removed logger dependency from rest actions 2020-05-01 12:08:44 +02:00
Alejandro Celaya
d067f52ac2 Updated changelog 2020-05-01 11:58:59 +02:00
Alejandro Celaya
b5947d1642 Created more unit tests 2020-05-01 11:57:46 +02:00
Alejandro Celaya
3232ab401f Documented new visits endpoint 2020-05-01 11:44:55 +02:00
Alejandro Celaya
1ef10f11cb Created new action to get default visit stats 2020-05-01 11:40:02 +02:00
Alejandro Celaya
5beaab85ac Renamed GetVisitsAction to ShortUrlVisitsAction 2020-05-01 11:17:07 +02:00
Alejandro Celaya
4498386f56 Fixed merge conflicts 2020-04-30 20:26:00 +02:00
Alejandro Celaya
a30f796100 Merge pull request #743 from acelaya-forks/feature/geolite-license
Feature/geolite license
2020-04-30 19:34:44 +02:00
Alejandro Celaya
93a2c83652 Enabled GeoLite installer config option 2020-04-29 20:31:06 +02:00
Alejandro Celaya
4d4423413d Added GEOLITE_LICENSE_KEY env var to basic docker example, to encourage using it 2020-04-29 19:44:08 +02:00
Alejandro Celaya
a1c74c4038 Updated changelog 2020-04-29 19:31:10 +02:00
Alejandro Celaya
f71bb5e307 Added support for GEOLITE_LICENSE_KEY env var for docker image 2020-04-29 19:27:35 +02:00
Alejandro Celaya
9190996e54 Added support for geolite_license_key config option 2020-04-29 19:26:34 +02:00
Alejandro Celaya
af8b6b7f96 Documented how to pass a GEOLITE license key 2020-04-29 19:24:18 +02:00
Alejandro Celaya
e775b0f12f Merge pull request #722 from shlinkio/develop
Release 2.1.3
2020-04-09 12:50:46 +02:00
96 changed files with 1664 additions and 329 deletions

View File

@@ -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)

View File

@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased]
## 2.2.0 - 2020-05-09
#### Added
@@ -19,7 +19,48 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
The updates are only published when serving Shlink with swoole.
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates.
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
* *Nothing*
#### Changed
@@ -35,9 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### 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.
* [#742](https://github.com/shlinkio/shlink/issues/742) Allowed a custom GeoLite2 license key to be provided, in order to avoid download limits.
## 2.1.3 - 2020-04-09

View File

@@ -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

View File

@@ -39,7 +39,7 @@
"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",
@@ -48,10 +48,10 @@
"predis/predis": "^1.1",
"pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9",
"shlinkio/shlink-common": "dev-master#e659cf9d9b5b3b131419e2f55f2e595f562baafc as 3.1.0",
"shlinkio/shlink-common": "^3.1.0",
"shlinkio/shlink-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4",
"shlinkio/shlink-installer": "dev-master#f51a2186cf474fb5773b0ef74b8533878de9dd1e as 5.0.0",
"shlinkio/shlink-installer": "^5.0.0",
"shlinkio/shlink-ip-geolocation": "^1.4",
"symfony/console": "^5.0",
"symfony/filesystem": "^5.0",
@@ -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</>",

View File

@@ -35,6 +35,8 @@ return [
Option\Mercure\MercurePublicUrlConfigOption::class,
Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class,
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\IpAnonymizationConfigOption::class,
],
'installation_commands' => [

View File

@@ -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',

View File

@@ -12,6 +12,7 @@ return [
'hostname' => '',
],
'validate_url' => false,
'anonymize_remote_addr' => true,
'visits_webhooks' => [],
'default_short_codes_length' => DEFAULT_SHORT_CODES_LENGTH,
],

View File

@@ -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,8 +30,9 @@ $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' => [
@@ -41,8 +43,9 @@ $buildDbConnection = function (): array {
'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',
],
@@ -50,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',
],
];

View 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

View File

@@ -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

View File

@@ -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

View 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;
}
}

View 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
View 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

View File

@@ -32,6 +32,17 @@ services:
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:
@@ -68,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:
@@ -81,7 +92,7 @@ services:
shlink_db_maria:
container_name: shlink_db_maria
image: mariadb:10.2
image: mariadb:10.5
ports:
- "3308:3306"
volumes:
@@ -103,7 +114,7 @@ services:
shlink_redis:
container_name: shlink_redis
image: redis:5.0-alpine
image: redis:6.0-alpine
ports:
- "6380:6379"
@@ -120,7 +131,7 @@ services:
shlink_mercure:
container_name: shlink_mercure
image: dunglas/mercure:v0.8
image: dunglas/mercure:v0.9
ports:
- "3080:80"
environment:

View File

@@ -18,7 +18,7 @@ It also expects these two env vars to be provided, in order to properly generate
So based on this, to run shlink on a local docker service, you should run a command like this:
```bash
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink:stable
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 shlinkio/shlink:stable
```
### Interact with shlink's CLI on a running container.
@@ -168,10 +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.
* `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:
@@ -199,9 +201,11 @@ docker run \
-e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \
-e 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
```
@@ -243,9 +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",
"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"
"mercure_jwt_secret": "super_secret_key",
"anonymize_remote_addr": false
}
```

View File

@@ -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 .

View File

@@ -117,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(),
],
@@ -160,6 +161,10 @@ return [
],
],
'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
],
'mercure' => $helper->getMercureConfig(),
];

View 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"
}
}
}

View File

@@ -0,0 +1,10 @@
{
"type": "object",
"required": ["visitsCount"],
"properties": {
"visitsCount": {
"type": "number",
"description": "The total amount of visits received."
}
}
}

View File

@@ -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"
}
}
}
}

View 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"
}
}
}
}
}
}
}

View 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"
}
}
}
}
}
}
}

View File

@@ -78,9 +78,15 @@
"$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"

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -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()],
);
}
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
}
}

View File

@@ -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;

View File

@@ -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,
@@ -53,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,

View File

@@ -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)

View File

@@ -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');
};

View File

@@ -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)

View File

@@ -33,9 +33,11 @@ class SimplifiedConfigParser
'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
'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' => [

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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());
}
}

View File

@@ -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(),

View File

@@ -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']),
);
}
}

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}

View 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,
];
}
}

View File

@@ -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
*/

View File

@@ -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
*/

View 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,
];
}
}

View 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([]);
}
}

View 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;
}

View File

@@ -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(),
);
}
}

View File

@@ -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];
}
}

View File

@@ -60,9 +60,11 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo',
],
'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' => [
@@ -91,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo',
],
'default_short_codes_length' => 8,
'anonymize_remote_addr' => false,
],
'delete_short_urls' => [
@@ -131,6 +134,10 @@ class SimplifiedConfigParserTest extends TestCase
],
],
'geolite2' => [
'license_key' => 'kjh23ljkbndskj345',
],
'mercure' => [
'public_hub_url' => 'public_url',
'internal_hub_url' => 'internal_url',

View File

@@ -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'];
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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();
}
}

View 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]);
}
}

View File

@@ -7,10 +7,11 @@ namespace Shlinkio\Shlink\Rest;
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 [
@@ -28,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,
@@ -46,36 +49,29 @@ return [
ConfigAbstractFactory::class => [
ApiKeyService::class => ['em'],
Action\HealthAction::class => ['em', AppOptions::class, 'Logger_Shlink'],
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', '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 => [

View File

@@ -26,7 +26,9 @@ 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(),

View File

@@ -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 [

View File

@@ -8,7 +8,6 @@ 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;
@@ -24,9 +23,8 @@ class HealthAction extends AbstractRestAction
private EntityManagerInterface $em;
private AppOptions $options;
public function __construct(EntityManagerInterface $em, AppOptions $options, ?LoggerInterface $logger = null)
public function __construct(EntityManagerInterface $em, AppOptions $options)
{
parent::__construct($logger);
$this->em = $em;
$this->options = $options;
}

View File

@@ -8,7 +8,6 @@ use Cake\Chronos\Chronos;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\JwtProviderInterface;
use Shlinkio\Shlink\Rest\Exception\MercureException;
use Throwable;
@@ -23,12 +22,8 @@ class MercureInfoAction extends AbstractRestAction
private JwtProviderInterface $jwtProvider;
private array $mercureConfig;
public function __construct(
JwtProviderInterface $jwtProvider,
array $mercureConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
public function __construct(JwtProviderInterface $jwtProvider, array $mercureConfig)
{
$this->jwtProvider = $jwtProvider;
$this->mercureConfig = $mercureConfig;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -7,10 +7,12 @@ 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\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use function Functional\map;
class ListTagsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/tags';
@@ -18,24 +20,31 @@ class ListTagsAction extends AbstractRestAction
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
public function __construct(TagServiceInterface $tagService)
{
parent::__construct($logger);
$this->tagService = $tagService;
}
/**
* Process an incoming server request and return a response, optionally delegating
* to the next middleware component to create the response.
*
*
* @throws \InvalidArgumentException
*/
public function handle(ServerRequestInterface $request): ResponseInterface
{
$query = $request->getQueryParams();
$withStats = ($query['withStats'] ?? null) === 'true';
if (! $withStats) {
return new JsonResponse([
'tags' => [
'data' => $this->tagService->listTags(),
],
]);
}
$tagsInfo = $this->tagService->tagsInfo();
$data = map($tagsInfo, fn (TagInfo $info) => (string) $info->tag());
return new JsonResponse([
'tags' => [
'data' => $this->tagService->listTags(),
'data' => $data,
'stats' => $tagsInfo,
],
]);
}

View File

@@ -7,9 +7,8 @@ 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\Exception\ValidationException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class UpdateTagAction extends AbstractRestAction
@@ -19,9 +18,8 @@ class UpdateTagAction extends AbstractRestAction
private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null)
public function __construct(TagServiceInterface $tagService)
{
parent::__construct($logger);
$this->tagService = $tagService;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class GlobalVisitsAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private VisitsStatsHelperInterface $statsHelper;
public function __construct(VisitsStatsHelperInterface $statsHelper)
{
$this->statsHelper = $statsHelper;
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
return new JsonResponse([
'visits' => $this->statsHelper->getVisitsStats(),
]);
}
}

View File

@@ -7,14 +7,13 @@ namespace Shlinkio\Shlink\Rest\Action\Visit;
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\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class GetVisitsAction extends AbstractRestAction
class ShortUrlVisitsAction extends AbstractRestAction
{
use PaginatorUtilsTrait;
@@ -23,9 +22,8 @@ class GetVisitsAction extends AbstractRestAction
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker, ?LoggerInterface $logger = null)
public function __construct(VisitsTrackerInterface $visitsTracker)
{
parent::__construct($logger);
$this->visitsTracker = $visitsTracker;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class TagVisitsAction extends AbstractRestAction
{
use PaginatorUtilsTrait;
protected const ROUTE_PATH = '/tags/{tag}/visits';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker)
{
$this->visitsTracker = $visitsTracker;
}
public function handle(Request $request): Response
{
$tag = $request->getAttribute('tag', '');
$visits = $this->visitsTracker->visitsForTag($tag, VisitsParams::fromRawData($request->getQueryParams()));
return new JsonResponse([
'visits' => $this->serializePaginator($visits),
]);
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class GlobalVisitsActionTest extends ApiTestCase
{
/** @test */
public function returnsExpectedVisitsStats(): void
{
$resp = $this->callApiWithKey(self::METHOD_GET, '/visits');
$payload = $this->getJsonResponsePayload($resp);
$this->assertArrayHasKey('visits', $payload);
$this->assertArrayHasKey('visitsCount', $payload['visits']);
$this->assertEquals(7, $payload['visits']['visitsCount']);
}
}

View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use GuzzleHttp\RequestOptions;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
class ListTagsActionTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideQueries
*/
public function expectedListOfTagsIsReturned(array $query, array $expectedTags): void
{
$resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query]);
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(['tags' => $expectedTags], $payload);
}
public function provideQueries(): iterable
{
yield 'stats not requested' => [[], [
'data' => ['bar', 'baz', 'foo'],
]];
yield 'stats requested' => [['withStats' => 'true'], [
'data' => ['bar', 'baz', 'foo'],
'stats' => [
[
'tag' => 'bar',
'shortUrlsCount' => 1,
'visitsCount' => 2,
],
[
'tag' => 'baz',
'shortUrlsCount' => 0,
'visitsCount' => 0,
],
[
'tag' => 'foo',
'shortUrlsCount' => 2,
'visitsCount' => 5,
],
],
]];
}
}

View File

@@ -11,7 +11,7 @@ use ShlinkioApiTest\Shlink\Rest\Utils\NotFoundUrlHelpersTrait;
use function GuzzleHttp\Psr7\build_query;
use function sprintf;
class GetVisitsActionTest extends ApiTestCase
class ShortUrlVisitsActionTest extends ApiTestCase
{
use NotFoundUrlHelpersTrait;

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
class TagVisitsActionTest extends ApiTestCase
{
/**
* @test
* @dataProvider provideTags
*/
public function expectedVisitsAreReturned(string $tag, int $expectedVisitsAmount): void
{
$resp = $this->callApiWithKey(self::METHOD_GET, sprintf('/tags/%s/visits', $tag));
$payload = $this->getJsonResponsePayload($resp);
$this->assertArrayHasKey('visits', $payload);
$this->assertArrayHasKey('data', $payload['visits']);
$this->assertCount($expectedVisitsAmount, $payload['visits']['data']);
}
public function provideTags(): iterable
{
yield 'foo' => ['foo', 5];
yield 'bar' => ['bar', 2];
yield 'baz' => ['baz', 0];
}
/** @test */
public function notFoundErrorIsReturnedForInvalidTags(): void
{
$resp = $this->callApiWithKey(self::METHOD_GET, '/tags/invalid_tag/visits');
$payload = $this->getJsonResponsePayload($resp);
$this->assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
$this->assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
$this->assertEquals('TAG_NOT_FOUND', $payload['type']);
$this->assertEquals('Tag with name "invalid_tag" could not be found', $payload['detail']);
$this->assertEquals('Tag not found', $payload['title']);
}
}

View File

@@ -24,6 +24,7 @@ class TagsFixture extends AbstractFixture implements DependentFixtureInterface
$manager->persist($fooTag);
$barTag = new Tag('bar');
$manager->persist($barTag);
$manager->persist(new Tag('baz'));
/** @var ShortUrl $abcShortUrl */
$abcShortUrl = $this->getReference('abc123_short_url');

View File

@@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\CreateTagsAction;
class CreateTagsActionTest extends TestCase

View File

@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\ServerRequest;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\DeleteTagsAction;
class DeleteTagsActionTest extends TestCase

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\ServerRequest;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
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 Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use function Shlinkio\Shlink\Common\json_decode;
class ListTagsActionTest extends TestCase
{
private ListTagsAction $action;
@@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase
$this->action = new ListTagsAction($this->tagService->reveal());
}
/** @test */
public function returnsDataFromService(): void
/**
* @test
* @dataProvider provideNoStatsQueries
*/
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
{
$listTags = $this->tagService->listTags()->willReturn([new Tag('foo'), new Tag('bar')]);
$tags = [new Tag('foo'), new Tag('bar')];
$listTags = $this->tagService->listTags()->willReturn($tags);
$resp = $this->action->handle(new ServerRequest());
/** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams($query));
$payload = $resp->getPayload();
$this->assertEquals([
'tags' => [
'data' => $tags,
],
], $payload);
$listTags->shouldHaveBeenCalled();
}
public function provideNoStatsQueries(): iterable
{
yield 'no query' => [[]];
yield 'withStats is false' => [['withStats' => 'withStats']];
yield 'withStats is something else' => [['withStats' => 'foo']];
}
/** @test */
public function returnsStatsWhenRequested(): void
{
$stats = [
new TagInfo(new Tag('foo'), 1, 1),
new TagInfo(new Tag('bar'), 3, 10),
];
$tagsInfo = $this->tagService->tagsInfo()->willReturn($stats);
/** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals()->withQueryParams(['withStats' => 'true']));
$payload = $resp->getPayload();
$this->assertEquals([
'tags' => [
'data' => ['foo', 'bar'],
'stats' => $stats,
],
], json_decode((string) $resp->getBody()));
$listTags->shouldHaveBeenCalled();
], $payload);
$tagsInfo->shouldHaveBeenCalled();
}
}

View File

@@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction;
class UpdateTagActionTest extends TestCase

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Shlinkio\Shlink\Rest\Action\Visit\GlobalVisitsAction;
class GlobalVisitsActionTest extends TestCase
{
private GlobalVisitsAction $action;
private ObjectProphecy $helper;
public function setUp(): void
{
$this->helper = $this->prophesize(VisitsStatsHelperInterface::class);
$this->action = new GlobalVisitsAction($this->helper->reveal());
}
/** @test */
public function statsAreReturnedFromHelper(): void
{
$stats = new VisitsStats(5);
$getStats = $this->helper->getVisitsStats()->willReturn($stats);
/** @var JsonResponse $resp */
$resp = $this->action->handle(ServerRequestFactory::fromGlobals());
$payload = $resp->getPayload();
$this->assertEquals($payload, ['visits' => $stats]);
$getStats->shouldHaveBeenCalledOnce();
}
}

View File

@@ -15,17 +15,17 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Action\Visit\GetVisitsAction;
use Shlinkio\Shlink\Rest\Action\Visit\ShortUrlVisitsAction;
class GetVisitsActionTest extends TestCase
class ShortUrlVisitsActionTest extends TestCase
{
private GetVisitsAction $action;
private ShortUrlVisitsAction $action;
private ObjectProphecy $visitsTracker;
public function setUp(): void
{
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
$this->action = new GetVisitsAction($this->visitsTracker->reveal());
$this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal());
}
/** @test */

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
use Laminas\Diactoros\ServerRequest;
use Laminas\Paginator\Adapter\ArrayAdapter;
use Laminas\Paginator\Paginator;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTracker;
use Shlinkio\Shlink\Rest\Action\Visit\TagVisitsAction;
class TagVisitsActionTest extends TestCase
{
private TagVisitsAction $action;
private ObjectProphecy $visitsTracker;
protected function setUp(): void
{
$this->visitsTracker = $this->prophesize(VisitsTracker::class);
$this->action = new TagVisitsAction($this->visitsTracker->reveal());
}
/** @test */
public function providingCorrectShortCodeReturnsVisits(): void
{
$tag = 'foo';
$getVisits = $this->visitsTracker->visitsForTag($tag, Argument::type(VisitsParams::class))->willReturn(
new Paginator(new ArrayAdapter([])),
);
$response = $this->action->handle((new ServerRequest())->withAttribute('tag', $tag));
$this->assertEquals(200, $response->getStatusCode());
$getVisits->shouldHaveBeenCalledOnce();
}
}

View File

@@ -4,3 +4,4 @@ parameters:
ignoreErrors:
- '#AbstractQuery::setParameters()#'
- '#mustRun()#'
- '#AssociationBuilder::setOrderBy#'