Compare commits

..

110 Commits

Author SHA1 Message Date
Alejandro Celaya
c3de39d313 Merge pull request #787 from shlinkio/develop
Release v2.2.2
2020-06-08 23:09:28 +02:00
Alejandro Celaya
8ecc9c69a2 Added v2.2.2 to changelog 2020-06-08 22:49:40 +02:00
Alejandro Celaya
e814f3afcf Merge pull request #784 from acelaya-forks/feature/tag-visits-many-short-urls
Feature/tag visits many short urls
2020-06-08 22:48:52 +02:00
Alejandro Celaya
a4eda9d761 Moved execution of API tests outside composer script 2020-06-08 22:38:51 +02:00
Alejandro Celaya
f3f3ef5c18 Removed unused import 2020-06-08 18:37:45 +02:00
Alejandro Celaya
296134078c Updated changelog 2020-06-08 18:37:45 +02:00
Alejandro Celaya
527faf27a8 Changed how visits for a tag are fetched, avoiding thousands of values to be loaded in memory 2020-06-08 18:37:22 +02:00
Alejandro Celaya
9c339b9c4f Merge pull request #785 from acelaya-forks/feature/improve-custom-slugs
Improved custom slug sluggification, allowing valid URL characters
2020-06-08 18:36:36 +02:00
Alejandro Celaya
f274cafa7c Updated changelog 2020-06-08 18:10:34 +02:00
Alejandro Celaya
371f246c41 Improved custom slug sluggification, allowing valid URL characters 2020-06-08 18:08:53 +02:00
Alejandro Celaya
95ae540799 Defined docker image to build in a var 2020-05-17 10:19:54 +02:00
Alejandro Celaya
f340e0e76e Temporary disabled ARM docker images to reduce build times 2020-05-17 09:37:05 +02:00
Alejandro Celaya
14e0766f72 Merge pull request #773 from acelaya-forks/feature/temporal-build-fix
Going back to single travis job for docker image building
2020-05-16 22:18:03 +02:00
Alejandro Celaya
17f3897746 Going back to single travis job for docker image building 2020-05-16 22:01:20 +02:00
Alejandro Celaya
3c3a30cc0e Merge pull request #772 from acelaya-forks/feature/separate-docker-builds
Separated docker builds in different platforms
2020-05-16 15:15:47 +02:00
Alejandro Celaya
726811f91f Separated docker builds in different platforms 2020-05-16 15:06:37 +02:00
Alejandro Celaya
75f5da5846 Fixed docker install in travis 2020-05-16 14:05:39 +02:00
Alejandro Celaya
489c739be2 Updated condition to run docker publish 2020-05-16 14:00:03 +02:00
Alejandro Celaya
9d6f14c81a Merge pull request #771 from acelaya-forks/feature/build-time-improvements
Changed travis build so that docker image publishing runs on its own …
2020-05-16 13:50:54 +02:00
Alejandro Celaya
788f9635dd Fixed travis config syntax error 2020-05-16 13:40:59 +02:00
Alejandro Celaya
09aa4cc977 Changed travis build so that docker image publishing runs on its own separated job 2020-05-16 13:28:29 +02:00
Alejandro Celaya
9252cc269b Merge pull request #770 from acelaya-forks/feature/multi-arch-improvements
Feature/multi arch improvements
2020-05-16 11:35:56 +02:00
Alejandro Celaya
65e6676c00 Removed docker image building on non-PR builds 2020-05-16 11:25:50 +02:00
Alejandro Celaya
135b62a9cc Documented multi-architecture on docker image 2020-05-16 10:39:47 +02:00
Alejandro Celaya
2ea58acde2 Updated changelog 2020-05-16 10:28:09 +02:00
Alejandro Celaya
e1085f3ef5 Merge pull request #756 from Starbix/multi-arch
Add multi arch support
2020-05-16 10:22:59 +02:00
Cédric Laubacher
f1db195a06 Merge branch 'develop' into multi-arch 2020-05-15 20:37:41 +02:00
Cédric Laubacher
fa646b0176 Add multi arch support 2020-05-15 18:32:35 +02:00
Alejandro Celaya
21ef1dfee8 Merge pull request #765 from acelaya-forks/feature/fix-dates-match
Feature/fix dates match
2020-05-11 13:27:38 +02:00
Alejandro Celaya
5ef548bc2a Updated changelog with v2.2.1 2020-05-11 13:19:01 +02:00
Alejandro Celaya
1fa9896524 Fixed error when trying to match creteria on a Short URL with dates 2020-05-11 13:12:55 +02:00
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
102 changed files with 1886 additions and 378 deletions

View File

@@ -1,15 +1,27 @@
dist: bionic
language: php language: php
branches: branches:
only: only:
- /.*/ - /.*/
php: jobs:
- '7.4' fast_finish: true
include:
- name: "Docker publish"
php: '7.4'
if: NOT type = pull_request
env:
- DOCKER_PUBLISH="true"
- name: "CI"
php: '7.4'
env:
- DOCKER_PUBLISH="false"
allow_failures:
- name: "Docker publish"
services: services:
- mysql
- postgresql
- docker - docker
cache: cache:
@@ -18,34 +30,37 @@ cache:
before_install: before_install:
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - 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 - phpenv config-rm xdebug.ini || return 0
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then sudo ./data/infra/ci/install-ms-odbc.sh ; fi
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_ms shlink_db shlink_db_postgres shlink_db_maria ; fi
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then yes | pecl install pdo_sqlsrv swoole-4.4.18 ; fi
install: install:
- composer self-update - if [[ "${DOCKER_PUBLISH}" == 'true' ]]; then sudo ./data/infra/ci/install-docker.sh ; fi
- composer install --no-interaction --prefer-dist - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then composer self-update ; fi
- if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then composer install --no-interaction --prefer-dist ; fi
before_script: before_script:
- mysql -e 'CREATE DATABASE shlink_test;' - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then docker-compose exec shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" ; fi
- psql -c 'create database shlink_test;' -U postgres
- mkdir build - mkdir build
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile) - export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
script: script:
- composer ci - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then bin/test/run-api-tests.sh --coverage-php build/coverage-api.cov && composer ci ; fi
- if [[ ! -z "$DOCKERFILE_CHANGED" && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then docker build -t shlink-docker-image:temp . ; fi - if [[ ! -z "${DOCKERFILE_CHANGED}" && "${TRAVIS_PHP_VERSION}" == "7.4" && "${DOCKER_PUBLISH}" == "false" ]]; then docker build -t shlink-docker-image:temp . ; fi
- if [[ "${DOCKER_PUBLISH}" == 'true' ]]; then bash ./docker/build ; fi
after_success: after_success:
- rm -f build/clover.xml - rm -f build/clover.xml
- wget https://phar.phpunit.de/phpcov-7.0.2.phar - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then wget https://phar.phpunit.de/phpcov-7.0.2.phar ; fi
- phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then phpdbg -qrr phpcov-7.0.2.phar merge build --clover build/clover.xml ; fi
- wget https://scrutinizer-ci.com/ocular.phar - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then wget https://scrutinizer-ci.com/ocular.phar ; fi
- php ocular.phar code-coverage:upload --format=php-clover build/clover.xml - if [[ "${DOCKER_PUBLISH}" == 'false' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/clover.xml ; fi
# Before deploying, build dist file for current travis tag # Before deploying, build dist file for current travis tag
before_deploy: before_deploy:
- rm -f ocular.phar - rm -f ocular.phar
- if [[ ! -z $TRAVIS_TAG && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi - if [[ ! -z ${TRAVIS_TAG} && "${TRAVIS_PHP_VERSION}" == "7.4" ]]; then ./build.sh ${TRAVIS_TAG#?} ; fi
deploy: deploy:
- provider: releases - provider: releases
@@ -54,11 +69,7 @@ deploy:
file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip" file: "./build/shlink_${TRAVIS_TAG#?}_dist.zip"
skip_cleanup: true skip_cleanup: true
on: on:
all_branches: true
condition: ${DOCKER_PUBLISH} == 'false'
tags: true tags: true
php: '7.4' php: '7.4'
- provider: script
script: bash ./docker/build
on:
all_branches: true
condition: $TRAVIS_PULL_REQUEST == 'false'
php: '7.4'

View File

@@ -4,22 +4,11 @@ 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). The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org).
## [Unreleased] ## 2.2.2 - 2020-06-08
#### Added #### Added
* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server. * [#709](https://github.com/shlinkio/shlink/issues/709) Added multi-architecture builds for the docker image.
Thanks to that, Shlink will be able to publish events that can be consumed in real time.
For now, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl:
* A visit occurs on any short URL: `https://shlink.io/new-visit`.
* A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`.
The updates are only published when serving Shlink with swoole.
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subsribe to updates.
#### Changed #### Changed
@@ -35,9 +24,106 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### Fixed #### Fixed
* [#769](https://github.com/shlinkio/shlink/issues/769) Fixed custom slugs not allowing valid URL characters, like `.`, `_` or `~`.
* [#781](https://github.com/shlinkio/shlink/issues/781) Fixed memory leak when loading visits for a tag which is used for big amounts of short URLs.
## 2.2.1 - 2020-05-11
#### Added
* *Nothing*
#### Changed
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#764](https://github.com/shlinkio/shlink/issues/764) Fixed error when trying to match an existing short URL which does not have `validSince` and/or `validUntil`, but you are providing either one of them for the new one.
## 2.2.0 - 2020-05-09
#### Added
* [#712](https://github.com/shlinkio/shlink/issues/712) Added support to integrate Shlink with a [mercure hub](https://mercure.rocks/) server.
Thanks to that, Shlink will be able to publish events that can be consumed in real time.
For now, two topics (events) are published, when new visits occur. Both include a payload with the visit and the shortUrl:
* A visit occurs on any short URL: `https://shlink.io/new-visit`.
* A visit occurs on short URLs with a specific short code: `https://shlink.io/new-visit/{shortCode}`.
The updates are only published when serving Shlink with swoole.
Also, Shlink exposes a new endpoint `GET /rest/v2/mercure-info`, which returns the public URL of the mercure hub, and a valid JWT that can be used to subscribe to updates.
* [#673](https://github.com/shlinkio/shlink/issues/673) Added new `[GET /visits]` rest endpoint which returns basic visits stats.
* [#674](https://github.com/shlinkio/shlink/issues/674) Added new `[GET /tags/{tag}/visits]` rest endpoint which returns visits by tag.
It works in the same way as the `[GET /short-urls/{shortCode}/visits]` one, returning the same response payload, and supporting the same query params, but the response is the list of visits in all short URLs which have provided tag.
* [#672](https://github.com/shlinkio/shlink/issues/672) Enhanced `[GET /tags]` rest endpoint so that it is possible to get basic stats info for every tag.
Now, if the `withStats=true` query param is provided, the response payload will include a new `stats` property which is a list with the amount of short URLs and visits for every tag.
Also, the `tag:list` CLI command has been changed and it always behaves like this.
* [#640](https://github.com/shlinkio/shlink/issues/640) Allowed to optionally disable visitors' IP address anonymization. This will make Shlink no longer be GDPR-compliant, but it's OK if you only plan to share your URLs in countries without this regulation.
#### Changed
* [#692](https://github.com/shlinkio/shlink/issues/692) Drastically improved performance when loading visits. Specially noticeable when loading big result sets.
* [#657](https://github.com/shlinkio/shlink/issues/657) Updated how DB tests are run in travis by using docker containers which allow all engines to be covered.
* [#751](https://github.com/shlinkio/shlink/issues/751) Updated PHP and swoole versions used in docker image, and removed mssql-tools, as they are not needed.
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#729](https://github.com/shlinkio/shlink/issues/729) Fixed weird error when fetching multiple visits result sets concurrently using mariadb or mysql. * [#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. * [#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. * [#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
* *Nothing*
#### Deprecated
* *Nothing*
#### Removed
* *Nothing*
#### Fixed
* [#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 ## 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 SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.4.15 ENV SWOOLE_VERSION 4.4.18
ENV LC_ALL "C" ENV LC_ALL "C"
WORKDIR /etc/shlink WORKDIR /etc/shlink
@@ -23,17 +23,22 @@ RUN \
apk add --no-cache libzip-dev zlib-dev libpng-dev && \ apk add --no-cache libzip-dev zlib-dev libpng-dev && \
docker-php-ext-install -j"$(nproc)" zip gd docker-php-ext-install -j"$(nproc)" zip gd
# Install swoole and sqlsrv driver # 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 && \ RUN if [ $(uname -m) == "x86_64" ]; then \
wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/mssql-tools_17.5.1.1-1_amd64.apk && \ wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_17.5.1.1-1_amd64.apk && \
apk add --allow-untrusted msodbcsql17_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 && \
apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ pecl install pdo_sqlsrv && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \ docker-php-ext-enable pdo_sqlsrv && \
docker-php-ext-enable swoole pdo_sqlsrv && \ apk del .phpize-deps && \
apk del .phpize-deps && \ rm msodbcsql17_17.5.1.1-1_amd64.apk ; \
rm msodbcsql17_17.5.1.1-1_amd64.apk && \ fi
rm mssql-tools_17.5.1.1-1_amd64.apk
# Install swoole
RUN apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
# Install shlink # Install shlink

View File

@@ -17,6 +17,7 @@
"ext-pdo": "*", "ext-pdo": "*",
"akrabat/ip-address-middleware": "^1.0", "akrabat/ip-address-middleware": "^1.0",
"cakephp/chronos": "^1.2", "cakephp/chronos": "^1.2",
"cocur/slugify": "^4.0",
"doctrine/cache": "^1.9", "doctrine/cache": "^1.9",
"doctrine/dbal": "^2.10", "doctrine/dbal": "^2.10",
"doctrine/migrations": "^2.2", "doctrine/migrations": "^2.2",
@@ -39,7 +40,7 @@
"mezzio/mezzio-helpers": "^5.3", "mezzio/mezzio-helpers": "^5.3",
"mezzio/mezzio-platesrenderer": "^2.1", "mezzio/mezzio-platesrenderer": "^2.1",
"mezzio/mezzio-problem-details": "^1.1", "mezzio/mezzio-problem-details": "^1.1",
"mezzio/mezzio-swoole": "^2.6", "mezzio/mezzio-swoole": "^2.6.4",
"monolog/monolog": "^2.0", "monolog/monolog": "^2.0",
"nikolaposa/monolog-factory": "^3.0", "nikolaposa/monolog-factory": "^3.0",
"ocramius/proxy-manager": "^2.7.0", "ocramius/proxy-manager": "^2.7.0",
@@ -48,16 +49,18 @@
"predis/predis": "^1.1", "predis/predis": "^1.1",
"pugx/shortid-php": "^0.5", "pugx/shortid-php": "^0.5",
"ramsey/uuid": "^3.9", "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-config": "^1.0",
"shlinkio/shlink-event-dispatcher": "^1.4", "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", "shlinkio/shlink-ip-geolocation": "^1.4",
"symfony/console": "^5.0", "symfony/console": "^5.1",
"symfony/filesystem": "^5.0", "symfony/filesystem": "^5.1",
"symfony/lock": "^5.0", "symfony/lock": "^5.1",
"symfony/mercure": "^0.3.0", "symfony/mercure": "^0.3.0",
"symfony/process": "^5.0" "symfony/process": "^5.1",
"symfony/string": "^5.1",
"symfony/translation-contracts": "^2.1"
}, },
"require-dev": { "require-dev": {
"devster/ubench": "^2.0", "devster/ubench": "^2.0",
@@ -109,8 +112,7 @@
], ],
"test:ci": [ "test:ci": [
"@test:unit: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", "test:unit": "phpdbg -qrr vendor/bin/phpunit --order-by=random --colors=always --coverage-php build/coverage-unit.cov --testdox",
"test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml", "test:unit:ci": "@test:unit --coverage-clover=build/clover.xml --coverage-xml=build/coverage-xml --log-junit=build/junit.xml",
@@ -121,11 +123,6 @@
"@test:db:postgres", "@test:db:postgres",
"@test:db:ms" "@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: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:mysql": "DB_DRIVER=mysql composer test:db:sqlite",
"test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite",
@@ -152,8 +149,7 @@
"test:ci": "<fg=blue;options=bold>Runs all test suites, generating all needed reports and logs for CI envs</>", "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": "<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: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": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL, MariaDB, PostgreSQL and MsSQL</>",
"test:db:ci": "<fg=blue;options=bold>Runs database test suites on a SQLite, MySQL and PostgreSQL</>",
"test:db:sqlite": "<fg=blue;options=bold>Runs database test suites on a SQLite database</>", "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: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</>", "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\MercurePublicUrlConfigOption::class,
Option\Mercure\MercureInternalUrlConfigOption::class, Option\Mercure\MercureInternalUrlConfigOption::class,
Option\Mercure\MercureJwtSecretConfigOption::class, Option\Mercure\MercureJwtSecretConfigOption::class,
Option\UrlShortener\GeoLiteLicenseKeyConfigOption::class,
Option\UrlShortener\IpAnonymizationConfigOption::class,
], ],
'installation_commands' => [ 'installation_commands' => [

View File

@@ -5,7 +5,8 @@ declare(strict_types=1);
return [ return [
'mezzio-swoole' => [ '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' => [ 'swoole-http-server' => [
'host' => '0.0.0.0', 'host' => '0.0.0.0',

View File

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

View File

@@ -20,6 +20,7 @@ $buildDbConnection = function (): array {
$driver = env('DB_DRIVER', 'sqlite'); $driver = env('DB_DRIVER', 'sqlite');
$isCi = env('TRAVIS', false); $isCi = env('TRAVIS', false);
$getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria'); $getMysqlHost = fn (string $driver) => sprintf('shlink_db%s', $driver === 'mysql' ? '' : '_maria');
$getCiMysqlPort = fn (string $driver) => $driver === 'mysql' ? '3307' : '3308';
$driverConfigMap = [ $driverConfigMap = [
'sqlite' => [ 'sqlite' => [
@@ -29,8 +30,9 @@ $buildDbConnection = function (): array {
'mysql' => [ 'mysql' => [
'driver' => 'pdo_mysql', 'driver' => 'pdo_mysql',
'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver), 'host' => $isCi ? '127.0.0.1' : $getMysqlHost($driver),
'port' => $isCi ? $getCiMysqlPort($driver) : '3306',
'user' => 'root', 'user' => 'root',
'password' => $isCi ? '' : 'root', 'password' => 'root',
'dbname' => 'shlink_test', 'dbname' => 'shlink_test',
'charset' => 'utf8', 'charset' => 'utf8',
'driverOptions' => [ 'driverOptions' => [
@@ -41,8 +43,9 @@ $buildDbConnection = function (): array {
'postgres' => [ 'postgres' => [
'driver' => 'pdo_pgsql', 'driver' => 'pdo_pgsql',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
'port' => $isCi ? '5433' : '5432',
'user' => 'postgres', 'user' => 'postgres',
'password' => $isCi ? '' : 'root', 'password' => 'root',
'dbname' => 'shlink_test', 'dbname' => 'shlink_test',
'charset' => 'utf8', 'charset' => 'utf8',
], ],
@@ -50,7 +53,7 @@ $buildDbConnection = function (): array {
'driver' => 'pdo_sqlsrv', 'driver' => 'pdo_sqlsrv',
'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_ms',
'user' => 'sa', 'user' => 'sa',
'password' => $isCi ? '' : 'Passw0rd!', 'password' => 'Passw0rd!',
'dbname' => 'shlink_test', 'dbname' => 'shlink_test',
], ],
]; ];

12
data/infra/ci/install-docker.sh Executable file
View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -ex
# install latest docker version
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"
apt-get update
apt-get -y -o Dpkg::Options::="--force-confnew" install docker-ce
# enable multiarch execution
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes

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> MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18 ENV APCU_VERSION 5.1.18
@@ -67,15 +67,12 @@ RUN rm /tmp/xdebug.tar.gz
# Install sqlsrv driver # 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 && \ 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 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 && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install pdo_sqlsrv && \ pecl install pdo_sqlsrv && \
docker-php-ext-enable pdo_sqlsrv && \ docker-php-ext-enable pdo_sqlsrv && \
apk del .phpize-deps && \ apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk && \ rm msodbcsql17_17.5.1.1-1_amd64.apk
rm mssql-tools_17.5.1.1-1_amd64.apk
# Install composer # Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php 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> MAINTAINER Alejandro Celaya <alejandro@alejandrocelaya.com>
ENV APCU_VERSION 5.1.18 ENV APCU_VERSION 5.1.18
ENV APCU_BC_VERSION 1.0.5 ENV APCU_BC_VERSION 1.0.5
ENV INOTIFY_VERSION 2.0.0 ENV INOTIFY_VERSION 2.0.0
ENV SWOOLE_VERSION 4.4.15 ENV SWOOLE_VERSION 4.4.18
RUN apk update RUN apk update
@@ -68,15 +68,12 @@ RUN rm /tmp/inotify.tar.gz
# Install swoole and mssql driver # 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 && \ 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 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 && \ apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \
pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \ pecl install swoole-${SWOOLE_VERSION} pdo_sqlsrv && \
docker-php-ext-enable swoole pdo_sqlsrv && \ docker-php-ext-enable swoole pdo_sqlsrv && \
apk del .phpize-deps && \ apk del .phpize-deps && \
rm msodbcsql17_17.5.1.1-1_amd64.apk && \ rm msodbcsql17_17.5.1.1-1_amd64.apk
rm mssql-tools_17.5.1.1-1_amd64.apk
# Install composer # Install composer
RUN php -r "readfile('https://getcomposer.org/installer');" | php 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: environment:
LC_ALL: C 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: shlink_swoole:
container_name: shlink_swoole container_name: shlink_swoole
build: build:
@@ -68,7 +79,7 @@ services:
shlink_db_postgres: shlink_db_postgres:
container_name: shlink_db_postgres container_name: shlink_db_postgres
image: postgres:10.7-alpine image: postgres:12.2-alpine
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
@@ -81,7 +92,7 @@ services:
shlink_db_maria: shlink_db_maria:
container_name: shlink_db_maria container_name: shlink_db_maria
image: mariadb:10.2 image: mariadb:10.5
ports: ports:
- "3308:3306" - "3308:3306"
volumes: volumes:
@@ -103,7 +114,7 @@ services:
shlink_redis: shlink_redis:
container_name: shlink_redis container_name: shlink_redis
image: redis:5.0-alpine image: redis:6.0-alpine
ports: ports:
- "6380:6379" - "6380:6379"
@@ -120,7 +131,7 @@ services:
shlink_mercure: shlink_mercure:
container_name: shlink_mercure container_name: shlink_mercure
image: dunglas/mercure:v0.8 image: dunglas/mercure:v0.9
ports: ports:
- "3080:80" - "3080:80"
environment: 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: So based on this, to run shlink on a local docker service, you should run a command like this:
```bash ```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. ### 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. * `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. * `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. * `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). * `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_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_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. * `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: An example using all env vars could look like this:
@@ -199,9 +201,11 @@ docker run \
-e TASK_WORKER_NUM=32 \ -e TASK_WORKER_NUM=32 \
-e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \ -e "VISITS_WEBHOOKS=http://my-api.com/api/v2.3/notify,https://third-party.io/foo" \
-e DEFAULT_SHORT_CODES_LENGTH=6 \ -e DEFAULT_SHORT_CODES_LENGTH=6 \
-e GEOLITE_LICENSE_KEY=kjh23ljkbndskj345 \
-e "MERCURE_PUBLIC_HUB_URL=https://example.com" \ -e "MERCURE_PUBLIC_HUB_URL=https://example.com" \
-e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \ -e "MERCURE_INTERNAL_HUB_URL=http://my-mercure-hub.prod.svc.cluster.local" \
-e MERCURE_JWT_SECRET=super_secret_key \ -e MERCURE_JWT_SECRET=super_secret_key \
-e ANONYMIZE_REMOTE_ADDR=false \
shlinkio/shlink:stable 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", "host": "something.rds.amazonaws.com",
"port": "3306" "port": "3306"
}, },
"geolite_license_key": "kjh23ljkbndskj345",
"mercure_public_hub_url": "https://example.com", "mercure_public_hub_url": "https://example.com",
"mercure_internal_hub_url": "http://my-mercure-hub.prod.svc.cluster.local", "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
} }
``` ```
@@ -257,7 +263,13 @@ Once created just run shlink with the volume:
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink:stable
``` ```
## Multi instance considerations ## Multi-architecture
Starting on v2.3.0, Shlink's docker image is built for multiple architectures.
The only limitation is that images for architectures other than `amd64` will not have support for Microsoft SQL databases, since there are no official binaries.
## Multi-instance considerations
These are some considerations to take into account when running multiple instances of shlink. These are some considerations to take into account when running multiple instances of shlink.

View File

@@ -1,15 +1,35 @@
#!/bin/bash #!/bin/bash
set -e set -e
# PLATFORMS="linux/arm/v7,linux/arm64/v8,linux/amd64"
PLATFORMS="linux/amd64"
DOCKER_IMAGE="shlinkio/shlink"
BUILDX_VER=v0.4.1
export DOCKER_CLI_EXPERIMENTAL=enabled
mkdir -vp ~/.docker/cli-plugins/ ~/dockercache
curl --silent -L "https://github.com/docker/buildx/releases/download/${BUILDX_VER}/buildx-${BUILDX_VER}.linux-amd64" > ~/.docker/cli-plugins/docker-buildx
chmod a+x ~/.docker/cli-plugins/docker-buildx
docker buildx create --use
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
# If there is a tag, regardless the branch, build that docker tag and also "stable" # If there is a tag, regardless the branch, build that docker tag and also "stable"
if [[ ! -z $TRAVIS_TAG ]]; then if [[ ! -z $TRAVIS_TAG ]]; then
docker build --build-arg SHLINK_VERSION=${TRAVIS_TAG#?} -t shlinkio/shlink:${TRAVIS_TAG#?} -t shlinkio/shlink:stable . TAGS="-t ${DOCKER_IMAGE}:${TRAVIS_TAG#?}"
docker push shlinkio/shlink:${TRAVIS_TAG#?} # Push stable tag only if this is not an alpha or beta tag
docker push shlinkio/shlink:stable [[ $TRAVIS_TAG != *"alpha"* && $TRAVIS_TAG != *"beta"* ]] && TAGS="${TAGS} -t ${DOCKER_IMAGE}:stable"
docker buildx build --push \
--build-arg SHLINK_VERSION=${TRAVIS_TAG#?} \
--platform ${PLATFORMS} \
${TAGS} .
# If build branch is develop, build latest (on master, when there's no tag, do not build anything) # If build branch is develop, build latest (on master, when there's no tag, do not build anything)
elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then elif [[ "$TRAVIS_BRANCH" == 'develop' ]]; then
docker build -t shlinkio/shlink:latest . docker buildx build --push \
docker push shlinkio/shlink:latest --platform ${PLATFORMS} \
-t ${DOCKER_IMAGE}:latest .
fi fi

View File

@@ -117,6 +117,7 @@ return [
'hostname' => env('SHORT_DOMAIN_HOST', ''), 'hostname' => env('SHORT_DOMAIN_HOST', ''),
], ],
'validate_url' => (bool) env('VALIDATE_URLS', false), 'validate_url' => (bool) env('VALIDATE_URLS', false),
'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
'visits_webhooks' => $helper->getVisitsWebhooks(), 'visits_webhooks' => $helper->getVisitsWebhooks(),
'default_short_codes_length' => $helper->getDefaultShortCodesLength(), 'default_short_codes_length' => $helper->getDefaultShortCodesLength(),
], ],
@@ -160,6 +161,10 @@ return [
], ],
], ],
'geolite2' => [
'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'),
],
'mercure' => $helper->getMercureConfig(), '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": [ "parameters": [
{ {
"$ref": "../parameters/version.json" "$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": { "responses": {
@@ -26,12 +39,20 @@
"properties": { "properties": {
"tags": { "tags": {
"type": "object", "type": "object",
"required": ["data"],
"properties": { "properties": {
"data": { "data": {
"type": "array", "type": "array",
"items": { "items": {
"type": "string" "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" "$ref": "paths/v1_tags.json"
}, },
"/rest/v{version}/visits": {
"$ref": "paths/v2_visits.json"
},
"/rest/v{version}/short-urls/{shortCode}/visits": { "/rest/v{version}/short-urls/{shortCode}/visits": {
"$ref": "paths/v1_short-urls_{shortCode}_visits.json" "$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": { "/rest/v{version}/mercure-info": {
"$ref": "paths/v2_mercure-info.json" "$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\CLI\Util\GeolocationDbUpdater;
use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit; use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory; use Shlinkio\Shlink\Installer\Factory\ProcessHelperFactory;
use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater;
@@ -78,10 +79,10 @@ return [
Command\Api\DisableKeyCommand::class => [ApiKeyService::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class],
Command\Api\ListKeysCommand::class => [ApiKeyService::class], Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [Service\Tag\TagService::class], Command\Tag\ListTagsCommand::class => [TagService::class],
Command\Tag\CreateTagCommand::class => [Service\Tag\TagService::class], Command\Tag\CreateTagCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [Service\Tag\TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [Service\Tag\TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class],
Command\Db\CreateDatabaseCommand::class => [ Command\Db\CreateDatabaseCommand::class => [
LockFactory::class, LockFactory::class,

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag; namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; 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\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputOption;

View File

@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Tag; namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes; 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\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption; 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\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
@@ -35,17 +35,20 @@ class ListTagsCommand extends Command
protected function execute(InputInterface $input, OutputInterface $output): ?int 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; return ExitCodes::EXIT_SUCCESS;
} }
private function getTagsRows(): array private function getTagsRows(): array
{ {
$tags = $this->tagService->listTags(); $tags = $this->tagService->tagsInfo();
if (empty($tags)) { 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\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; 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\Command\Command;
use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;

View File

@@ -8,7 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\CreateTagCommand; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -7,7 +7,7 @@ namespace ShlinkioTest\Shlink\CLI\Command\Tag;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\DeleteTagsCommand; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -8,7 +8,8 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
use Shlinkio\Shlink\Core\Entity\Tag; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
@@ -31,28 +32,32 @@ class ListTagsCommandTest extends TestCase
/** @test */ /** @test */
public function noTagsPrintsEmptyMessage(): void public function noTagsPrintsEmptyMessage(): void
{ {
$listTags = $this->tagService->listTags()->willReturn([]); $tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
$this->commandTester->execute([]); $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertStringContainsString('No tags yet', $output); $this->assertStringContainsString('No tags found', $output);
$listTags->shouldHaveBeenCalled(); $tagsInfo->shouldHaveBeenCalled();
} }
/** @test */ /** @test */
public function listOfTagsIsPrinted(): void public function listOfTagsIsPrinted(): void
{ {
$listTags = $this->tagService->listTags()->willReturn([ $tagsInfo = $this->tagService->tagsInfo()->willReturn([
new Tag('foo'), new TagInfo(new Tag('foo'), 10, 2),
new Tag('bar'), new TagInfo(new Tag('bar'), 7, 32),
]); ]);
$this->commandTester->execute([]); $this->commandTester->execute([]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$this->assertStringContainsString('foo', $output); $this->assertStringContainsString('| foo', $output);
$this->assertStringContainsString('bar', $output); $this->assertStringContainsString('| bar', $output);
$listTags->shouldHaveBeenCalled(); $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\CLI\Command\Tag\RenameTagCommand;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; 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\Application;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;

View File

@@ -27,7 +27,8 @@ return [
Service\VisitsTracker::class => ConfigAbstractFactory::class, Service\VisitsTracker::class => ConfigAbstractFactory::class,
Service\ShortUrlService::class => ConfigAbstractFactory::class, Service\ShortUrlService::class => ConfigAbstractFactory::class,
Visit\VisitLocator::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\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class, Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
@@ -53,10 +54,15 @@ return [
Options\UrlShortenerOptions::class => ['config.url_shortener'], Options\UrlShortenerOptions::class => ['config.url_shortener'],
Service\UrlShortener::class => [Util\UrlValidator::class, 'em', Resolver\PersistenceDomainResolver::class], 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], Service\ShortUrlService::class => ['em', Service\ShortUrl\ShortUrlResolver::class, Util\UrlValidator::class],
Visit\VisitLocator::class => ['em'], Visit\VisitLocator::class => ['em'],
Service\Tag\TagService::class => ['em'], Visit\VisitsStatsHelper::class => ['em'],
Tag\TagService::class => ['em'],
Service\ShortUrl\DeleteShortUrlService::class => [ Service\ShortUrl\DeleteShortUrlService::class => [
'em', 'em',
Options\DeleteShortUrlsOptions::class, Options\DeleteShortUrlsOptions::class,

View File

@@ -60,6 +60,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig))
->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE')
->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE')
->setOrderBy(['name' => 'ASC'])
->build(); ->build();
$builder->createManyToOne('domain', Entity\Domain::class) $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) $builder->createField('name', Types::STRING)
->unique() ->unique()
->build(); ->build();
$builder->addInverseManyToMany('shortUrls', Entity\ShortUrl::class, 'tags');
}; };

View File

@@ -32,6 +32,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->columnName('`date`') ->columnName('`date`')
->build(); ->build();
$builder->addIndex(['date'], 'IDX_visits_date');
$builder->createField('remoteAddr', Types::STRING) $builder->createField('remoteAddr', Types::STRING)
->columnName('remote_addr') ->columnName('remote_addr')
->length(Visitor::REMOTE_ADDRESS_MAX_LENGTH) ->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'], 'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
'visits_webhooks' => ['url_shortener', 'visits_webhooks'], 'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'], '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_public_hub_url' => ['mercure', 'public_hub_url'],
'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'], 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
'mercure_jwt_secret' => ['mercure', 'jwt_secret'], 'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
'anonymize_remote_addr' => ['url_shortener', 'anonymize_remote_addr'],
]; ];
private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [ private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
'delete_short_url_threshold' => [ 'delete_short_url_threshold' => [

View File

@@ -204,10 +204,10 @@ class ShortUrl extends AbstractEntity
if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) { if ($meta->hasDomain() && $meta->getDomain() !== $this->resolveDomain()) {
return false; return false;
} }
if ($meta->hasValidSince() && ! $meta->getValidSince()->eq($this->validSince)) { if ($meta->hasValidSince() && ($this->validSince === null || ! $meta->getValidSince()->eq($this->validSince))) {
return false; return false;
} }
if ($meta->hasValidUntil() && ! $meta->getValidUntil()->eq($this->validUntil)) { if ($meta->hasValidUntil() && ($this->validUntil === null || ! $meta->getValidUntil()->eq($this->validUntil))) {
return false; return false;
} }

View File

@@ -4,16 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Entity; namespace Shlinkio\Shlink\Core\Entity;
use Doctrine\Common\Collections;
use JsonSerializable; use JsonSerializable;
use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Entity\AbstractEntity;
class Tag extends AbstractEntity implements JsonSerializable class Tag extends AbstractEntity implements JsonSerializable
{ {
private string $name; private string $name;
private Collections\Collection $shortUrls;
public function __construct(string $name) public function __construct(string $name)
{ {
$this->name = $name; $this->name = $name;
$this->shortUrls = new Collections\ArrayCollection();
} }
public function rename(string $name): void public function rename(string $name): void

View File

@@ -21,24 +21,24 @@ class Visit extends AbstractEntity implements JsonSerializable
private ShortUrl $shortUrl; private ShortUrl $shortUrl;
private ?VisitLocation $visitLocation = null; 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->shortUrl = $shortUrl;
$this->date = $date ?? Chronos::now(); $this->date = $date ?? Chronos::now();
$this->userAgent = $visitor->getUserAgent(); $this->userAgent = $visitor->getUserAgent();
$this->referer = $visitor->getReferer(); $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 // Localhost addresses do not need to be anonymized
if ($address === null || $address === IpAddress::LOCALHOST) { if (! $anonymize || $address === null || $address === IpAddress::LOCALHOST) {
return $address; return $address;
} }
try { try {
return (string) IpAddress::fromString($address)->getObfuscatedCopy(); return (string) IpAddress::fromString($address)->getAnonymizedCopy();
} catch (InvalidArgumentException $e) { } catch (InvalidArgumentException $e) {
return null; 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; namespace Shlinkio\Shlink\Core\Paginator\Adapter;
use Laminas\Paginator\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
class VisitsPaginatorAdapter implements AdapterInterface class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{ {
private VisitRepositoryInterface $visitRepository; private VisitRepositoryInterface $visitRepository;
private ShortUrlIdentifier $identifier; private ShortUrlIdentifier $identifier;
@@ -36,7 +35,7 @@ class VisitsPaginatorAdapter implements AdapterInterface
); );
} }
public function count(): int protected function doCount(): int
{ {
return $this->visitRepository->countVisitsByShortCode( return $this->visitRepository->countVisitsByShortCode(
$this->identifier->shortCode(), $this->identifier->shortCode(),

View File

@@ -6,6 +6,9 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use function Functional\map;
class TagRepository extends EntityRepository implements TagRepositoryInterface class TagRepository extends EntityRepository implements TagRepositoryInterface
{ {
@@ -21,4 +24,25 @@ class TagRepository extends EntityRepository implements TagRepositoryInterface
return $qb->getQuery()->execute(); 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; namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository; use Doctrine\Persistence\ObjectRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagRepositoryInterface extends ObjectRepository interface TagRepositoryInterface extends ObjectRepository
{ {
public function deleteByName(array $names): int; public function deleteByName(array $names): int;
/**
* @return TagInfo[]
*/
public function findTagsWithInfo(): array;
} }

View File

@@ -5,9 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository; namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\ORM\EntityRepository; use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Doctrine\ORM\QueryBuilder; use Doctrine\ORM\QueryBuilder;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation;
use const PHP_INT_MAX;
class VisitRepository extends EntityRepository implements VisitRepositoryInterface class VisitRepository extends EntityRepository implements VisitRepositoryInterface
{ {
@@ -21,7 +26,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
->from(Visit::class, 'v') ->from(Visit::class, 'v')
->where($qb->expr()->isNull('v.visitLocation')); ->where($qb->expr()->isNull('v.visitLocation'));
return $this->findVisitsForQuery($qb, $blockSize); return $this->visitsIterableForQuery($qb, $blockSize);
} }
/** /**
@@ -37,7 +42,7 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty')) ->andWhere($qb->expr()->eq('vl.isEmpty', ':isEmpty'))
->setParameter('isEmpty', true); ->setParameter('isEmpty', true);
return $this->findVisitsForQuery($qb, $blockSize); return $this->visitsIterableForQuery($qb, $blockSize);
} }
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
@@ -46,10 +51,10 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
$qb->select('v') $qb->select('v')
->from(Visit::class, '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) $originalQueryBuilder = $qb->setMaxResults($blockSize)
->orderBy('v.id', 'ASC'); ->orderBy('v.id', 'ASC');
@@ -82,23 +87,13 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
?int $offset = null ?int $offset = null
): array { ): array {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('v') return $this->resolveVisitsWithNativeQuery($qb, $limit, $offset);
->orderBy('v.date', 'DESC');
if ($limit !== null) {
$qb->setMaxResults($limit);
}
if ($offset !== null) {
$qb->setFirstResult($offset);
}
return $qb->getQuery()->getResult();
} }
public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int public function countVisitsByShortCode(string $shortCode, ?string $domain = null, ?DateRange $dateRange = null): int
{ {
$qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange); $qb = $this->createVisitsByShortCodeQueryBuilder($shortCode, $domain, $dateRange);
$qb->select('COUNT(DISTINCT v.id)'); $qb->select('COUNT(v.id)');
return (int) $qb->getQuery()->getSingleScalarResult(); return (int) $qb->getQuery()->getSingleScalarResult();
} }
@@ -108,31 +103,95 @@ class VisitRepository extends EntityRepository implements VisitRepositoryInterfa
?string $domain, ?string $domain,
?DateRange $dateRange ?DateRange $dateRange
): QueryBuilder { ): 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 = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v') $qb->from(Visit::class, 'v')
->join('v.shortUrl', 'su') ->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
->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'));
}
// Apply date range filtering // Apply date range filtering
if ($dateRange !== null && $dateRange->getStartDate() !== null) { $this->applyDatesInline($qb, $dateRange);
$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());
}
return $qb; 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
{
// 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', 's')
->join('s.tags', 't')
->where($qb->expr()->eq('t.name', '\'' . $tag . '\''));
// Apply date range filtering
$this->applyDatesInline($qb, $dateRange);
return $qb;
}
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, ?string $domain = null,
?DateRange $dateRange = null ?DateRange $dateRange = null
): int; ): 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 Laminas\Paginator\Paginator;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; 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 class VisitsTracker implements VisitsTrackerInterface
{ {
private ORM\EntityManagerInterface $em; private ORM\EntityManagerInterface $em;
private EventDispatcherInterface $eventDispatcher; 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->em = $em;
$this->eventDispatcher = $eventDispatcher; $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 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->persist($visit);
$this->em->flush(); $this->em->flush();
@@ -43,8 +49,6 @@ class VisitsTracker implements VisitsTrackerInterface
} }
/** /**
* Returns the visits on certain short code
*
* @return Visit[]|Paginator * @return Visit[]|Paginator
* @throws ShortUrlNotFoundException * @throws ShortUrlNotFoundException
*/ */
@@ -56,7 +60,7 @@ class VisitsTracker implements VisitsTrackerInterface
throw ShortUrlNotFoundException::fromNotFound($identifier); throw ShortUrlNotFoundException::fromNotFound($identifier);
} }
/** @var VisitRepository $repo */ /** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class); $repo = $this->em->getRepository(Visit::class);
$paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params)); $paginator = new Paginator(new VisitsPaginatorAdapter($repo, $identifier, $params));
$paginator->setItemCountPerPage($params->getItemsPerPage()) $paginator->setItemCountPerPage($params->getItemsPerPage())
@@ -64,4 +68,26 @@ class VisitsTracker implements VisitsTrackerInterface
return $paginator; 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\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
interface VisitsTrackerInterface interface VisitsTrackerInterface
{ {
/**
* Tracks a new visit to provided short code from provided visitor
*/
public function track(ShortUrl $shortUrl, Visitor $visitor): void; public function track(ShortUrl $shortUrl, Visitor $visitor): void;
/** /**
* Returns the visits on certain short code
*
* @return Visit[]|Paginator * @return Visit[]|Paginator
* @throws ShortUrlNotFoundException * @throws ShortUrlNotFoundException
*/ */
public function info(ShortUrlIdentifier $identifier, VisitsParams $params): Paginator; 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); declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Doctrine\ORM; use Doctrine\ORM;
@@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository; 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; use Shlinkio\Shlink\Core\Util\TagManagerTrait;
class TagService implements TagServiceInterface class TagService implements TagServiceInterface
@@ -25,7 +27,6 @@ class TagService implements TagServiceInterface
/** /**
* @return Tag[] * @return Tag[]
* @throws \UnexpectedValueException
*/ */
public function listTags(): array public function listTags(): array
{ {
@@ -34,6 +35,16 @@ class TagService implements TagServiceInterface
return $tags; return $tags;
} }
/**
* @return TagInfo[]
*/
public function tagsInfo(): array
{
/** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class);
return $repo->findTagsWithInfo();
}
/** /**
* @param string[] $tagNames * @param string[] $tagNames
*/ */

View File

@@ -2,12 +2,13 @@
declare(strict_types=1); declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Service\Tag; namespace Shlinkio\Shlink\Core\Tag;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\TagConflictException; use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
interface TagServiceInterface interface TagServiceInterface
{ {
@@ -16,6 +17,11 @@ interface TagServiceInterface
*/ */
public function listTags(): array; public function listTags(): array;
/**
* @return TagInfo[]
*/
public function tagsInfo(): array;
/** /**
* @param string[] $tagNames * @param string[] $tagNames
*/ */

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Util;
use Cocur\Slugify\SlugifyInterface;
use Symfony\Component\String\AbstractUnicodeString;
use Symfony\Component\String\Slugger\SluggerInterface;
use function Symfony\Component\String\s;
class CocurSymfonySluggerBridge implements SluggerInterface
{
private SlugifyInterface $slugger;
public function __construct(SlugifyInterface $slugger)
{
$this->slugger = $slugger;
}
public function slug(string $string, string $separator = '-', ?string $locale = null): AbstractUnicodeString
{
return s($this->slugger->slugify($string, $separator));
}
}

View File

@@ -4,11 +4,13 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation; namespace Shlinkio\Shlink\Core\Validation;
use Cocur\Slugify\Slugify;
use DateTime; use DateTime;
use Laminas\InputFilter\Input; use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter; use Laminas\InputFilter\InputFilter;
use Laminas\Validator; use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Common\Validation;
use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\Core\MIN_SHORT_CODES_LENGTH;
@@ -46,7 +48,10 @@ class ShortUrlMetaInputFilter extends InputFilter
// FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's
// empty, is by using the deprecated setContinueIfEmpty // empty, is by using the deprecated setContinueIfEmpty
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
$customSlug->getFilterChain()->attach(new Validation\SluggerFilter()); $customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
'regexp' => '/[^A-Za-z0-9._~]+/',
'lowercase' => false,
]))));
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING, Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE, Validator\NotEmpty::SPACE,

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; 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\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_chunk;
class TagRepositoryTest extends DatabaseTestCase class TagRepositoryTest extends DatabaseTestCase
{ {
protected const ENTITIES_TO_EMPTY = [ protected const ENTITIES_TO_EMPTY = [
Visit::class,
ShortUrl::class,
Tag::class, Tag::class,
]; ];
@@ -40,4 +48,53 @@ class TagRepositoryTest extends DatabaseTestCase
$this->assertEquals(2, $this->repo->deleteByName($toDelete)); $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; namespace ShlinkioTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
@@ -27,6 +29,7 @@ class VisitRepositoryTest extends DatabaseTestCase
Visit::class, Visit::class,
ShortUrl::class, ShortUrl::class,
Domain::class, Domain::class,
Tag::class,
]; ];
private VisitRepository $repo; 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(''); $shortUrl = new ShortUrl('');
$domain = 'example.com'; $domain = 'example.com';
$shortCode = $shortUrl->getShortCode(); $shortCode = $shortUrl->getShortCode();
$shortUrlWithDomain = new ShortUrl('', ShortUrlMeta::fromRawData([
'customSlug' => $shortCode,
'domain' => $domain,
]));
$this->getEntityManager()->persist($shortUrl); $this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->persist($shortUrlWithDomain);
for ($i = 0; $i < 6; $i++) { 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( $visit = new Visit(
$shortUrlWithDomain, $shortUrl,
Visitor::emptyInstance(), Visitor::emptyInstance(),
true,
Chronos::parse(sprintf('2016-01-0%s', $i + 1)), Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
); );
$this->getEntityManager()->persist($visit); $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', 'https://third-party.io/foo',
], ],
'default_short_codes_length' => 8, 'default_short_codes_length' => 8,
'geolite_license_key' => 'kjh23ljkbndskj345',
'mercure_public_hub_url' => 'public_url', 'mercure_public_hub_url' => 'public_url',
'mercure_internal_hub_url' => 'internal_url', 'mercure_internal_hub_url' => 'internal_url',
'mercure_jwt_secret' => 'super_secret_value', 'mercure_jwt_secret' => 'super_secret_value',
'anonymize_remote_addr' => false,
]; ];
$expected = [ $expected = [
'app_options' => [ 'app_options' => [
@@ -91,6 +93,7 @@ class SimplifiedConfigParserTest extends TestCase
'https://third-party.io/foo', 'https://third-party.io/foo',
], ],
'default_short_codes_length' => 8, 'default_short_codes_length' => 8,
'anonymize_remote_addr' => false,
], ],
'delete_short_urls' => [ 'delete_short_urls' => [
@@ -131,6 +134,10 @@ class SimplifiedConfigParserTest extends TestCase
], ],
], ],
'geolite2' => [
'license_key' => 'kjh23ljkbndskj345',
],
'mercure' => [ 'mercure' => [
'public_hub_url' => 'public_url', 'public_hub_url' => 'public_url',
'internal_hub_url' => 'internal_url', 'internal_hub_url' => 'internal_url',

View File

@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Core\Entity; namespace ShlinkioTest\Shlink\Core\Entity;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException;
@@ -74,4 +75,38 @@ class ShortUrlTest extends TestCase
yield [null, DEFAULT_SHORT_CODES_LENGTH]; yield [null, DEFAULT_SHORT_CODES_LENGTH];
yield from map(range(4, 10), fn (int $value) => [$value, $value]); yield from map(range(4, 10), fn (int $value) => [$value, $value]);
} }
/**
* @test
* @dataProvider provideCriteriaToMatch
*/
public function criteriaIsMatchedWhenDatesMatch(ShortUrl $shortUrl, ShortUrlMeta $meta, bool $expected): void
{
$this->assertEquals($expected, $shortUrl->matchesCriteria($meta, []));
}
public function provideCriteriaToMatch(): iterable
{
$start = Chronos::parse('2020-03-05 20:18:30');
$end = Chronos::parse('2021-03-05 20:18:30');
yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validSince' => $start]), false];
yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validUntil' => $end]), false];
yield [new ShortUrl('foo'), ShortUrlMeta::fromRawData(['validSince' => $start, 'validUntil' => $end]), false];
yield [
new ShortUrl('foo', ShortUrlMeta::fromRawData(['validSince' => $start])),
ShortUrlMeta::fromRawData(['validSince' => $start]),
true,
];
yield [
new ShortUrl('foo', ShortUrlMeta::fromRawData(['validUntil' => $end])),
ShortUrlMeta::fromRawData(['validUntil' => $end]),
true,
];
yield [
new ShortUrl('foo', ShortUrlMeta::fromRawData(['validUntil' => $end, 'validSince' => $start])),
ShortUrlMeta::fromRawData(['validUntil' => $end, 'validSince' => $start]),
true,
];
}
} }

View File

@@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Entity;
use Cake\Chronos\Chronos; use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\Common\Util\IpAddress;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
@@ -18,7 +19,7 @@ class VisitTest extends TestCase
*/ */
public function isProperlyJsonSerialized(?Chronos $date): void 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([ $this->assertEquals([
'referer' => 'some site', 'referer' => 'some site',
@@ -33,4 +34,25 @@ class VisitTest extends TestCase
yield 'null date' => [null]; yield 'null date' => [null];
yield 'not null date' => [Chronos::now()->subDays(10)]; 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

@@ -58,11 +58,14 @@ class ShortUrlMetaTest extends TestCase
]]; ]];
} }
/** @test */ /**
public function properlyCreatedInstanceReturnsValues(): void * @test
* @dataProvider provideCustomSlugs
*/
public function properlyCreatedInstanceReturnsValues(string $customSlug, string $expectedSlug): void
{ {
$meta = ShortUrlMeta::fromRawData( $meta = ShortUrlMeta::fromRawData(
['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => 'foobar'], ['validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug],
); );
$this->assertTrue($meta->hasValidSince()); $this->assertTrue($meta->hasValidSince());
@@ -72,9 +75,18 @@ class ShortUrlMetaTest extends TestCase
$this->assertNull($meta->getValidUntil()); $this->assertNull($meta->getValidUntil());
$this->assertTrue($meta->hasCustomSlug()); $this->assertTrue($meta->hasCustomSlug());
$this->assertEquals('foobar', $meta->getCustomSlug()); $this->assertEquals($expectedSlug, $meta->getCustomSlug());
$this->assertFalse($meta->hasMaxVisits()); $this->assertFalse($meta->hasMaxVisits());
$this->assertNull($meta->getMaxVisits()); $this->assertNull($meta->getMaxVisits());
} }
public function provideCustomSlugs(): iterable
{
yield ['foobar', 'foobar'];
yield ['foo bar', 'foo-bar'];
yield ['wp-admin.php', 'wp-admin.php'];
yield ['UPPER_lower', 'UPPER_lower'];
yield ['more~url_special.chars', 'more~url_special.chars'];
}
} }

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; namespace ShlinkioTest\Shlink\Core\Service\Tag;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; 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\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository; 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 class TagServiceTest extends TestCase
{ {
private TagService $service; private TagService $service;
private ObjectProphecy $em; private ObjectProphecy $em;
private ObjectProphecy $repo;
public function setUp(): void public function setUp(): void
{ {
$this->em = $this->prophesize(EntityManagerInterface::class); $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()); $this->service = new TagService($this->em->reveal());
} }
@@ -31,36 +35,41 @@ class TagServiceTest extends TestCase
{ {
$expected = [new Tag('foo'), new Tag('bar')]; $expected = [new Tag('foo'), new Tag('bar')];
$repo = $this->prophesize(EntityRepository::class); $find = $this->repo->findBy(Argument::cetera())->willReturn($expected);
$find = $repo->findBy(Argument::cetera())->willReturn($expected);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$result = $this->service->listTags(); $result = $this->service->listTags();
$this->assertEquals($expected, $result); $this->assertEquals($expected, $result);
$find->shouldHaveBeenCalled(); $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 */ /** @test */
public function deleteTagsDelegatesOnRepository(): void public function deleteTagsDelegatesOnRepository(): void
{ {
$repo = $this->prophesize(TagRepository::class); $delete = $this->repo->deleteByName(['foo', 'bar'])->willReturn(4);
$delete = $repo->deleteByName(['foo', 'bar'])->willReturn(4);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$this->service->deleteTags(['foo', 'bar']); $this->service->deleteTags(['foo', 'bar']);
$delete->shouldHaveBeenCalled(); $delete->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
} }
/** @test */ /** @test */
public function createTagsPersistsEntities(): void public function createTagsPersistsEntities(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null); $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
@@ -68,7 +77,6 @@ class TagServiceTest extends TestCase
$this->assertCount(2, $result); $this->assertCount(2, $result);
$find->shouldHaveBeenCalled(); $find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$persist->shouldHaveBeenCalledTimes(2); $persist->shouldHaveBeenCalledTimes(2);
$flush->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled();
} }
@@ -76,12 +84,9 @@ class TagServiceTest extends TestCase
/** @test */ /** @test */
public function renameInvalidTagThrowsException(): void public function renameInvalidTagThrowsException(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(null);
$find = $repo->findOneBy(Argument::cetera())->willReturn(null);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$find->shouldBeCalled(); $find->shouldBeCalled();
$getRepo->shouldBeCalled();
$this->expectException(TagNotFoundException::class); $this->expectException(TagNotFoundException::class);
$this->service->renameTag('foo', 'bar'); $this->service->renameTag('foo', 'bar');
@@ -95,10 +100,8 @@ class TagServiceTest extends TestCase
{ {
$expected = new Tag('foo'); $expected = new Tag('foo');
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn($expected);
$find = $repo->findOneBy(Argument::cetera())->willReturn($expected); $countTags = $this->repo->count(Argument::cetera())->willReturn($count);
$countTags = $repo->count(Argument::cetera())->willReturn($count);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush()->willReturn(null); $flush = $this->em->flush()->willReturn(null);
$tag = $this->service->renameTag($oldName, $newName); $tag = $this->service->renameTag($oldName, $newName);
@@ -106,7 +109,6 @@ class TagServiceTest extends TestCase
$this->assertSame($expected, $tag); $this->assertSame($expected, $tag);
$this->assertEquals($newName, (string) $tag); $this->assertEquals($newName, (string) $tag);
$find->shouldHaveBeenCalled(); $find->shouldHaveBeenCalled();
$getRepo->shouldHaveBeenCalled();
$flush->shouldHaveBeenCalled(); $flush->shouldHaveBeenCalled();
$countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1); $countTags->shouldHaveBeenCalledTimes($count > 0 ? 0 : 1);
} }
@@ -120,14 +122,11 @@ class TagServiceTest extends TestCase
/** @test */ /** @test */
public function renameTagToAnExistingNameThrowsException(): void public function renameTagToAnExistingNameThrowsException(): void
{ {
$repo = $this->prophesize(TagRepository::class); $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
$find = $repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo')); $countTags = $this->repo->count(Argument::cetera())->willReturn(1);
$countTags = $repo->count(Argument::cetera())->willReturn(1);
$getRepo = $this->em->getRepository(Tag::class)->willReturn($repo->reveal());
$flush = $this->em->flush(Argument::any())->willReturn(null); $flush = $this->em->flush(Argument::any())->willReturn(null);
$find->shouldBeCalled(); $find->shouldBeCalled();
$getRepo->shouldBeCalled();
$countTags->shouldBeCalled(); $countTags->shouldBeCalled();
$flush->shouldNotBeCalled(); $flush->shouldNotBeCalled();
$this->expectException(TagConflictException::class); $this->expectException(TagConflictException::class);

View File

@@ -6,20 +6,22 @@ namespace ShlinkioTest\Shlink\Core\Service;
use Doctrine\ORM\EntityManager; use Doctrine\ORM\EntityManager;
use Laminas\Stdlib\ArrayUtils; use Laminas\Stdlib\ArrayUtils;
use PHPUnit\Framework\Assert;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Argument; use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Psr\EventDispatcher\EventDispatcherInterface; use Psr\EventDispatcher\EventDispatcherInterface;
use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited; use Shlinkio\Shlink\Core\EventDispatcher\ShortUrlVisited;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Service\VisitsTracker; use Shlinkio\Shlink\Core\Service\VisitsTracker;
@@ -37,7 +39,7 @@ class VisitsTrackerTest extends TestCase
$this->em = $this->prophesize(EntityManager::class); $this->em = $this->prophesize(EntityManager::class);
$this->eventDispatcher = $this->prophesize(EventDispatcherInterface::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 */ /** @test */
@@ -53,25 +55,6 @@ class VisitsTrackerTest extends TestCase
$this->eventDispatcher->dispatch(Argument::type(ShortUrlVisited::class))->shouldHaveBeenCalled(); $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 */ /** @test */
public function infoReturnsVisitsForCertainShortCode(): void public function infoReturnsVisitsForCertainShortCode(): void
{ {
@@ -105,4 +88,40 @@ class VisitsTrackerTest extends TestCase
$this->visitsTracker->info(new ShortUrlIdentifier($shortCode), new VisitsParams()); $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\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Factory\InvokableFactory; use Laminas\ServiceManager\Factory\InvokableFactory;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware; use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Shlinkio\Shlink\Core\Service; use Shlinkio\Shlink\Core\Service;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Core\Visit;
use Shlinkio\Shlink\Rest\Service\ApiKeyService; use Shlinkio\Shlink\Rest\Service\ApiKeyService;
return [ return [
@@ -28,7 +29,9 @@ return [
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class, Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\EditShortUrlTagsAction::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\ListTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class, Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
@@ -46,36 +49,29 @@ return [
ConfigAbstractFactory::class => [ ConfigAbstractFactory::class => [
ApiKeyService::class => ['em'], ApiKeyService::class => ['em'],
Action\HealthAction::class => ['em', AppOptions::class, 'Logger_Shlink'], Action\HealthAction::class => ['em', AppOptions::class],
Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure', 'Logger_Shlink'], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'],
Action\ShortUrl\CreateShortUrlAction::class => [ Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, 'config.url_shortener.domain'],
Service\UrlShortener::class,
'config.url_shortener.domain',
'Logger_Shlink',
],
Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ Action\ShortUrl\SingleStepCreateShortUrlAction::class => [
Service\UrlShortener::class, Service\UrlShortener::class,
ApiKeyService::class, ApiKeyService::class,
'config.url_shortener.domain', 'config.url_shortener.domain',
'Logger_Shlink',
], ],
Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'], Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class],
Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class, 'Logger_Shlink'], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class],
Action\ShortUrl\ResolveShortUrlAction::class => [ Action\ShortUrl\ResolveShortUrlAction::class => [
Service\ShortUrl\ShortUrlResolver::class, Service\ShortUrl\ShortUrlResolver::class,
'config.url_shortener.domain', 'config.url_shortener.domain',
], ],
Action\Visit\GetVisitsAction::class => [Service\VisitsTracker::class, 'Logger_Shlink'], Action\Visit\ShortUrlVisitsAction::class => [Service\VisitsTracker::class],
Action\ShortUrl\ListShortUrlsAction::class => [ Action\Visit\TagVisitsAction::class => [Service\VisitsTracker::class],
Service\ShortUrlService::class, Action\Visit\GlobalVisitsAction::class => [Visit\VisitsStatsHelper::class],
'config.url_shortener.domain', Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, 'config.url_shortener.domain'],
'Logger_Shlink', Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
], Action\Tag\ListTagsAction::class => [TagService::class],
Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class, 'Logger_Shlink'], Action\Tag\DeleteTagsAction::class => [TagService::class],
Action\Tag\ListTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\DeleteTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class], Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Tag\CreateTagsAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Action\Tag\UpdateTagAction::class => [Service\Tag\TagService::class, LoggerInterface::class],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => [

View File

@@ -26,7 +26,9 @@ return [
Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
// Visits // Visits
Action\Visit\GetVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
// Tags // Tags
Action\Tag\ListTagsAction::getRouteDef(), Action\Tag\ListTagsAction::getRouteDef(),

View File

@@ -7,8 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action;
use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\RequestMethodInterface;
use Fig\Http\Message\StatusCodeInterface; use Fig\Http\Message\StatusCodeInterface;
use Psr\Http\Server\RequestHandlerInterface; use Psr\Http\Server\RequestHandlerInterface;
use Psr\Log\LoggerInterface;
use Psr\Log\NullLogger;
use function array_merge; use function array_merge;
@@ -17,13 +15,6 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet
protected const ROUTE_PATH = ''; protected const ROUTE_PATH = '';
protected const ROUTE_ALLOWED_METHODS = []; 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 public static function getRouteDef(array $prevMiddleware = [], array $postMiddleware = []): array
{ {
return [ return [

View File

@@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Options\AppOptions; use Shlinkio\Shlink\Core\Options\AppOptions;
use Throwable; use Throwable;
@@ -24,9 +23,8 @@ class HealthAction extends AbstractRestAction
private EntityManagerInterface $em; private EntityManagerInterface $em;
private AppOptions $options; 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->em = $em;
$this->options = $options; $this->options = $options;
} }

View File

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

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -19,12 +18,8 @@ abstract class AbstractCreateShortUrlAction extends AbstractRestAction
private UrlShortenerInterface $urlShortener; private UrlShortenerInterface $urlShortener;
private array $domainConfig; private array $domainConfig;
public function __construct( public function __construct(UrlShortenerInterface $urlShortener, array $domainConfig)
UrlShortenerInterface $urlShortener, {
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlShortener = $urlShortener; $this->urlShortener = $urlShortener;
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
} }

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
@@ -19,9 +18,8 @@ class DeleteShortUrlAction extends AbstractRestAction
private DeleteShortUrlServiceInterface $deleteShortUrlService; private DeleteShortUrlServiceInterface $deleteShortUrlService;
public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService, ?LoggerInterface $logger = null) public function __construct(DeleteShortUrlServiceInterface $deleteShortUrlService)
{ {
parent::__construct($logger);
$this->deleteShortUrlService = $deleteShortUrlService; $this->deleteShortUrlService = $deleteShortUrlService;
} }

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit; use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -20,9 +19,8 @@ class EditShortUrlAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService; private ShortUrlServiceInterface $shortUrlService;
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null) public function __construct(ShortUrlServiceInterface $shortUrlService)
{ {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService; $this->shortUrlService = $shortUrlService;
} }

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -20,9 +19,8 @@ class EditShortUrlTagsAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService; private ShortUrlServiceInterface $shortUrlService;
public function __construct(ShortUrlServiceInterface $shortUrlService, ?LoggerInterface $logger = null) public function __construct(ShortUrlServiceInterface $shortUrlService)
{ {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService; $this->shortUrlService = $shortUrlService;
} }

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
@@ -24,12 +23,8 @@ class ListShortUrlsAction extends AbstractRestAction
private ShortUrlServiceInterface $shortUrlService; private ShortUrlServiceInterface $shortUrlService;
private array $domainConfig; private array $domainConfig;
public function __construct( public function __construct(ShortUrlServiceInterface $shortUrlService, array $domainConfig)
ShortUrlServiceInterface $shortUrlService, {
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->shortUrlService = $shortUrlService; $this->shortUrlService = $shortUrlService;
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
} }

View File

@@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Transformer\ShortUrlDataTransformer;
@@ -21,12 +20,8 @@ class ResolveShortUrlAction extends AbstractRestAction
private ShortUrlResolverInterface $urlResolver; private ShortUrlResolverInterface $urlResolver;
private array $domainConfig; private array $domainConfig;
public function __construct( public function __construct(ShortUrlResolverInterface $urlResolver, array $domainConfig)
ShortUrlResolverInterface $urlResolver, {
array $domainConfig,
?LoggerInterface $logger = null
) {
parent::__construct($logger);
$this->urlResolver = $urlResolver; $this->urlResolver = $urlResolver;
$this->domainConfig = $domainConfig; $this->domainConfig = $domainConfig;
} }

View File

@@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Rest\Action\ShortUrl;
use Laminas\Diactoros\Uri; use Laminas\Diactoros\Uri;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\Model\CreateShortUrlData; use Shlinkio\Shlink\Core\Model\CreateShortUrlData;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
@@ -22,10 +21,9 @@ class SingleStepCreateShortUrlAction extends AbstractCreateShortUrlAction
public function __construct( public function __construct(
UrlShortenerInterface $urlShortener, UrlShortenerInterface $urlShortener,
ApiKeyServiceInterface $apiKeyService, ApiKeyServiceInterface $apiKeyService,
array $domainConfig, array $domainConfig
?LoggerInterface $logger = null
) { ) {
parent::__construct($urlShortener, $domainConfig, $logger); parent::__construct($urlShortener, $domainConfig);
$this->apiKeyService = $apiKeyService; $this->apiKeyService = $apiKeyService;
} }

View File

@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class CreateTagsAction extends AbstractRestAction class CreateTagsAction extends AbstractRestAction
@@ -18,9 +17,8 @@ class CreateTagsAction extends AbstractRestAction
private TagServiceInterface $tagService; private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) public function __construct(TagServiceInterface $tagService)
{ {
parent::__construct($logger);
$this->tagService = $tagService; $this->tagService = $tagService;
} }

View File

@@ -7,8 +7,7 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class DeleteTagsAction extends AbstractRestAction class DeleteTagsAction extends AbstractRestAction
@@ -18,9 +17,8 @@ class DeleteTagsAction extends AbstractRestAction
private TagServiceInterface $tagService; private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) public function __construct(TagServiceInterface $tagService)
{ {
parent::__construct($logger);
$this->tagService = $tagService; $this->tagService = $tagService;
} }

View File

@@ -7,10 +7,12 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Service\Tag\TagServiceInterface; use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use function Functional\map;
class ListTagsAction extends AbstractRestAction class ListTagsAction extends AbstractRestAction
{ {
protected const ROUTE_PATH = '/tags'; protected const ROUTE_PATH = '/tags';
@@ -18,24 +20,31 @@ class ListTagsAction extends AbstractRestAction
private TagServiceInterface $tagService; private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) public function __construct(TagServiceInterface $tagService)
{ {
parent::__construct($logger);
$this->tagService = $tagService; $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 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([ return new JsonResponse([
'tags' => [ '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 Laminas\Diactoros\Response\EmptyResponse;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Core\Exception\ValidationException; 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; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class UpdateTagAction extends AbstractRestAction class UpdateTagAction extends AbstractRestAction
@@ -19,9 +18,8 @@ class UpdateTagAction extends AbstractRestAction
private TagServiceInterface $tagService; private TagServiceInterface $tagService;
public function __construct(TagServiceInterface $tagService, ?LoggerInterface $logger = null) public function __construct(TagServiceInterface $tagService)
{ {
parent::__construct($logger);
$this->tagService = $tagService; $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 Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ResponseInterface as Response;
use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait; use Shlinkio\Shlink\Common\Paginator\Util\PaginatorUtilsTrait;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface; use Shlinkio\Shlink\Core\Service\VisitsTrackerInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
class GetVisitsAction extends AbstractRestAction class ShortUrlVisitsAction extends AbstractRestAction
{ {
use PaginatorUtilsTrait; use PaginatorUtilsTrait;
@@ -23,9 +22,8 @@ class GetVisitsAction extends AbstractRestAction
private VisitsTrackerInterface $visitsTracker; private VisitsTrackerInterface $visitsTracker;
public function __construct(VisitsTrackerInterface $visitsTracker, ?LoggerInterface $logger = null) public function __construct(VisitsTrackerInterface $visitsTracker)
{ {
parent::__construct($logger);
$this->visitsTracker = $visitsTracker; $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 GuzzleHttp\Psr7\build_query;
use function sprintf; use function sprintf;
class GetVisitsActionTest extends ApiTestCase class ShortUrlVisitsActionTest extends ApiTestCase
{ {
use NotFoundUrlHelpersTrait; 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); $manager->persist($fooTag);
$barTag = new Tag('bar'); $barTag = new Tag('bar');
$manager->persist($barTag); $manager->persist($barTag);
$manager->persist(new Tag('baz'));
/** @var ShortUrl $abcShortUrl */ /** @var ShortUrl $abcShortUrl */
$abcShortUrl = $this->getReference('abc123_short_url'); $abcShortUrl = $this->getReference('abc123_short_url');

View File

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

View File

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

View File

@@ -4,15 +4,15 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Tag; namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag; 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 Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use function Shlinkio\Shlink\Common\json_decode;
class ListTagsActionTest extends TestCase class ListTagsActionTest extends TestCase
{ {
private ListTagsAction $action; private ListTagsAction $action;
@@ -24,18 +24,53 @@ class ListTagsActionTest extends TestCase
$this->action = new ListTagsAction($this->tagService->reveal()); $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([ $this->assertEquals([
'tags' => [ 'tags' => [
'data' => ['foo', 'bar'], 'data' => ['foo', 'bar'],
'stats' => $stats,
], ],
], json_decode((string) $resp->getBody())); ], $payload);
$listTags->shouldHaveBeenCalled(); $tagsInfo->shouldHaveBeenCalled();
} }
} }

View File

@@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase;
use Prophecy\Prophecy\ObjectProphecy; use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ValidationException; 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; use Shlinkio\Shlink\Rest\Action\Tag\UpdateTagAction;
class UpdateTagActionTest extends TestCase 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\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams; use Shlinkio\Shlink\Core\Model\VisitsParams;
use Shlinkio\Shlink\Core\Service\VisitsTracker; 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; private ObjectProphecy $visitsTracker;
public function setUp(): void public function setUp(): void
{ {
$this->visitsTracker = $this->prophesize(VisitsTracker::class); $this->visitsTracker = $this->prophesize(VisitsTracker::class);
$this->action = new GetVisitsAction($this->visitsTracker->reveal()); $this->action = new ShortUrlVisitsAction($this->visitsTracker->reveal());
} }
/** @test */ /** @test */

Some files were not shown because too many files have changed in this diff Show More