diff --git a/.dockerignore b/.dockerignore
index 9fb114c1..870f3610 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -9,6 +9,7 @@ data/GeoLite2-City*
data/database.sqlite
data/shlink-tests.db
CHANGELOG.md
+CONTRIBUTING.md
UPGRADE.md
composer.lock
vendor
diff --git a/.github/ISSUE_TEMPLATE/Bug.md b/.github/ISSUE_TEMPLATE/Bug.md
index 17351fe7..9999a699 100644
--- a/.github/ISSUE_TEMPLATE/Bug.md
+++ b/.github/ISSUE_TEMPLATE/Bug.md
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
* Shlink Version: x.y.z
* PHP Version: x.y.z
-* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
+* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary
diff --git a/.github/ISSUE_TEMPLATE/Question_Support.md b/.github/ISSUE_TEMPLATE/Question_Support.md
index 92e516d7..78afcbf4 100644
--- a/.github/ISSUE_TEMPLATE/Question_Support.md
+++ b/.github/ISSUE_TEMPLATE/Question_Support.md
@@ -18,7 +18,7 @@ With that said, please fill in the information requested next. More information
* Shlink Version: x.y.z
* PHP Version: x.y.z
-* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted swoole|Docker image
+* How do you serve Shlink: Self-hosted Apache|Self-hosted nginx|Self-hosted openswoole|Docker image
* Database engine used: MySQL|MariaDB|PostgreSQL|MicrosoftSQL|SQLite (x.y.z)
#### Summary
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 051be99f..d79e69a3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,7 @@ on:
branches:
- main
- develop
+ - 2.x
jobs:
static-analysis:
@@ -22,7 +23,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1
+ extensions: openswoole-4.9.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer ${{ matrix.command }}
@@ -44,7 +45,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1
+ extensions: openswoole-4.9.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
@@ -79,7 +80,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1, pdo_sqlsrv-5.10.0beta2
+ extensions: openswoole-4.9.1, pdo_sqlsrv-5.10.0beta2
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
@@ -114,7 +115,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1
+ extensions: openswoole-4.9.1
coverage: pcov
ini-values: pcov.directory=module
- run: composer install --no-interaction --prefer-dist
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index 070192c2..dc9e516a 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -20,7 +20,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1
+ extensions: openswoole-4.9.1
- if: ${{ matrix.swoole == 'yes' }}
run: ./build.sh ${GITHUB_REF#refs/tags/v}
- if: ${{ matrix.swoole == 'no' }}
diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml
index f33d3a85..21d2a6fe 100644
--- a/.github/workflows/publish-swagger-spec.yml
+++ b/.github/workflows/publish-swagger-spec.yml
@@ -23,7 +23,7 @@ jobs:
with:
php-version: ${{ matrix.php-version }}
tools: composer
- extensions: openswoole-4.8.1
+ extensions: openswoole-4.9.1
coverage: none
- run: composer install --no-interaction --prefer-dist
- run: composer swagger:inline
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5c3b6298..fede80b6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,54 @@ 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).
+## [3.0.0] - 2022-01-28
+### Added
+* [#767](https://github.com/shlinkio/shlink/issues/767) Added full support to use emojis everywhere, whether it is custom slugs, titles, referrers, etc.
+* [#1274](https://github.com/shlinkio/shlink/issues/1274) Added support to filter short URLs lists by all provided tags.
+
+ The `GET /short-urls` endpoint now accepts a `tagsMode=all` param which will make only short URLs matching **all** the tags in the `tags[]` query param, to be returned.
+
+ The `short-urls:list` command now accepts a `-i`/`--including-all-tags` flag which behaves the same.
+
+* [#1273](https://github.com/shlinkio/shlink/issues/1273) Added support for pagination in tags lists, allowing to improve performance by loading subsets of tags.
+
+ For backwards compatibility, lists continue returning all items by default, but the `GET /tags` endpoint now supports `page` and `itemsPerPage` query params, to make sure only a subset of the tags is returned.
+
+ This is supported both when invoking the endpoint with and without `withStats=true` query param.
+
+ Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned.
+
+* [#1063](https://github.com/shlinkio/shlink/issues/1063) Added new endpoint that allows fetching all existing non-orphan visits, in case you need a global view of all visits received by your Shlink instance.
+
+ This can be achieved using the `GET /visits/non-orphan` endpoint.
+
+### Changed
+* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% of the original size.
+* [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4.
+* [#1283](https://github.com/shlinkio/shlink/issues/1283) Changed behavior of `DELETE_SHORT_URL_THRESHOLD` env var, disabling the feature if a value was not provided.
+* [#1300](https://github.com/shlinkio/shlink/issues/1300) Changed default ordering for short URLs list, returning always from newest to oldest.
+* [#1299](https://github.com/shlinkio/shlink/issues/1299) Updated to the latest base docker images, based in PHP 8.1.1, and bumped openswoole to v4.9.1.
+* [#1282](https://github.com/shlinkio/shlink/issues/1282) Env vars now have precedence over installer options.
+* [#1328](https://github.com/shlinkio/shlink/issues/1328) Refactored ShortUrlsRepository to use DTOs in methods with too many arguments.
+
+### Deprecated
+* [#1315](https://github.com/shlinkio/shlink/issues/1315) Deprecated `GET /tags?withStats=true` endpoint. Use `GET /tags/stats` instead.
+
+### Removed
+* [#1275](https://github.com/shlinkio/shlink/issues/1275) Removed everything that was deprecated in Shlink 2.x.
+
+ See [UPGRADE](UPGRADE.md#from-v2x-to-v3x) doc in order to get details on how to migrate to this version.
+
+* [#1347](https://github.com/shlinkio/shlink/issues/1347) Dropped support for regular swoole in favor of openswoole.
+
+ Since openswoole support was introduced in the previous release version, Shlink will still consider the swoole extension as openswoole, as at the moment, functionality hasn't deviated too much, and will simplify migrating to Shlink 3.0.0
+
+ However, there's no longer active testing with regular swoole, and it is considered no longer supported. If some incompatibility arises, the only supported solution will be to migrate to openswoole.
+
+### Fixed
+* *Nothing*
+
+
## [2.10.3] - 2022-01-23
### Added
* *Nothing*
@@ -906,7 +954,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* Preview generation feature completely removed.
* Authentication against REST API using JWT is no longer supported.
- See [UPGRADE](UPGRADE.md) doc in order to get details on how to migrate to this version.
+ See [UPGRADE](UPGRADE.md#from-v1x-to-v2x) doc in order to get details on how to migrate to this version.
### Fixed
* [#600](https://github.com/shlinkio/shlink/issues/600) Fixed health action so that it works with and without version in the path.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 28f174dc..bb3e7c83 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,7 +31,7 @@ Then you will have to follow these steps:
* Run `./indocker bin/cli db:migrate` to get database migrations up to date.
* Run `./indocker bin/cli api-key:generate` to get your first API key generated.
-Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through swoole.
+Once you finish this, you will have the project exposed in ports `8000` through nginx+php-fpm and `8080` through openswoole.
> Note: The `indocker` shell script is a helper tool used to run commands inside the main docker container.
@@ -80,7 +80,7 @@ The purposes of every folder are:
* `data`: Common runtime-generated git-ignored assets, like logs, caches, etc.
* `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records.
* `module`: Contains a subfolder for every module in the project. Modules contain the source code, tests and configurations for every context in the project.
-* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with swoole.
+* `public`: Few assets (like `favicon.ico` or `robots.txt`) and the web entry point are stored here. This web entry point is not used when serving the app with openswoole.
## Project tests
@@ -96,7 +96,7 @@ In order to ensure stability and no regressions are introduced while developing
The project provides some tooling to run them against any of the supported database engines.
-* **API tests**: These are E2E tests that spin up an instance of the app with swoole, and test it from the outside by interacting with the REST API.
+* **API tests**: These are E2E tests that spin up an instance of the app with openswoole, and test it from the outside by interacting with the REST API.
These are the best tests to catch regressions, and to verify everything behaves as expected.
diff --git a/Dockerfile b/Dockerfile
index 30ca29e3..d72e6ca6 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,8 +1,8 @@
-FROM php:8.1.0-alpine3.15 as base
+FROM php:8.1.1-alpine3.15 as base
ARG SHLINK_VERSION=latest
ENV SHLINK_VERSION ${SHLINK_VERSION}
-ENV OPENSWOOLE_VERSION 4.8.1
+ENV OPENSWOOLE_VERSION 4.9.1
ENV PDO_SQLSRV_VERSION 5.10.0beta2
ENV MS_ODBC_SQL_VERSION 17.5.2.2
ENV LC_ALL "C"
@@ -11,42 +11,28 @@ WORKDIR /etc/shlink
# Install required PHP extensions
RUN \
- # Install extensions with no extra dependencies
- docker-php-ext-install -j"$(nproc)" pdo_mysql calendar sockets bcmath && \
- # Install sqlite
- apk add --no-cache sqlite-libs sqlite-dev && \
+ # Temp install dev dependencies needed to compile the extensions
+ apk add --no-cache --virtual .dev-deps sqlite-dev postgresql-dev icu-dev libzip-dev zlib-dev libpng-dev && \
+ docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd && \
+ apk add --no-cache sqlite-libs && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
- # Install postgres
- apk add --no-cache postgresql-dev && \
- docker-php-ext-install -j"$(nproc)" pdo_pgsql && \
- # Install intl
- apk add --no-cache icu-dev && \
- docker-php-ext-install -j"$(nproc)" intl && \
- # Install zip and gd
- apk add --no-cache libzip-dev zlib-dev libpng-dev && \
- docker-php-ext-install -j"$(nproc)" zip gd && \
- # Install gmp
- apk add --no-cache gmp-dev && \
- docker-php-ext-install -j"$(nproc)" gmp
+ # Remove temp dev extensions, and install prod equivalents that are required at runtime
+ apk del .dev-deps && \
+ apk add --no-cache postgresql icu libzip libpng
-# Install sqlsrv driver
-RUN if [ $(uname -m) == "x86_64" ]; then \
- wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
- apk add --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
- apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
- pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
- docker-php-ext-enable pdo_sqlsrv && \
- apk del .phpize-deps && \
- rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
- fi
-
-# Install openswoole
-RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
+# Install openswoole and sqlsrv driver for x86_64 builds
+RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \
pecl install openswoole-${OPENSWOOLE_VERSION} && \
docker-php-ext-enable openswoole && \
+ if [ $(uname -m) == "x86_64" ]; then \
+ wget https://download.microsoft.com/download/e/4/e/e4e67866-dffd-428c-aac7-8d28ddafb39b/msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
+ apk add --no-cache --allow-untrusted msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk && \
+ pecl install pdo_sqlsrv-${PDO_SQLSRV_VERSION} && \
+ docker-php-ext-enable pdo_sqlsrv && \
+ rm msodbcsql17_${MS_ODBC_SQL_VERSION}-1_amd64.apk ; \
+ fi; \
apk del .phpize-deps
-
# Install shlink
FROM base as builder
COPY . .
diff --git a/README.md b/README.md
index 9aad62d9..df9a04d8 100644
--- a/README.md
+++ b/README.md
@@ -6,9 +6,10 @@
[](https://packagist.org/packages/shlinkio/shlink)
[](https://hub.docker.com/r/shlinkio/shlink/)
[](https://github.com/shlinkio/shlink/blob/main/LICENSE)
+[](https://twitter.com/shlinkio)
[](https://slnk.to/donate)
-A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain.
+A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own domain.
## Table of Contents
@@ -26,7 +27,7 @@ This document contains the very basics to get started with Shlink. If you want t
## Docker image
-Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](https://shlink.io/documentation/install-docker-image/).
+You can learn how to use the official docker image by reading [the docs](https://shlink.io/documentation/install-docker-image/).
The idea is that you can just generate a container using the image and provide the custom config via env vars.
@@ -36,11 +37,11 @@ First, make sure the host where you are going to run shlink fulfills these requi
* PHP 8.0 or 8.1
* The next PHP extensions: json, curl, pdo, intl, gd and gmp.
- * apcu extension is recommended if you don't plan to use swoole or openswoole.
+ * apcu extension is recommended if you don't plan to use openswoole.
* xml extension is required if you want to generate QR codes in svg format.
* sockets and bcmath extensions are required if you want to integrate with a RabbitMQ instance.
* MySQL, MariaDB, PostgreSQL, Microsoft SQL Server or SQLite.
-* The web server of your choice with PHP integration (Apache or Nginx recommended).
+* [Openswoole](https://openswoole.com/) or the web server of your choice with PHP integration (Apache or Nginx recommended).
### Download
@@ -50,7 +51,7 @@ In order to run Shlink, you will need a built version of the project. There are
The easiest way to install shlink is by using one of the pre-bundled distributable packages.
- Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without swoole/openswoole integration.
+ Go to the [latest version](https://github.com/shlinkio/shlink/releases/latest) and download the `shlink*_dist.zip` file that suits your needs. You will find one for every supported PHP version and with/without openswoole integration.
Finally, decompress the file in the location of your choice.
@@ -60,7 +61,7 @@ In order to run Shlink, you will need a built version of the project. There are
* Clone the project with git (`git clone https://github.com/shlinkio/shlink.git`), or download it by clicking the **Clone or download** green button.
* Download the [Composer](https://getcomposer.org/download/) PHP package manager inside the project folder.
- * Run `./build.sh 1.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line).
+ * Run `./build.sh 3.0.0`, replacing the version with the version number you are going to build (the version number is used as part of the generated dist file name, and to set the value returned when running `shlink -V` from the command line).
After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice.
@@ -72,24 +73,24 @@ Despite how you built the project, you now need to configure it, by following th
* If you are going to use MySQL, MariaDB, PostgreSQL or Microsoft SQL Server, create an empty database with the name of your choice.
* Recursively grant write permissions to the `data` directory. Shlink uses it to cache some information.
-* Setup the application by running the `bin/install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
-* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with shlink's API.
+* Set up the application by running the `vendor/bin/shlink-installer install` script. It is a command line tool that will guide you through the installation process. **Take into account that this tool has to be run directly on the server where you plan to host Shlink. Do not run it before uploading/moving it there.**
+* Generate your first API key by running `bin/cli api-key:generate`. You will need the key in order to interact with Shlink's API.
## Using shlink
Once shlink is installed, there are two main ways to interact with it:
-* **The command line**. Try running `bin/cli` and see all the [available commands](#shlink-cli-help).
+* **The command line**: Try running `bin/cli` to see all the available commands.
- All of those commands can be run with the `--help`/`-h` flag in order to see how to use them and all the available options.
+ All of them can be run with the `--help`/`-h` flag in order to see how to use them and all the available options.
It is probably a good idea to symlink the CLI entry point (`bin/cli`) to somewhere in your path, so that you can run shlink from any directory.
-* **The REST API**. The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
+* **The REST API**: The complete docs on how to use the API can be found [here](https://shlink.io/documentation/api-docs), and a sandbox which also documents every endpoint can be found in the [API Spec](https://api-spec.shlink.io/) portal.
However, you probably don't want to consume the raw API yourself. That's why a nice [web client](https://github.com/shlinkio/shlink-web-client) is provided that can be directly used from [https://app.shlink.io](https://app.shlink.io), or hosted by yourself.
-Both the API and CLI allow you to do the same operations, except for API key management, which can be done from the command line interface only.
+Both the API and CLI allow you to do mostly the same operations, except for API key management, which can be done from the command line interface only.
## Contributing
diff --git a/UPGRADE.md b/UPGRADE.md
index ebe6ad17..bce1bdde 100644
--- a/UPGRADE.md
+++ b/UPGRADE.md
@@ -1,5 +1,53 @@
# Upgrading
+## From v2.x to v3.x
+
+### Changes in REST API
+
+* The `type` property returned when trying to delete a URL that reached the visits threshold, when using the `DELETE /short-urls/{shortCode}` endpoint, is now `INVALID_SHORT_URL_DELETION` instead of `INVALID_SHORTCODE_DELETION`.
+* The `INVALID_AUTHORIZATION` error no longer includes the `expectedTypes` property. Use `expectedHeaders` one instead.
+* The `GET /rest/v2/short-urls` endpoint no longer allows ordering by `visitsCount`, `visitCount` or `originalUrl`. Use `visits` instead of the first two, and `longUrl` instead of the last one.
+* The `GET /rest/v2/short-urls` endpoint no longer allows providing the ordering params with array notation, as in `/shortUrls?orderBy[longUrl]=DESC`. Instead, use the following notation `/shortUrls?orderBy=longUrl-DESC`.
+* The `GET /rest/v2/short-urls` endpoint now has a default ordering of newest-to-oldest. Use `/shortUrls?orderBy=dateCreated-ASC` in order to keep the oldest-to-newest behavior.
+* Requests expecting a body no longer support url-encoded payloads. Instead, always use JSON bodies with `Content-Type: application/json`.
+* The next endpoints have been removed:
+ * `PUT /rest/v2/short-urls/{shortCode}/tags`: Use the `PATCH /rest/v2/short-urls/{shortCode}` endpoint to set the short URL tags.
+ * `POST /rest/v2/tags`: Use `POST /rest/v2/short-urls` or `PATCH /rest/v2/short-urls/{shortCodes}` to create new tags already attached to a short URL. Creating orphan tags makes no sense.
+
+### Changes in CLI
+
+* The next commands have been removed:
+ * `short-url:generate`: Use `short-url:create` instead.
+ * `tag:create`: Creating orphan tags makes no sense.
+* Params in camelCase format are no longer supported. They all have an equivalent kebab-case replacement. (for example, from `--startDate` to `--start-date`).
+* The `short-url:create` command no longer accepts the `--no-validate-url` flag. Now URLs are never validated, unless `--validate-url` is passed.
+* The CLI installer tool entry-points have changed.
+ * `bin/install`: replaced by `vendor/bin/shlink-installer install`
+ * `bin/update`: replaced by `vendor/bin/shlink-installer update`
+ * `bin/set-option`: replaced by `vendor/bin/shlink-installer set-option`
+
+### Changes in config
+
+* The next env vars have been removed:
+ * `INVALID_SHORT_URL_REDIRECT_TO`: Replaced by `DEFAULT_INVALID_SHORT_URL_REDIRECT`.
+ * `REGULAR_404_REDIRECT_TO`: Replaced by `DEFAULT_REGULAR_404_REDIRECT`.
+ * `BASE_URL_REDIRECT_TO`: Replaced by `DEFAULT_BASE_URL_REDIRECT`.
+ * `SHORT_DOMAIN_HOST`: Replaced by `DEFAULT_DOMAIN`.
+ * `SHORT_DOMAIN_SCHEMA`: Replaced by `IS_HTTPS_ENABLED`.
+ * `USE_HTTPS`: Replaced by `IS_HTTPS_ENABLED`.
+ * `VALIDATE_URLS`: There's no replacement. URLs are not validated, unless explicitly requested during creation or edition.
+* The next env vars behavior has changed:
+ * `DELETE_SHORT_URL_THRESHOLD`: Now, if this env var is not provided, the "visits threshold" won't be checked at all when deleting short URLs. Make sure you explicitly provide a value if you want to enable this feature.
+* Environment variables now have precedence over configuration set via the installer tool.
+
+### Other changes
+
+* A default GeoLite2 license key is no longer provided. If you don't provide your own as explained in [the docs](https://shlink.io/documentation/geolite-license-key/), Shlink will not try to update the file anymore.
+* The docker image no longer accepts providing configuration via json files mounted in the `config/params` folder. Only env vars are supported now.
+* If you were manually serving Shlink with swoole, the entry script has to be changed from `/path/to/shlink/vendor/bin/mezzio-swoole start` to `/path/to/shlink/vendor/bin/laminas mezzio:swoole:start`
+* The `GET /{shortCode}/qr-code/{size}` url has been removed. Use `GET /{shortCode}/qr-code?size={size}` instead.
+* Regular swoole extension is no longer supported. Use openswoole instead, as a direct replacement. In most of the cases you just need to uninstall one and install the other, the rest is transparent.
+
## From v1.x to v2.x
### PHP 7.4 required
diff --git a/bin/helper/mezzio-swoole b/bin/helper/mezzio-swoole
deleted file mode 100755
index 2c341326..00000000
--- a/bin/helper/mezzio-swoole
+++ /dev/null
@@ -1,51 +0,0 @@
-#!/usr/bin/env php
-get('config')['laminas-cli']['commands'] ?? [],
- fn ($c, string $command) => str_starts_with($command, $commandsPrefix),
-);
-$registeredCommands = [];
-
-foreach ($commands as $newName => $commandServiceName) {
- [, $oldName] = explode($commandsPrefix, $newName);
- $registeredCommands[$oldName] = $commandServiceName;
-
- $container->addDelegator($commandServiceName, static function ($c, $n, callable $factory) use ($oldName) {
- /** @var Command $command */
- $command = $factory();
- $command->setAliases([$oldName]);
-
- return $command;
- });
-}
-
-$commandLine = new CommandLine('Mezzio web server', $version);
-$commandLine->setAutoExit(true);
-$commandLine->setCommandLoader(new ContainerCommandLoader($container, $registeredCommands));
-$commandLine->run();
diff --git a/bin/install b/bin/install
deleted file mode 100755
index d20db86d..00000000
--- a/bin/install
+++ /dev/null
@@ -1,12 +0,0 @@
-#!/usr/bin/env php
- false,
- // Disabling config cache for cli, ensures it's never used for swoole and also that console commands don't generate
- // a cache file that's then used by non-swoole web executions
+ // Disabling config cache for cli, ensures it's never used for openswoole and also that console commands don't
+ // generate a cache file that's then used by non-openswoole web executions
ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli',
];
diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php
index 7f64abd7..3d562f78 100644
--- a/config/autoload/delete_short_urls.global.php
+++ b/config/autoload/delete_short_urls.global.php
@@ -4,15 +4,17 @@ declare(strict_types=1);
namespace Shlinkio\Shlink;
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
-use const Shlinkio\Shlink\DEFAULT_DELETE_SHORT_URL_THRESHOLD;
+return (static function (): array {
+ $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD()->loadFromEnv();
-return [
+ return [
- 'delete_short_urls' => [
- 'check_visits_threshold' => true,
- 'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', DEFAULT_DELETE_SHORT_URL_THRESHOLD),
- ],
+ 'delete_short_urls' => [
+ 'check_visits_threshold' => $threshold !== null,
+ 'visits_threshold' => (int) ($threshold ?? DEFAULT_DELETE_SHORT_URL_THRESHOLD),
+ ],
-];
+ ];
+})();
diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php
index 08427898..d98d37dc 100644
--- a/config/autoload/entity-manager.global.php
+++ b/config/autoload/entity-manager.global.php
@@ -3,12 +3,12 @@
declare(strict_types=1);
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use function Functional\contains;
-use function Shlinkio\Shlink\Common\env;
return (static function (): array {
- $driver = env('DB_DRIVER');
+ $driver = EnvVars::DB_DRIVER()->loadFromEnv();
$isMysqlCompatible = contains(['maria', 'mysql'], $driver);
$resolveDriver = static fn () => match ($driver) {
@@ -21,20 +21,27 @@ return (static function (): array {
'mssql' => '1433',
default => '3306',
};
- $resolveConnection = static fn () => match (true) {
- $driver === null || $driver === 'sqlite' => [
+ $resolveCharset = static fn () => match ($driver) {
+ // This does not determine charsets or collations in tables or columns, but the charset used in the data
+ // flowing in the connection, so it has to match what has been set in the database.
+ 'maria', 'mysql' => 'utf8mb4',
+ 'postgres' => 'utf8',
+ default => null,
+ };
+ $resolveConnection = static fn () => match ($driver) {
+ null, 'sqlite' => [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
],
default => [
'driver' => $resolveDriver(),
- 'dbname' => env('DB_NAME', 'shlink'),
- 'user' => env('DB_USER'),
- 'password' => env('DB_PASSWORD'),
- 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null),
- 'port' => env('DB_PORT', $resolveDefaultPort()),
- 'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null,
- 'charset' => 'utf8',
+ 'dbname' => EnvVars::DB_NAME()->loadFromEnv('shlink'),
+ 'user' => EnvVars::DB_USER()->loadFromEnv(),
+ 'password' => EnvVars::DB_PASSWORD()->loadFromEnv(),
+ 'host' => EnvVars::DB_HOST()->loadFromEnv(EnvVars::DB_UNIX_SOCKET()->loadFromEnv()),
+ 'port' => EnvVars::DB_PORT()->loadFromEnv($resolveDefaultPort()),
+ 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET()->loadFromEnv() : null,
+ 'charset' => $resolveCharset(),
],
};
diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist
index 0624aa51..4a0750a5 100644
--- a/config/autoload/entity-manager.local.php.dist
+++ b/config/autoload/entity-manager.local.php.dist
@@ -11,6 +11,7 @@ return [
'driver' => 'pdo_mysql',
'host' => 'shlink_db_mysql',
'dbname' => 'shlink',
+ 'charset' => 'utf8mb4',
],
],
diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php
index 3d8f0848..cf1f57fc 100644
--- a/config/autoload/geolite2.global.php
+++ b/config/autoload/geolite2.global.php
@@ -2,14 +2,14 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'geolite2' => [
'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb',
'temp_dir' => __DIR__ . '/../../data',
- 'license_key' => env('GEOLITE_LICENSE_KEY', 'G4Lm0C60yJsnkdPi'), // Deprecated. Remove hardcoded license on v3
+ 'license_key' => EnvVars::GEOLITE_LICENSE_KEY()->loadFromEnv(),
],
];
diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php
index 238dea42..81f9941a 100644
--- a/config/autoload/installer.global.php
+++ b/config/autoload/installer.global.php
@@ -18,22 +18,19 @@ return [
Option\Database\DatabaseUserConfigOption::class,
Option\Database\DatabasePasswordConfigOption::class,
Option\Database\DatabaseUnixSocketConfigOption::class,
- Option\Database\DatabaseSqlitePathConfigOption::class,
- Option\Database\DatabaseMySqlOptionsConfigOption::class,
Option\UrlShortener\ShortDomainHostConfigOption::class,
Option\UrlShortener\ShortDomainSchemaConfigOption::class,
- Option\UrlShortener\ValidateUrlConfigOption::class,
Option\Visit\VisitsWebhooksConfigOption::class,
Option\Visit\OrphanVisitsWebhooksConfigOption::class,
Option\Redirect\BaseUrlRedirectConfigOption::class,
Option\Redirect\InvalidShortUrlRedirectConfigOption::class,
Option\Redirect\Regular404RedirectConfigOption::class,
- Option\Visit\CheckVisitsThresholdConfigOption::class,
Option\Visit\VisitsThresholdConfigOption::class,
Option\BasePathConfigOption::class,
Option\Worker\TaskWorkerNumConfigOption::class,
Option\Worker\WebWorkerNumConfigOption::class,
- Option\RedisServersConfigOption::class,
+ Option\Redis\RedisServersConfigOption::class,
+ Option\Redis\RedisSentinelServiceConfigOption::class,
Option\UrlShortener\ShortCodeLengthOption::class,
Option\Mercure\EnableMercureConfigOption::class,
Option\Mercure\MercurePublicUrlConfigOption::class,
diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php
index 60054147..bdbdb8e5 100644
--- a/config/autoload/locks.global.php
+++ b/config/autoload/locks.global.php
@@ -5,10 +5,9 @@ declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Predis\ClientInterface as PredisClient;
use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Lock;
-use function Shlinkio\Shlink\Common\env;
-
use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY;
return [
@@ -25,7 +24,7 @@ return [
LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class,
],
'aliases' => [
- 'lock_store' => env('REDIS_SERVERS') === null ? 'local_lock_store' : 'redis_lock_store',
+ 'lock_store' => EnvVars::REDIS_SERVERS()->existsInEnv() ? 'redis_lock_store' : 'local_lock_store',
'redis_lock_store' => Lock\Store\RedisStore::class,
'local_lock_store' => Lock\Store\FlockStore::class,
diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php
index aff8c6ee..ba261369 100644
--- a/config/autoload/mercure.global.php
+++ b/config/autoload/mercure.global.php
@@ -4,20 +4,19 @@ declare(strict_types=1);
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use Symfony\Component\Mercure\Hub;
use Symfony\Component\Mercure\HubInterface;
-use function Shlinkio\Shlink\Common\env;
-
return (static function (): array {
- $publicUrl = env('MERCURE_PUBLIC_HUB_URL');
+ $publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL()->loadFromEnv();
return [
'mercure' => [
'public_hub_url' => $publicUrl,
- 'internal_hub_url' => env('MERCURE_INTERNAL_HUB_URL', $publicUrl),
- 'jwt_secret' => env('MERCURE_JWT_SECRET'),
+ 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL()->loadFromEnv($publicUrl),
+ 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET()->loadFromEnv(),
'jwt_issuer' => 'Shlink',
],
diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php
index 5f528620..d72198af 100644
--- a/config/autoload/qr-codes.global.php
+++ b/config/autoload/qr-codes.global.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION;
use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT;
@@ -13,11 +13,15 @@ use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE;
return [
'qr_codes' => [
- 'size' => (int) env('DEFAULT_QR_CODE_SIZE', DEFAULT_QR_CODE_SIZE),
- 'margin' => (int) env('DEFAULT_QR_CODE_MARGIN', DEFAULT_QR_CODE_MARGIN),
- 'format' => env('DEFAULT_QR_CODE_FORMAT', DEFAULT_QR_CODE_FORMAT),
- 'error_correction' => env('DEFAULT_QR_CODE_ERROR_CORRECTION', DEFAULT_QR_CODE_ERROR_CORRECTION),
- 'round_block_size' => (bool) env('DEFAULT_QR_CODE_ROUND_BLOCK_SIZE', DEFAULT_QR_CODE_ROUND_BLOCK_SIZE),
+ 'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE()->loadFromEnv(DEFAULT_QR_CODE_SIZE),
+ 'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN()->loadFromEnv(DEFAULT_QR_CODE_MARGIN),
+ 'format' => EnvVars::DEFAULT_QR_CODE_FORMAT()->loadFromEnv(DEFAULT_QR_CODE_FORMAT),
+ 'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION()->loadFromEnv(
+ DEFAULT_QR_CODE_ERROR_CORRECTION,
+ ),
+ 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()->loadFromEnv(
+ DEFAULT_QR_CODE_ROUND_BLOCK_SIZE,
+ ),
],
];
diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php
index b08dccf2..faa5f569 100644
--- a/config/autoload/rabbit.global.php
+++ b/config/autoload/rabbit.global.php
@@ -5,18 +5,17 @@ declare(strict_types=1);
use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory;
use Laminas\ServiceManager\Proxy\LazyServiceFactory;
use PhpAmqpLib\Connection\AMQPStreamConnection;
-
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'rabbitmq' => [
- 'enabled' => (bool) env('RABBITMQ_ENABLED', false),
- 'host' => env('RABBITMQ_HOST'),
- 'port' => (int) env('RABBITMQ_PORT', '5672'),
- 'user' => env('RABBITMQ_USER'),
- 'password' => env('RABBITMQ_PASSWORD'),
- 'vhost' => env('RABBITMQ_VHOST', '/'),
+ 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED()->loadFromEnv(false),
+ 'host' => EnvVars::RABBITMQ_HOST()->loadFromEnv(),
+ 'port' => (int) EnvVars::RABBITMQ_PORT()->loadFromEnv('5672'),
+ 'user' => EnvVars::RABBITMQ_USER()->loadFromEnv(),
+ 'password' => EnvVars::RABBITMQ_PASSWORD()->loadFromEnv(),
+ 'vhost' => EnvVars::RABBITMQ_VHOST()->loadFromEnv('/'),
],
'dependencies' => [
diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php
index d2c73884..08439b2a 100644
--- a/config/autoload/redirects.global.php
+++ b/config/autoload/redirects.global.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
@@ -10,16 +10,16 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
return [
'not_found_redirects' => [
- // Deprecated env vars
- 'invalid_short_url' => env('DEFAULT_INVALID_SHORT_URL_REDIRECT', env('INVALID_SHORT_URL_REDIRECT_TO')),
- 'regular_404' => env('DEFAULT_REGULAR_404_REDIRECT', env('REGULAR_404_REDIRECT_TO')),
- 'base_url' => env('DEFAULT_BASE_URL_REDIRECT', env('BASE_URL_REDIRECT_TO')),
+ 'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT()->loadFromEnv(),
+ 'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT()->loadFromEnv(),
+ 'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT()->loadFromEnv(),
],
- 'url_shortener' => [
- // TODO Move these options to their own config namespace. Maybe "redirects".
- 'redirect_status_code' => (int) env('REDIRECT_STATUS_CODE', DEFAULT_REDIRECT_STATUS_CODE),
- 'redirect_cache_lifetime' => (int) env('REDIRECT_CACHE_LIFETIME', DEFAULT_REDIRECT_CACHE_LIFETIME),
+ 'redirects' => [
+ 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE()->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE),
+ 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME()->loadFromEnv(
+ DEFAULT_REDIRECT_CACHE_LIFETIME,
+ ),
],
];
diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php
index 22101b65..f87d77f3 100644
--- a/config/autoload/redis.global.php
+++ b/config/autoload/redis.global.php
@@ -2,18 +2,18 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
- $redisServers = env('REDIS_SERVERS');
+ $redisServers = EnvVars::REDIS_SERVERS()->loadFromEnv();
- return match (true) {
- $redisServers === null => [],
+ return match ($redisServers) {
+ null => [],
default => [
'cache' => [
'redis' => [
'servers' => $redisServers,
- 'sentinel_service' => env('REDIS_SENTINEL_SERVICE'),
+ 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE()->loadFromEnv(),
],
],
],
diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php
index a6c6d5f0..fd1f9525 100644
--- a/config/autoload/router.global.php
+++ b/config/autoload/router.global.php
@@ -3,13 +3,12 @@
declare(strict_types=1);
use Mezzio\Router\FastRouteRouter;
-
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'router' => [
- 'base_path' => env('BASE_PATH', ''),
+ 'base_path' => EnvVars::BASE_PATH()->loadFromEnv(''),
'fastroute' => [
FastRouteRouter::CONFIG_CACHE_ENABLED => true,
diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php
index f7159ed7..9d2c423f 100644
--- a/config/autoload/swoole.global.php
+++ b/config/autoload/swoole.global.php
@@ -2,12 +2,12 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\MIN_TASK_WORKERS;
return (static function () {
- $taskWorkers = (int) env('TASK_WORKER_NUM', 16);
+ $taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16);
return [
@@ -17,11 +17,11 @@ return (static function () {
'swoole-http-server' => [
'host' => '0.0.0.0',
- 'port' => (int) env('PORT', 8080),
+ 'port' => (int) EnvVars::PORT()->loadFromEnv(8080),
'process-name' => 'shlink',
'options' => [
- 'worker_num' => (int) env('WEB_WORKER_NUM', 16),
+ 'worker_num' => (int) EnvVars::WEB_WORKER_NUM()->loadFromEnv(16),
'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS),
],
],
diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php
index 26fe4639..b2596830 100644
--- a/config/autoload/tracking.global.php
+++ b/config/autoload/tracking.global.php
@@ -2,35 +2,35 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return [
'tracking' => [
// Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations
// This applies only if IP address tracking is enabled
- 'anonymize_remote_addr' => (bool) env('ANONYMIZE_REMOTE_ADDR', true),
+ 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR()->loadFromEnv(true),
// Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence
- 'track_orphan_visits' => (bool) env('TRACK_ORPHAN_VISITS', true),
+ 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS()->loadFromEnv(true),
// A query param that, if provided, will disable tracking of one particular visit. Always takes precedence
- 'disable_track_param' => env('DISABLE_TRACK_PARAM'),
+ 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM()->loadFromEnv(),
// If true, visits will not be tracked at all
- 'disable_tracking' => (bool) env('DISABLE_TRACKING', false),
+ 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING()->loadFromEnv(false),
// If true, visits will be tracked, but neither the IP address, nor the location will be resolved
- 'disable_ip_tracking' => (bool) env('DISABLE_IP_TRACKING', false),
+ 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING()->loadFromEnv(false),
// If true, the referrer will not be tracked
- 'disable_referrer_tracking' => (bool) env('DISABLE_REFERRER_TRACKING', false),
+ 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING()->loadFromEnv(false),
// If true, the user agent will not be tracked
- 'disable_ua_tracking' => (bool) env('DISABLE_UA_TRACKING', false),
+ 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING()->loadFromEnv(false),
// A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default
- 'disable_tracking_from' => env('DISABLE_TRACKING_FROM'),
+ 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM()->loadFromEnv(),
],
];
diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php
index e14ceddb..25de914a 100644
--- a/config/autoload/url-shortener.global.php
+++ b/config/autoload/url-shortener.global.php
@@ -2,40 +2,27 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
return (static function (): array {
$shortCodesLength = max(
- (int) env('DEFAULT_SHORT_CODES_LENGTH', DEFAULT_SHORT_CODES_LENGTH),
+ (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH()->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH),
MIN_SHORT_CODES_LENGTH,
);
- $resolveSchema = static function (): string {
- // Deprecated. For v3, IS_HTTPS_ENABLED should be true by default, instead of null
-// return ((bool) env('IS_HTTPS_ENABLED', true)) ? 'https' : 'http';
- $isHttpsEnabled = env('IS_HTTPS_ENABLED', env('USE_HTTPS'));
- if ($isHttpsEnabled !== null) {
- $boolIsHttpsEnabled = (bool) $isHttpsEnabled;
- return $boolIsHttpsEnabled ? 'https' : 'http';
- }
-
- return env('SHORT_DOMAIN_SCHEMA', 'http');
- };
return [
'url_shortener' => [
'domain' => [
- // Deprecated SHORT_DOMAIN_* env vars
- 'schema' => $resolveSchema(),
- 'hostname' => env('DEFAULT_DOMAIN', env('SHORT_DOMAIN_HOST', '')),
+ 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http',
+ 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''),
],
- 'validate_url' => (bool) env('VALIDATE_URLS', false), // Deprecated
'default_short_codes_length' => $shortCodesLength,
- 'auto_resolve_titles' => (bool) env('AUTO_RESOLVE_TITLES', false),
- 'append_extra_path' => (bool) env('REDIRECT_APPEND_EXTRA_PATH', false),
+ 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES()->loadFromEnv(false),
+ 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH()->loadFromEnv(false),
],
];
diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php
index 585d3eb2..8e768e39 100644
--- a/config/autoload/webhooks.global.php
+++ b/config/autoload/webhooks.global.php
@@ -2,17 +2,17 @@
declare(strict_types=1);
-use function Shlinkio\Shlink\Common\env;
+use Shlinkio\Shlink\Core\Config\EnvVars;
return (static function (): array {
- $webhooks = env('VISITS_WEBHOOKS');
+ $webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv();
return [
- 'url_shortener' => [
- // TODO Move these options to their own config namespace
- 'visits_webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
- 'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false),
+ 'visits_webhooks' => [
+ 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks),
+ 'notify_orphan_visits_to_webhooks' =>
+ (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()->loadFromEnv(false),
],
];
diff --git a/config/config.php b/config/config.php
index ccb61cbb..3dad2105 100644
--- a/config/config.php
+++ b/config/config.php
@@ -9,15 +9,20 @@ use Laminas\Diactoros;
use Mezzio;
use Mezzio\ProblemDetails;
use Mezzio\Swoole;
+use Shlinkio\Shlink\Config\ConfigAggregator\EnvVarLoaderProvider;
use function class_exists;
-use function Shlinkio\Shlink\Common\env;
+use function Shlinkio\Shlink\Config\env;
use const PHP_SAPI;
$isCli = PHP_SAPI === 'cli';
+$isTestEnv = env('APP_ENV') === 'test';
return (new ConfigAggregator\ConfigAggregator([
+ ! $isTestEnv
+ ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::cases())
+ : new ConfigAggregator\ArrayProvider([]),
Mezzio\ConfigProvider::class,
Mezzio\Router\ConfigProvider::class,
Mezzio\Router\FastRouteRouter\ConfigProvider::class,
@@ -35,12 +40,9 @@ return (new ConfigAggregator\ConfigAggregator([
CLI\ConfigProvider::class,
Rest\ConfigProvider::class,
new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'),
- env('APP_ENV') === 'test'
+ $isTestEnv
? new ConfigAggregator\PhpFileProvider('config/test/*.global.php')
- // Deprecated. When the SimplifiedConfigParser is removed, load only generated_config.php here
- : new ConfigAggregator\LaminasConfigProvider('config/params/{generated_config.php,*.config.{php,json}}'),
+ : new ConfigAggregator\ArrayProvider([]),
], 'data/cache/app_config.php', [
- Core\Config\SimplifiedConfigParser::class,
Core\Config\BasePathPrefixer::class,
- Core\Config\DeprecatedConfigParser::class,
]))->getMergedConfig();
diff --git a/config/constants.php b/config/constants.php
index 8171cd66..978964c5 100644
--- a/config/constants.php
+++ b/config/constants.php
@@ -12,7 +12,6 @@ const MIN_SHORT_CODES_LENGTH = 4;
const DEFAULT_REDIRECT_STATUS_CODE = StatusCodeInterface::STATUS_FOUND;
const DEFAULT_REDIRECT_CACHE_LIFETIME = 30;
const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory';
-const CUSTOM_SLUGS_REGEXP = '/[^\pL\pN._~]/u'; // Any unicode letter or number, plus ".", "_" and "~" chars
const TITLE_TAG_VALUE = '/
]*>(.*?)<\/title>/i'; // Matches the value inside an html title tag
const DEFAULT_QR_CODE_SIZE = 300;
const DEFAULT_QR_CODE_MARGIN = 0;
diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php
index f1a983fe..89807b26 100644
--- a/config/test/test_config.global.php
+++ b/config/test/test_config.global.php
@@ -22,7 +22,7 @@ use SebastianBergmann\CodeCoverage\Report\PHP;
use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml;
use function Laminas\Stratigility\middleware;
-use function Shlinkio\Shlink\Common\env;
+use function Shlinkio\Shlink\Config\env;
use function sprintf;
use function sys_get_temp_dir;
@@ -55,6 +55,7 @@ $buildDbConnection = static function (): array {
'user' => 'postgres',
'password' => 'root',
'dbname' => 'shlink_test',
+ 'charset' => 'utf8',
],
'mssql' => [
'driver' => 'pdo_sqlsrv',
@@ -70,6 +71,7 @@ $buildDbConnection = static function (): array {
'user' => 'root',
'password' => 'root',
'dbname' => 'shlink_test',
+ 'charset' => 'utf8mb4',
],
};
};
@@ -107,6 +109,7 @@ return [
'process-name' => 'shlink_test',
'options' => [
'pid_file' => sys_get_temp_dir() . '/shlink-test-swoole.pid',
+ 'log_file' => __DIR__ . '/../../data/log/api-tests/output.log',
'enable_coroutine' => false,
],
],
diff --git a/data/infra/examples/shlink-daemon-logrotate.conf b/data/infra/examples/shlink-daemon-logrotate.conf
index a7111f3c..2a11ed0b 100644
--- a/data/infra/examples/shlink-daemon-logrotate.conf
+++ b/data/infra/examples/shlink-daemon-logrotate.conf
@@ -1,4 +1,4 @@
-/var/log/shlink/shlink_swoole.log {
+/var/log/shlink/shlink_openswoole.log {
su root root
daily
missingok
@@ -8,6 +8,6 @@
notifempty
create 0640 root root
postrotate
- /etc/init.d/shlink_swoole restart
+ /etc/init.d/shlink_openswoole restart
endscript
}
diff --git a/data/infra/examples/shlink-daemon.sh b/data/infra/examples/shlink-daemon.sh
index ce905721..c32590f9 100644
--- a/data/infra/examples/shlink-daemon.sh
+++ b/data/infra/examples/shlink-daemon.sh
@@ -1,26 +1,26 @@
#!/bin/bash
### BEGIN INIT INFO
-# Provides: shlink_swoole
+# Provides: shlink_openswoole
# Required-Start: $local_fs $network $named $time $syslog
# Required-Stop: $local_fs $network $named $time $syslog
# Default-Start: 2 3 4 5
# Default-Stop: 0 1 6
-# Description: Shlink non-blocking server with swoole
+# Description: Shlink non-blocking server with openswoole
### END INIT INFO
SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start
RUNAS=root
-PIDFILE=/var/run/shlink_swoole.pid
+PIDFILE=/var/run/shlink_openswoole.pid
LOGDIR=/var/log/shlink
-LOGFILE=${LOGDIR}/shlink_swoole.log
+LOGFILE=${LOGDIR}/shlink_openswoole.log
start() {
if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then
- echo 'Shlink with swoole already running' >&2
+ echo 'Shlink with openswoole already running' >&2
return 1
fi
- echo 'Starting shlink with swoole' >&2
+ echo 'Starting shlink with openswoole' >&2
mkdir -p "$LOGDIR"
touch "$LOGFILE"
local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!"
@@ -30,10 +30,10 @@ start() {
stop() {
if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then
- echo 'Shlink with swoole not running' >&2
+ echo 'Shlink with openswoole not running' >&2
return 1
fi
- echo 'Stopping shlink with swoole' >&2
+ echo 'Stopping shlink with openswoole' >&2
kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE"
echo 'Shlink stopped' >&2
}
diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile
index 96556869..3380fd06 100644
--- a/data/infra/php.Dockerfile
+++ b/data/infra/php.Dockerfile
@@ -1,4 +1,4 @@
-FROM php:8.1.0-fpm-alpine3.15
+FROM php:8.1.1-fpm-alpine3.15
MAINTAINER Alejandro Celaya
ENV APCU_VERSION 5.1.21
@@ -31,9 +31,6 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
-RUN apk add --no-cache gmp-dev
-RUN docker-php-ext-install gmp
-
RUN docker-php-ext-install sockets
RUN docker-php-ext-install bcmath
diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile
index 570ca2a9..2b86fb3e 100644
--- a/data/infra/swoole.Dockerfile
+++ b/data/infra/swoole.Dockerfile
@@ -1,9 +1,9 @@
-FROM php:8.1.0-alpine3.15
+FROM php:8.1.1-alpine3.15
MAINTAINER Alejandro Celaya
ENV APCU_VERSION 5.1.21
ENV INOTIFY_VERSION 3.0.0
-ENV OPENSWOOLE_VERSION 4.8.1
+ENV OPENSWOOLE_VERSION 4.9.1
ENV PDO_SQLSRV_VERSION 5.10.0beta2
ENV MS_ODBC_SQL_VERSION 17.5.2.2
@@ -33,9 +33,6 @@ RUN docker-php-ext-install gd
RUN apk add --no-cache postgresql-dev
RUN docker-php-ext-install pdo_pgsql
-RUN apk add --no-cache gmp-dev
-RUN docker-php-ext-install gmp
-
RUN docker-php-ext-install sockets
RUN docker-php-ext-install bcmath
diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php
index 70831eb9..aeb1eb16 100644
--- a/data/migrations/Version20160819142757.php
+++ b/data/migrations/Version20160819142757.php
@@ -5,45 +5,45 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\SqlitePlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
+use function is_subclass_of;
+
/**
* Auto-generated Migration: Please modify to your needs!
*/
class Version20160819142757 extends AbstractMigration
{
- private const MYSQL = 'mysql';
- private const SQLITE = 'sqlite';
-
/**
* @throws Exception
* @throws SchemaException
*/
public function up(Schema $schema): void
{
- $db = $this->connection->getDatabasePlatform()->getName();
+ $platformClass = $this->connection->getDatabasePlatform();
$table = $schema->getTable('short_urls');
$column = $table->getColumn('short_code');
- if ($db === self::MYSQL) {
- $column->setPlatformOption('collation', 'utf8_bin');
- } elseif ($db === self::SQLITE) {
- $column->setPlatformOption('collate', 'BINARY');
- }
+ match (true) {
+ is_subclass_of($platformClass, MySQLPlatform::class) => $column
+ ->setPlatformOption('charset', 'utf8mb4')
+ ->setPlatformOption('collation', 'utf8mb4_bin'),
+ is_subclass_of($platformClass, SqlitePlatform::class) => $column->setPlatformOption('collate', 'BINARY'),
+ default => null,
+ };
}
- /**
- * @throws Exception
- */
public function down(Schema $schema): void
{
- $this->connection->getDatabasePlatform()->getName();
+ // Nothing to roll back
}
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20160820191203.php b/data/migrations/Version20160820191203.php
index 592e556e..dea327b1 100644
--- a/data/migrations/Version20160820191203.php
+++ b/data/migrations/Version20160820191203.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -76,6 +77,6 @@ class Version20160820191203 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20171021093246.php b/data/migrations/Version20171021093246.php
index 92c078fa..a810f49c 100644
--- a/data/migrations/Version20171021093246.php
+++ b/data/migrations/Version20171021093246.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Types;
@@ -48,6 +49,6 @@ class Version20171021093246 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20171022064541.php b/data/migrations/Version20171022064541.php
index 88b5f468..fb5f8d7a 100644
--- a/data/migrations/Version20171022064541.php
+++ b/data/migrations/Version20171022064541.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Types;
@@ -45,6 +46,6 @@ class Version20171022064541 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20180801183328.php b/data/migrations/Version20180801183328.php
index 14f2b22c..5fd40030 100644
--- a/data/migrations/Version20180801183328.php
+++ b/data/migrations/Version20180801183328.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
@@ -42,6 +43,6 @@ final class Version20180801183328 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20180913205455.php b/data/migrations/Version20180913205455.php
index 23d51d79..fe04a395 100644
--- a/data/migrations/Version20180913205455.php
+++ b/data/migrations/Version20180913205455.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use PDO;
@@ -69,6 +70,6 @@ final class Version20180913205455 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20180915110857.php b/data/migrations/Version20180915110857.php
index 8b83053b..b31ac105 100644
--- a/data/migrations/Version20180915110857.php
+++ b/data/migrations/Version20180915110857.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
@@ -50,6 +51,6 @@ final class Version20180915110857 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20181020060559.php b/data/migrations/Version20181020060559.php
index 85d2c9ba..908bf304 100644
--- a/data/migrations/Version20181020060559.php
+++ b/data/migrations/Version20181020060559.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Schema\Table;
@@ -58,7 +59,7 @@ final class Version20181020060559 extends AbstractMigration
foreach (self::COLUMNS as $camelCaseName => $snakeCaseName) {
$qb->set($snakeCaseName, $camelCaseName);
}
- $qb->execute();
+ $qb->executeStatement();
}
public function down(Schema $schema): void
@@ -68,6 +69,6 @@ final class Version20181020060559 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20181020065148.php b/data/migrations/Version20181020065148.php
index e7b3cf5f..873e7f11 100644
--- a/data/migrations/Version20181020065148.php
+++ b/data/migrations/Version20181020065148.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\Migrations\AbstractMigration;
@@ -41,6 +42,6 @@ final class Version20181020065148 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20181110175521.php b/data/migrations/Version20181110175521.php
index 6e26837e..9fb989fa 100644
--- a/data/migrations/Version20181110175521.php
+++ b/data/migrations/Version20181110175521.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
@@ -37,6 +38,6 @@ final class Version20181110175521 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20190824075137.php b/data/migrations/Version20190824075137.php
index 0681e6fe..663111ff 100644
--- a/data/migrations/Version20190824075137.php
+++ b/data/migrations/Version20190824075137.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
@@ -37,6 +38,6 @@ final class Version20190824075137 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20190930165521.php b/data/migrations/Version20190930165521.php
index 5699863c..97863843 100644
--- a/data/migrations/Version20190930165521.php
+++ b/data/migrations/Version20190930165521.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
use Doctrine\DBAL\Types\Types;
@@ -55,6 +56,6 @@ final class Version20190930165521 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20191001201532.php b/data/migrations/Version20191001201532.php
index 20de0486..fa13b85d 100644
--- a/data/migrations/Version20191001201532.php
+++ b/data/migrations/Version20191001201532.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Index;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
@@ -49,6 +50,6 @@ final class Version20191001201532 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20191020074522.php b/data/migrations/Version20191020074522.php
index b225f733..c1b9aea9 100644
--- a/data/migrations/Version20191020074522.php
+++ b/data/migrations/Version20191020074522.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Column;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\SchemaException;
@@ -37,6 +38,6 @@ final class Version20191020074522 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20200105165647.php b/data/migrations/Version20200105165647.php
index ed68850a..fb3b7961 100644
--- a/data/migrations/Version20200105165647.php
+++ b/data/migrations/Version20200105165647.php
@@ -5,6 +5,8 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -38,7 +40,7 @@ final class Version20200105165647 extends AbstractMigration
'zeroValue' => '0',
'emptyString' => '',
])
- ->execute();
+ ->executeStatement();
}
}
@@ -61,14 +63,14 @@ final class Version20200105165647 extends AbstractMigration
*/
public function postUp(Schema $schema): void
{
- $platformName = $this->connection->getDatabasePlatform()->getName();
- $castType = $platformName === 'postgres' ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)';
+ $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform;
+ $castType = $isPostgres ? 'DOUBLE PRECISION' : 'DECIMAL(9,2)';
foreach (self::COLUMNS as $newName => $oldName) {
$qb = $this->connection->createQueryBuilder();
$qb->update('visit_locations')
->set($newName, 'CAST(' . $oldName . ' AS ' . $castType . ')')
- ->execute();
+ ->executeStatement();
}
}
@@ -78,7 +80,7 @@ final class Version20200105165647 extends AbstractMigration
$qb = $this->connection->createQueryBuilder();
$qb->update('visit_locations')
->set($oldName, $newName)
- ->execute();
+ ->executeStatement();
}
}
@@ -96,6 +98,6 @@ final class Version20200105165647 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20200106215144.php b/data/migrations/Version20200106215144.php
index 0b760ced..830daf64 100644
--- a/data/migrations/Version20200106215144.php
+++ b/data/migrations/Version20200106215144.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -47,6 +48,6 @@ final class Version20200106215144 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20200110182849.php b/data/migrations/Version20200110182849.php
index 6c66788e..b267bfbc 100644
--- a/data/migrations/Version20200110182849.php
+++ b/data/migrations/Version20200110182849.php
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Exception;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
@@ -36,6 +38,9 @@ final class Version20200110182849 extends AbstractMigration
);
}
+ /**
+ * @throws Exception
+ */
public function setDefaultValueForColumnInTable(string $tableName, string $columnName): void
{
$qb = $this->connection->createQueryBuilder();
@@ -43,7 +48,7 @@ final class Version20200110182849 extends AbstractMigration
->set($columnName, ':emptyValue')
->setParameter('emptyValue', self::DEFAULT_EMPTY_VALUE)
->where($qb->expr()->isNull($columnName))
- ->execute();
+ ->executeStatement();
}
public function down(Schema $schema): void
@@ -53,6 +58,6 @@ final class Version20200110182849 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20200323190014.php b/data/migrations/Version20200323190014.php
index 92abb87c..f76df5e7 100644
--- a/data/migrations/Version20200323190014.php
+++ b/data/migrations/Version20200323190014.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -32,7 +33,7 @@ final class Version20200323190014 extends AbstractMigration
->andWhere($qb->expr()->eq('lon', 0))
->setParameter('isEmpty', true)
->setParameter('emptyString', '')
- ->execute();
+ ->executeStatement();
}
public function down(Schema $schema): void
@@ -45,6 +46,6 @@ final class Version20200323190014 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20200503170404.php b/data/migrations/Version20200503170404.php
index 418cbea3..ad2c63df 100644
--- a/data/migrations/Version20200503170404.php
+++ b/data/migrations/Version20200503170404.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
@@ -27,6 +28,6 @@ final class Version20200503170404 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20201023090929.php b/data/migrations/Version20201023090929.php
index 0a36f06a..4655cbd5 100644
--- a/data/migrations/Version20201023090929.php
+++ b/data/migrations/Version20201023090929.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -44,6 +45,6 @@ final class Version20201023090929 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20201102113208.php b/data/migrations/Version20201102113208.php
index 79cec197..92647c7f 100644
--- a/data/migrations/Version20201102113208.php
+++ b/data/migrations/Version20201102113208.php
@@ -6,6 +6,7 @@ namespace ShlinkMigrations;
use Cake\Chronos\Chronos;
use Doctrine\DBAL\Driver\Result;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -86,6 +87,6 @@ final class Version20201102113208 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210102174433.php b/data/migrations/Version20210102174433.php
index 60ce36cf..58ea36cd 100644
--- a/data/migrations/Version20210102174433.php
+++ b/data/migrations/Version20210102174433.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -52,6 +53,6 @@ final class Version20210102174433 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210118153932.php b/data/migrations/Version20210118153932.php
index d81c4857..476f8d84 100644
--- a/data/migrations/Version20210118153932.php
+++ b/data/migrations/Version20210118153932.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
@@ -26,6 +27,6 @@ final class Version20210118153932 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210202181026.php b/data/migrations/Version20210202181026.php
index 4ecfa8de..7a63b814 100644
--- a/data/migrations/Version20210202181026.php
+++ b/data/migrations/Version20210202181026.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -36,6 +37,6 @@ final class Version20210202181026 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php
index 6d9e9822..706132cc 100644
--- a/data/migrations/Version20210207100807.php
+++ b/data/migrations/Version20210207100807.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -43,6 +44,6 @@ final class Version20210207100807 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210306165711.php b/data/migrations/Version20210306165711.php
index cb69741f..ba1a4476 100644
--- a/data/migrations/Version20210306165711.php
+++ b/data/migrations/Version20210306165711.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -37,6 +38,6 @@ final class Version20210306165711 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210522051601.php b/data/migrations/Version20210522051601.php
index 70e0fb34..279c7a7e 100644
--- a/data/migrations/Version20210522051601.php
+++ b/data/migrations/Version20210522051601.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -26,6 +27,6 @@ final class Version20210522051601 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210522124633.php b/data/migrations/Version20210522124633.php
index f56b8a92..921e0831 100644
--- a/data/migrations/Version20210522124633.php
+++ b/data/migrations/Version20210522124633.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -28,6 +29,6 @@ final class Version20210522124633 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20210720143824.php b/data/migrations/Version20210720143824.php
index 09e97cfa..407c5c79 100644
--- a/data/migrations/Version20210720143824.php
+++ b/data/migrations/Version20210720143824.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Types\Types;
@@ -41,6 +42,6 @@ final class Version20210720143824 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20211002072605.php b/data/migrations/Version20211002072605.php
index 03c98885..970d51d6 100644
--- a/data/migrations/Version20211002072605.php
+++ b/data/migrations/Version20211002072605.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkMigrations;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Types\Types;
use Doctrine\Migrations\AbstractMigration;
@@ -26,6 +27,6 @@ final class Version20211002072605 extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/data/migrations/Version20220110113313.php b/data/migrations/Version20220110113313.php
new file mode 100644
index 00000000..2b2fb4ea
--- /dev/null
+++ b/data/migrations/Version20220110113313.php
@@ -0,0 +1,73 @@
+ [
+ 'original_url' => 'unicode_ci',
+ 'short_code' => 'bin',
+ 'import_original_short_code' => 'unicode_ci',
+ 'title' => 'unicode_ci',
+ ],
+ 'domains' => [
+ 'authority' => 'unicode_ci',
+ 'base_url_redirect' => 'unicode_ci',
+ 'regular_not_found_redirect' => 'unicode_ci',
+ 'invalid_short_url_redirect' => 'unicode_ci',
+ ],
+ 'tags' => [
+ 'name' => 'unicode_ci',
+ ],
+ 'visits' => [
+ 'referer' => 'unicode_ci',
+ 'user_agent' => 'unicode_ci',
+ 'visited_url' => 'unicode_ci',
+ ],
+ 'visit_locations' => [
+ 'country_code' => 'unicode_ci',
+ 'country_name' => 'unicode_ci',
+ 'region_name' => 'unicode_ci',
+ 'city_name' => 'unicode_ci',
+ 'timezone' => 'unicode_ci',
+ ],
+ ];
+
+ public function up(Schema $schema): void
+ {
+ $this->skipIf(! $this->isMySql(), 'This only sets MySQL-specific database options');
+
+ foreach (self::COLLATIONS as $tableName => $columns) {
+ $table = $schema->getTable($tableName);
+
+ foreach ($columns as $columnName => $collation) {
+ $table->getColumn($columnName)
+ ->setPlatformOption('charset', self::CHARSET)
+ ->setPlatformOption('collation', self::CHARSET . '_' . $collation);
+ }
+ }
+ }
+
+ public function down(Schema $schema): void
+ {
+ // No down
+ }
+
+ public function isTransactional(): bool
+ {
+ return ! $this->isMySql();
+ }
+
+ private function isMySql(): bool
+ {
+ return $this->connection->getDatabasePlatform() instanceof MySQLPlatform;
+ }
+}
diff --git a/data/migrations_template.txt b/data/migrations_template.txt
index fa671070..23040083 100644
--- a/data/migrations_template.txt
+++ b/data/migrations_template.txt
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ;
+use Doctrine\DBAL\Platforms\MySQLPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
@@ -21,6 +22,6 @@ final class extends AbstractMigration
public function isTransactional(): bool
{
- return $this->connection->getDatabasePlatform()->getName() !== 'mysql';
+ return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform);
}
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 3d552f9a..739c0079 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -18,6 +18,8 @@ services:
build:
context: .
dockerfile: ./data/infra/php.Dockerfile
+ ports:
+ - '8888:8888'
volumes:
- ./:/home/shlink/www
- ./data/infra/php.ini:/usr/local/etc/php/php.ini
@@ -98,7 +100,7 @@ services:
shlink_db_maria:
container_name: shlink_db_maria
- image: mariadb:10.5
+ image: mariadb:10.7
ports:
- "3308:3306"
volumes:
diff --git a/docker/README.md b/docker/README.md
index b7b92dcf..c1279b2d 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -5,7 +5,7 @@
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
-It exposes a shlink instance served with [openswoole](https://www.swoole.co.uk/), which can be linked to external databases to persist data.
+It exposes a shlink instance served with [openswoole](https://openswoole.com/), which can be linked to external databases to persist data.
## Usage
diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh
index 8f48e20a..f1c4c495 100644
--- a/docker/docker-entrypoint.sh
+++ b/docker/docker-entrypoint.sh
@@ -24,11 +24,9 @@ if [ ! -z "${GEOLITE_LICENSE_KEY}" ]; then
php bin/cli visit:download-db -n ${flags}
fi
-# Periodicaly run visit:locate every hour
-# https://shlink.io/documentation/long-running-tasks/#locate-visits
-# set env var "ENABLE_PERIODIC_VISIT_LOCATE=true" to enable
+# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided
if [ $ENABLE_PERIODIC_VISIT_LOCATE ]; then
- echo "Configuring periodic visit locate..."
+ echo "Configuring periodic visit location..."
echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root
/usr/sbin/crond &
fi
diff --git a/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md
new file mode 100644
index 00000000..df11538c
--- /dev/null
+++ b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md
@@ -0,0 +1,51 @@
+# Update env vars behavior to have precedence over installer options
+
+* Status: Accepted
+* Date: 2022-01-15
+
+## Context and problem statement
+
+Shlink supports providing configuration via the installer tool that generates a config file that gets merged with the rest of the config, or via environment variables.
+
+It is potentially possible to combine both, but if you do so, you will find out the installer tool config has precedence over env vars, which is not very intuitive.
+
+A [Twitter survey](https://twitter.com/shlinkio/status/1480614855006732289) has also showed up all participants also found the behavior should be the opposite.
+
+## Considered option
+
+* Move the logic to read env vars to another config file which always overrides installer options.
+* Move the logic to read env vars to a config post-processor which overrides config dynamically, only if the appropriate env var had been defined.
+* Make the installer generate a config file which also includes the logic to load env vars on it.
+* Make the installer no longer generate the config structure, and instead generate a map with env vars and their values. Then Shlink would define those env vars if not defined already.
+
+## Decision outcome
+
+The most viable option was finally to re-think the installer tool, and make it generate a map of env vars and their values.
+
+Then Shlink reads this as the first config file, which sets the values as env vars if not yet defined, and later on, the values are read as usual wherever needed.
+
+## Pros and Cons of the Options
+
+### Read all env vars in a single config file
+
+* Bad: This option had to be discarded, as it would always override the installer config no matter what.
+
+### Read all env vars in a config post-processor
+
+* Good because it would not require any change in the installer.
+* Bad because it requires moving all env var reading logic somewhere else, while having it together with their contextual config is quite convenient.
+* Bad because it requires defining a map between the config path from the installer and the env var to set.
+
+### Make the installer generate a config file which also reads env vars
+
+* Good because it would not require changing Shlink.
+* Bad because it requires looking for a new way to generate the installer config.
+* Bad because it would mean reading the env vars in multiple places.
+
+### Re-think the installer to no longer generate internal config, and instead, just define values for regular env vars
+
+* Bad because it requires changes both in Shlink and the installer.
+* Bad because it's more error-prone, and the option with higher chances to introduce a regression.
+* Good because it finally decouples Shlink internal config (which is an implementation detail) from any external tool, including the installer, allowing to change it at will.
+* Good because it opens the door to eventually simplify the installer. For the moment, it requires a bit of extra logic to support importing the old config.
+* Good because it allows keeping the logic to read env vars next to the config where it applies.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index af03faac..8fd4a662 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -2,6 +2,7 @@
Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome.
+* [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md)
* [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md)
* [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md)
* [2021-01-17 Support restrictions and permissions in API keys](2021-01-17-support-restrictions-and-permissions-in-api-keys.md)
diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json
index 04afdd3a..6e8bb015 100644
--- a/docs/swagger/paths/v1_short-urls.json
+++ b/docs/swagger/paths/v1_short-urls.json
@@ -49,10 +49,20 @@
}
}
},
+ {
+ "name": "tagsMode",
+ "in": "query",
+ "description": "Tells how the filtering by tags should work, returning short URLs containing \"any\" of the tags, or \"all\" the tags. It's ignored if no tags are provided, and defaults to \"any\" if not provided.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": ["any", "all"]
+ }
+ },
{
"name": "orderBy",
"in": "query",
- "description": "The field from which you want to order the result. (Since v1.3.0)",
+ "description": "The field from which you want to order the result.",
"required": false,
"schema": {
"type": "string",
diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json
index eec1cec3..2f7a9600 100644
--- a/docs/swagger/paths/v1_short-urls_{shortCode}.json
+++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json
@@ -320,7 +320,7 @@
},
"example": {
"title": "Cannot delete short URL",
- "type": "INVALID_SHORTCODE_DELETION",
+ "type": "INVALID_SHORT_URL_DELETION",
"detail": "Impossible to delete short URL with short code \"abc123\", since it has more than \"15\" visits.",
"status": 422,
"shortCode": "abc123",
diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json b/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json
deleted file mode 100644
index 645c6ef2..00000000
--- a/docs/swagger/paths/v1_short-urls_{shortCode}_tags.json
+++ /dev/null
@@ -1,106 +0,0 @@
-{
- "put": {
- "deprecated": true,
- "operationId": "editShortUrlTags",
- "tags": [
- "Short URLs"
- ],
- "summary": "Edit tags on short URL",
- "description": "Edit the tags on URL identified by provided short code.
This endpoint is deprecated. Use the [Edit short URL](#/Short%20URLs/editShortUrl) endpoint to edit tags.",
- "parameters": [
- {
- "$ref": "../parameters/version.json"
- },
- {
- "name": "shortCode",
- "in": "path",
- "description": "The short code for the short URL in which we want to edit tags.",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "$ref": "../parameters/domain.json"
- }
- ],
- "requestBody": {
- "description": "Request body.",
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "tags"
- ],
- "properties": {
- "tags": {
- "type": "array",
- "items": {
- "type": "string"
- },
- "description": "The list of tags to set to the short URL."
- }
- }
- }
- }
- }
- },
- "security": [
- {
- "ApiKey": []
- }
- ],
- "responses": {
- "200": {
- "description": "List of tags.",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "tags": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- }
- }
- }
- },
- "400": {
- "description": "The request body does not contain a \"tags\" param with array type.",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "../definitions/Error.json"
- }
- }
- }
- },
- "404": {
- "description": "No short URL was found for provided short code.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "../definitions/Error.json"
- }
- }
- }
- },
- "default": {
- "description": "Unexpected error.",
- "content": {
- "application/json": {
- "schema": {
- "$ref": "../definitions/Error.json"
- }
- }
- }
- }
- }
- }
-}
diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json
index 12cdef81..a8219bf1 100644
--- a/docs/swagger/paths/v1_tags.json
+++ b/docs/swagger/paths/v1_tags.json
@@ -5,7 +5,7 @@
"Tags"
],
"summary": "List existing tags",
- "description": "Returns the list of all tags used in any short URL, ordered by name",
+ "description": "Returns the list of all tags used in any short URL",
"security": [
{
"ApiKey": []
@@ -17,7 +17,8 @@
},
{
"name": "withStats",
- "description": "Whether you want to include also a list with general stats by tag or not.",
+ "deprecated": true,
+ "description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.",
"in": "query",
"required": false,
"schema": {
@@ -27,6 +28,46 @@
"false"
]
}
+ },
+ {
+ "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"
+ }
+ },
+ {
+ "name": "searchTerm",
+ "in": "query",
+ "description": "A query used to filter results by searching for it on the tag name.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "orderBy",
+ "in": "query",
+ "description": "To determine how to order the results.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "tag-ASC",
+ "tag-DESC"
+ ]
+ }
}
],
"responses": {
@@ -53,122 +94,28 @@
"items": {
"$ref": "../definitions/TagInfo.json"
}
+ },
+ "pagination": {
+ "$ref": "../definitions/Pagination.json"
}
}
}
}
},
- "examples": {
- "Without stats": {
- "value": {
- "tags": {
- "data": [
- "games",
- "php",
- "shlink",
- "tech"
- ]
- }
- }
- },
- "With stats": {
- "value": {
- "tags": {
- "data": [
- "games",
- "shlink"
- ],
- "stats": [
- {
- "tag": "games",
- "shortUrlsCount": 10,
- "visitsCount": 521
- },
- {
- "tag": "shlink",
- "shortUrlsCount": 7,
- "visitsCount": 1087
- }
- ]
- }
- }
- }
- }
- }
- }
- },
- "default": {
- "description": "Unexpected error.",
- "content": {
- "application/problem+json": {
- "schema": {
- "$ref": "../definitions/Error.json"
- }
- }
- }
- }
- }
- },
-
- "post": {
- "deprecated": true,
- "operationId": "createTags",
- "tags": [
- "Tags"
- ],
- "summary": "Create tags",
- "description": "Provided a list of tags, creates all that do not yet exist
This endpoint is deprecated, as tags are automatically created while creating a short URL",
- "security": [
- {
- "ApiKey": []
- }
- ],
- "parameters": [
- {
- "$ref": "../parameters/version.json"
- }
- ],
- "requestBody": {
- "description": "Request body.",
- "required": true,
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "required": [
- "tags"
- ],
- "properties": {
+ "example": {
"tags": {
- "description": "The list of tag names to create",
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
- }
- }
- }
- },
- "responses": {
- "200": {
- "description": "The list of tags",
- "content": {
- "application/json": {
- "schema": {
- "type": "object",
- "properties": {
- "tags": {
- "type": "object",
- "properties": {
- "data": {
- "type": "array",
- "items": {
- "type": "string"
- }
- }
- }
+ "data": [
+ "games",
+ "php",
+ "shlink",
+ "tech"
+ ],
+ "pagination": {
+ "currentPage": 5,
+ "pagesCount": 10,
+ "itemsPerPage": 4,
+ "itemsInCurrentPage": 4,
+ "totalItems": 38
}
}
}
diff --git a/docs/swagger/paths/v2_tags_stats.json b/docs/swagger/paths/v2_tags_stats.json
new file mode 100644
index 00000000..91771335
--- /dev/null
+++ b/docs/swagger/paths/v2_tags_stats.json
@@ -0,0 +1,127 @@
+{
+ "get": {
+ "operationId": "tagsWithStats",
+ "tags": [
+ "Tags"
+ ],
+ "summary": "Get tags with stats",
+ "description": "Returns the list of all tags used in any short URL, together with the amount of short URLs and visits for it",
+ "security": [
+ {
+ "ApiKey": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "../parameters/version.json"
+ },
+ {
+ "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"
+ }
+ },
+ {
+ "name": "searchTerm",
+ "in": "query",
+ "description": "A query used to filter results by searching for it on the tag name.",
+ "required": false,
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "name": "orderBy",
+ "in": "query",
+ "description": "To determine how to order the results.
**Important!** Ordering by `shortUrlsCount` or `visitsCount` has a [known performance issue](https://github.com/shlinkio/shlink/issues/1346) which makes loading a subset of the list take as much as loading the whole list.
If you plan to order by any of these fields, it's worth loading the whole list with no pagination.",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": [
+ "tag-ASC",
+ "tag-DESC",
+ "shortUrlsCount-ASC",
+ "shortUrlsCount-DESC",
+ "visitsCount-ASC",
+ "visitsCount-DESC"
+ ]
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The list of tags",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "tags": {
+ "type": "object",
+ "required": ["data"],
+ "properties": {
+ "data": {
+ "description": "The tag stats will be returned only if the withStats param was provided with value 'true'",
+ "type": "array",
+ "items": {
+ "$ref": "../definitions/TagInfo.json"
+ }
+ },
+ "pagination": {
+ "$ref": "../definitions/Pagination.json"
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "tags": {
+ "data": [
+ {
+ "tag": "games",
+ "shortUrlsCount": 10,
+ "visitsCount": 521
+ },
+ {
+ "tag": "shlink",
+ "shortUrlsCount": 7,
+ "visitsCount": 1087
+ }
+ ],
+ "pagination": {
+ "currentPage": 5,
+ "pagesCount": 5,
+ "itemsPerPage": 10,
+ "itemsInCurrentPage": 2,
+ "totalItems": 42
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "Unexpected error.",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "../definitions/Error.json"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/docs/swagger/paths/v2_visits_non-orphan.json b/docs/swagger/paths/v2_visits_non-orphan.json
new file mode 100644
index 00000000..da0bdd14
--- /dev/null
+++ b/docs/swagger/paths/v2_visits_non-orphan.json
@@ -0,0 +1,146 @@
+{
+ "get": {
+ "operationId": "getNonOrphanVisits",
+ "tags": [
+ "Visits"
+ ],
+ "summary": "List non-orphan visits",
+ "description": "Get the list of visits to any short URL.",
+ "parameters": [
+ {
+ "$ref": "../parameters/version.json"
+ },
+ {
+ "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"
+ }
+ },
+ {
+ "name": "excludeBots",
+ "in": "query",
+ "description": "Tells if visits from potential bots should be excluded from the result set",
+ "required": false,
+ "schema": {
+ "type": "string",
+ "enum": ["true"]
+ }
+ }
+ ],
+ "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"
+ }
+ }
+ }
+ }
+ },
+ "example": {
+ "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,
+ "potentialBot": false
+ },
+ {
+ "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"
+ },
+ "potentialBot": false
+ },
+ {
+ "referer": null,
+ "date": "2015-08-20T05:05:03+04:00",
+ "userAgent": "some_web_crawler/1.4",
+ "visitLocation": null,
+ "potentialBot": true
+ }
+ ],
+ "pagination": {
+ "currentPage": 5,
+ "pagesCount": 12,
+ "itemsPerPage": 10,
+ "itemsInCurrentPage": 10,
+ "totalItems": 115
+ }
+ }
+ }
+ }
+ }
+ },
+ "default": {
+ "description": "Unexpected error.",
+ "content": {
+ "application/problem+json": {
+ "schema": {
+ "$ref": "../definitions/Error.json"
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/docs/swagger/paths/{shortCode}_qr-code_{size}.json b/docs/swagger/paths/{shortCode}_qr-code_{size}.json
deleted file mode 100644
index 54c5152e..00000000
--- a/docs/swagger/paths/{shortCode}_qr-code_{size}.json
+++ /dev/null
@@ -1,66 +0,0 @@
-{
- "get": {
- "operationId": "shortUrlQrCodeSize",
- "deprecated": true,
- "tags": [
- "URL Shortener"
- ],
- "summary": "Short URL QR code",
- "description": "Generates a QR code image pointing to a short URL",
- "parameters": [
- {
- "name": "shortCode",
- "in": "path",
- "description": "The short code to resolve.",
- "required": true,
- "schema": {
- "type": "string"
- }
- },
- {
- "name": "size",
- "in": "path",
- "description": "The size of the image to be returned.",
- "required": true,
- "schema": {
- "type": "integer",
- "minimum": 50,
- "maximum": 1000,
- "default": 300
- }
- },
- {
- "name": "format",
- "in": "query",
- "description": "The format for the QR code image, being valid values png and svg. Not providing the param or providing any other value will fall back to png.",
- "required": false,
- "schema": {
- "type": "string",
- "enum": [
- "png",
- "svg"
- ]
- }
- }
- ],
- "responses": {
- "200": {
- "description": "QR code in PNG format",
- "content": {
- "image/png": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- },
- "image/svg+xml": {
- "schema": {
- "type": "string",
- "format": "binary"
- }
- }
- }
- }
- }
- }
-}
diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json
index 705069cc..3730b527 100644
--- a/docs/swagger/swagger.json
+++ b/docs/swagger/swagger.json
@@ -78,13 +78,13 @@
"/rest/v{version}/short-urls/{shortCode}": {
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
- "/rest/v{version}/short-urls/{shortCode}/tags": {
- "$ref": "paths/v1_short-urls_{shortCode}_tags.json"
- },
"/rest/v{version}/tags": {
"$ref": "paths/v1_tags.json"
},
+ "/rest/v{version}/tags/stats": {
+ "$ref": "paths/v2_tags_stats.json"
+ },
"/rest/v{version}/visits": {
"$ref": "paths/v2_visits.json"
@@ -98,6 +98,9 @@
"/rest/v{version}/visits/orphan": {
"$ref": "paths/v2_visits_orphan.json"
},
+ "/rest/v{version}/visits/non-orphan": {
+ "$ref": "paths/v2_visits_non-orphan.json"
+ },
"/rest/v{version}/domains": {
"$ref": "paths/v2_domains.json"
@@ -122,9 +125,6 @@
},
"/{shortCode}/qr-code": {
"$ref": "paths/{shortCode}_qr-code.json"
- },
- "/{shortCode}/qr-code/{size}": {
- "$ref": "paths/{shortCode}_qr-code_{size}.json"
}
}
}
diff --git a/infection-api.json b/infection-api.json
index 398cd653..27453018 100644
--- a/infection-api.json
+++ b/infection-api.json
@@ -7,6 +7,7 @@
"timeout": 5,
"logs": {
"text": "build/infection-api/infection-log.txt",
+ "html": "build/infection-api/infection-log.html",
"summary": "build/infection-api/summary-log.txt",
"debug": "build/infection-api/debug-log.txt"
},
diff --git a/infection-db.json b/infection-db.json
index a429c995..d633cb05 100644
--- a/infection-db.json
+++ b/infection-db.json
@@ -7,6 +7,7 @@
"timeout": 5,
"logs": {
"text": "build/infection-db/infection-log.txt",
+ "html": "build/infection-db/infection-log.html",
"summary": "build/infection-db/summary-log.txt",
"debug": "build/infection-db/debug-log.txt"
},
diff --git a/infection.json b/infection.json
index 1b4ed6b5..9a4f7f00 100644
--- a/infection.json
+++ b/infection.json
@@ -7,10 +7,11 @@
"timeout": 5,
"logs": {
"text": "build/infection-unit/infection-log.txt",
+ "html": "build/infection-unit/infection-log.html",
"summary": "build/infection-unit/summary-log.txt",
"debug": "build/infection-unit/debug-log.txt",
- "badge": {
- "branch": "develop"
+ "stryker": {
+ "report": "develop"
}
},
"tmpDir": "build/infection-unit/temp",
diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php
index e06ad727..2b5b5afd 100644
--- a/module/CLI/config/cli.config.php
+++ b/module/CLI/config/cli.config.php
@@ -22,7 +22,6 @@ return [
Command\Api\ListKeysCommand::NAME => Command\Api\ListKeysCommand::class,
Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class,
- Command\Tag\CreateTagCommand::NAME => Command\Tag\CreateTagCommand::class,
Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class,
Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class,
diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index 41d415dc..da23b0f6 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -53,7 +53,6 @@ return [
Command\Api\ListKeysCommand::class => ConfigAbstractFactory::class,
Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class,
- Command\Tag\CreateTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class,
Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class,
@@ -101,7 +100,6 @@ return [
Command\Api\ListKeysCommand::class => [ApiKeyService::class],
Command\Tag\ListTagsCommand::class => [TagService::class],
- Command\Tag\CreateTagCommand::class => [TagService::class],
Command\Tag\RenameTagCommand::class => [TagService::class],
Command\Tag\DeleteTagsCommand::class => [TagService::class],
diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php
index a43c9e65..2655d1fb 100644
--- a/module/CLI/src/Command/Api/GenerateKeyCommand.php
+++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php
@@ -6,11 +6,11 @@ namespace Shlinkio\Shlink\CLI\Command\Api;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\CLI\ApiKey\RoleResolverInterface;
-use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -19,7 +19,7 @@ use Symfony\Component\Console\Style\SymfonyStyle;
use function Shlinkio\Shlink\Core\arrayToString;
use function sprintf;
-class GenerateKeyCommand extends BaseCommand
+class GenerateKeyCommand extends Command
{
public const NAME = 'api-key:generate';
@@ -63,7 +63,7 @@ class GenerateKeyCommand extends BaseCommand
InputOption::VALUE_REQUIRED,
'The name by which this API key will be known.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'expiration-date',
'e',
InputOption::VALUE_REQUIRED,
@@ -86,7 +86,7 @@ class GenerateKeyCommand extends BaseCommand
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
- $expirationDate = $this->getOptionWithDeprecatedFallback($input, 'expiration-date');
+ $expirationDate = $input->getOption('expiration-date');
$apiKey = $this->apiKeyService->create(
isset($expirationDate) ? Chronos::parse($expirationDate) : null,
$input->getOption('name'),
diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php
index 23258993..0a331086 100644
--- a/module/CLI/src/Command/Api/ListKeysCommand.php
+++ b/module/CLI/src/Command/Api/ListKeysCommand.php
@@ -4,12 +4,12 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Api;
-use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\Rest\Service\ApiKeyServiceInterface;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -19,7 +19,7 @@ use function Functional\map;
use function implode;
use function sprintf;
-class ListKeysCommand extends BaseCommand
+class ListKeysCommand extends Command
{
private const ERROR_STRING_PATTERN = '%s>';
private const SUCCESS_STRING_PATTERN = '%s';
@@ -37,7 +37,7 @@ class ListKeysCommand extends BaseCommand
$this
->setName(self::NAME)
->setDescription('Lists all the available API keys.')
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'enabled-only',
'e',
InputOption::VALUE_NONE,
@@ -47,7 +47,7 @@ class ListKeysCommand extends BaseCommand
protected function execute(InputInterface $input, OutputInterface $output): ?int
{
- $enabledOnly = $this->getOptionWithDeprecatedFallback($input, 'enabled-only');
+ $enabledOnly = $input->getOption('enabled-only');
$rows = map($this->apiKeyService->listKeys($enabledOnly), function (ApiKey $apiKey) use ($enabledOnly) {
$expiration = $apiKey->getExpirationDate();
diff --git a/module/CLI/src/Command/BaseCommand.php b/module/CLI/src/Command/BaseCommand.php
deleted file mode 100644
index fbee8681..00000000
--- a/module/CLI/src/Command/BaseCommand.php
+++ /dev/null
@@ -1,47 +0,0 @@
-addOption($name, $shortcut, $mode, $description, $default);
-
- if (str_contains($name, '-')) {
- $camelCaseName = kebabCaseToCamelCase($name);
- $this->addOption($camelCaseName, null, $mode, sprintf('[DEPRECATED] Alias for "%s".', $name), $default);
- }
-
- return $this;
- }
-
- // @phpstan-ignore-next-line
- protected function getOptionWithDeprecatedFallback(InputInterface $input, string $name) // phpcs:ignore
- {
- $rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
- $camelCaseName = kebabCaseToCamelCase($name);
- $resolvedOptionName = str_contains($rawInput, $camelCaseName) ? $camelCaseName : $name;
-
- return $input->getOption($resolvedOptionName);
- }
-}
diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
index 62b50456..3334ae6a 100644
--- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
+++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php
@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
-use Shlinkio\Shlink\CLI\Command\BaseCommand;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
@@ -12,6 +11,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Service\UrlShortenerInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -22,11 +22,9 @@ use function array_map;
use function Functional\curry;
use function Functional\flatten;
use function Functional\unique;
-use function method_exists;
use function sprintf;
-use function str_contains;
-class CreateShortUrlCommand extends BaseCommand
+class CreateShortUrlCommand extends Command
{
public const NAME = 'short-url:create';
@@ -45,7 +43,6 @@ class CreateShortUrlCommand extends BaseCommand
{
$this
->setName(self::NAME)
- ->setAliases(['short-url:generate']) // Deprecated
->setDescription('Generates a short URL for provided long URL and returns it')
->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse')
->addOption(
@@ -54,33 +51,33 @@ class CreateShortUrlCommand extends BaseCommand
InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED,
'Tags to apply to the new short URL',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'valid-since',
's',
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'valid-until',
'u',
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'custom-slug',
'c',
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'max-visits',
'm',
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
@@ -92,7 +89,7 @@ class CreateShortUrlCommand extends BaseCommand
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'short-code-length',
'l',
InputOption::VALUE_REQUIRED,
@@ -104,12 +101,6 @@ class CreateShortUrlCommand extends BaseCommand
InputOption::VALUE_NONE,
'Forces the long URL to be validated, regardless what is globally configured.',
)
- ->addOption(
- 'no-validate-url',
- null,
- InputOption::VALUE_NONE,
- '[DEPRECATED] Forces the long URL to not be validated, regardless what is globally configured.',
- )
->addOption(
'crawlable',
'r',
@@ -161,25 +152,19 @@ class CreateShortUrlCommand extends BaseCommand
$explodeWithComma = curry('explode')(',');
$tags = unique(flatten(array_map($explodeWithComma, $input->getOption('tags'))));
- $customSlug = $this->getOptionWithDeprecatedFallback($input, 'custom-slug');
- $maxVisits = $this->getOptionWithDeprecatedFallback($input, 'max-visits');
- $shortCodeLength = $this->getOptionWithDeprecatedFallback(
- $input,
- 'short-code-length',
- ) ?? $this->defaultShortCodeLength;
- $doValidateUrl = $this->doValidateUrl($input);
+ $customSlug = $input->getOption('custom-slug');
+ $maxVisits = $input->getOption('max-visits');
+ $shortCodeLength = $input->getOption('short-code-length') ?? $this->defaultShortCodeLength;
+ $doValidateUrl = $input->getOption('validate-url');
try {
$shortUrl = $this->urlShortener->shorten(ShortUrlMeta::fromRawData([
ShortUrlInputFilter::LONG_URL => $longUrl,
- ShortUrlInputFilter::VALID_SINCE => $this->getOptionWithDeprecatedFallback($input, 'valid-since'),
- ShortUrlInputFilter::VALID_UNTIL => $this->getOptionWithDeprecatedFallback($input, 'valid-until'),
+ ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'),
+ ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'),
ShortUrlInputFilter::CUSTOM_SLUG => $customSlug,
ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null,
- ShortUrlInputFilter::FIND_IF_EXISTS => $this->getOptionWithDeprecatedFallback(
- $input,
- 'find-if-exists',
- ),
+ ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'),
ShortUrlInputFilter::DOMAIN => $input->getOption('domain'),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl,
@@ -199,20 +184,6 @@ class CreateShortUrlCommand extends BaseCommand
}
}
- private function doValidateUrl(InputInterface $input): ?bool
- {
- $rawInput = method_exists($input, '__toString') ? $input->__toString() : '';
-
- if (str_contains($rawInput, '--no-validate-url')) {
- return false;
- }
- if (str_contains($rawInput, '--validate-url')) {
- return true;
- }
-
- return null;
- }
-
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ?? ($this->io = new SymfonyStyle($input, $output));
diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
index cbc6e3ee..751006bf 100644
--- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
+++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php
@@ -11,7 +11,6 @@ use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Common\Rest\DataTransformerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
-use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface;
use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter;
@@ -52,7 +51,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
'The first page to list (10 items per page unless "--all" is provided).',
'1',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'search-term',
'st',
InputOption::VALUE_REQUIRED,
@@ -64,14 +63,20 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
InputOption::VALUE_REQUIRED,
'A comma-separated list of tags to filter results.',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
+ 'including-all-tags',
+ 'i',
+ InputOption::VALUE_NONE,
+ 'If tags is provided, returns only short URLs having ALL tags.',
+ )
+ ->addOption(
'order-by',
'o',
InputOption::VALUE_REQUIRED,
'The field from which you want to order by. '
- . 'Define ordering dir by passing ASC or DESC after "," or "-".',
+ . 'Define ordering dir by passing ASC or DESC after "-" or ",".',
)
- ->addOptionWithDeprecatedFallback(
+ ->addOption(
'show-tags',
null,
InputOption::VALUE_NONE,
@@ -113,8 +118,11 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$io = new SymfonyStyle($input, $output);
$page = (int) $input->getOption('page');
- $searchTerm = $this->getOptionWithDeprecatedFallback($input, 'search-term');
+ $searchTerm = $input->getOption('search-term');
$tags = $input->getOption('tags');
+ $tagsMode = $input->getOption('including-all-tags') === true
+ ? ShortUrlsParams::TAGS_MODE_ALL
+ : ShortUrlsParams::TAGS_MODE_ANY;
$tags = ! empty($tags) ? explode(',', $tags) : [];
$all = $input->getOption('all');
$startDate = $this->getStartDateOption($input, $output);
@@ -125,7 +133,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
$data = [
ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm,
ShortUrlsParamsInputFilter::TAGS => $tags,
- ShortUrlsOrdering::ORDER_BY => $orderBy,
+ ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode,
+ ShortUrlsParamsInputFilter::ORDER_BY => $orderBy,
ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(),
ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(),
];
@@ -175,7 +184,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
private function processOrderBy(InputInterface $input): ?string
{
- $orderBy = $this->getOptionWithDeprecatedFallback($input, 'order-by');
+ $orderBy = $input->getOption('order-by');
if (empty($orderBy)) {
return null;
}
@@ -195,7 +204,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand
'Date created' => $pickProp('dateCreated'),
'Visits count' => $pickProp('visitsCount'),
];
- if ($this->getOptionWithDeprecatedFallback($input, 'show-tags')) {
+ if ($input->getOption('show-tags')) {
$columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']);
}
if ($input->getOption('show-api-key')) {
diff --git a/module/CLI/src/Command/Tag/CreateTagCommand.php b/module/CLI/src/Command/Tag/CreateTagCommand.php
deleted file mode 100644
index 99eef614..00000000
--- a/module/CLI/src/Command/Tag/CreateTagCommand.php
+++ /dev/null
@@ -1,52 +0,0 @@
-setName(self::NAME)
- ->setDescription('[Deprecated] Creates one or more tags.')
- ->addOption(
- 'name',
- 't',
- InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
- 'The name of the tags to create',
- );
- }
-
- protected function execute(InputInterface $input, OutputInterface $output): ?int
- {
- $io = new SymfonyStyle($input, $output);
- $tagNames = $input->getOption('name');
-
- if (empty($tagNames)) {
- $io->warning('You have to provide at least one tag name');
- return ExitCodes::EXIT_WARNING;
- }
-
- $this->tagService->createTags($tagNames);
- $io->success('Tags properly created');
- return ExitCodes::EXIT_SUCCESS;
- }
-}
diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php
index 9eebe36f..9c7269fa 100644
--- a/module/CLI/src/Command/Tag/ListTagsCommand.php
+++ b/module/CLI/src/Command/Tag/ListTagsCommand.php
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Tag;
use Shlinkio\Shlink\CLI\Util\ExitCodes;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
+use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
@@ -38,15 +39,14 @@ class ListTagsCommand extends Command
private function getTagsRows(): array
{
- $tags = $this->tagService->tagsInfo();
+ $tags = $this->tagService->tagsInfo(TagsParams::fromRawData([]))->getCurrentPageResults();
if (empty($tags)) {
return [['No tags found', '-', '-']];
}
return map(
$tags,
- static fn (TagInfo $tagInfo) =>
- [$tagInfo->tag()->__toString(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
+ static fn (TagInfo $tagInfo) => [$tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()],
);
}
}
diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
index 9d7f5723..c3e3c407 100644
--- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
+++ b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php
@@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Cake\Chronos\Chronos;
-use Shlinkio\Shlink\CLI\Command\BaseCommand;
+use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
@@ -14,7 +14,7 @@ use Throwable;
use function is_string;
use function sprintf;
-abstract class AbstractWithDateRangeCommand extends BaseCommand
+abstract class AbstractWithDateRangeCommand extends Command
{
private const START_DATE = 'start-date';
private const END_DATE = 'end-date';
@@ -23,18 +23,8 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
{
$this->doConfigure();
$this
- ->addOptionWithDeprecatedFallback(
- self::START_DATE,
- 's',
- InputOption::VALUE_REQUIRED,
- $this->getStartDateDesc(self::START_DATE),
- )
- ->addOptionWithDeprecatedFallback(
- self::END_DATE,
- 'e',
- InputOption::VALUE_REQUIRED,
- $this->getEndDateDesc(self::END_DATE),
- );
+ ->addOption(self::START_DATE, 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc(self::START_DATE))
+ ->addOption(self::END_DATE, 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc(self::END_DATE));
}
protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos
@@ -49,7 +39,7 @@ abstract class AbstractWithDateRangeCommand extends BaseCommand
private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos
{
- $value = $this->getOptionWithDeprecatedFallback($input, $key);
+ $value = $input->getOption($key);
if (empty($value) || ! is_string($value)) {
return null;
}
diff --git a/module/CLI/test/CliTestUtilsTrait.php b/module/CLI/test/CliTestUtilsTrait.php
index 412131dc..ec7dd9d9 100644
--- a/module/CLI/test/CliTestUtilsTrait.php
+++ b/module/CLI/test/CliTestUtilsTrait.php
@@ -9,6 +9,7 @@ use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Tester\CommandTester;
trait CliTestUtilsTrait
@@ -25,6 +26,7 @@ trait CliTestUtilsTrait
$command->getDefinition()->willReturn($name);
$command->isEnabled()->willReturn(true);
$command->getAliases()->willReturn([]);
+ $command->getDefinition()->willReturn(new InputDefinition());
$command->setApplication(Argument::type(Application::class))->willReturn(function (): void {
});
diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php
index 08389d61..3ec90412 100644
--- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php
+++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php
@@ -149,7 +149,7 @@ class CreateShortUrlCommandTest extends TestCase
* @test
* @dataProvider provideFlags
*/
- public function urlValidationHasExpectedValueBasedOnProvidedTags(array $options, ?bool $expectedValidateUrl): void
+ public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void
{
$shortUrl = ShortUrl::createEmpty();
$urlToShortCode = $this->urlShortener->shorten(
@@ -168,8 +168,6 @@ class CreateShortUrlCommandTest extends TestCase
public function provideFlags(): iterable
{
yield 'no flags' => [[], null];
- yield 'no-validate-url only' => [['--no-validate-url' => true], false];
yield 'validate-url' => [['--validate-url' => true], true];
- yield 'both flags' => [['--validate-url' => true, '--no-validate-url' => true], false];
}
}
diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php
index 4a974d73..38d3bcd3 100644
--- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php
+++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php
@@ -184,6 +184,7 @@ class ListShortUrlsCommandTest extends TestCase
?int $page,
?string $searchTerm,
array $tags,
+ string $tagsMode,
?string $startDate = null,
?string $endDate = null,
): void {
@@ -191,6 +192,7 @@ class ListShortUrlsCommandTest extends TestCase
'page' => $page,
'searchTerm' => $searchTerm,
'tags' => $tags,
+ 'tagsMode' => $tagsMode,
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
]))->willReturn(new Paginator(new ArrayAdapter([])));
@@ -203,20 +205,23 @@ class ListShortUrlsCommandTest extends TestCase
public function provideArgs(): iterable
{
- yield [[], 1, null, []];
- yield [['--page' => $page = 3], $page, null, []];
- yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, []];
+ yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY];
+ yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY];
+ yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL];
+ yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY];
yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'],
$page,
$searchTerm,
explode(',', $tags),
+ ShortUrlsParams::TAGS_MODE_ANY,
];
yield [
['--start-date' => $startDate = '2019-01-01'],
1,
null,
[],
+ ShortUrlsParams::TAGS_MODE_ANY,
$startDate,
];
yield [
@@ -224,6 +229,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
+ ShortUrlsParams::TAGS_MODE_ANY,
null,
$endDate,
];
@@ -232,6 +238,7 @@ class ListShortUrlsCommandTest extends TestCase
1,
null,
[],
+ ShortUrlsParams::TAGS_MODE_ANY,
$startDate,
$endDate,
];
@@ -241,7 +248,7 @@ class ListShortUrlsCommandTest extends TestCase
* @test
* @dataProvider provideOrderBy
*/
- public function orderByIsProperlyComputed(array $commandArgs, string|array|null $expectedOrderBy): void
+ public function orderByIsProperlyComputed(array $commandArgs, ?string $expectedOrderBy): void
{
$listShortUrls = $this->shortUrlService->listShortUrls(ShortUrlsParams::fromRawData([
'orderBy' => $expectedOrderBy,
@@ -256,9 +263,10 @@ class ListShortUrlsCommandTest extends TestCase
public function provideOrderBy(): iterable
{
yield [[], null];
- yield [['--order-by' => 'foo'], 'foo'];
- yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']];
- yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']];
+ yield [['--order-by' => 'visits'], 'visits'];
+ yield [['--order-by' => 'longUrl,ASC'], 'longUrl-ASC'];
+ yield [['--order-by' => 'shortCode,DESC'], 'shortCode-DESC'];
+ yield [['--order-by' => 'title-DESC'], 'title-DESC'];
}
/** @test */
@@ -268,6 +276,7 @@ class ListShortUrlsCommandTest extends TestCase
'page' => 1,
'searchTerm' => null,
'tags' => [],
+ 'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY,
'startDate' => null,
'endDate' => null,
'orderBy' => null,
diff --git a/module/CLI/test/Command/Tag/CreateTagCommandTest.php b/module/CLI/test/Command/Tag/CreateTagCommandTest.php
deleted file mode 100644
index 7062cb45..00000000
--- a/module/CLI/test/Command/Tag/CreateTagCommandTest.php
+++ /dev/null
@@ -1,51 +0,0 @@
-tagService = $this->prophesize(TagServiceInterface::class);
- $this->commandTester = $this->testerForCommand(new CreateTagCommand($this->tagService->reveal()));
- }
-
- /** @test */
- public function errorIsReturnedWhenNoTagsAreProvided(): void
- {
- $this->commandTester->execute([]);
-
- $output = $this->commandTester->getDisplay();
- self::assertStringContainsString('You have to provide at least one tag name', $output);
- }
-
- /** @test */
- public function serviceIsInvokedOnSuccess(): void
- {
- $tagNames = ['foo', 'bar'];
- $createTags = $this->tagService->createTags($tagNames)->willReturn(new ArrayCollection());
-
- $this->commandTester->execute([
- '--name' => $tagNames,
- ]);
- $output = $this->commandTester->getDisplay();
-
- self::assertStringContainsString('Tags properly created', $output);
- $createTags->shouldHaveBeenCalled();
- }
-}
diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php
index f79aa03d..499442d0 100644
--- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php
+++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php
@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Command\Tag;
+use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
+use Prophecy\Argument;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand;
-use Shlinkio\Shlink\Core\Entity\Tag;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait;
@@ -29,7 +31,7 @@ class ListTagsCommandTest extends TestCase
/** @test */
public function noTagsPrintsEmptyMessage(): void
{
- $tagsInfo = $this->tagService->tagsInfo()->willReturn([]);
+ $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
@@ -41,10 +43,10 @@ class ListTagsCommandTest extends TestCase
/** @test */
public function listOfTagsIsPrinted(): void
{
- $tagsInfo = $this->tagService->tagsInfo()->willReturn([
- new TagInfo(new Tag('foo'), 10, 2),
- new TagInfo(new Tag('bar'), 7, 32),
- ]);
+ $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([
+ new TagInfo('foo', 10, 2),
+ new TagInfo('bar', 7, 32),
+ ])));
$this->commandTester->execute([]);
$output = $this->commandTester->getDisplay();
diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php
index fdfecef9..516ad8a1 100644
--- a/module/Core/config/dependencies.config.php
+++ b/module/Core/config/dependencies.config.php
@@ -23,6 +23,7 @@ return [
Options\AppOptions::class => ConfigAbstractFactory::class,
Options\DeleteShortUrlsOptions::class => ConfigAbstractFactory::class,
Options\NotFoundRedirectOptions::class => ConfigAbstractFactory::class,
+ Options\RedirectOptions::class => ConfigAbstractFactory::class,
Options\UrlShortenerOptions::class => ConfigAbstractFactory::class,
Options\TrackingOptions::class => ConfigAbstractFactory::class,
Options\QrCodeOptions::class => ConfigAbstractFactory::class,
@@ -32,7 +33,7 @@ return [
Service\ShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\DeleteShortUrlService::class => ConfigAbstractFactory::class,
Service\ShortUrl\ShortUrlResolver::class => ConfigAbstractFactory::class,
- Service\ShortUrl\ShortCodeHelper::class => ConfigAbstractFactory::class,
+ Service\ShortUrl\ShortCodeUniquenessHelper::class => ConfigAbstractFactory::class,
Tag\TagService::class => ConfigAbstractFactory::class,
@@ -86,16 +87,17 @@ return [
Options\AppOptions::class => ['config.app_options'],
Options\DeleteShortUrlsOptions::class => ['config.delete_short_urls'],
Options\NotFoundRedirectOptions::class => ['config.not_found_redirects'],
+ Options\RedirectOptions::class => ['config.redirects'],
Options\UrlShortenerOptions::class => ['config.url_shortener'],
Options\TrackingOptions::class => ['config.tracking'],
Options\QrCodeOptions::class => ['config.qr_codes'],
- Options\WebhookOptions::class => ['config.url_shortener'], // TODO This config is currently under url_shortener
+ Options\WebhookOptions::class => ['config.visits_webhooks'],
Service\UrlShortener::class => [
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class,
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
- Service\ShortUrl\ShortCodeHelper::class,
+ Service\ShortUrl\ShortCodeUniquenessHelper::class,
],
Visit\VisitsTracker::class => [
'em',
@@ -118,12 +120,12 @@ return [
Service\ShortUrl\ShortUrlResolver::class,
],
Service\ShortUrl\ShortUrlResolver::class => ['em'],
- Service\ShortUrl\ShortCodeHelper::class => ['em'],
+ Service\ShortUrl\ShortCodeUniquenessHelper::class => ['em'],
Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'],
Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class],
Util\DoctrineBatchHelper::class => ['em'],
- Util\RedirectResponseHelper::class => [Options\UrlShortenerOptions::class],
+ Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
@@ -163,7 +165,7 @@ return [
Importer\ImportedLinksProcessor::class => [
'em',
ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class,
- Service\ShortUrl\ShortCodeHelper::class,
+ Service\ShortUrl\ShortCodeUniquenessHelper::class,
Util\DoctrineBatchHelper::class,
],
diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php
index 596f41da..68427b42 100644
--- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php
+++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Domain.php
@@ -21,21 +21,21 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->option('unsigned', true)
->build();
- $builder->createField('authority', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('authority', Types::STRING), $emConfig)
->unique()
->build();
- $builder->createField('baseUrlRedirect', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::STRING), $emConfig)
->columnName('base_url_redirect')
->nullable()
->build();
- $builder->createField('regular404Redirect', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::STRING), $emConfig)
->columnName('regular_not_found_redirect')
->nullable()
->build();
- $builder->createField('invalidShortUrlRedirect', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::STRING), $emConfig)
->columnName('invalid_short_url_redirect')
->nullable()
->build();
diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php
index 83fd7e79..4aefe26b 100644
--- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php
+++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.ShortUrl.php
@@ -23,12 +23,12 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->option('unsigned', true)
->build();
- $builder->createField('longUrl', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig)
->columnName('original_url')
->length(2048)
->build();
- $builder->createField('shortCode', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin')
->columnName('short_code')
->length(255)
->build();
@@ -57,7 +57,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->nullable()
->build();
- $builder->createField('importOriginalShortCode', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('importOriginalShortCode', Types::STRING), $emConfig)
->columnName('import_original_short_code')
->nullable()
->build();
@@ -85,7 +85,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
$builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain');
- $builder->createField('title', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('title', Types::STRING), $emConfig)
->columnName('title')
->length(512)
->nullable()
diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php
index 97d15758..9f02ec72 100644
--- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php
+++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Tag.php
@@ -21,7 +21,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->option('unsigned', true)
->build();
- $builder->createField('name', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('name', Types::STRING), $emConfig)
->unique()
->build();
diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php
index 8886e141..969bfd1d 100644
--- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php
+++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php
@@ -23,7 +23,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->option('unsigned', true)
->build();
- $builder->createField('referer', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('referer', Types::STRING), $emConfig)
->nullable()
->length(Visitor::REFERER_MAX_LENGTH)
->build();
@@ -40,7 +40,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->nullable()
->build();
- $builder->createField('userAgent', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('userAgent', Types::STRING), $emConfig)
->columnName('user_agent')
->length(Visitor::USER_AGENT_MAX_LENGTH)
->nullable()
@@ -55,7 +55,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
->cascadePersist()
->build();
- $builder->createField('visitedUrl', Types::STRING)
+ fieldWithUtf8Charset($builder->createField('visitedUrl', Types::STRING), $emConfig)
->columnName('visited_url')
->length(Visitor::VISITED_URL_MAX_LENGTH)
->nullable()
diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php
index 955fa1fa..0216f7aa 100644
--- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php
+++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.VisitLocation.php
@@ -29,7 +29,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void {
];
foreach ($columns as $columnName => $fieldName) {
- $builder->createField($fieldName, Types::STRING)
+ fieldWithUtf8Charset($builder->createField($fieldName, Types::STRING), $emConfig)
->columnName($columnName)
->nullable()
->build();
diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php
index c3f4b66a..07e33c73 100644
--- a/module/Core/config/routes.config.php
+++ b/module/Core/config/routes.config.php
@@ -43,16 +43,6 @@ return [
],
'allowed_methods' => [RequestMethod::METHOD_GET],
],
-
- // Deprecated
- [
- 'name' => 'old_' . Action\QrCodeAction::class,
- 'path' => '/{shortCode}/qr-code/{size:[0-9]+}',
- 'middleware' => [
- Action\QrCodeAction::class,
- ],
- 'allowed_methods' => [RequestMethod::METHOD_GET],
- ],
],
];
diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php
index bba0c17b..567fde47 100644
--- a/module/Core/functions/functions.php
+++ b/module/Core/functions/functions.php
@@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core;
use Cake\Chronos\Chronos;
use DateTimeInterface;
+use Doctrine\ORM\Mapping\Builder\FieldBuilder;
use Jaybizzle\CrawlerDetect\CrawlerDetect;
use Laminas\InputFilter\InputFilter;
use PUGX\Shortid\Factory as ShortIdFactory;
@@ -13,13 +14,10 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use function Functional\reduce_left;
use function is_array;
-use function lcfirst;
use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
-use function str_replace;
-use function ucwords;
function generateRandomShortCode(int $length): string
{
@@ -34,7 +32,7 @@ function generateRandomShortCode(int $length): string
function parseDateFromQuery(array $query, string $dateName): ?Chronos
{
- return ! isset($query[$dateName]) || empty($query[$dateName]) ? null : Chronos::parse($query[$dateName]);
+ return empty($query[$dateName] ?? null) ? null : Chronos::parse($query[$dateName]);
}
function parseDateRangeFromQuery(array $query, string $startDateName, string $endDateName): DateRange
@@ -100,11 +98,6 @@ function arrayToString(array $array, int $indentSize = 4): string
}, '');
}
-function kebabCaseToCamelCase(string $name): string
-{
- return lcfirst(str_replace(' ', '', ucwords(str_replace('-', ' ', $name))));
-}
-
function isCrawler(string $userAgent): bool
{
static $detector;
@@ -114,3 +107,12 @@ function isCrawler(string $userAgent): bool
return $detector->isCrawler($userAgent);
}
+
+function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $collation = 'unicode_ci'): FieldBuilder
+{
+ return match ($emConfig['connection']['driver'] ?? null) {
+ 'pdo_mysql' => $field->option('charset', 'utf8mb4')
+ ->option('collation', 'utf8mb4_' . $collation),
+ default => $field,
+ };
+}
diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php
index 03643e4c..42d643d3 100644
--- a/module/Core/src/Action/Model/QrCodeParams.php
+++ b/module/Core/src/Action/Model/QrCodeParams.php
@@ -16,7 +16,6 @@ use Endroid\QrCode\Writer\PngWriter;
use Endroid\QrCode\Writer\SvgWriter;
use Endroid\QrCode\Writer\WriterInterface;
use Psr\Http\Message\ServerRequestInterface;
-use Psr\Http\Message\ServerRequestInterface as Request;
use Shlinkio\Shlink\Core\Options\QrCodeOptions;
use function Functional\contains;
@@ -43,7 +42,7 @@ final class QrCodeParams
$query = $request->getQueryParams();
return new self(
- self::resolveSize($request, $query, $defaults),
+ self::resolveSize($query, $defaults),
self::resolveMargin($query, $defaults),
self::resolveWriter($query, $defaults),
self::resolveErrorCorrection($query, $defaults),
@@ -51,10 +50,9 @@ final class QrCodeParams
);
}
- private static function resolveSize(Request $request, array $query, QrCodeOptions $defaults): int
+ private static function resolveSize(array $query, QrCodeOptions $defaults): int
{
- // FIXME Size attribute is deprecated. After v3.0.0, always use the query param instead
- $size = (int) $request->getAttribute('size', $query['size'] ?? $defaults->size());
+ $size = (int) ($query['size'] ?? $defaults->size());
if ($size < self::MIN_SIZE) {
return self::MIN_SIZE;
}
diff --git a/module/Core/src/Config/DeprecatedConfigParser.php b/module/Core/src/Config/DeprecatedConfigParser.php
deleted file mode 100644
index b3421146..00000000
--- a/module/Core/src/Config/DeprecatedConfigParser.php
+++ /dev/null
@@ -1,41 +0,0 @@
-getConstants(ReflectionClassConstant::IS_PUBLIC));
+ }
+
+ private function __construct(private string $envVar)
+ {
+ }
+
+ public static function __callStatic(string $name, array $arguments): self
+ {
+ if (! contains(self::cases(), $name)) {
+ throw new InvalidArgumentException('Invalid env var: "' . $name . '"');
+ }
+
+ return new self($name);
+ }
+
+ public function loadFromEnv(mixed $default = null): mixed
+ {
+ return env($this->envVar, $default);
+ }
+
+ public function existsInEnv(): bool
+ {
+ return $this->loadFromEnv() !== null;
+ }
+}
diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php
index 531254f7..caa100c3 100644
--- a/module/Core/src/Config/NotFoundRedirectResolver.php
+++ b/module/Core/src/Config/NotFoundRedirectResolver.php
@@ -70,7 +70,10 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface
$replacePlaceholderForPattern(self::DOMAIN_PLACEHOLDER, $domain, $modifier),
$replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier),
);
- $replacePlaceholdersInPath = $replacePlaceholders('\Functional\id');
+ $replacePlaceholdersInPath = compose(
+ $replacePlaceholders('\Functional\id'),
+ static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), // Fix duplicated bars
+ );
$replacePlaceholdersInQuery = $replacePlaceholders('\urlencode');
return $redirectUri
diff --git a/module/Core/src/Config/SimplifiedConfigParser.php b/module/Core/src/Config/SimplifiedConfigParser.php
deleted file mode 100644
index 2b0b1d71..00000000
--- a/module/Core/src/Config/SimplifiedConfigParser.php
+++ /dev/null
@@ -1,94 +0,0 @@
- ['tracking', 'disable_track_param'],
- 'short_domain_schema' => ['url_shortener', 'domain', 'schema'],
- 'short_domain_host' => ['url_shortener', 'domain', 'hostname'],
- 'validate_url' => ['url_shortener', 'validate_url'],
- 'invalid_short_url_redirect_to' => ['not_found_redirects', 'invalid_short_url'],
- 'regular_404_redirect_to' => ['not_found_redirects', 'regular_404'],
- 'base_url_redirect_to' => ['not_found_redirects', 'base_url'],
- 'db_config' => ['entity_manager', 'connection'],
- 'delete_short_url_threshold' => ['delete_short_urls', 'visits_threshold'],
- 'redis_servers' => ['cache', 'redis', 'servers'],
- 'base_path' => ['router', 'base_path'],
- 'web_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'worker_num'],
- 'task_worker_num' => ['mezzio-swoole', 'swoole-http-server', 'options', 'task_worker_num'],
- 'visits_webhooks' => ['url_shortener', 'visits_webhooks'],
- 'default_short_codes_length' => ['url_shortener', 'default_short_codes_length'],
- 'geolite_license_key' => ['geolite2', 'license_key'],
- 'mercure_public_hub_url' => ['mercure', 'public_hub_url'],
- 'mercure_internal_hub_url' => ['mercure', 'internal_hub_url'],
- 'mercure_jwt_secret' => ['mercure', 'jwt_secret'],
- 'anonymize_remote_addr' => ['tracking', 'anonymize_remote_addr'],
- 'redirect_status_code' => ['url_shortener', 'redirect_status_code'],
- 'redirect_cache_lifetime' => ['url_shortener', 'redirect_cache_lifetime'],
- 'port' => ['mezzio-swoole', 'swoole-http-server', 'port'],
- ];
- private const SIMPLIFIED_CONFIG_SIDE_EFFECTS = [
- 'delete_short_url_threshold' => [
- 'path' => ['delete_short_urls', 'check_visits_threshold'],
- 'value' => true,
- ],
- 'redis_servers' => [
- 'path' => ['dependencies', 'aliases', 'lock_store'],
- 'value' => 'redis_lock_store',
- ],
- ];
- private const SIMPLIFIED_MERGEABLE_CONFIG = ['db_config'];
-
- public function __invoke(array $config): array
- {
- $configForExistingKeys = $this->getConfigForKeysInMappingOrderedByMapping($config);
-
- return reduce_left($configForExistingKeys, function ($value, string $key, $c, PathCollection $collection) {
- $path = self::SIMPLIFIED_CONFIG_MAPPING[$key];
- if (contains(self::SIMPLIFIED_MERGEABLE_CONFIG, $key)) {
- $value = ArrayUtils::merge($collection->getValueInPath($path), $value);
- }
-
- $collection->setValueInPath($value, $path);
- if (array_key_exists($key, self::SIMPLIFIED_CONFIG_SIDE_EFFECTS)) {
- ['path' => $sideEffectPath, 'value' => $sideEffectValue] = self::SIMPLIFIED_CONFIG_SIDE_EFFECTS[$key];
- $collection->setValueInPath($sideEffectValue, $sideEffectPath);
- }
-
- return $collection;
- }, new PathCollection($config))->toArray();
- }
-
- private function getConfigForKeysInMappingOrderedByMapping(array $config): array
- {
- // Ignore any config which is not defined in the mapping
- $configForExistingKeys = array_intersect_key($config, self::SIMPLIFIED_CONFIG_MAPPING);
-
- // Order the config by their key, based on the order it was defined in the mapping.
- // This mainly allows deprecating keys and defining new ones that will replace the older and always take
- // preference, while the old one keeps working for backwards compatibility if the new one is not provided.
- $simplifiedConfigOrder = array_flip(array_keys(self::SIMPLIFIED_CONFIG_MAPPING));
- uksort(
- $configForExistingKeys,
- fn (string $a, string $b): int => $simplifiedConfigOrder[$a] - $simplifiedConfigOrder[$b],
- );
-
- return $configForExistingKeys;
- }
-}
diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php
index 98919b35..e6f3bd0d 100644
--- a/module/Core/src/Exception/DeleteShortUrlException.php
+++ b/module/Core/src/Exception/DeleteShortUrlException.php
@@ -16,7 +16,7 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE
use CommonProblemDetailsExceptionTrait;
private const TITLE = 'Cannot delete short URL';
- private const TYPE = 'INVALID_SHORTCODE_DELETION'; // FIXME Deprecated: Should be INVALID_SHORT_URL_DELETION
+ private const TYPE = 'INVALID_SHORT_URL_DELETION';
public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self
{
diff --git a/module/Core/src/Exception/ValidationException.php b/module/Core/src/Exception/ValidationException.php
index 3a211592..326eec11 100644
--- a/module/Core/src/Exception/ValidationException.php
+++ b/module/Core/src/Exception/ValidationException.php
@@ -42,6 +42,9 @@ class ValidationException extends InvalidArgumentException implements ProblemDet
$e->invalidElements = $invalidData;
$e->additional = ['invalidElements' => array_keys($invalidData)];
+ // TODO Expose reasons for the validation to fail
+ // $e->additional = ['invalidElements' => array_keys($invalidData), 'reasons' => $invalidData];
+
return $e;
}
diff --git a/module/Core/src/Importer/ImportedLinksProcessor.php b/module/Core/src/Importer/ImportedLinksProcessor.php
index cddfbb88..fe4f24df 100644
--- a/module/Core/src/Importer/ImportedLinksProcessor.php
+++ b/module/Core/src/Importer/ImportedLinksProcessor.php
@@ -8,7 +8,7 @@ use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
-use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
+use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\ImportedLinksProcessorInterface;
@@ -25,7 +25,7 @@ class ImportedLinksProcessor implements ImportedLinksProcessorInterface
public function __construct(
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
- private ShortCodeHelperInterface $shortCodeHelper,
+ private ShortCodeUniquenessHelperInterface $shortCodeHelper,
private DoctrineBatchHelperInterface $batchHelper,
) {
$this->shortUrlRepo = $this->em->getRepository(ShortUrl::class);
diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php
new file mode 100644
index 00000000..ae107fdc
--- /dev/null
+++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php
@@ -0,0 +1,41 @@
+page = $this->determinePage($page);
+ $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
+ }
+
+ private function determinePage(?int $page): int
+ {
+ return $page === null || $page <= 0 ? self::FIRST_PAGE : $page;
+ }
+
+ private function determineItemsPerPage(?int $itemsPerPage): int
+ {
+ return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage;
+ }
+
+ public function getPage(): int
+ {
+ return $this->page;
+ }
+
+ public function getItemsPerPage(): int
+ {
+ return $this->itemsPerPage;
+ }
+}
diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php
new file mode 100644
index 00000000..bd648227
--- /dev/null
+++ b/module/Core/src/Model/Ordering.php
@@ -0,0 +1,43 @@
+field;
+ }
+
+ public function orderDirection(): string
+ {
+ return $this->dir;
+ }
+
+ public function hasOrderField(): bool
+ {
+ return $this->field !== null;
+ }
+}
diff --git a/module/Core/src/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php
deleted file mode 100644
index 4184fcc6..00000000
--- a/module/Core/src/Model/ShortUrlsOrdering.php
+++ /dev/null
@@ -1,76 +0,0 @@
-validateAndInit($query);
-
- return $instance;
- }
-
- /**
- * @throws ValidationException
- */
- private function validateAndInit(array $data): void
- {
- $orderBy = $data[self::ORDER_BY] ?? null;
- if ($orderBy === null) {
- return;
- }
-
- // FIXME Providing the ordering as array is considered deprecated. To be removed in v3.0.0
- $isArray = is_array($orderBy);
- if (! $isArray && ! is_string($orderBy)) {
- throw ValidationException::fromArray([
- 'orderBy' => '"Order by" must be an array, string or null',
- ]);
- }
-
- if (! $isArray) {
- [$field, $dir] = array_pad(explode('-', $orderBy), 2, null);
- $this->orderField = $field;
- $this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION;
- } else {
- $this->orderField = key($orderBy);
- $this->orderDirection = $orderBy[$this->orderField];
- }
- }
-
- public function orderField(): ?string
- {
- return $this->orderField;
- }
-
- public function orderDirection(): string
- {
- return $this->orderDirection;
- }
-
- public function hasOrderField(): bool
- {
- return $this->orderField !== null;
- }
-}
diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php
index b3761ea8..9abfd10f 100644
--- a/module/Core/src/Model/ShortUrlsParams.php
+++ b/module/Core/src/Model/ShortUrlsParams.php
@@ -13,13 +13,18 @@ use function Shlinkio\Shlink\Core\parseDateField;
final class ShortUrlsParams
{
+ public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits'];
public const DEFAULT_ITEMS_PER_PAGE = 10;
+ public const TAGS_MODE_ANY = 'any';
+ public const TAGS_MODE_ALL = 'all';
private int $page;
private int $itemsPerPage;
private ?string $searchTerm;
private array $tags;
- private ShortUrlsOrdering $orderBy;
+ /** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */
+ private string $tagsMode = self::TAGS_MODE_ANY;
+ private Ordering $orderBy;
private ?DateRange $dateRange;
private function __construct()
@@ -59,10 +64,11 @@ final class ShortUrlsParams
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::START_DATE)),
parseDateField($inputFilter->getValue(ShortUrlsParamsInputFilter::END_DATE)),
);
- $this->orderBy = ShortUrlsOrdering::fromRawData($query);
+ $this->orderBy = Ordering::fromTuple($inputFilter->getValue(ShortUrlsParamsInputFilter::ORDER_BY));
$this->itemsPerPage = (int) (
$inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE
);
+ $this->tagsMode = $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE) ?? self::TAGS_MODE_ANY;
}
public function page(): int
@@ -85,7 +91,7 @@ final class ShortUrlsParams
return $this->tags;
}
- public function orderBy(): ShortUrlsOrdering
+ public function orderBy(): Ordering
{
return $this->orderBy;
}
@@ -94,4 +100,12 @@ final class ShortUrlsParams
{
return $this->dateRange;
}
+
+ /**
+ * @return self::TAGS_MODE_ANY|self::TAGS_MODE_ALL
+ */
+ public function tagsMode(): string
+ {
+ return $this->tagsMode;
+ }
}
diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php
index dd5a656d..718a4bc5 100644
--- a/module/Core/src/Model/VisitsParams.php
+++ b/module/Core/src/Model/VisitsParams.php
@@ -4,49 +4,29 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Model;
-use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use function Shlinkio\Shlink\Core\parseDateRangeFromQuery;
-final class VisitsParams
+final class VisitsParams extends AbstractInfinitePaginableListParams
{
- private const FIRST_PAGE = 1;
-
private DateRange $dateRange;
- private int $page;
- private int $itemsPerPage;
public function __construct(
?DateRange $dateRange = null,
- int $page = self::FIRST_PAGE,
+ ?int $page = null,
?int $itemsPerPage = null,
private bool $excludeBots = false,
) {
+ parent::__construct($page, $itemsPerPage);
$this->dateRange = $dateRange ?? DateRange::emptyInstance();
- $this->page = $this->determinePage($page);
- $this->itemsPerPage = $this->determineItemsPerPage($itemsPerPage);
- }
-
- private function determinePage(int $page): int
- {
- return $page > 0 ? $page : self::FIRST_PAGE;
- }
-
- private function determineItemsPerPage(?int $itemsPerPage): int
- {
- if ($itemsPerPage !== null && $itemsPerPage < 0) {
- return Paginator::ALL_ITEMS;
- }
-
- return $itemsPerPage ?? Paginator::ALL_ITEMS;
}
public static function fromRawData(array $query): self
{
return new self(
parseDateRangeFromQuery($query, 'startDate', 'endDate'),
- (int) ($query['page'] ?? self::FIRST_PAGE),
+ isset($query['page']) ? (int) $query['page'] : null,
isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null,
isset($query['excludeBots']),
);
@@ -57,16 +37,6 @@ final class VisitsParams
return $this->dateRange;
}
- public function getPage(): int
- {
- return $this->page;
- }
-
- public function getItemsPerPage(): int
- {
- return $this->itemsPerPage;
- }
-
public function excludeBots(): bool
{
return $this->excludeBots;
diff --git a/module/Core/src/Options/AppOptions.php b/module/Core/src/Options/AppOptions.php
index 8fde2663..e81f9fdb 100644
--- a/module/Core/src/Options/AppOptions.php
+++ b/module/Core/src/Options/AppOptions.php
@@ -10,8 +10,8 @@ use function sprintf;
class AppOptions extends AbstractOptions
{
- private string $name = '';
- private string $version = '1.0';
+ private string $name = 'Shlink';
+ private string $version = '3.0.0';
public function getName(): string
{
@@ -35,13 +35,6 @@ class AppOptions extends AbstractOptions
return $this;
}
- /** @deprecated */
- protected function setDisableTrackParam(?string $disableTrackParam): self
- {
- // Keep just for backwards compatibility during hydration
- return $this;
- }
-
public function __toString(): string
{
return sprintf('%s:v%s', $this->name, $this->version);
diff --git a/module/Core/src/Options/RedirectOptions.php b/module/Core/src/Options/RedirectOptions.php
new file mode 100644
index 00000000..5479c59b
--- /dev/null
+++ b/module/Core/src/Options/RedirectOptions.php
@@ -0,0 +1,45 @@
+redirectStatusCode;
+ }
+
+ protected function setRedirectStatusCode(int $redirectStatusCode): void
+ {
+ $this->redirectStatusCode = $this->normalizeRedirectStatusCode($redirectStatusCode);
+ }
+
+ private function normalizeRedirectStatusCode(int $statusCode): int
+ {
+ return contains([301, 302], $statusCode) ? $statusCode : DEFAULT_REDIRECT_STATUS_CODE;
+ }
+
+ public function redirectCacheLifetime(): int
+ {
+ return $this->redirectCacheLifetime;
+ }
+
+ protected function setRedirectCacheLifetime(int $redirectCacheLifetime): void
+ {
+ $this->redirectCacheLifetime = $redirectCacheLifetime > 0
+ ? $redirectCacheLifetime
+ : DEFAULT_REDIRECT_CACHE_LIFETIME;
+ }
+}
diff --git a/module/Core/src/Options/TrackingOptions.php b/module/Core/src/Options/TrackingOptions.php
index db74b61b..ba51b8e9 100644
--- a/module/Core/src/Options/TrackingOptions.php
+++ b/module/Core/src/Options/TrackingOptions.php
@@ -8,7 +8,9 @@ use Laminas\Stdlib\AbstractOptions;
use function array_key_exists;
use function explode;
+use function Functional\map;
use function is_array;
+use function trim;
class TrackingOptions extends AbstractOptions
{
@@ -108,10 +110,10 @@ class TrackingOptions extends AbstractOptions
protected function setDisableTrackingFrom(string|array|null $disableTrackingFrom): void
{
- if (is_array($disableTrackingFrom)) {
- $this->disableTrackingFrom = $disableTrackingFrom;
- } else {
- $this->disableTrackingFrom = $disableTrackingFrom === null ? [] : explode(',', $disableTrackingFrom);
- }
+ $this->disableTrackingFrom = match (true) {
+ is_array($disableTrackingFrom) => $disableTrackingFrom,
+ $disableTrackingFrom === null => [],
+ default => map(explode(',', $disableTrackingFrom), static fn (string $value) => trim($value)),
+ };
}
}
diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php
index f760220e..775254ce 100644
--- a/module/Core/src/Options/UrlShortenerOptions.php
+++ b/module/Core/src/Options/UrlShortenerOptions.php
@@ -6,18 +6,11 @@ namespace Shlinkio\Shlink\Core\Options;
use Laminas\Stdlib\AbstractOptions;
-use function Functional\contains;
-
-use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME;
-use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE;
-
class UrlShortenerOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore
private bool $validateUrl = true;
- private int $redirectStatusCode = DEFAULT_REDIRECT_STATUS_CODE;
- private int $redirectCacheLifetime = DEFAULT_REDIRECT_CACHE_LIFETIME;
private bool $autoResolveTitles = false;
private bool $appendExtraPath = false;
@@ -31,33 +24,6 @@ class UrlShortenerOptions extends AbstractOptions
$this->validateUrl = $validateUrl;
}
- public function redirectStatusCode(): int
- {
- return $this->redirectStatusCode;
- }
-
- protected function setRedirectStatusCode(int $redirectStatusCode): void
- {
- $this->redirectStatusCode = $this->normalizeRedirectStatusCode($redirectStatusCode);
- }
-
- private function normalizeRedirectStatusCode(int $statusCode): int
- {
- return contains([301, 302], $statusCode) ? $statusCode : DEFAULT_REDIRECT_STATUS_CODE;
- }
-
- public function redirectCacheLifetime(): int
- {
- return $this->redirectCacheLifetime;
- }
-
- protected function setRedirectCacheLifetime(int $redirectCacheLifetime): void
- {
- $this->redirectCacheLifetime = $redirectCacheLifetime > 0
- ? $redirectCacheLifetime
- : DEFAULT_REDIRECT_CACHE_LIFETIME;
- }
-
public function autoResolveTitles(): bool
{
return $this->autoResolveTitles;
@@ -77,16 +43,4 @@ class UrlShortenerOptions extends AbstractOptions
{
$this->appendExtraPath = $appendExtraPath;
}
-
- /** @deprecated */
- protected function setAnonymizeRemoteAddr(bool $anonymizeRemoteAddr): void
- {
- // Keep just for backwards compatibility during hydration
- }
-
- /** @deprecated */
- protected function setTrackOrphanVisits(bool $trackOrphanVisits): void
- {
- // Keep just for backwards compatibility during hydration
- }
}
diff --git a/module/Core/src/Options/WebhookOptions.php b/module/Core/src/Options/WebhookOptions.php
index c86789b2..6eb07692 100644
--- a/module/Core/src/Options/WebhookOptions.php
+++ b/module/Core/src/Options/WebhookOptions.php
@@ -10,22 +10,22 @@ class WebhookOptions extends AbstractOptions
{
protected $__strictMode__ = false; // phpcs:ignore
- private array $visitsWebhooks = [];
+ private array $webhooks = [];
private bool $notifyOrphanVisitsToWebhooks = false;
public function webhooks(): array
{
- return $this->visitsWebhooks;
+ return $this->webhooks;
}
public function hasWebhooks(): bool
{
- return ! empty($this->visitsWebhooks);
+ return ! empty($this->webhooks);
}
- protected function setVisitsWebhooks(array $visitsWebhooks): void
+ protected function setWebhooks(array $webhooks): void
{
- $this->visitsWebhooks = $visitsWebhooks;
+ $this->webhooks = $webhooks;
}
public function notifyOrphanVisits(): bool
diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php
index 6e4877f6..9852c530 100644
--- a/module/Core/src/Repository/ShortUrlRepository.php
+++ b/module/Core/src/Repository/ShortUrlRepository.php
@@ -5,16 +5,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\DBAL\LockMode;
+use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
use Doctrine\ORM\Query\Expr\Join;
use Doctrine\ORM\QueryBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType;
-use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
+use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
-use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
+use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use function array_column;
@@ -24,41 +27,32 @@ use function Functional\contains;
class ShortUrlRepository extends EntitySpecificationRepository implements ShortUrlRepositoryInterface
{
/**
- * @param string[] $tags
* @return ShortUrl[]
*/
- public function findList(
- ?int $limit = null,
- ?int $offset = null,
- ?string $searchTerm = null,
- array $tags = [],
- ?ShortUrlsOrdering $orderBy = null,
- ?DateRange $dateRange = null,
- ?Specification $spec = null,
- ): array {
- $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
+ public function findList(ShortUrlsListFiltering $filtering): array
+ {
+ $qb = $this->createListQueryBuilder($filtering);
$qb->select('DISTINCT s')
- ->setMaxResults($limit)
- ->setFirstResult($offset);
+ ->setMaxResults($filtering->limit())
+ ->setFirstResult($filtering->offset());
// In case the ordering has been specified, the query could be more complex. Process it
- if ($orderBy?->hasOrderField()) {
- return $this->processOrderByForList($qb, $orderBy);
+ if ($filtering->orderBy()->hasOrderField()) {
+ return $this->processOrderByForList($qb, $filtering->orderBy());
}
- // With no order by, order by date and just return the list of ShortUrls
- $qb->orderBy('s.dateCreated');
- return $qb->getQuery()->getResult();
+ // With no explicit order by, fallback to dateCreated-DESC
+ return $qb->orderBy('s.dateCreated', 'DESC')->getQuery()->getResult();
}
- private function processOrderByForList(QueryBuilder $qb, ShortUrlsOrdering $orderBy): array
+ private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array
{
$fieldName = $orderBy->orderField();
$order = $orderBy->orderDirection();
- // visitsCount and visitCount are deprecated. Only visits should work
- if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) {
- // FIXME This query is inefficient. Debug it.
+ if ($fieldName === 'visits') {
+ // FIXME This query is inefficient.
+ // Diagnostic: It might need to use a sub-query, as done with the tags list query.
$qb->addSelect('COUNT(DISTINCT v) AS totalVisits')
->leftJoin('s.visits', 'v')
->groupBy('s')
@@ -67,44 +61,29 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return array_column($qb->getQuery()->getResult(), 0);
}
- // Map public field names to column names
- $fieldNameMap = [
- 'originalUrl' => 'longUrl', // Deprecated
- 'longUrl' => 'longUrl',
- 'shortCode' => 'shortCode',
- 'dateCreated' => 'dateCreated',
- 'title' => 'title',
- ];
- $resolvedFieldName = $fieldNameMap[$fieldName] ?? null;
- if ($resolvedFieldName !== null) {
- $qb->orderBy('s.' . $resolvedFieldName, $order);
+ $orderableFields = ['longUrl', 'shortCode', 'dateCreated', 'title'];
+ if (contains($orderableFields, $fieldName)) {
+ $qb->orderBy('s.' . $fieldName, $order);
}
return $qb->getQuery()->getResult();
}
- public function countList(
- ?string $searchTerm = null,
- array $tags = [],
- ?DateRange $dateRange = null,
- ?Specification $spec = null,
- ): int {
- $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec);
+ public function countList(ShortUrlsCountFiltering $filtering): int
+ {
+ $qb = $this->createListQueryBuilder($filtering);
$qb->select('COUNT(DISTINCT s)');
return (int) $qb->getQuery()->getSingleScalarResult();
}
- private function createListQueryBuilder(
- ?string $searchTerm,
- array $tags,
- ?DateRange $dateRange,
- ?Specification $spec,
- ): QueryBuilder {
+ private function createListQueryBuilder(ShortUrlsCountFiltering $filtering): QueryBuilder
+ {
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(ShortUrl::class, 's')
->where('1=1');
+ $dateRange = $filtering->dateRange();
if ($dateRange?->startDate() !== null) {
$qb->andWhere($qb->expr()->gte('s.dateCreated', ':startDate'));
$qb->setParameter('startDate', $dateRange->startDate(), ChronosDateTimeType::CHRONOS_DATETIME);
@@ -114,6 +93,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
$qb->setParameter('endDate', $dateRange->endDate(), ChronosDateTimeType::CHRONOS_DATETIME);
}
+ $searchTerm = $filtering->searchTerm();
+ $tags = $filtering->tags();
// Apply search term to every searchable field if not empty
if (! empty($searchTerm)) {
// Left join with tags only if no tags were provided. In case of tags, an inner join will be done later
@@ -135,21 +116,23 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
// Filter by tags if provided
if (! empty($tags)) {
- $qb->join('s.tags', 't')
- ->andWhere($qb->expr()->in('t.name', $tags));
+ $tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY;
+ $tagsMode === ShortUrlsParams::TAGS_MODE_ANY
+ ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags))
+ : $this->joinAllTags($qb, $tags);
}
- $this->applySpecification($qb, $spec, 's');
+ $this->applySpecification($qb, $filtering->apiKey()?->spec(), 's');
return $qb;
}
- public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl
+ public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl
{
// When ordering DESC, Postgres puts nulls at the beginning while the rest of supported DB engines put them at
// the bottom
- $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform()->getName();
- $ordering = $dbPlatform === 'postgresql' ? 'ASC' : 'DESC';
+ $dbPlatform = $this->getEntityManager()->getConnection()->getDatabasePlatform();
+ $ordering = $dbPlatform instanceof PostgreSQLPlatform ? 'ASC' : 'DESC';
$dql = <<getEntityManager()->createQuery($dql);
$query->setMaxResults(1)
->setParameters([
- 'shortCode' => $shortCode,
- 'domain' => $domain,
+ 'shortCode' => $identifier->shortCode(),
+ 'domain' => $identifier->domain(),
]);
// Since we ordered by domain, we will have first the URL matching provided domain, followed by the one
@@ -269,11 +252,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return $qb->getQuery()->getOneOrNullResult();
}
- foreach ($tags as $index => $tag) {
- $alias = 't_' . $index;
- $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
- ->setParameter('tag' . $index, $tag);
- }
+ $this->joinAllTags($qb, $tags);
// If tags where provided, we need an extra join to see the amount of tags that every short URL has, so that we
// can discard those that also have more tags, making sure only those fully matching are included.
@@ -285,6 +264,15 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU
return $qb->getQuery()->getOneOrNullResult();
}
+ private function joinAllTags(QueryBuilder $qb, array $tags): void
+ {
+ foreach ($tags as $index => $tag) {
+ $alias = 't_' . $index;
+ $qb->join('s.tags', $alias, Join::WITH, $alias . '.name = :tag' . $index)
+ ->setParameter('tag' . $index, $tag);
+ }
+ }
+
public function findOneByImportedUrl(ImportedShlinkUrl $url): ?ShortUrl
{
$qb = $this->createQueryBuilder('s');
diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php
index e2927286..cfc36e0e 100644
--- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php
+++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php
@@ -7,33 +7,20 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Happyr\DoctrineSpecification\Specification\Specification;
-use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
-use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
- public function findList(
- ?int $limit = null,
- ?int $offset = null,
- ?string $searchTerm = null,
- array $tags = [],
- ?ShortUrlsOrdering $orderBy = null,
- ?DateRange $dateRange = null,
- ?Specification $spec = null,
- ): array;
+ public function findList(ShortUrlsListFiltering $filtering): array;
- public function countList(
- ?string $searchTerm = null,
- array $tags = [],
- ?DateRange $dateRange = null,
- ?Specification $spec = null,
- ): int;
+ public function countList(ShortUrlsCountFiltering $filtering): int;
- public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl;
+ public function findOneWithDomainFallback(ShortUrlIdentifier $identifier): ?ShortUrl;
public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl;
diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php
index d21122d0..aa24e0a1 100644
--- a/module/Core/src/Repository/TagRepository.php
+++ b/module/Core/src/Repository/TagRepository.php
@@ -4,16 +4,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Repository;
+use Doctrine\ORM\Query\ResultSetMappingBuilder;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository;
use Happyr\DoctrineSpecification\Spec;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
+use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Core\Tag\Spec\CountTagsWithName;
+use Shlinkio\Shlink\Rest\ApiKey\Role;
use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
+use Shlinkio\Shlink\Rest\ApiKey\Spec\WithInlinedApiKeySpecsEnsuringJoin;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
+use function Functional\contains;
use function Functional\map;
+use const PHP_INT_MAX;
+
class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface
{
public function deleteByName(array $names): int
@@ -32,24 +39,79 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito
/**
* @return TagInfo[]
*/
- public function findTagsWithInfo(?ApiKey $apiKey = null): array
+ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array
{
- $qb = $this->createQueryBuilder('t');
- $qb->select('t AS tag', 'COUNT(DISTINCT s.id) AS shortUrlsCount', 'COUNT(DISTINCT v.id) AS visitsCount')
- ->leftJoin('t.shortUrls', 's')
- ->leftJoin('s.visits', 'v')
- ->groupBy('t')
- ->orderBy('t.name', 'ASC');
+ $orderField = $filtering?->orderBy()?->orderField();
+ $orderDir = $filtering?->orderBy()?->orderDirection();
+ $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField);
- if ($apiKey !== null) {
- $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't');
+ $conn = $this->getEntityManager()->getConnection();
+ $subQb = $this->createQueryBuilder('t');
+ $subQb->select('t.id', 't.name');
+
+ if (! $orderMainQuery) {
+ $subQb->orderBy('t.name', $orderDir ?? 'ASC')
+ ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
+ ->setFirstResult($filtering?->offset() ?? 0);
}
- $query = $qb->getQuery();
+ $searchTerm = $filtering?->searchTerm();
+ if ($searchTerm !== null) {
+ $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%')));
+ }
+
+ $apiKey = $filtering?->apiKey();
+ $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't');
+
+ // A native query builder needs to be used here, because DQL and ORM query builders do not support
+ // sub-queries at "from" and "join" level.
+ // If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient.
+ $nativeQb = $conn->createQueryBuilder();
+ $nativeQb
+ ->select(
+ 't.id_0 AS id',
+ 't.name_1 AS name',
+ 'COUNT(DISTINCT s.id) AS short_urls_count',
+ 'COUNT(DISTINCT v.id) AS visits_count',
+ )
+ ->from('(' . $subQb->getQuery()->getSQL() . ')', 't')
+ ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id'))
+ ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id'))
+ ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id'))
+ ->groupBy('t.id_0', 't.name_1');
+
+ // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates
+ $apiKey?->mapRoles(static fn (string $roleName, array $meta) => match ($roleName) {
+ Role::DOMAIN_SPECIFIC => $nativeQb->andWhere(
+ $nativeQb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))),
+ ),
+ Role::AUTHORED_SHORT_URLS => $nativeQb->andWhere(
+ $nativeQb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())),
+ ),
+ default => $nativeQb,
+ });
+
+ if ($orderMainQuery) {
+ $nativeQb
+ ->orderBy(
+ $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count',
+ $orderDir ?? 'ASC',
+ )
+ ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX)
+ ->setFirstResult($filtering?->offset() ?? 0);
+ }
+
+ // Add ordering by tag name, as a fallback in case of same amount, or as default ordering
+ $nativeQb->addOrderBy('t.name_1', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir);
+
+ $rsm = new ResultSetMappingBuilder($this->getEntityManager());
+ $rsm->addScalarResult('name', 'tag');
+ $rsm->addScalarResult('short_urls_count', 'shortUrlsCount');
+ $rsm->addScalarResult('visits_count', 'visitsCount');
return map(
- $query->getResult(),
- fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
+ $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(),
+ static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']),
);
}
diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php
index 924706ff..9cbea269 100644
--- a/module/Core/src/Repository/TagRepositoryInterface.php
+++ b/module/Core/src/Repository/TagRepositoryInterface.php
@@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\Repository;
use Doctrine\Persistence\ObjectRepository;
use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
+use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
@@ -16,7 +17,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe
/**
* @return TagInfo[]
*/
- public function findTagsWithInfo(?ApiKey $apiKey = null): array;
+ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array;
public function tagExists(string $tag, ?ApiKey $apiKey = null): bool;
}
diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php
index 5c39c21e..b43d676d 100644
--- a/module/Core/src/Repository/VisitRepository.php
+++ b/module/Core/src/Repository/VisitRepository.php
@@ -14,9 +14,8 @@ use Shlinkio\Shlink\Core\Entity\VisitLocation;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
+use Shlinkio\Shlink\Core\Visit\Spec\CountOfNonOrphanVisits;
use Shlinkio\Shlink\Core\Visit\Spec\CountOfOrphanVisits;
-use Shlinkio\Shlink\Core\Visit\Spec\CountOfShortUrlVisits;
-use Shlinkio\Shlink\Rest\Entity\ApiKey;
use const PHP_INT_MAX;
@@ -53,10 +52,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
public function findAllVisits(int $blockSize = self::DEFAULT_BLOCK_SIZE): iterable
{
- $qb = $this->getEntityManager()->createQueryBuilder();
- $qb->select('v')
- ->from(Visit::class, 'v');
-
+ $qb = $this->createQueryBuilder('v');
return $this->visitsIterableForQuery($qb, $blockSize);
}
@@ -107,11 +103,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
): QueryBuilder {
/** @var ShortUrlRepositoryInterface $shortUrlRepo */
$shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class);
- $shortUrl = $shortUrlRepo->findOne($identifier, $filtering->spec());
- $shortUrlId = $shortUrl?->getId() ?? '-1';
+ $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->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
+ // Parameters in this query need to be part of the query itself, as we need to use it as sub-query later
+ // Since they are not provided by the caller, it's reasonably safe
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->where($qb->expr()->eq('v.shortUrl', $shortUrlId));
@@ -142,38 +137,27 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
private function createVisitsByTagQueryBuilder(string $tag, VisitsCountFiltering $filtering): QueryBuilder
{
- // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
- // Since they are not strictly provided by the caller, it's reasonably safe
+ // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later.
$qb = $this->getEntityManager()->createQueryBuilder();
$qb->from(Visit::class, 'v')
->join('v.shortUrl', 's')
->join('s.tags', 't')
- ->where($qb->expr()->eq('t.name', '\'' . $tag . '\'')); // This needs to be concatenated, not bound
+ ->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag)));
if ($filtering->excludeBots()) {
$qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
}
$this->applyDatesInline($qb, $filtering->dateRange());
- $this->applySpecification($qb, $filtering->spec(), 'v');
+ $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v');
return $qb;
}
public function findOrphanVisits(VisitsListFiltering $filtering): array
{
- // Parameters in this query need to be inlined, not bound, as we need to use it as 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')
- ->where($qb->expr()->isNull('v.shortUrl'));
-
- if ($filtering->excludeBots()) {
- $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
- }
-
- $this->applyDatesInline($qb, $filtering->dateRange());
-
+ $qb = $this->createAllVisitsQueryBuilder($filtering);
+ $qb->andWhere($qb->expr()->isNull('v.shortUrl'));
return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
}
@@ -182,18 +166,49 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering));
}
- public function countVisits(?ApiKey $apiKey = null): int
+ /**
+ * @return Visit[]
+ */
+ public function findNonOrphanVisits(VisitsListFiltering $filtering): array
{
- return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey));
+ $qb = $this->createAllVisitsQueryBuilder($filtering);
+ $qb->andWhere($qb->expr()->isNotNull('v.shortUrl'));
+
+ $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec());
+
+ return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset());
+ }
+
+ public function countNonOrphanVisits(VisitsCountFiltering $filtering): int
+ {
+ return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering));
+ }
+
+ private function createAllVisitsQueryBuilder(VisitsListFiltering $filtering): QueryBuilder
+ {
+ // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
+ // Since they are not provided by the caller, it's reasonably safe
+ $qb = $this->getEntityManager()->createQueryBuilder();
+ $qb->from(Visit::class, 'v');
+
+ if ($filtering->excludeBots()) {
+ $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false'));
+ }
+
+ $this->applyDatesInline($qb, $filtering->dateRange());
+
+ return $qb;
}
private function applyDatesInline(QueryBuilder $qb, ?DateRange $dateRange): void
{
+ $conn = $this->getEntityManager()->getConnection();
+
if ($dateRange?->startDate() !== null) {
- $qb->andWhere($qb->expr()->gte('v.date', '\'' . $dateRange->startDate()->toDateTimeString() . '\''));
+ $qb->andWhere($qb->expr()->gte('v.date', $conn->quote($dateRange->startDate()->toDateTimeString())));
}
if ($dateRange?->endDate() !== null) {
- $qb->andWhere($qb->expr()->lte('v.date', '\'' . $dateRange->endDate()->toDateTimeString() . '\''));
+ $qb->andWhere($qb->expr()->lte('v.date', $conn->quote($dateRange->endDate()->toDateTimeString())));
}
}
@@ -204,13 +219,13 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo
$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
+ // Falling back to values that will behave as no limit/offset, but will work around 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
+ // A native query builder needs to be used here, because DQL and ORM query builders do not support
// 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();
diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php
index 28f1e9a8..3d480c01 100644
--- a/module/Core/src/Repository/VisitRepositoryInterface.php
+++ b/module/Core/src/Repository/VisitRepositoryInterface.php
@@ -10,8 +10,8 @@ use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
-use Shlinkio\Shlink\Rest\Entity\ApiKey;
+// TODO Split into VisitsListsRepository and VisitsLocationRepository
interface VisitRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface
{
public const DEFAULT_BLOCK_SIZE = 10000;
@@ -52,5 +52,10 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification
public function countOrphanVisits(VisitsCountFiltering $filtering): int;
- public function countVisits(?ApiKey $apiKey = null): int;
+ /**
+ * @return Visit[]
+ */
+ public function findNonOrphanVisits(VisitsListFiltering $filtering): array;
+
+ public function countNonOrphanVisits(VisitsCountFiltering $filtering): int;
}
diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php b/module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelper.php
similarity index 90%
rename from module/Core/src/Service/ShortUrl/ShortCodeHelper.php
rename to module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelper.php
index 5bb992c5..461a14b6 100644
--- a/module/Core/src/Service/ShortUrl/ShortCodeHelper.php
+++ b/module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelper.php
@@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
-class ShortCodeHelper implements ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelper
+class ShortCodeUniquenessHelper implements ShortCodeUniquenessHelperInterface
{
public function __construct(private EntityManagerInterface $em)
{
diff --git a/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php b/module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelperInterface.php
similarity index 72%
rename from module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php
rename to module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelperInterface.php
index a020a30c..975a2b8b 100644
--- a/module/Core/src/Service/ShortUrl/ShortCodeHelperInterface.php
+++ b/module/Core/src/Service/ShortUrl/ShortCodeUniquenessHelperInterface.php
@@ -6,7 +6,7 @@ namespace Shlinkio\Shlink\Core\Service\ShortUrl;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
-interface ShortCodeHelperInterface // TODO Rename to ShortCodeUniquenessHelperInterface
+interface ShortCodeUniquenessHelperInterface
{
public function ensureShortCodeUniqueness(ShortUrl $shortUrlToBeCreated, bool $hasCustomSlug): bool;
}
diff --git a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php
index 61c57d36..3abd90c8 100644
--- a/module/Core/src/Service/ShortUrl/ShortUrlResolver.php
+++ b/module/Core/src/Service/ShortUrl/ShortUrlResolver.php
@@ -39,7 +39,7 @@ class ShortUrlResolver implements ShortUrlResolverInterface
{
/** @var ShortUrlRepository $shortUrlRepo */
$shortUrlRepo = $this->em->getRepository(ShortUrl::class);
- $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier->shortCode(), $identifier->domain());
+ $shortUrl = $shortUrlRepo->findOneWithDomainFallback($identifier);
if (! $shortUrl?->isEnabled()) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
diff --git a/module/Core/src/Service/ShortUrlService.php b/module/Core/src/Service/ShortUrlService.php
index f1e3bf32..c0b69ee5 100644
--- a/module/Core/src/Service/ShortUrlService.php
+++ b/module/Core/src/Service/ShortUrlService.php
@@ -12,10 +12,10 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlEdit;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
-use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
use Shlinkio\Shlink\Core\Service\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php
index 41779715..8fa54493 100644
--- a/module/Core/src/Service/UrlShortener.php
+++ b/module/Core/src/Service/UrlShortener.php
@@ -10,7 +10,7 @@ use Shlinkio\Shlink\Core\Exception\InvalidUrlException;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
-use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
+use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
@@ -20,7 +20,7 @@ class UrlShortener implements UrlShortenerInterface
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private EntityManagerInterface $em,
private ShortUrlRelationResolverInterface $relationResolver,
- private ShortCodeHelperInterface $shortCodeHelper,
+ private ShortCodeUniquenessHelperInterface $shortCodeHelper,
) {
}
diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php
index 1ec36677..3cc98786 100644
--- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php
+++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php
@@ -17,19 +17,17 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface
public function stringify(ShortUrl $shortUrl): string
{
- return (new Uri())->withPath($shortUrl->getShortCode())
- ->withScheme($this->domainConfig['schema'] ?? 'http')
- ->withHost($this->resolveDomain($shortUrl))
- ->__toString();
+ $uriWithoutShortCode = (new Uri())->withScheme($this->domainConfig['schema'] ?? 'http')
+ ->withHost($this->resolveDomain($shortUrl))
+ ->withPath($this->basePath)
+ ->__toString();
+
+ // The short code needs to be appended to avoid it from being URL-encoded
+ return sprintf('%s/%s', $uriWithoutShortCode, $shortUrl->getShortCode());
}
private function resolveDomain(ShortUrl $shortUrl): string
{
- $domain = $shortUrl->getDomain();
- if ($domain === null) {
- return $this->domainConfig['hostname'] ?? '';
- }
-
- return sprintf('%s%s', $domain->getAuthority(), $this->basePath);
+ return $shortUrl->getDomain()?->getAuthority() ?? $this->domainConfig['hostname'] ?? '';
}
}
diff --git a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php
similarity index 51%
rename from module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
rename to module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php
index 93b69d33..cc2fcd4d 100644
--- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php
+++ b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php
@@ -2,11 +2,13 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Core\Paginator\Adapter;
+namespace Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter;
use Pagerfanta\Adapter\AdapterInterface;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapter implements AdapterInterface
@@ -18,26 +20,15 @@ class ShortUrlRepositoryAdapter implements AdapterInterface
) {
}
- public function getSlice($offset, $length): array // phpcs:ignore
+ public function getSlice(int $offset, int $length): iterable
{
return $this->repository->findList(
- $length,
- $offset,
- $this->params->searchTerm(),
- $this->params->tags(),
- $this->params->orderBy(),
- $this->params->dateRange(),
- $this->apiKey?->spec(),
+ ShortUrlsListFiltering::fromLimitsAndParams($length, $offset, $this->params, $this->apiKey),
);
}
public function getNbResults(): int
{
- return $this->repository->countList(
- $this->params->searchTerm(),
- $this->params->tags(),
- $this->params->dateRange(),
- $this->apiKey?->spec(),
- );
+ return $this->repository->countList(ShortUrlsCountFiltering::fromParams($this->params, $this->apiKey));
}
}
diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php
new file mode 100644
index 00000000..9577f80c
--- /dev/null
+++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php
@@ -0,0 +1,51 @@
+searchTerm(), $params->tags(), $params->tagsMode(), $params->dateRange(), $apiKey);
+ }
+
+ public function searchTerm(): ?string
+ {
+ return $this->searchTerm;
+ }
+
+ public function tags(): array
+ {
+ return $this->tags;
+ }
+
+ public function tagsMode(): ?string
+ {
+ return $this->tagsMode;
+ }
+
+ public function dateRange(): ?DateRange
+ {
+ return $this->dateRange;
+ }
+
+ public function apiKey(): ?ApiKey
+ {
+ return $this->apiKey;
+ }
+}
diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php
new file mode 100644
index 00000000..089915e3
--- /dev/null
+++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php
@@ -0,0 +1,55 @@
+orderBy(),
+ $params->searchTerm(),
+ $params->tags(),
+ $params->tagsMode(),
+ $params->dateRange(),
+ $apiKey,
+ );
+ }
+
+ public function offset(): ?int
+ {
+ return $this->offset;
+ }
+
+ public function limit(): ?int
+ {
+ return $this->limit;
+ }
+
+ public function orderBy(): Ordering
+ {
+ return $this->orderBy;
+ }
+}
diff --git a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
index 809d19b7..01440bb3 100644
--- a/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
+++ b/module/Core/src/ShortUrl/Spec/BelongsToApiKeyInlined.php
@@ -17,6 +17,7 @@ class BelongsToApiKeyInlined implements Filter
public function getFilter(QueryBuilder $qb, string $dqlAlias): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
- return $qb->expr()->eq('s.authorApiKey', '\'' . $this->apiKey->getId() . '\'')->__toString();
+ $conn = $qb->getEntityManager()->getConnection();
+ return $qb->expr()->eq('s.authorApiKey', $conn->quote($this->apiKey->getId()))->__toString();
}
}
diff --git a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
index 46fba689..baaed1a6 100644
--- a/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
+++ b/module/Core/src/ShortUrl/Spec/BelongsToDomainInlined.php
@@ -16,6 +16,7 @@ class BelongsToDomainInlined implements Filter
public function getFilter(QueryBuilder $qb, string $context): string
{
// Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later
- return $qb->expr()->eq('s.domain', '\'' . $this->domainId . '\'')->__toString();
+ $conn = $qb->getEntityManager()->getConnection();
+ return $qb->expr()->eq('s.domain', $conn->quote($this->domainId))->__toString();
}
}
diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php
index 1a436cd4..6e917399 100644
--- a/module/Core/src/Tag/Model/TagInfo.php
+++ b/module/Core/src/Tag/Model/TagInfo.php
@@ -5,15 +5,14 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag\Model;
use JsonSerializable;
-use Shlinkio\Shlink\Core\Entity\Tag;
final class TagInfo implements JsonSerializable
{
- public function __construct(private Tag $tag, private int $shortUrlsCount, private int $visitsCount)
+ public function __construct(private string $tag, private int $shortUrlsCount, private int $visitsCount)
{
}
- public function tag(): Tag
+ public function tag(): string
{
return $this->tag;
}
diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php
new file mode 100644
index 00000000..8f078788
--- /dev/null
+++ b/module/Core/src/Tag/Model/TagsListFiltering.php
@@ -0,0 +1,50 @@
+searchTerm(), $params->orderBy(), $apiKey);
+ }
+
+ public function limit(): ?int
+ {
+ return $this->limit;
+ }
+
+ public function offset(): ?int
+ {
+ return $this->offset;
+ }
+
+ public function searchTerm(): ?string
+ {
+ return $this->searchTerm;
+ }
+
+ public function orderBy(): ?Ordering
+ {
+ return $this->orderBy;
+ }
+
+ public function apiKey(): ?ApiKey
+ {
+ return $this->apiKey;
+ }
+}
diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php
new file mode 100644
index 00000000..3f40debe
--- /dev/null
+++ b/module/Core/src/Tag/Model/TagsParams.php
@@ -0,0 +1,49 @@
+searchTerm;
+ }
+
+ public function orderBy(): Ordering
+ {
+ return $this->orderBy;
+ }
+
+ public function withStats(): bool
+ {
+ return $this->withStats;
+ }
+}
diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php
new file mode 100644
index 00000000..ba6bc78d
--- /dev/null
+++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php
@@ -0,0 +1,40 @@
+apiKey),
+ ];
+
+ $searchTerm = $this->params->searchTerm();
+ if ($searchTerm !== null) {
+ $conditions[] = Spec::like('name', $searchTerm);
+ }
+
+ return (int) $this->repo->matchSingleScalarResult(Spec::andX(...$conditions));
+ }
+}
diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php
new file mode 100644
index 00000000..c2917200
--- /dev/null
+++ b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php
@@ -0,0 +1,17 @@
+repo->findTagsWithInfo(
+ TagsListFiltering::fromRangeAndParams($length, $offset, $this->params, $this->apiKey),
+ );
+ }
+}
diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php
new file mode 100644
index 00000000..d6bc0b7b
--- /dev/null
+++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php
@@ -0,0 +1,31 @@
+apiKey),
+ Spec::orderBy(
+ 'name', // Ordering by other fields makes no sense here
+ $this->params->orderBy()->orderDirection(),
+ ),
+ Spec::limit($length),
+ Spec::offset($offset),
+ ];
+
+ $searchTerm = $this->params->searchTerm();
+ if ($searchTerm !== null) {
+ $conditions[] = Spec::like('name', $searchTerm);
+ }
+
+ return $this->repo->match(Spec::andX(...$conditions));
+ }
+}
diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php
index 61ed211d..40eb413f 100644
--- a/module/Core/src/Tag/TagService.php
+++ b/module/Core/src/Tag/TagService.php
@@ -4,9 +4,9 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag;
-use Doctrine\Common\Collections\Collection;
use Doctrine\ORM;
-use Happyr\DoctrineSpecification\Spec;
+use Pagerfanta\Adapter\AdapterInterface;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
@@ -15,41 +15,42 @@ use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
-use Shlinkio\Shlink\Core\Util\TagManagerTrait;
-use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin;
+use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
+use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsInfoPaginatorAdapter;
+use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class TagService implements TagServiceInterface
{
- use TagManagerTrait;
-
public function __construct(private ORM\EntityManagerInterface $em)
{
}
/**
- * @return Tag[]
+ * @return Tag[]|Paginator
*/
- public function listTags(?ApiKey $apiKey = null): array
+ public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var TagRepository $repo */
$repo = $this->em->getRepository(Tag::class);
- /** @var Tag[] $tags */
- $tags = $repo->match(Spec::andX(
- Spec::orderBy('name'),
- new WithApiKeySpecsEnsuringJoin($apiKey),
- ));
- return $tags;
+ return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params);
}
/**
- * @return TagInfo[]
+ * @return TagInfo[]|Paginator
*/
- public function tagsInfo(?ApiKey $apiKey = null): array
+ public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator
{
/** @var TagRepositoryInterface $repo */
$repo = $this->em->getRepository(Tag::class);
- return $repo->findTagsWithInfo($apiKey);
+ return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params);
+ }
+
+ private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator
+ {
+ return (new Paginator($adapter))
+ ->setMaxPerPage($params->getItemsPerPage())
+ ->setCurrentPage($params->getPage());
}
/**
@@ -67,21 +68,6 @@ class TagService implements TagServiceInterface
$repo->deleteByName($tagNames);
}
- /**
- * Provided a list of tag names, creates all that do not exist yet
- *
- * @deprecated
- * @param string[] $tagNames
- * @return Collection|Tag[]
- */
- public function createTags(array $tagNames): Collection
- {
- $tags = $this->tagNamesToEntities($this->em, $tagNames);
- $this->em->flush();
-
- return $tags;
- }
-
/**
* @throws TagNotFoundException
* @throws TagConflictException
diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php
index 34cf1871..284fc341 100644
--- a/module/Core/src/Tag/TagServiceInterface.php
+++ b/module/Core/src/Tag/TagServiceInterface.php
@@ -4,26 +4,27 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Tag;
-use Doctrine\Common\Collections\Collection;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException;
use Shlinkio\Shlink\Core\Exception\TagConflictException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
+use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
interface TagServiceInterface
{
/**
- * @return Tag[]
+ * @return Tag[]|Paginator
*/
- public function listTags(?ApiKey $apiKey = null): array;
+ public function listTags(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
- * @return TagInfo[]
+ * @return TagInfo[]|Paginator
*/
- public function tagsInfo(?ApiKey $apiKey = null): array;
+ public function tagsInfo(TagsParams $params, ?ApiKey $apiKey = null): Paginator;
/**
* @param string[] $tagNames
@@ -31,13 +32,6 @@ interface TagServiceInterface
*/
public function deleteTags(array $tagNames, ?ApiKey $apiKey = null): void;
- /**
- * @deprecated
- * @param string[] $tagNames
- * @return Collection|Tag[]
- */
- public function createTags(array $tagNames): Collection;
-
/**
* @throws TagNotFoundException
* @throws TagConflictException
diff --git a/module/Core/src/Util/CocurSymfonySluggerBridge.php b/module/Core/src/Util/CocurSymfonySluggerBridge.php
deleted file mode 100644
index da60836e..00000000
--- a/module/Core/src/Util/CocurSymfonySluggerBridge.php
+++ /dev/null
@@ -1,22 +0,0 @@
-slugger->slugify($string, $separator));
- }
-}
diff --git a/module/Core/src/Util/RedirectResponseHelper.php b/module/Core/src/Util/RedirectResponseHelper.php
index 5f9edf99..312c2a95 100644
--- a/module/Core/src/Util/RedirectResponseHelper.php
+++ b/module/Core/src/Util/RedirectResponseHelper.php
@@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Util;
use Fig\Http\Message\StatusCodeInterface;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
-use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
+use Shlinkio\Shlink\Core\Options\RedirectOptions;
use function sprintf;
class RedirectResponseHelper implements RedirectResponseHelperInterface
{
- public function __construct(private UrlShortenerOptions $options)
+ public function __construct(private RedirectOptions $options)
{
}
diff --git a/module/Core/src/Util/TagManagerTrait.php b/module/Core/src/Util/TagManagerTrait.php
deleted file mode 100644
index 9fac8700..00000000
--- a/module/Core/src/Util/TagManagerTrait.php
+++ /dev/null
@@ -1,37 +0,0 @@
- $tags,
- ])->getValue(ShortUrlInputFilter::TAGS);
-
- $entities = map($normalizedTags, function (string $tagName) use ($em) {
- $tag = $em->getRepository(Tag::class)->findOneBy(['name' => $tagName]) ?? new Tag($tagName);
- $em->persist($tag);
-
- return $tag;
- });
-
- return new Collections\ArrayCollection($entities);
- }
-}
diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php
index 2497f85d..6cd578fb 100644
--- a/module/Core/src/Validation/ShortUrlInputFilter.php
+++ b/module/Core/src/Validation/ShortUrlInputFilter.php
@@ -4,19 +4,18 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
-use Cocur\Slugify\Slugify;
use DateTime;
use Laminas\Filter;
use Laminas\InputFilter\Input;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator;
use Shlinkio\Shlink\Common\Validation;
-use Shlinkio\Shlink\Core\Util\CocurSymfonySluggerBridge;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
+use function is_string;
+use function str_replace;
use function substr;
-use const Shlinkio\Shlink\CUSTOM_SLUGS_REGEXP;
use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH;
class ShortUrlInputFilter extends InputFilter
@@ -77,11 +76,9 @@ class ShortUrlInputFilter extends InputFilter
// 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
$customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true);
- $customSlug->getFilterChain()->attach(new Validation\SluggerFilter(new CocurSymfonySluggerBridge(new Slugify([
- 'regexp' => CUSTOM_SLUGS_REGEXP,
- 'lowercase' => false, // We want to keep it case-sensitive
- 'rulesets' => ['default'],
- ]))));
+ $customSlug->getFilterChain()->attach(new Filter\Callback(
+ static fn (mixed $value) => is_string($value) ? str_replace([' ', '/'], '-', $value) : $value,
+ ));
$customSlug->getValidatorChain()->attach(new Validator\NotEmpty([
Validator\NotEmpty::STRING,
Validator\NotEmpty::SPACE,
diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php
index c62845d4..6c0443aa 100644
--- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php
+++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php
@@ -5,8 +5,10 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Validation;
use Laminas\InputFilter\InputFilter;
+use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Validation;
+use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
class ShortUrlsParamsInputFilter extends InputFilter
{
@@ -18,6 +20,8 @@ class ShortUrlsParamsInputFilter extends InputFilter
public const START_DATE = 'startDate';
public const END_DATE = 'endDate';
public const ITEMS_PER_PAGE = 'itemsPerPage';
+ public const TAGS_MODE = 'tagsMode';
+ public const ORDER_BY = 'orderBy';
public function __construct(array $data)
{
@@ -36,5 +40,14 @@ class ShortUrlsParamsInputFilter extends InputFilter
$this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS));
$this->add($this->createTagsInput(self::TAGS, false));
+
+ $tagsMode = $this->createInput(self::TAGS_MODE, false);
+ $tagsMode->getValidatorChain()->attach(new InArray([
+ 'haystack' => [ShortUrlsParams::TAGS_MODE_ALL, ShortUrlsParams::TAGS_MODE_ANY],
+ 'strict' => InArray::COMPARE_STRICT,
+ ]));
+ $this->add($tagsMode);
+
+ $this->add($this->createOrderByInput(self::ORDER_BY, ShortUrlsParams::ORDERABLE_FIELDS));
}
}
diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php
new file mode 100644
index 00000000..ba5b6663
--- /dev/null
+++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php
@@ -0,0 +1,42 @@
+repo->countNonOrphanVisits(new VisitsCountFiltering(
+ $this->params->getDateRange(),
+ $this->params->excludeBots(),
+ $this->apiKey,
+ ));
+ }
+
+ public function getSlice(int $offset, int $length): iterable
+ {
+ return $this->repo->findNonOrphanVisits(new VisitsListFiltering(
+ $this->params->getDateRange(),
+ $this->params->excludeBots(),
+ $this->apiKey,
+ $length,
+ $offset,
+ ));
+ }
+}
diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
similarity index 82%
rename from module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
rename to module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
index 18c2c435..8a47c9d7 100644
--- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
+++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php
@@ -2,9 +2,10 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Core\Paginator\Adapter;
+namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Model\VisitsParams;
+use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
@@ -23,7 +24,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
));
}
- public function getSlice($offset, $length): iterable // phpcs:ignore
+ public function getSlice(int $offset, int $length): iterable
{
return $this->repo->findOrphanVisits(new VisitsListFiltering(
$this->params->getDateRange(),
diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php
similarity index 72%
rename from module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php
rename to module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php
index 9ff13e3c..2e47fbf8 100644
--- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php
+++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php
@@ -2,33 +2,34 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Core\Paginator\Adapter;
+namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
-use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
+use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
-class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
+class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
private ShortUrlIdentifier $identifier,
private VisitsParams $params,
- private ?Specification $spec,
+ private ?ApiKey $apiKey,
) {
}
- public function getSlice($offset, $length): array // phpcs:ignore
+ public function getSlice(int $offset, int $length): iterable
{
return $this->visitRepository->findVisitsByShortCode(
$this->identifier,
new VisitsListFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
- $this->spec,
+ $this->apiKey,
$length,
$offset,
),
@@ -42,7 +43,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
new VisitsCountFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
- $this->spec,
+ $this->apiKey,
),
);
}
diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php
similarity index 76%
rename from module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php
rename to module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php
index 20af1598..162b6cba 100644
--- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php
+++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php
@@ -2,15 +2,16 @@
declare(strict_types=1);
-namespace Shlinkio\Shlink\Core\Paginator\Adapter;
+namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter;
use Shlinkio\Shlink\Core\Model\VisitsParams;
+use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
-class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
+class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter
{
public function __construct(
private VisitRepositoryInterface $visitRepository,
@@ -20,14 +21,14 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
) {
}
- public function getSlice($offset, $length): array // phpcs:ignore
+ public function getSlice(int $offset, int $length): iterable
{
return $this->visitRepository->findVisitsByTag(
$this->tag,
new VisitsListFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
- $this->apiKey?->spec(true),
+ $this->apiKey,
$length,
$offset,
),
@@ -41,7 +42,7 @@ class VisitsForTagPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte
new VisitsCountFiltering(
$this->params->getDateRange(),
$this->params->excludeBots(),
- $this->apiKey?->spec(true),
+ $this->apiKey,
),
);
}
diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php
index bf459768..140ec9b9 100644
--- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php
+++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php
@@ -4,18 +4,23 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Persistence;
-use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
class VisitsCountFiltering
{
public function __construct(
private ?DateRange $dateRange = null,
private bool $excludeBots = false,
- private ?Specification $spec = null,
+ private ?ApiKey $apiKey = null,
) {
}
+ public static function withApiKey(?ApiKey $apiKey): self
+ {
+ return new self(null, false, $apiKey);
+ }
+
public function dateRange(): ?DateRange
{
return $this->dateRange;
@@ -26,8 +31,8 @@ class VisitsCountFiltering
return $this->excludeBots;
}
- public function spec(): ?Specification
+ public function apiKey(): ?ApiKey
{
- return $this->spec;
+ return $this->apiKey;
}
}
diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php
index fb715182..b17964a6 100644
--- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php
+++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php
@@ -4,19 +4,19 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\Core\Visit\Persistence;
-use Happyr\DoctrineSpecification\Specification\Specification;
use Shlinkio\Shlink\Common\Util\DateRange;
+use Shlinkio\Shlink\Rest\Entity\ApiKey;
final class VisitsListFiltering extends VisitsCountFiltering
{
public function __construct(
?DateRange $dateRange = null,
bool $excludeBots = false,
- ?Specification $spec = null,
+ ?ApiKey $apiKey = null,
private ?int $limit = null,
private ?int $offset = null,
) {
- parent::__construct($dateRange, $excludeBots, $spec);
+ parent::__construct($dateRange, $excludeBots, $apiKey);
}
public function limit(): ?int
diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php
index 7cefa8a2..dc45e12f 100644
--- a/module/Core/src/Visit/RequestTracker.php
+++ b/module/Core/src/Visit/RequestTracker.php
@@ -83,10 +83,9 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface
$disableTrackingFrom = $this->trackingOptions->disableTrackingFrom();
return some($disableTrackingFrom, function (string $value) use ($ip, $remoteAddrParts): bool {
- $range = match (true) {
- str_contains($value, '*') => $this->parseValueWithWildcards($value, $remoteAddrParts),
- default => Factory::parseRangeString($value),
- };
+ $range = str_contains($value, '*')
+ ? $this->parseValueWithWildcards($value, $remoteAddrParts)
+ : Factory::parseRangeString($value);
return $range !== null && $ip->matches($range);
});
diff --git a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php
new file mode 100644
index 00000000..52be52a8
--- /dev/null
+++ b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php
@@ -0,0 +1,39 @@
+filtering->dateRange()),
+ ];
+
+ if ($this->filtering->excludeBots()) {
+ $conditions[] = Spec::eq('potentialBot', false);
+ }
+
+ $apiKey = $this->filtering->apiKey();
+ if ($apiKey !== null) {
+ $conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl');
+ }
+
+ return Spec::countOf(Spec::andX(...$conditions));
+ }
+}
diff --git a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php b/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php
deleted file mode 100644
index 49d8db93..00000000
--- a/module/Core/src/Visit/Spec/CountOfShortUrlVisits.php
+++ /dev/null
@@ -1,27 +0,0 @@
-apiKey, 'shortUrl'),
- ));
- }
-}
diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php
index 8138d170..914a9c5b 100644
--- a/module/Core/src/Visit/VisitsStatsHelper.php
+++ b/module/Core/src/Visit/VisitsStatsHelper.php
@@ -14,14 +14,15 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\VisitsParams;
-use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
-use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsForTagPaginatorAdapter;
-use Shlinkio\Shlink\Core\Paginator\Adapter\VisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepository;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
use Shlinkio\Shlink\Core\Visit\Model\VisitsStats;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -37,7 +38,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
$visitsRepo = $this->em->getRepository(Visit::class);
return new VisitsStats(
- $visitsRepo->countVisits($apiKey),
+ $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)),
$visitsRepo->countOrphanVisits(new VisitsCountFiltering()),
);
}
@@ -51,18 +52,19 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
VisitsParams $params,
?ApiKey $apiKey = null,
): Paginator {
- $spec = $apiKey?->spec();
-
/** @var ShortUrlRepositoryInterface $repo */
$repo = $this->em->getRepository(ShortUrl::class);
- if (! $repo->shortCodeIsInUse($identifier, $spec)) {
+ if (! $repo->shortCodeIsInUse($identifier, $apiKey?->spec())) {
throw ShortUrlNotFoundException::fromNotFound($identifier);
}
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
- return $this->createPaginator(new VisitsPaginatorAdapter($repo, $identifier, $params, $spec), $params);
+ return $this->createPaginator(
+ new ShortUrlVisitsPaginatorAdapter($repo, $identifier, $params, $apiKey),
+ $params,
+ );
}
/**
@@ -80,7 +82,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
/** @var VisitRepositoryInterface $repo */
$repo = $this->em->getRepository(Visit::class);
- return $this->createPaginator(new VisitsForTagPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
+ return $this->createPaginator(new TagVisitsPaginatorAdapter($repo, $tag, $params, $apiKey), $params);
}
/**
@@ -94,6 +96,14 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface
return $this->createPaginator(new OrphanVisitsPaginatorAdapter($repo, $params), $params);
}
+ public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator
+ {
+ /** @var VisitRepositoryInterface $repo */
+ $repo = $this->em->getRepository(Visit::class);
+
+ return $this->createPaginator(new NonOrphanVisitsPaginatorAdapter($repo, $params, $apiKey), $params);
+ }
+
private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator
{
$paginator = new Paginator($adapter);
diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php
index 5e15be4f..3616b531 100644
--- a/module/Core/src/Visit/VisitsStatsHelperInterface.php
+++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php
@@ -37,4 +37,9 @@ interface VisitsStatsHelperInterface
* @return Visit[]|Paginator
*/
public function orphanVisits(VisitsParams $params): Paginator;
+
+ /**
+ * @return Visit[]|Paginator
+ */
+ public function nonOrphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator;
}
diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
index 382e58dd..3f69e7d9 100644
--- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
+++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Domain\Repository;
+namespace ShlinkioDbTest\Shlink\Core\Domain\Repository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -21,7 +21,7 @@ class DomainRepositoryTest extends DatabaseTestCase
{
private DomainRepository $repo;
- protected function beforeEach(): void
+ protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(Domain::class);
}
diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
index d4ff42b8..4ad89629 100644
--- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
+++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Repository;
+namespace ShlinkioDbTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use Doctrine\Common\Collections\ArrayCollection;
@@ -11,11 +11,14 @@ use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
+use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
-use Shlinkio\Shlink\Core\Model\ShortUrlsOrdering;
+use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
@@ -30,7 +33,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
private ShortUrlRepository $repo;
private PersistenceShortUrlRelationResolver $relationResolver;
- public function beforeEach(): void
+ protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(ShortUrl::class);
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
@@ -54,25 +57,32 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
- self::assertSame($regularOne, $this->repo->findOneWithDomainFallback($regularOne->getShortCode()));
self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
- $withDomainDuplicatingRegular->getShortCode(),
+ ShortUrlIdentifier::fromShortCodeAndDomain($regularOne->getShortCode()),
+ ));
+ self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode()),
));
self::assertSame($withDomain, $this->repo->findOneWithDomainFallback(
- $withDomain->getShortCode(),
- 'example.com',
+ ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'example.com'),
));
self::assertSame(
$withDomainDuplicatingRegular,
- $this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
+ $this->repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($withDomainDuplicatingRegular->getShortCode(), 'doma.in'),
+ ),
);
- self::assertSame(
- $regularOne,
- $this->repo->findOneWithDomainFallback($withDomainDuplicatingRegular->getShortCode(), 'other-domain.com'),
- );
- self::assertNull($this->repo->findOneWithDomainFallback('invalid'));
- self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode()));
- self::assertNull($this->repo->findOneWithDomainFallback($withDomain->getShortCode(), 'other-domain.com'));
+ self::assertSame($regularOne, $this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain(
+ $withDomainDuplicatingRegular->getShortCode(),
+ 'other-domain.com',
+ )));
+ self::assertNull($this->repo->findOneWithDomainFallback(ShortUrlIdentifier::fromShortCodeAndDomain('invalid')));
+ self::assertNull($this->repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode()),
+ ));
+ self::assertNull($this->repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($withDomain->getShortCode(), 'other-domain.com'),
+ ));
}
/** @test */
@@ -84,7 +94,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
}
$this->getEntityManager()->flush();
- self::assertEquals($count, $this->repo->countList());
+ self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering()));
}
/** @test */
@@ -111,38 +121,49 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
- $result = $this->repo->findList(null, null, 'foo', ['bar']);
+ $result = $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), 'foo', ['bar']),
+ );
self::assertCount(1, $result);
- self::assertEquals(1, $this->repo->countList('foo', ['bar']));
+ self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering('foo', ['bar'])));
self::assertSame($foo, $result[0]);
- $result = $this->repo->findList();
+ $result = $this->repo->findList(new ShortUrlsListFiltering(null, null, Ordering::emptyInstance()));
self::assertCount(3, $result);
- $result = $this->repo->findList(2);
+ $result = $this->repo->findList(new ShortUrlsListFiltering(2, null, Ordering::emptyInstance()));
self::assertCount(2, $result);
- $result = $this->repo->findList(2, 1);
+ $result = $this->repo->findList(new ShortUrlsListFiltering(2, 1, Ordering::emptyInstance()));
self::assertCount(2, $result);
- self::assertCount(1, $this->repo->findList(2, 2));
+ self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(2, 2, Ordering::emptyInstance())));
- $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
- 'orderBy' => ['visits' => 'DESC'],
- ]));
+ $result = $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['visits', 'DESC'])),
+ );
self::assertCount(3, $result);
self::assertSame($bar, $result[0]);
- $result = $this->repo->findList(null, null, null, [], null, DateRange::withEndDate(Chronos::now()->subDays(2)));
+ $result = $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withEndDate(
+ Chronos::now()->subDays(2),
+ )),
+ );
self::assertCount(1, $result);
- self::assertEquals(1, $this->repo->countList(null, [], DateRange::withEndDate(Chronos::now()->subDays(2))));
+ self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::withEndDate(
+ Chronos::now()->subDays(2),
+ ))));
self::assertSame($foo2, $result[0]);
- self::assertCount(
- 2,
- $this->repo->findList(null, null, null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))),
- );
- self::assertEquals(2, $this->repo->countList(null, [], DateRange::withStartDate(Chronos::now()->subDays(2))));
+ self::assertCount(2, $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withStartDate(
+ Chronos::now()->subDays(2),
+ )),
+ ));
+ self::assertEquals(2, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))),
+ ));
}
/** @test */
@@ -155,9 +176,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
- $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([
- 'orderBy' => ['longUrl' => 'ASC'],
- ]));
+ $result = $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::fromTuple(['longUrl', 'ASC'])),
+ );
self::assertCount(count($urls), $result);
self::assertEquals('a', $result[0]->getLongUrl());
@@ -166,6 +187,119 @@ class ShortUrlRepositoryTest extends DatabaseTestCase
self::assertEquals('z', $result[3]->getLongUrl());
}
+ /** @test */
+ public function findListReturnsOnlyThoseWithMatchingTags(): void
+ {
+ $shortUrl1 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+ 'longUrl' => 'foo1',
+ 'tags' => ['foo', 'bar'],
+ ]), $this->relationResolver);
+ $this->getEntityManager()->persist($shortUrl1);
+ $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+ 'longUrl' => 'foo2',
+ 'tags' => ['foo', 'baz'],
+ ]), $this->relationResolver);
+ $this->getEntityManager()->persist($shortUrl2);
+ $shortUrl3 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+ 'longUrl' => 'foo3',
+ 'tags' => ['foo'],
+ ]), $this->relationResolver);
+ $this->getEntityManager()->persist($shortUrl3);
+ $shortUrl4 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+ 'longUrl' => 'foo4',
+ 'tags' => ['bar', 'baz'],
+ ]), $this->relationResolver);
+ $this->getEntityManager()->persist($shortUrl4);
+ $shortUrl5 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([
+ 'longUrl' => 'foo5',
+ 'tags' => ['bar', 'baz'],
+ ]), $this->relationResolver);
+ $this->getEntityManager()->persist($shortUrl5);
+
+ $this->getEntityManager()->flush();
+
+ self::assertCount(5, $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar']),
+ ));
+ self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['foo', 'bar'],
+ ShortUrlsParams::TAGS_MODE_ANY,
+ )));
+ self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['foo', 'bar'],
+ ShortUrlsParams::TAGS_MODE_ALL,
+ )));
+ self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'])));
+ self::assertEquals(5, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY),
+ ));
+ self::assertEquals(1, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL),
+ ));
+
+ self::assertCount(4, $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']),
+ ));
+ self::assertCount(4, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['bar', 'baz'],
+ ShortUrlsParams::TAGS_MODE_ANY,
+ )));
+ self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['bar', 'baz'],
+ ShortUrlsParams::TAGS_MODE_ALL,
+ )));
+ self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz'])));
+ self::assertEquals(4, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY),
+ ));
+ self::assertEquals(2, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL),
+ ));
+
+ self::assertCount(5, $this->repo->findList(
+ new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['foo', 'bar', 'baz']),
+ ));
+ self::assertCount(5, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['foo', 'bar', 'baz'],
+ ShortUrlsParams::TAGS_MODE_ANY,
+ )));
+ self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering(
+ null,
+ null,
+ Ordering::emptyInstance(),
+ null,
+ ['foo', 'bar', 'baz'],
+ ShortUrlsParams::TAGS_MODE_ALL,
+ )));
+ self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'])));
+ self::assertEquals(5, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY),
+ ));
+ self::assertEquals(0, $this->repo->countList(
+ new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL),
+ ));
+ }
+
/** @test */
public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void
{
diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php
index 92498d9a..fe544376 100644
--- a/module/Core/test-db/Repository/TagRepositoryTest.php
+++ b/module/Core/test-db/Repository/TagRepositoryTest.php
@@ -2,29 +2,32 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Repository;
+namespace ShlinkioDbTest\Shlink\Core\Repository;
use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Entity\Visit;
+use Shlinkio\Shlink\Core\Model\Ordering;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver;
+use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase;
use function array_chunk;
+use function count;
class TagRepositoryTest extends DatabaseTestCase
{
private TagRepository $repo;
private PersistenceShortUrlRelationResolver $relationResolver;
- protected function beforeEach(): void
+ protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(Tag::class);
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
@@ -50,48 +53,153 @@ class TagRepositoryTest extends DatabaseTestCase
self::assertEquals(2, $this->repo->deleteByName($toDelete));
}
- /** @test */
- public function properTagsInfoIsReturned(): void
+ /**
+ * @test
+ * @dataProvider provideFilters
+ */
+ public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void
{
$names = ['foo', 'bar', 'baz', 'another'];
foreach ($names as $name) {
$this->getEntityManager()->persist(new Tag($name));
}
+
+ $apiKey = $filtering?->apiKey();
+ if ($apiKey !== null) {
+ $this->getEntityManager()->persist($apiKey);
+ }
+
$this->getEntityManager()->flush();
[$firstUrlTags] = array_chunk($names, 3);
$secondUrlTags = [$names[0]];
- $metaWithTags = fn (array $tags) => ShortUrlMeta::fromRawData(['longUrl' => '', 'tags' => $tags]);
+ $metaWithTags = fn (array $tags, ?ApiKey $apiKey) => ShortUrlMeta::fromRawData(
+ ['longUrl' => '', 'tags' => $tags, 'apiKey' => $apiKey],
+ );
- $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags), $this->relationResolver);
+ $shortUrl = ShortUrl::fromMeta($metaWithTags($firstUrlTags, $apiKey), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl);
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()));
- $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags), $this->relationResolver);
+ $shortUrl2 = ShortUrl::fromMeta($metaWithTags($secondUrlTags, null), $this->relationResolver);
$this->getEntityManager()->persist($shortUrl2);
$this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance()));
+
+ // One of the tags has two extra short URLs, but with no visits
+ $this->getEntityManager()->persist(
+ ShortUrl::fromMeta($metaWithTags(['bar'], null), $this->relationResolver),
+ );
+ $this->getEntityManager()->persist(
+ ShortUrl::fromMeta($metaWithTags(['bar'], $apiKey), $this->relationResolver),
+ );
+
$this->getEntityManager()->flush();
- $result = $this->repo->findTagsWithInfo();
+ $result = $this->repo->findTagsWithInfo($filtering);
- self::assertCount(4, $result);
- self::assertEquals(0, $result[0]->shortUrlsCount());
- self::assertEquals(0, $result[0]->visitsCount());
- self::assertEquals($names[3], $result[0]->tag()->__toString());
+ self::assertCount(count($expectedList), $result);
+ foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount]) {
+ self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount());
+ self::assertEquals($visitsCount, $result[$index]->visitsCount());
+ self::assertEquals($tag, $result[$index]->tag());
+ }
+ }
- self::assertEquals(1, $result[1]->shortUrlsCount());
- self::assertEquals(3, $result[1]->visitsCount());
- self::assertEquals($names[1], $result[1]->tag()->__toString());
+ public function provideFilters(): iterable
+ {
+ $defaultList = [
+ ['another', 0, 0],
+ ['bar', 3, 3],
+ ['baz', 1, 3],
+ ['foo', 2, 4],
+ ];
- self::assertEquals(1, $result[2]->shortUrlsCount());
- self::assertEquals(3, $result[2]->visitsCount());
- self::assertEquals($names[2], $result[2]->tag()->__toString());
-
- self::assertEquals(2, $result[3]->shortUrlsCount());
- self::assertEquals(4, $result[3]->visitsCount());
- self::assertEquals($names[0], $result[3]->tag()->__toString());
+ yield 'no filter' => [null, $defaultList];
+ yield 'empty filter' => [new TagsListFiltering(), $defaultList];
+ yield 'limit' => [new TagsListFiltering(2), [
+ ['another', 0, 0],
+ ['bar', 3, 3],
+ ]];
+ yield 'offset' => [new TagsListFiltering(null, 3), [
+ ['foo', 2, 4],
+ ]];
+ yield 'limit and offset' => [new TagsListFiltering(2, 1), [
+ ['bar', 3, 3],
+ ['baz', 1, 3],
+ ]];
+ yield 'search term' => [new TagsListFiltering(null, null, 'ba'), [
+ ['bar', 3, 3],
+ ['baz', 1, 3],
+ ]];
+ yield 'ASC ordering' => [
+ new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])),
+ $defaultList,
+ ];
+ yield 'DESC ordering' => [new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), [
+ ['foo', 2, 4],
+ ['baz', 1, 3],
+ ['bar', 3, 3],
+ ['another', 0, 0],
+ ]];
+ yield 'short URLs count ASC ordering' => [
+ new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'ASC'])),
+ [
+ ['another', 0, 0],
+ ['baz', 1, 3],
+ ['foo', 2, 4],
+ ['bar', 3, 3],
+ ],
+ ];
+ yield 'short URLs count DESC ordering' => [
+ new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'DESC'])),
+ [
+ ['bar', 3, 3],
+ ['foo', 2, 4],
+ ['baz', 1, 3],
+ ['another', 0, 0],
+ ],
+ ];
+ yield 'visits count ASC ordering' => [
+ new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'ASC'])),
+ [
+ ['another', 0, 0],
+ ['bar', 3, 3],
+ ['baz', 1, 3],
+ ['foo', 2, 4],
+ ],
+ ];
+ yield 'visits count DESC ordering' => [
+ new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])),
+ [
+ ['foo', 2, 4],
+ ['bar', 3, 3],
+ ['baz', 1, 3],
+ ['another', 0, 0],
+ ],
+ ];
+ yield 'visits count DESC ordering and limit' => [
+ new TagsListFiltering(2, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])),
+ [
+ ['foo', 2, 4],
+ ['bar', 3, 3],
+ ],
+ ];
+ yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta(
+ ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()),
+ )), [
+ ['bar', 2, 3],
+ ['baz', 1, 3],
+ ['foo', 1, 3],
+ ]];
+ yield 'combined' => [new TagsListFiltering(1, null, null, Ordering::fromTuple(
+ ['shortUrls', 'DESC'],
+ ), ApiKey::fromMeta(
+ ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()),
+ )), [
+ ['foo', 1, 3],
+ ]];
}
/** @test */
diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php
index c78583af..c23bd8aa 100644
--- a/module/Core/test-db/Repository/VisitRepositoryTest.php
+++ b/module/Core/test-db/Repository/VisitRepositoryTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Repository;
+namespace ShlinkioDbTest\Shlink\Core\Repository;
use Cake\Chronos\Chronos;
use ReflectionObject;
@@ -29,13 +29,16 @@ use function Functional\map;
use function is_string;
use function range;
use function sprintf;
+use function str_pad;
+
+use const STR_PAD_LEFT;
class VisitRepositoryTest extends DatabaseTestCase
{
private VisitRepository $repo;
private PersistenceShortUrlRelationResolver $relationResolver;
- protected function beforeEach(): void
+ protected function setUp(): void
{
$this->repo = $this->getEntityManager()->getRepository(Visit::class);
$this->relationResolver = new PersistenceShortUrlRelationResolver($this->getEntityManager());
@@ -189,19 +192,19 @@ class VisitRepositoryTest extends DatabaseTestCase
self::assertNotEmpty($this->repo->findVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
- new VisitsListFiltering(null, false, $adminApiKey->spec()),
+ new VisitsListFiltering(null, false, $adminApiKey),
));
self::assertNotEmpty($this->repo->findVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
- new VisitsListFiltering(null, false, $adminApiKey->spec()),
+ new VisitsListFiltering(null, false, $adminApiKey),
));
self::assertEmpty($this->repo->findVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode1),
- new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
+ new VisitsListFiltering(null, false, $restrictedApiKey),
));
self::assertNotEmpty($this->repo->findVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode2),
- new VisitsListFiltering(null, false, $restrictedApiKey->spec()),
+ new VisitsListFiltering(null, false, $restrictedApiKey),
));
}
@@ -294,10 +297,20 @@ class VisitRepositoryTest extends DatabaseTestCase
$this->getEntityManager()->flush();
- self::assertEquals(4 + 5 + 7, $this->repo->countVisits());
- self::assertEquals(4, $this->repo->countVisits($apiKey1));
- self::assertEquals(5 + 7, $this->repo->countVisits($apiKey2));
- self::assertEquals(4 + 7, $this->repo->countVisits($domainApiKey));
+ self::assertEquals(4 + 5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering()));
+ self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1)));
+ self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2)));
+ self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey)));
+ self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
+ Chronos::parse('2016-01-05')->startOfDay(),
+ ))));
+ self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
+ Chronos::parse('2016-01-03')->startOfDay(),
+ ), false, $apiKey1)));
+ self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate(
+ Chronos::parse('2016-01-07')->startOfDay(),
+ ), false, $apiKey2)));
+ self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2)));
self::assertEquals(4, $this->repo->countOrphanVisits(new VisitsCountFiltering()));
self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true)));
}
@@ -388,6 +401,49 @@ class VisitRepositoryTest extends DatabaseTestCase
));
}
+ /** @test */
+ public function findNonOrphanVisitsReturnsExpectedResult(): void
+ {
+ $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '1']));
+ $this->getEntityManager()->persist($shortUrl);
+ $this->createVisitsForShortUrl($shortUrl, 7);
+
+ $shortUrl2 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '2']));
+ $this->getEntityManager()->persist($shortUrl2);
+ $this->createVisitsForShortUrl($shortUrl2, 4);
+
+ $shortUrl3 = ShortUrl::fromMeta(ShortUrlMeta::fromRawData(['longUrl' => '3']));
+ $this->getEntityManager()->persist($shortUrl3);
+ $this->createVisitsForShortUrl($shortUrl3, 10);
+
+ $this->getEntityManager()->flush();
+
+ self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering()));
+ self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::emptyInstance())));
+ self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartDate(
+ Chronos::parse('2016-01-05')->endOfDay(),
+ ))));
+ self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withEndDate(
+ Chronos::parse('2016-01-04')->endOfDay(),
+ ))));
+ self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
+ Chronos::parse('2016-01-03')->startOfDay(),
+ Chronos::parse('2016-01-04')->endOfDay(),
+ ))));
+ self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
+ Chronos::parse('2016-01-03')->startOfDay(),
+ Chronos::parse('2016-01-08')->endOfDay(),
+ ))));
+ self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate(
+ Chronos::parse('2016-01-03')->startOfDay(),
+ Chronos::parse('2016-01-08')->endOfDay(),
+ ), false, null, 10, 10)));
+ self::assertCount(15, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, true)));
+ self::assertCount(10, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10)));
+ self::assertCount(1, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 10, 20)));
+ self::assertCount(5, $this->repo->findNonOrphanVisits(new VisitsListFiltering(null, false, null, 5, 5)));
+ }
+
/**
* @return array{string, string, ShortUrl}
*/
@@ -429,7 +485,7 @@ class VisitRepositoryTest extends DatabaseTestCase
$shortUrl,
$botsAmount < 1 ? Visitor::emptyInstance() : Visitor::botInstance(),
),
- Chronos::parse(sprintf('2016-01-0%s', $i + 1)),
+ Chronos::parse(sprintf('2016-01-%s', str_pad((string) ($i + 1), 2, '0', STR_PAD_LEFT)))->startOfDay(),
);
$botsAmount--;
diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php
new file mode 100644
index 00000000..7e75aa22
--- /dev/null
+++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php
@@ -0,0 +1,69 @@
+repo = $this->getEntityManager()->getRepository(Tag::class);
+ }
+
+ /**
+ * @test
+ * @dataProvider provideFilters
+ */
+ public function expectedListOfTagsIsReturned(
+ ?string $searchTerm,
+ ?string $orderBy,
+ int $offset,
+ int $length,
+ array $expectedTags,
+ int $expectedTotalCount,
+ ): void {
+ $names = ['foo', 'bar', 'baz', 'another'];
+ foreach ($names as $name) {
+ $this->getEntityManager()->persist(new Tag($name));
+ }
+ $this->getEntityManager()->flush();
+
+ $adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData([
+ 'searchTerm' => $searchTerm,
+ 'orderBy' => $orderBy,
+ ]), null);
+
+ $tagNames = map($adapter->getSlice($offset, $length), static fn (Tag $tag) => $tag->__toString());
+
+ self::assertEquals($expectedTags, $tagNames);
+ self::assertEquals($expectedTotalCount, $adapter->getNbResults());
+ }
+
+ public function provideFilters(): iterable
+ {
+ yield [null, null, 0, 10, ['another', 'bar', 'baz', 'foo'], 4];
+ yield [null, null, 2, 10, ['baz', 'foo'], 4];
+ yield [null, null, 1, 3, ['bar', 'baz', 'foo'], 4];
+ yield [null, null, 3, 3, ['foo'], 4];
+ yield [null, null, 0, 2, ['another', 'bar'], 4];
+ yield ['ba', null, 0, 10, ['bar', 'baz'], 2];
+ yield ['ba', null, 0, 1, ['bar'], 2];
+ yield ['foo', null, 0, 10, ['foo'], 1];
+ yield ['a', null, 0, 10, ['another', 'bar', 'baz'], 3];
+ yield [null, 'tag-DESC', 0, 10, ['foo', 'baz', 'bar', 'another'], 4];
+ yield [null, 'tag-ASC', 0, 10, ['another', 'bar', 'baz', 'foo'], 4];
+ yield [null, 'tag-DESC', 0, 2, ['foo', 'baz'], 4];
+ yield ['ba', 'tag-DESC', 0, 1, ['baz'], 2];
+ }
+}
diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php
index 664a51a2..419febec 100644
--- a/module/Core/test/Action/QrCodeActionTest.php
+++ b/module/Core/test/Action/QrCodeActionTest.php
@@ -154,18 +154,12 @@ class QrCodeActionTest extends TestCase
];
yield 'no size' => [[], ServerRequestFactory::fromGlobals(), 300];
yield 'no size, different default' => [['size' => 500], ServerRequestFactory::fromGlobals(), 500];
- yield 'size in attr' => [[], ServerRequestFactory::fromGlobals()->withAttribute('size', '400'), 400];
yield 'size in query' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']), 123];
yield 'size in query, default margin' => [
['margin' => 25],
ServerRequestFactory::fromGlobals()->withQueryParams(['size' => '123']),
173,
];
- yield 'size in query and attr' => [
- [],
- ServerRequestFactory::fromGlobals()->withAttribute('size', '350')->withQueryParams(['size' => '123']),
- 350,
- ];
yield 'margin' => [[], ServerRequestFactory::fromGlobals()->withQueryParams(['margin' => '35']), 370];
yield 'margin and different default' => [
['size' => 400],
diff --git a/module/Core/test/Config/DeprecatedConfigParserTest.php b/module/Core/test/Config/DeprecatedConfigParserTest.php
deleted file mode 100644
index c58d9050..00000000
--- a/module/Core/test/Config/DeprecatedConfigParserTest.php
+++ /dev/null
@@ -1,111 +0,0 @@
-postProcessor = new DeprecatedConfigParser();
- }
-
- /** @test */
- public function returnsConfigAsIsIfNewValueIsDefined(): void
- {
- $config = [
- 'not_found_redirects' => [
- 'invalid_short_url' => 'somewhere',
- ],
- ];
-
- $result = ($this->postProcessor)($config);
-
- self::assertEquals($config, $result);
- }
-
- /** @test */
- public function doesNotProvideNewConfigIfOldOneIsDefinedButDisabled(): void
- {
- $config = [
- 'url_shortener' => [
- 'not_found_short_url' => [
- 'enable_redirection' => false,
- 'redirect_to' => 'somewhere',
- ],
- ],
- ];
-
- $result = ($this->postProcessor)($config);
-
- self::assertEquals($config, $result);
- }
-
- /** @test */
- public function mapsOldConfigToNewOneWhenOldOneIsEnabled(): void
- {
- $config = [
- 'url_shortener' => [
- 'not_found_short_url' => [
- 'enable_redirection' => true,
- 'redirect_to' => 'somewhere',
- ],
- ],
- ];
- $expected = array_merge($config, [
- 'not_found_redirects' => [
- 'invalid_short_url' => 'somewhere',
- ],
- ]);
-
- $result = ($this->postProcessor)($config);
-
- self::assertEquals($expected, $result);
- }
-
- /** @test */
- public function definesNewConfigAsNullIfOldOneIsEnabledWithNoRedirectValue(): void
- {
- $config = [
- 'url_shortener' => [
- 'not_found_short_url' => [
- 'enable_redirection' => true,
- ],
- ],
- ];
- $expected = array_merge($config, [
- 'not_found_redirects' => [
- 'invalid_short_url' => null,
- ],
- ]);
-
- $result = ($this->postProcessor)($config);
-
- self::assertEquals($expected, $result);
- }
-
- /** @test */
- public function removesTheOldSecretKey(): void
- {
- $config = [
- 'app_options' => [
- 'secret_key' => 'foobar',
- ],
- ];
- $expected = [
- 'app_options' => [],
- ];
-
- $result = ($this->postProcessor)($config);
-
- self::assertEquals($expected, $result);
- }
-}
diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php
new file mode 100644
index 00000000..a7ccbcee
--- /dev/null
+++ b/module/Core/test/Config/EnvVarsTest.php
@@ -0,0 +1,139 @@
+expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Invalid env var: "' . $envVar . '"');
+
+ EnvVars::{$envVar}();
+ }
+
+ public function provideInvalidEnvVars(): iterable
+ {
+ yield 'foo' => ['foo'];
+ yield 'bar' => ['bar'];
+ yield 'invalid' => ['invalid'];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideExistingEnvVars
+ */
+ public function existsInEnvReturnsExpectedValue(EnvVars $envVar, bool $exists): void
+ {
+ self::assertEquals($exists, $envVar->existsInEnv());
+ }
+
+ public function provideExistingEnvVars(): iterable
+ {
+ yield 'DB_NAME' => [EnvVars::DB_NAME(), true];
+ yield 'BASE_PATH' => [EnvVars::BASE_PATH(), true];
+ yield 'DB_DRIVER' => [EnvVars::DB_DRIVER(), false];
+ yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT(), false];
+ }
+
+ /**
+ * @test
+ * @dataProvider provideEnvVarsValues
+ */
+ public function expectedValueIsLoadedFromEnv(EnvVars $envVar, mixed $expected, mixed $default): void
+ {
+ self::assertEquals($expected, $envVar->loadFromEnv($default));
+ }
+
+ public function provideEnvVarsValues(): iterable
+ {
+ yield 'DB_NAME without default' => [EnvVars::DB_NAME(), 'shlink', null];
+ yield 'DB_NAME with default' => [EnvVars::DB_NAME(), 'shlink', 'foobar'];
+ yield 'BASE_PATH without default' => [EnvVars::BASE_PATH(), 'the_base_path', null];
+ yield 'BASE_PATH with default' => [EnvVars::BASE_PATH(), 'the_base_path', 'foobar'];
+ yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER(), null, null];
+ yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER(), 'foobar', 'foobar'];
+ }
+}
diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php
index 0dc25768..aa98d102 100644
--- a/module/Core/test/Config/NotFoundRedirectResolverTest.php
+++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php
@@ -99,7 +99,7 @@ class NotFoundRedirectResolverTest extends TestCase
new NotFoundRedirectOptions([
'regular404' => 'https://redirect-here.com/{ORIGINAL_PATH}/{DOMAIN}/?d={DOMAIN}&p={ORIGINAL_PATH}',
]),
- 'https://redirect-here.com//foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar', // TODO Fix duplicated slash
+ 'https://redirect-here.com/foo/bar/doma.in/?d=doma.in&p=%2Ffoo%2Fbar',
];
yield 'invalid short URL' => [
new Uri('/foo'),
@@ -111,7 +111,7 @@ class NotFoundRedirectResolverTest extends TestCase
new Uri('/foo'),
$this->notFoundType($this->requestForRoute(RedirectAction::class)),
new NotFoundRedirectOptions(['invalidShortUrl' => 'https://redirect-here.com/{ORIGINAL_PATH}']),
- 'https://redirect-here.com//foo', // TODO Fix duplicated slash
+ 'https://redirect-here.com/foo',
];
}
diff --git a/module/Core/test/Config/SimplifiedConfigParserTest.php b/module/Core/test/Config/SimplifiedConfigParserTest.php
deleted file mode 100644
index 48d41c00..00000000
--- a/module/Core/test/Config/SimplifiedConfigParserTest.php
+++ /dev/null
@@ -1,158 +0,0 @@
-postProcessor = new SimplifiedConfigParser();
- }
-
- /** @test */
- public function properlyMapsSimplifiedConfig(): void
- {
- $config = [
- 'tracking' => [
- 'disable_track_param' => 'foo',
- ],
-
- 'entity_manager' => [
- 'connection' => [
- 'driver' => 'mysql',
- 'host' => 'shlink_db_mysql',
- 'port' => '3306',
- ],
- ],
- ];
- $simplified = [
- 'disable_track_param' => 'bar',
- 'short_domain_schema' => 'https',
- 'short_domain_host' => 'doma.in',
- 'validate_url' => true,
- 'delete_short_url_threshold' => 50,
- 'invalid_short_url_redirect_to' => 'foobar.com',
- 'regular_404_redirect_to' => 'bar.com',
- 'base_url_redirect_to' => 'foo.com',
- 'redis_servers' => [
- 'tcp://1.1.1.1:1111',
- 'tcp://1.2.2.2:2222',
- ],
- 'db_config' => [
- 'dbname' => 'shlink',
- 'user' => 'foo',
- 'password' => 'bar',
- 'port' => '1234',
- ],
- 'base_path' => '/foo/bar',
- 'task_worker_num' => 50,
- 'visits_webhooks' => [
- 'http://my-api.com/api/v2.3/notify',
- 'https://third-party.io/foo',
- ],
- 'default_short_codes_length' => 8,
- 'geolite_license_key' => 'kjh23ljkbndskj345',
- 'mercure_public_hub_url' => 'public_url',
- 'mercure_internal_hub_url' => 'internal_url',
- 'mercure_jwt_secret' => 'super_secret_value',
- 'anonymize_remote_addr' => false,
- 'redirect_status_code' => 301,
- 'redirect_cache_lifetime' => 90,
- 'port' => 8888,
- ];
- $expected = [
- 'tracking' => [
- 'disable_track_param' => 'bar',
- 'anonymize_remote_addr' => false,
- ],
-
- 'entity_manager' => [
- 'connection' => [
- 'driver' => 'mysql',
- 'host' => 'shlink_db_mysql',
- 'dbname' => 'shlink',
- 'user' => 'foo',
- 'password' => 'bar',
- 'port' => '1234',
- ],
- ],
-
- 'url_shortener' => [
- 'domain' => [
- 'schema' => 'https',
- 'hostname' => 'doma.in',
- ],
- 'validate_url' => true,
- 'visits_webhooks' => [
- 'http://my-api.com/api/v2.3/notify',
- 'https://third-party.io/foo',
- ],
- 'default_short_codes_length' => 8,
- 'redirect_status_code' => 301,
- 'redirect_cache_lifetime' => 90,
- ],
-
- 'delete_short_urls' => [
- 'visits_threshold' => 50,
- 'check_visits_threshold' => true,
- ],
-
- 'dependencies' => [
- 'aliases' => [
- 'lock_store' => 'redis_lock_store',
- ],
- ],
-
- 'cache' => [
- 'redis' => [
- 'servers' => [
- 'tcp://1.1.1.1:1111',
- 'tcp://1.2.2.2:2222',
- ],
- ],
- ],
-
- 'router' => [
- 'base_path' => '/foo/bar',
- ],
-
- 'not_found_redirects' => [
- 'invalid_short_url' => 'foobar.com',
- 'regular_404' => 'bar.com',
- 'base_url' => 'foo.com',
- ],
-
- 'mezzio-swoole' => [
- 'swoole-http-server' => [
- 'port' => 8888,
- 'options' => [
- 'task_worker_num' => 50,
- ],
- ],
- ],
-
- 'geolite2' => [
- 'license_key' => 'kjh23ljkbndskj345',
- ],
-
- 'mercure' => [
- 'public_hub_url' => 'public_url',
- 'internal_hub_url' => 'internal_url',
- 'jwt_secret' => 'super_secret_value',
- ],
- ];
-
- $result = ($this->postProcessor)(array_merge($config, $simplified));
-
- self::assertEquals(array_merge($expected, $simplified), $result);
- }
-}
diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php
index 99609bb4..56324e40 100644
--- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php
+++ b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php
@@ -162,7 +162,7 @@ class NotifyVisitToWebHooksTest extends TestCase
$this->em->reveal(),
$this->logger->reveal(),
new WebhookOptions(
- ['visits_webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits],
+ ['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits],
),
new ShortUrlDataTransformer(new ShortUrlStringifier([])),
new AppOptions(['name' => 'Shlink', 'version' => '1.2.3']),
diff --git a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php
index 8c616ce1..b331bdc2 100644
--- a/module/Core/test/Exception/DeleteShortUrlExceptionTest.php
+++ b/module/Core/test/Exception/DeleteShortUrlExceptionTest.php
@@ -37,7 +37,7 @@ class DeleteShortUrlExceptionTest extends TestCase
'threshold' => $threshold,
], $e->getAdditionalData());
self::assertEquals('Cannot delete short URL', $e->getTitle());
- self::assertEquals('INVALID_SHORTCODE_DELETION', $e->getType());
+ self::assertEquals('INVALID_SHORT_URL_DELETION', $e->getType());
self::assertEquals(422, $e->getStatus());
}
diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php
index 1a4a4de1..70662bb1 100644
--- a/module/Core/test/Importer/ImportedLinksProcessorTest.php
+++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php
@@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
-use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
+use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface;
use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl;
@@ -46,7 +46,7 @@ class ImportedLinksProcessorTest extends TestCase
$this->repo = $this->prophesize(ShortUrlRepositoryInterface::class);
$this->em->getRepository(ShortUrl::class)->willReturn($this->repo->reveal());
- $this->shortCodeHelper = $this->prophesize(ShortCodeHelperInterface::class);
+ $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class);
$batchHelper = $this->prophesize(DoctrineBatchHelperInterface::class);
$batchHelper->wrapIterable(Argument::cetera())->willReturnArgument(0);
diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php
index 9a5eac72..1933b3b6 100644
--- a/module/Core/test/Model/ShortUrlMetaTest.php
+++ b/module/Core/test/Model/ShortUrlMetaTest.php
@@ -30,34 +30,39 @@ class ShortUrlMetaTest extends TestCase
public function provideInvalidData(): iterable
{
+ yield [[]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::VALID_SINCE => '',
ShortUrlInputFilter::VALID_UNTIL => '',
ShortUrlInputFilter::CUSTOM_SLUG => 'foobar',
ShortUrlInputFilter::MAX_VISITS => 'invalid',
]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::VALID_SINCE => '2017',
ShortUrlInputFilter::MAX_VISITS => 5,
]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::VALID_SINCE => new stdClass(),
ShortUrlInputFilter::VALID_UNTIL => 'foo',
]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::VALID_UNTIL => 500,
ShortUrlInputFilter::DOMAIN => 4,
]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::SHORT_CODE_LENGTH => 3,
]];
yield [[
- ShortUrlInputFilter::CUSTOM_SLUG => '/',
- ]];
- yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::CUSTOM_SLUG => '',
]];
yield [[
+ ShortUrlInputFilter::LONG_URL => 'foo',
ShortUrlInputFilter::CUSTOM_SLUG => ' ',
]];
yield [[
@@ -92,12 +97,16 @@ class ShortUrlMetaTest extends TestCase
public function provideCustomSlugs(): iterable
{
+ yield ['🔥', '🔥'];
+ yield ['🦣 🍅', '🦣-🍅'];
yield ['foobar', 'foobar'];
yield ['foo bar', 'foo-bar'];
+ yield ['foo bar baz', 'foo-bar-baz'];
+ yield ['foo bar-baz', 'foo-bar-baz'];
+ yield ['foo/bar/baz', 'foo-bar-baz'];
yield ['wp-admin.php', 'wp-admin.php'];
yield ['UPPER_lower', 'UPPER_lower'];
yield ['more~url_special.chars', 'more~url_special.chars'];
- yield ['äéñ', 'äen'];
yield ['구글', '구글'];
yield ['グーグル', 'グーグル'];
yield ['谷歌', '谷歌'];
diff --git a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php b/module/Core/test/Service/ShortUrl/ShortCodeUniquenessHelperTest.php
similarity index 91%
rename from module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php
rename to module/Core/test/Service/ShortUrl/ShortCodeUniquenessHelperTest.php
index b30f8cab..7e962dc8 100644
--- a/module/Core/test/Service/ShortUrl/ShortCodeHelperTest.php
+++ b/module/Core/test/Service/ShortUrl/ShortCodeUniquenessHelperTest.php
@@ -12,20 +12,20 @@ use Shlinkio\Shlink\Core\Entity\Domain;
use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
-use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelper;
+use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeUniquenessHelper;
-class ShortCodeHelperTest extends TestCase
+class ShortCodeUniquenessHelperTest extends TestCase
{
use ProphecyTrait;
- private ShortCodeHelper $helper;
+ private ShortCodeUniquenessHelper $helper;
private ObjectProphecy $em;
private ObjectProphecy $shortUrl;
protected function setUp(): void
{
$this->em = $this->prophesize(EntityManagerInterface::class);
- $this->helper = new ShortCodeHelper($this->em->reveal());
+ $this->helper = new ShortCodeUniquenessHelper($this->em->reveal());
$this->shortUrl = $this->prophesize(ShortUrl::class);
$this->shortUrl->getShortCode()->willReturn('abc123');
diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
index 41f2b492..70857e5e 100644
--- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
+++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php
@@ -86,7 +86,9 @@ class ShortUrlResolverTest extends TestCase
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
- $findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
+ $findOneByShortCode = $repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
+ )->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode));
@@ -105,7 +107,9 @@ class ShortUrlResolverTest extends TestCase
$shortCode = $shortUrl->getShortCode();
$repo = $this->prophesize(ShortUrlRepositoryInterface::class);
- $findOneByShortCode = $repo->findOneWithDomainFallback($shortCode, null)->willReturn($shortUrl);
+ $findOneByShortCode = $repo->findOneWithDomainFallback(
+ ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
+ )->willReturn($shortUrl);
$getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
$this->expectException(ShortUrlNotFoundException::class);
diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php
index 6bac432e..bdd508b4 100644
--- a/module/Core/test/Service/UrlShortenerTest.php
+++ b/module/Core/test/Service/UrlShortenerTest.php
@@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\Model\ShortUrlMeta;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepository;
-use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeHelperInterface;
+use Shlinkio\Shlink\Core\Service\ShortUrl\ShortCodeUniquenessHelperInterface;
use Shlinkio\Shlink\Core\Service\UrlShortener;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver;
@@ -48,7 +48,7 @@ class UrlShortenerTest extends TestCase
$repo = $this->prophesize(ShortUrlRepository::class);
$this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal());
- $this->shortCodeHelper = $this->prophesize(ShortCodeHelperInterface::class);
+ $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class);
$this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true);
$this->urlShortener = new UrlShortener(
diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php
index b4acc417..4fed4329 100644
--- a/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php
+++ b/module/Core/test/ShortUrl/Helper/ShortUrlStringifierTest.php
@@ -43,6 +43,18 @@ class ShortUrlStringifierTest extends TestCase
$shortUrlWithShortCode('bar'),
'http://example.com/bar',
];
+ yield 'special chars in short code' => [
+ ['hostname' => 'example.com'],
+ '',
+ $shortUrlWithShortCode('グーグル'),
+ 'http://example.com/グーグル',
+ ];
+ yield 'emojis in short code' => [
+ ['hostname' => 'example.com'],
+ '',
+ $shortUrlWithShortCode('🦣-🍅'),
+ 'http://example.com/🦣-🍅',
+ ];
yield 'hostname with base path in config' => [
['hostname' => 'example.com/foo/bar'],
'',
diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php
similarity index 75%
rename from module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php
rename to module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php
index 33fdb8f6..336526b1 100644
--- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php
+++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php
@@ -2,15 +2,17 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
+namespace ShlinkioTest\Shlink\Core\ShortUrl\Paginator\Adapter;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Model\ShortUrlsParams;
-use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlRepositoryAdapter;
use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface;
+use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering;
+use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlRepositoryAdapterTest extends TestCase
@@ -46,7 +48,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase
$orderBy = $params->orderBy();
$dateRange = $params->dateRange();
- $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce();
+ $this->repo->findList(
+ new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange),
+ )->shouldBeCalledOnce();
$adapter->getSlice(5, 10);
}
@@ -70,7 +74,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase
$adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey);
$dateRange = $params->dateRange();
- $this->repo->countList($searchTerm, $tags, $dateRange, $apiKey->spec())->shouldBeCalledOnce();
+ $this->repo->countList(
+ new ShortUrlsCountFiltering($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey),
+ )->shouldBeCalledOnce();
$adapter->getNbResults();
}
@@ -80,11 +86,11 @@ class ShortUrlRepositoryAdapterTest extends TestCase
yield ['search'];
yield ['search', []];
yield ['search', ['foo', 'bar']];
- yield ['search', ['foo', 'bar'], null, null, 'order'];
- yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
- yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'order'];
- yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'order'];
- yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'order'];
+ yield ['search', ['foo', 'bar'], null, null, 'longUrl'];
+ yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'longUrl'];
+ yield ['search', ['foo', 'bar'], null, Chronos::now()->toAtomString(), 'longUrl'];
+ yield ['search', ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString(), 'longUrl'];
+ yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), null, 'longUrl'];
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString()];
yield [null, ['foo', 'bar'], Chronos::now()->toAtomString(), Chronos::now()->toAtomString()];
}
diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php
new file mode 100644
index 00000000..2fc354ba
--- /dev/null
+++ b/module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php
@@ -0,0 +1,48 @@
+repo = $this->prophesize(TagRepositoryInterface::class);
+ $this->adapter = new TagsInfoPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
+ }
+
+ /** @test */
+ public function getSliceIsDelegatedToRepository(): void
+ {
+ $findTags = $this->repo->findTagsWithInfo(Argument::cetera())->willReturn([]);
+
+ $this->adapter->getSlice(1, 1);
+
+ $findTags->shouldHaveBeenCalledOnce();
+ }
+
+ /** @test */
+ public function getNbResultsIsDelegatedToRepository(): void
+ {
+ $match = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(3);
+
+ $result = $this->adapter->getNbResults();
+
+ self::assertEquals(3, $result);
+ $match->shouldHaveBeenCalledOnce();
+ }
+}
diff --git a/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php
new file mode 100644
index 00000000..4cbfd703
--- /dev/null
+++ b/module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php
@@ -0,0 +1,37 @@
+repo = $this->prophesize(TagRepositoryInterface::class);
+ $this->adapter = new TagsPaginatorAdapter($this->repo->reveal(), TagsParams::fromRawData([]), null);
+ }
+
+ /** @test */
+ public function getSliceDelegatesToRepository(): void
+ {
+ $match = $this->repo->match(Argument::cetera())->willReturn([]);
+
+ $this->adapter->getSlice(1, 1);
+
+ $match->shouldHaveBeenCalledOnce();
+ }
+}
diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php
similarity index 70%
rename from module/Core/test/Service/Tag/TagServiceTest.php
rename to module/Core/test/Tag/TagServiceTest.php
index 33ae7be0..8c301f0f 100644
--- a/module/Core/test/Service/Tag/TagServiceTest.php
+++ b/module/Core/test/Tag/TagServiceTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Service\Tag;
+namespace ShlinkioTest\Shlink\Core\Tag;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\TestCase;
@@ -16,6 +16,8 @@ use Shlinkio\Shlink\Core\Exception\TagNotFoundException;
use Shlinkio\Shlink\Core\Repository\TagRepository;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\Model\TagRenaming;
+use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering;
+use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagService;
use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta;
use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition;
@@ -46,27 +48,63 @@ class TagServiceTest extends TestCase
$expected = [new Tag('foo'), new Tag('bar')];
$match = $this->repo->match(Argument::cetera())->willReturn($expected);
+ $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
- $result = $this->service->listTags();
+ $result = $this->service->listTags(TagsParams::fromRawData([]));
- self::assertEquals($expected, $result);
+ self::assertEquals($expected, $result->getCurrentPageResults());
$match->shouldHaveBeenCalled();
+ $count->shouldHaveBeenCalled();
}
/**
* @test
- * @dataProvider provideAdminApiKeys
+ * @dataProvider provideApiKeysAndSearchTerm
*/
- public function tagsInfoDelegatesOnRepository(?ApiKey $apiKey): void
+ public function tagsInfoDelegatesOnRepository(
+ ?ApiKey $apiKey,
+ TagsParams $params,
+ TagsListFiltering $expectedFiltering,
+ int $countCalls,
+ ): void {
+ $expected = [new TagInfo('foo', 1, 1), new TagInfo('bar', 3, 10)];
+
+ $find = $this->repo->findTagsWithInfo($expectedFiltering)->willReturn($expected);
+ $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2);
+
+ $result = $this->service->tagsInfo($params, $apiKey);
+
+ self::assertEquals($expected, $result->getCurrentPageResults());
+ $find->shouldHaveBeenCalledOnce();
+ $count->shouldHaveBeenCalledTimes($countCalls);
+ }
+
+ public function provideApiKeysAndSearchTerm(): iterable
{
- $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)];
-
- $find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected);
-
- $result = $this->service->tagsInfo($apiKey);
-
- self::assertEquals($expected, $result);
- $find->shouldHaveBeenCalled();
+ yield 'no API key, no filter' => [
+ null,
+ $params = TagsParams::fromRawData([]),
+ TagsListFiltering::fromRangeAndParams(2, 0, $params, null),
+ 1,
+ ];
+ yield 'admin API key, no filter' => [
+ $apiKey = ApiKey::create(),
+ $params = TagsParams::fromRawData([]),
+ TagsListFiltering::fromRangeAndParams(2, 0, $params, $apiKey),
+ 1,
+ ];
+ yield 'no API key, search term' => [
+ null,
+ $params = TagsParams::fromRawData(['searchTerm' => 'foobar']),
+ TagsListFiltering::fromRangeAndParams(2, 0, $params, null),
+ 1,
+ ];
+ yield 'admin API key, limits' => [
+ $apiKey = ApiKey::create(),
+ $params = TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]),
+ TagsListFiltering::fromRangeAndParams(1, 0, $params, $apiKey),
+ 0,
+ ];
}
/**
@@ -97,21 +135,6 @@ class TagServiceTest extends TestCase
);
}
- /** @test */
- public function createTagsPersistsEntities(): void
- {
- $find = $this->repo->findOneBy(Argument::cetera())->willReturn(new Tag('foo'));
- $persist = $this->em->persist(Argument::type(Tag::class))->willReturn(null);
- $flush = $this->em->flush()->willReturn(null);
-
- $result = $this->service->createTags(['foo', 'bar']);
-
- self::assertCount(2, $result);
- $find->shouldHaveBeenCalled();
- $persist->shouldHaveBeenCalledTimes(2);
- $flush->shouldHaveBeenCalled();
- }
-
/**
* @test
* @dataProvider provideAdminApiKeys
diff --git a/module/Core/test/Util/RedirectResponseHelperTest.php b/module/Core/test/Util/RedirectResponseHelperTest.php
index eb26768f..651d4bc7 100644
--- a/module/Core/test/Util/RedirectResponseHelperTest.php
+++ b/module/Core/test/Util/RedirectResponseHelperTest.php
@@ -6,17 +6,17 @@ namespace ShlinkioTest\Shlink\Core\Util;
use Laminas\Diactoros\Response\RedirectResponse;
use PHPUnit\Framework\TestCase;
-use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
+use Shlinkio\Shlink\Core\Options\RedirectOptions;
use Shlinkio\Shlink\Core\Util\RedirectResponseHelper;
class RedirectResponseHelperTest extends TestCase
{
private RedirectResponseHelper $helper;
- private UrlShortenerOptions $shortenerOpts;
+ private RedirectOptions $shortenerOpts;
protected function setUp(): void
{
- $this->shortenerOpts = new UrlShortenerOptions();
+ $this->shortenerOpts = new RedirectOptions();
$this->helper = new RedirectResponseHelper($this->shortenerOpts);
}
diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php
new file mode 100644
index 00000000..4c4c00e5
--- /dev/null
+++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php
@@ -0,0 +1,79 @@
+repo = $this->prophesize(VisitRepositoryInterface::class);
+ $this->params = VisitsParams::fromRawData([]);
+ $this->apiKey = ApiKey::create();
+
+ $this->adapter = new NonOrphanVisitsPaginatorAdapter($this->repo->reveal(), $this->params, $this->apiKey);
+ }
+
+ /** @test */
+ public function countDelegatesToRepository(): void
+ {
+ $expectedCount = 5;
+ $repoCount = $this->repo->countNonOrphanVisits(
+ new VisitsCountFiltering($this->params->getDateRange(), $this->params->excludeBots(), $this->apiKey),
+ )->willReturn($expectedCount);
+
+ $result = $this->adapter->getNbResults();
+
+ self::assertEquals($expectedCount, $result);
+ $repoCount->shouldHaveBeenCalledOnce();
+ }
+
+ /**
+ * @test
+ * @dataProvider provideLimitAndOffset
+ */
+ public function getSliceDelegatesToRepository(int $limit, int $offset): void
+ {
+ $visitor = Visitor::emptyInstance();
+ $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)];
+ $repoFind = $this->repo->findNonOrphanVisits(new VisitsListFiltering(
+ $this->params->getDateRange(),
+ $this->params->excludeBots(),
+ $this->apiKey,
+ $limit,
+ $offset,
+ ))->willReturn($list);
+
+ $result = $this->adapter->getSlice($offset, $limit);
+
+ self::assertEquals($list, $result);
+ $repoFind->shouldHaveBeenCalledOnce();
+ }
+
+ public function provideLimitAndOffset(): iterable
+ {
+ yield [1, 5];
+ yield [10, 4];
+ yield [30, 18];
+ }
+}
diff --git a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php
similarity index 93%
rename from module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php
rename to module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php
index 1cc21eef..0ea91f29 100644
--- a/module/Core/test/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php
+++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
+namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
@@ -10,8 +10,8 @@ use Prophecy\Prophecy\ObjectProphecy;
use Shlinkio\Shlink\Core\Entity\Visit;
use Shlinkio\Shlink\Core\Model\Visitor;
use Shlinkio\Shlink\Core\Model\VisitsParams;
-use Shlinkio\Shlink\Core\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php
similarity index 84%
rename from module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php
rename to module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php
index 413ae1cd..04e17bc6 100644
--- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php
+++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php
@@ -2,7 +2,7 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
+namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
@@ -10,13 +10,13 @@ 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;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
-class VisitsPaginatorAdapterTest extends TestCase
+class ShortUrlVisitsPaginatorAdapterTest extends TestCase
{
use ProphecyTrait;
@@ -54,7 +54,7 @@ class VisitsPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter($apiKey);
$countVisits = $this->repo->countVisitsByShortCode(
ShortUrlIdentifier::fromShortCodeAndDomain(''),
- new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
+ new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
)->willReturn(3);
for ($i = 0; $i < $count; $i++) {
@@ -64,13 +64,13 @@ class VisitsPaginatorAdapterTest extends TestCase
$countVisits->shouldHaveBeenCalledOnce();
}
- private function createAdapter(?ApiKey $apiKey): VisitsPaginatorAdapter
+ private function createAdapter(?ApiKey $apiKey): ShortUrlVisitsPaginatorAdapter
{
- return new VisitsPaginatorAdapter(
+ return new ShortUrlVisitsPaginatorAdapter(
$this->repo->reveal(),
new ShortUrlIdentifier(''),
VisitsParams::fromRawData([]),
- $apiKey?->spec(),
+ $apiKey,
);
}
}
diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
similarity index 86%
rename from module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
rename to module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
index c92e21c6..442e7128 100644
--- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
+++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php
@@ -2,15 +2,15 @@
declare(strict_types=1);
-namespace ShlinkioTest\Shlink\Core\Paginator\Adapter;
+namespace ShlinkioTest\Shlink\Core\Visit\Paginator\Adapter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
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;
+use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering;
use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
@@ -53,7 +53,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$adapter = $this->createAdapter($apiKey);
$countVisits = $this->repo->countVisitsByTag(
'foo',
- new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey->spec()),
+ new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey),
)->willReturn(3);
for ($i = 0; $i < $count; $i++) {
@@ -63,9 +63,9 @@ class VisitsForTagPaginatorAdapterTest extends TestCase
$countVisits->shouldHaveBeenCalledOnce();
}
- private function createAdapter(?ApiKey $apiKey): VisitsForTagPaginatorAdapter
+ private function createAdapter(?ApiKey $apiKey): TagVisitsPaginatorAdapter
{
- return new VisitsForTagPaginatorAdapter(
+ return new TagVisitsPaginatorAdapter(
$this->repo->reveal(),
'foo',
VisitsParams::fromRawData([]),
diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php
index ab76bbf1..731697e6 100644
--- a/module/Core/test/Visit/VisitsStatsHelperTest.php
+++ b/module/Core/test/Visit/VisitsStatsHelperTest.php
@@ -53,7 +53,7 @@ class VisitsStatsHelperTest extends TestCase
public function returnsExpectedVisitsStats(int $expectedCount): void
{
$repo = $this->prophesize(VisitRepository::class);
- $count = $repo->countVisits(null)->willReturn($expectedCount * 3);
+ $count = $repo->countNonOrphanVisits(new VisitsCountFiltering())->willReturn($expectedCount * 3);
$countOrphan = $repo->countOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(
$expectedCount,
);
@@ -174,4 +174,23 @@ class VisitsStatsHelperTest extends TestCase
$countVisits->shouldHaveBeenCalledOnce();
$getRepo->shouldHaveBeenCalledOnce();
}
+
+ /** @test */
+ public function nonOrphanVisitsAreReturnedAsExpected(): void
+ {
+ $list = map(range(0, 3), fn () => Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()));
+ $repo = $this->prophesize(VisitRepository::class);
+ $countVisits = $repo->countNonOrphanVisits(Argument::type(VisitsCountFiltering::class))->willReturn(
+ count($list),
+ );
+ $listVisits = $repo->findNonOrphanVisits(Argument::type(VisitsListFiltering::class))->willReturn($list);
+ $getRepo = $this->em->getRepository(Visit::class)->willReturn($repo->reveal());
+
+ $paginator = $this->helper->nonOrphanVisits(new VisitsParams());
+
+ self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults()));
+ $listVisits->shouldHaveBeenCalledOnce();
+ $countVisits->shouldHaveBeenCalledOnce();
+ $getRepo->shouldHaveBeenCalledOnce();
+ }
}
diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php
index 98b385b0..5f0d5c05 100644
--- a/module/Rest/config/dependencies.config.php
+++ b/module/Rest/config/dependencies.config.php
@@ -30,14 +30,14 @@ return [
Action\ShortUrl\DeleteShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ResolveShortUrlAction::class => ConfigAbstractFactory::class,
Action\ShortUrl\ListShortUrlsAction::class => ConfigAbstractFactory::class,
- Action\ShortUrl\EditShortUrlTagsAction::class => ConfigAbstractFactory::class,
Action\Visit\ShortUrlVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\TagVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class,
Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class,
+ Action\Visit\NonOrphanVisitsAction::class => ConfigAbstractFactory::class,
Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class,
+ Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class,
Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class,
- Action\Tag\CreateTagsAction::class => ConfigAbstractFactory::class,
Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class,
Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class,
Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class,
@@ -75,11 +75,11 @@ return [
Visit\VisitsStatsHelper::class,
Visit\Transformer\OrphanVisitDataTransformer::class,
],
+ Action\Visit\NonOrphanVisitsAction::class => [Visit\VisitsStatsHelper::class],
Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class],
- Action\ShortUrl\EditShortUrlTagsAction::class => [Service\ShortUrlService::class],
Action\Tag\ListTagsAction::class => [TagService::class],
+ Action\Tag\TagsStatsAction::class => [TagService::class],
Action\Tag\DeleteTagsAction::class => [TagService::class],
- Action\Tag\CreateTagsAction::class => [TagService::class],
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class],
Action\Domain\DomainRedirectsAction::class => [DomainService::class],
diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php
index 991f4bb3..16f83149 100644
--- a/module/Rest/config/routes.config.php
+++ b/module/Rest/config/routes.config.php
@@ -28,18 +28,18 @@ return [
Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\ListShortUrlsAction::getRouteDef(),
- Action\ShortUrl\EditShortUrlTagsAction::getRouteDef([$dropDomainMiddleware]),
// Visits
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
Action\Visit\GlobalVisitsAction::getRouteDef(),
Action\Visit\OrphanVisitsAction::getRouteDef(),
+ Action\Visit\NonOrphanVisitsAction::getRouteDef(),
// Tags
Action\Tag\ListTagsAction::getRouteDef(),
+ Action\Tag\TagsStatsAction::getRouteDef(),
Action\Tag\DeleteTagsAction::getRouteDef(),
- Action\Tag\CreateTagsAction::getRouteDef(),
Action\Tag\UpdateTagAction::getRouteDef(),
// Domains
diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php
deleted file mode 100644
index feda3a62..00000000
--- a/module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php
+++ /dev/null
@@ -1,47 +0,0 @@
-getParsedBody();
-
- if (! isset($bodyParams['tags'])) {
- throw ValidationException::fromArray([
- 'tags' => 'List of tags has to be provided',
- ]);
- }
- ['tags' => $tags] = $bodyParams;
- $identifier = ShortUrlIdentifier::fromApiRequest($request);
- $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
-
- $shortUrl = $this->shortUrlService->updateShortUrl($identifier, ShortUrlEdit::fromRawData([
- ShortUrlInputFilter::TAGS => $tags,
- ]), $apiKey);
- return new JsonResponse(['tags' => $shortUrl->getTags()->toArray()]);
- }
-}
diff --git a/module/Rest/src/Action/Tag/CreateTagsAction.php b/module/Rest/src/Action/Tag/CreateTagsAction.php
deleted file mode 100644
index 09c860f5..00000000
--- a/module/Rest/src/Action/Tag/CreateTagsAction.php
+++ /dev/null
@@ -1,35 +0,0 @@
-getParsedBody();
- $tags = $body['tags'] ?? [];
-
- return new JsonResponse([
- 'tags' => [
- 'data' => $this->tagService->createTags($tags)->toArray(),
- ],
- ]);
- }
-}
diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php
index 3d34bd19..ab81400c 100644
--- a/module/Rest/src/Action/Tag/ListTagsAction.php
+++ b/module/Rest/src/Action/Tag/ListTagsAction.php
@@ -7,7 +7,9 @@ namespace Shlinkio\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
+use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
+use Shlinkio\Shlink\Core\Tag\Model\TagsParams;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
@@ -16,6 +18,8 @@ use function Functional\map;
class ListTagsAction extends AbstractRestAction
{
+ use PagerfantaUtilsTrait;
+
protected const ROUTE_PATH = '/tags';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
@@ -25,26 +29,20 @@ class ListTagsAction extends AbstractRestAction
public function handle(ServerRequestInterface $request): ResponseInterface
{
- $query = $request->getQueryParams();
- $withStats = ($query['withStats'] ?? null) === 'true';
+ $params = TagsParams::fromRawData($request->getQueryParams());
$apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
- if (! $withStats) {
+ if (! $params->withStats()) {
return new JsonResponse([
- 'tags' => [
- 'data' => $this->tagService->listTags($apiKey),
- ],
+ 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)),
]);
}
- $tagsInfo = $this->tagService->tagsInfo($apiKey);
- $data = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString());
+ // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead
+ $tagsInfo = $this->tagService->tagsInfo($params, $apiKey);
+ $rawTags = $this->serializePaginator($tagsInfo, null, 'stats');
+ $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag());
- return new JsonResponse([
- 'tags' => [
- 'data' => $data,
- 'stats' => $tagsInfo,
- ],
- ]);
+ return new JsonResponse(['tags' => $rawTags]);
}
}
diff --git a/module/Rest/src/Action/Tag/TagsStatsAction.php b/module/Rest/src/Action/Tag/TagsStatsAction.php
new file mode 100644
index 00000000..cec8edd6
--- /dev/null
+++ b/module/Rest/src/Action/Tag/TagsStatsAction.php
@@ -0,0 +1,35 @@
+getQueryParams());
+ $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
+ $tagsInfo = $this->tagService->tagsInfo($params, $apiKey);
+
+ return new JsonResponse(['tags' => $this->serializePaginator($tagsInfo)]);
+ }
+}
diff --git a/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php
new file mode 100644
index 00000000..7d77a5b1
--- /dev/null
+++ b/module/Rest/src/Action/Visit/NonOrphanVisitsAction.php
@@ -0,0 +1,37 @@
+getQueryParams());
+ $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request);
+ $visits = $this->visitsHelper->nonOrphanVisits($params, $apiKey);
+
+ return new JsonResponse([
+ 'visits' => $this->serializePaginator($visits),
+ ]);
+ }
+}
diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php
index c3677029..557abd00 100644
--- a/module/Rest/src/ApiKey/Role.php
+++ b/module/Rest/src/ApiKey/Role.php
@@ -21,21 +21,22 @@ class Role
self::DOMAIN_SPECIFIC => 'Domain only',
];
- public static function toSpec(ApiKeyRole $role, bool $inlined, ?string $context = null): Specification
+ public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification
{
- if ($role->name() === self::AUTHORED_SHORT_URLS) {
- $apiKey = $role->apiKey();
- return $inlined ? Spec::andX(new BelongsToApiKeyInlined($apiKey)) : new BelongsToApiKey($apiKey, $context);
- }
+ return match ($role->name()) {
+ self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context),
+ self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context),
+ default => Spec::andX(),
+ };
+ }
- if ($role->name() === self::DOMAIN_SPECIFIC) {
- $domainId = self::domainIdFromMeta($role->meta());
- return $inlined
- ? Spec::andX(new BelongsToDomainInlined($domainId))
- : new BelongsToDomain($domainId, $context);
- }
-
- return Spec::andX();
+ public static function toInlinedSpec(ApiKeyRole $role): Specification
+ {
+ return match ($role->name()) {
+ self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())),
+ self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))),
+ default => Spec::andX(),
+ };
}
public static function domainIdFromMeta(array $meta): string
diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
index ddfabe81..1f8c2fd3 100644
--- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
+++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php
@@ -20,7 +20,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification
{
return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX(
Spec::join($this->fieldToJoin, 's'),
- $this->apiKey->spec(false, $this->fieldToJoin),
+ $this->apiKey->spec($this->fieldToJoin),
);
}
}
diff --git a/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php
new file mode 100644
index 00000000..8e535570
--- /dev/null
+++ b/module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php
@@ -0,0 +1,26 @@
+apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX(
+ Spec::join($this->fieldToJoin, 's'),
+ $this->apiKey->inlinedSpec(),
+ );
+ }
+}
diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php
index 121bea18..2940bc69 100644
--- a/module/Rest/src/Entity/ApiKey.php
+++ b/module/Rest/src/Entity/ApiKey.php
@@ -96,9 +96,15 @@ class ApiKey extends AbstractEntity
return $this->key;
}
- public function spec(bool $inlined = false, ?string $context = null): Specification
+ public function spec(?string $context = null): Specification
{
- $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $inlined, $context))->getValues();
+ $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toSpec($role, $context))->getValues();
+ return Spec::andX(...$specs);
+ }
+
+ public function inlinedSpec(): Specification
+ {
+ $specs = $this->roles->map(fn (ApiKeyRole $role) => Role::toInlinedSpec($role))->getValues();
return Spec::andX(...$specs);
}
diff --git a/module/Rest/src/Exception/MissingAuthenticationException.php b/module/Rest/src/Exception/MissingAuthenticationException.php
index 4e1057bc..99dbc0df 100644
--- a/module/Rest/src/Exception/MissingAuthenticationException.php
+++ b/module/Rest/src/Exception/MissingAuthenticationException.php
@@ -24,10 +24,7 @@ class MissingAuthenticationException extends RuntimeException implements Problem
'Expected one of the following authentication headers, ["%s"], but none were provided',
implode('", "', $expectedHeaders),
));
- $e->additional = [
- 'expectedTypes' => $expectedHeaders, // Deprecated
- 'expectedHeaders' => $expectedHeaders,
- ];
+ $e->additional = ['expectedHeaders' => $expectedHeaders];
return $e;
}
diff --git a/module/Rest/src/Middleware/BodyParserMiddleware.php b/module/Rest/src/Middleware/BodyParserMiddleware.php
index 2711d900..8922de03 100644
--- a/module/Rest/src/Middleware/BodyParserMiddleware.php
+++ b/module/Rest/src/Middleware/BodyParserMiddleware.php
@@ -10,12 +10,8 @@ use Psr\Http\Message\ServerRequestInterface as Request;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
-use function array_shift;
-use function explode;
use function Functional\contains;
-use function parse_str;
use function Shlinkio\Shlink\Common\json_decode;
-use function trim;
class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterface
{
@@ -36,20 +32,7 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
return $handler->handle($request);
}
- // If the accepted content is JSON, try to parse the body from JSON
- $contentType = $this->getRequestContentType($request);
- if (contains(['application/json', 'text/json', 'application/x-json'], $contentType)) {
- return $handler->handle($this->parseFromJson($request));
- }
-
- return $handler->handle($this->parseFromUrlEncoded($request));
- }
-
- private function getRequestContentType(Request $request): string
- {
- $contentType = $request->getHeaderLine('Content-type');
- $contentTypes = explode(';', $contentType);
- return trim(array_shift($contentTypes));
+ return $handler->handle($this->parseFromJson($request));
}
private function parseFromJson(Request $request): Request
@@ -62,20 +45,4 @@ class BodyParserMiddleware implements MiddlewareInterface, RequestMethodInterfac
$parsedJson = json_decode($rawBody);
return $request->withParsedBody($parsedJson);
}
-
- /**
- * @deprecated To be removed on Shlink v3.0.0, supporting only JSON requests.
- */
- private function parseFromUrlEncoded(Request $request): Request
- {
- $rawBody = $request->getBody()->__toString();
- if (empty($rawBody)) {
- return $request;
- }
-
- $parsedBody = [];
- parse_str($rawBody, $parsedBody);
-
- return $request->withParsedBody($parsedBody);
- }
}
diff --git a/module/Rest/src/Service/ApiKeyService.php b/module/Rest/src/Service/ApiKeyService.php
index d66e70e2..7d7e0710 100644
--- a/module/Rest/src/Service/ApiKeyService.php
+++ b/module/Rest/src/Service/ApiKeyService.php
@@ -41,7 +41,7 @@ class ApiKeyService implements ApiKeyServiceInterface
$expirationDate !== null && $name !== null => ApiKey::fromMeta(
ApiKeyMeta::withNameAndExpirationDate($name, $expirationDate),
),
- $expirationDate !== null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)),
+ $expirationDate !== null => ApiKey::fromMeta(ApiKeyMeta::withExpirationDate($expirationDate)),
$name !== null => ApiKey::fromMeta(ApiKeyMeta::withName($name)),
default => ApiKey::create(),
};
diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php
index e9768a69..0abd2021 100644
--- a/module/Rest/test-api/Action/CreateShortUrlTest.php
+++ b/module/Rest/test-api/Action/CreateShortUrlTest.php
@@ -315,11 +315,22 @@ class CreateShortUrlTest extends ApiTestCase
yield ['https://mobile.twitter.com/shlinkio/status/1360637738421268481'];
}
+ /** @test */
+ public function canCreateShortUrlsWithEmojis(): void
+ {
+ [$statusCode, $payload] = $this->createShortUrl([
+ 'longUrl' => 'https://emojipedia.org/fire/',
+ 'title' => '🔥🔥🔥',
+ 'customSlug' => '🦣🦣🦣',
+ ]);
+ self::assertEquals(self::STATUS_OK, $statusCode);
+ self::assertEquals('🔥🔥🔥', $payload['title']);
+ self::assertEquals('🦣🦣🦣', $payload['shortCode']);
+ self::assertEquals('http://doma.in/🦣🦣🦣', $payload['shortUrl']);
+ }
+
/**
- * @return array {
- * @var int $statusCode
- * @var array $payload
- * }
+ * @return array{int $statusCode, array $payload}
*/
private function createShortUrl(array $body = [], string $apiKey = 'valid_api_key'): array
{
diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php
index bb512832..5cac3dbd 100644
--- a/module/Rest/test-api/Action/DeleteShortUrlTest.php
+++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php
@@ -33,26 +33,6 @@ class DeleteShortUrlTest extends ApiTestCase
self::assertEquals($domain, $payload['domain'] ?? null);
}
- /** @test */
- public function unprocessableEntityIsReturnedWhenTryingToDeleteUrlWithTooManyVisits(): void
- {
- // Generate visits first
- for ($i = 0; $i < 20; $i++) {
- self::assertEquals(self::STATUS_FOUND, $this->callShortUrl('abc123')->getStatusCode());
- }
- $expectedDetail = 'Impossible to delete short URL with short code "abc123", since it has more than "15" '
- . 'visits.';
-
- $resp = $this->callApiWithKey(self::METHOD_DELETE, '/short-urls/abc123');
- $payload = $this->getJsonResponsePayload($resp);
-
- self::assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $resp->getStatusCode());
- self::assertEquals(self::STATUS_UNPROCESSABLE_ENTITY, $payload['status']);
- self::assertEquals('INVALID_SHORTCODE_DELETION', $payload['type']);
- self::assertEquals($expectedDetail, $payload['detail']);
- self::assertEquals('Cannot delete short URL', $payload['title']);
- }
-
/** @test */
public function properShortUrlIsDeletedWhenDomainIsProvided(): void
{
diff --git a/module/Rest/test-api/Action/EditShortUrlTagsTest.php b/module/Rest/test-api/Action/EditShortUrlTagsTest.php
deleted file mode 100644
index f940a52d..00000000
--- a/module/Rest/test-api/Action/EditShortUrlTagsTest.php
+++ /dev/null
@@ -1,94 +0,0 @@
-callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => []]);
- $payload = $this->getJsonResponsePayload($resp);
-
- self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode());
- self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']);
- self::assertEquals('INVALID_ARGUMENT', $payload['type']);
- self::assertEquals($expectedDetail, $payload['detail']);
- self::assertEquals('Invalid data', $payload['title']);
- }
-
- /**
- * @test
- * @dataProvider provideInvalidUrls
- */
- public function providingInvalidShortCodeReturnsBadRequest(
- string $shortCode,
- ?string $domain,
- string $expectedDetail,
- string $apiKey,
- ): void {
- $url = $this->buildShortUrlPath($shortCode, $domain, '/tags');
- $resp = $this->callApiWithKey(self::METHOD_PUT, $url, [RequestOptions::JSON => [
- 'tags' => ['foo', 'bar'],
- ]], $apiKey);
- $payload = $this->getJsonResponsePayload($resp);
-
- self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode());
- self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']);
- self::assertEquals('INVALID_SHORTCODE', $payload['type']);
- self::assertEquals($expectedDetail, $payload['detail']);
- self::assertEquals('Short URL not found', $payload['title']);
- self::assertEquals($shortCode, $payload['shortCode']);
- self::assertEquals($domain, $payload['domain'] ?? null);
- }
-
- /** @test */
- public function allowsEditingTagsWithTwoEndpoints(): void
- {
- $getUrlTagsFromApi = fn () => $this->getJsonResponsePayload(
- $this->callApiWithKey(self::METHOD_GET, '/short-urls/abc123'),
- )['tags'] ?? null;
- self::assertEquals(['foo'], $getUrlTagsFromApi());
-
- $this->callApiWithKey(self::METHOD_PUT, '/short-urls/abc123/tags', [RequestOptions::JSON => [
- 'tags' => ['a', 'e'],
- ]]);
- self::assertEquals(['a', 'e'], $getUrlTagsFromApi());
-
- $this->callApiWithKey(self::METHOD_PATCH, '/short-urls/abc123', [RequestOptions::JSON => [
- 'tags' => ['i', 'o', 'u'],
- ]]);
- self::assertEquals(['i', 'o', 'u'], $getUrlTagsFromApi());
- }
-
- /** @test */
- public function tagsAreSetOnProperShortUrlBasedOnProvidedDomain(): void
- {
- $urlWithoutDomain = '/short-urls/ghi789/tags';
- $urlWithDomain = $urlWithoutDomain . '?domain=example.com';
-
- $setTagsWithDomain = $this->callApiWithKey(self::METHOD_PUT, $urlWithDomain, [RequestOptions::JSON => [
- 'tags' => ['foo', 'bar'],
- ]]);
- $fetchWithoutDomain = $this->getJsonResponsePayload(
- $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789'),
- );
- $fetchWithDomain = $this->getJsonResponsePayload(
- $this->callApiWithKey(self::METHOD_GET, '/short-urls/ghi789?domain=example.com'),
- );
-
- self::assertEquals(self::STATUS_OK, $setTagsWithDomain->getStatusCode());
- self::assertEquals([], $fetchWithoutDomain['tags']);
- self::assertEquals(['bar', 'foo'], $fetchWithDomain['tags']);
- }
-}
diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php
index fcc07719..b28a0b5d 100644
--- a/module/Rest/test-api/Action/ListShortUrlsTest.php
+++ b/module/Rest/test-api/Action/ListShortUrlsTest.php
@@ -140,12 +140,12 @@ class ListShortUrlsTest extends ApiTestCase
public function provideFilteredLists(): iterable
{
yield [[], [
+ self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_CUSTOM_SLUG,
+ self::SHORT_URL_META,
+ self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_DOCS,
- self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
- self::SHORT_URL_META,
- self::SHORT_URL_CUSTOM_SLUG,
- self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [['orderBy' => 'shortCode'], [
self::SHORT_URL_SHLINK_WITH_TITLE,
@@ -155,14 +155,6 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
- yield [['orderBy' => ['shortCode' => 'DESC']], [ // Deprecated
- self::SHORT_URL_DOCS,
- self::SHORT_URL_CUSTOM_DOMAIN,
- self::SHORT_URL_META,
- self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
- self::SHORT_URL_CUSTOM_SLUG,
- self::SHORT_URL_SHLINK_WITH_TITLE,
- ], 'valid_api_key'];
yield [['orderBy' => 'shortCode-DESC'], [
self::SHORT_URL_DOCS,
self::SHORT_URL_CUSTOM_DOMAIN,
@@ -180,29 +172,48 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [
- self::SHORT_URL_META,
- self::SHORT_URL_CUSTOM_SLUG,
self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_CUSTOM_SLUG,
+ self::SHORT_URL_META,
], 'valid_api_key'];
yield [['endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
+ self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
self::SHORT_URL_SHLINK_WITH_TITLE,
self::SHORT_URL_DOCS,
- self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN,
], 'valid_api_key'];
yield [['tags' => ['foo']], [
- self::SHORT_URL_SHLINK_WITH_TITLE,
- self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_META,
+ self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['tags' => ['bar']], [
self::SHORT_URL_META,
], 'valid_api_key'];
+ yield [['tags' => ['foo', 'bar']], [
+ self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_META,
+ self::SHORT_URL_SHLINK_WITH_TITLE,
+ ], 'valid_api_key'];
+ yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [
+ self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_META,
+ self::SHORT_URL_SHLINK_WITH_TITLE,
+ ], 'valid_api_key'];
+ yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [
+ self::SHORT_URL_META,
+ ], 'valid_api_key'];
+ yield [['tags' => ['foo', 'bar', 'baz']], [
+ self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_META,
+ self::SHORT_URL_SHLINK_WITH_TITLE,
+ ], 'valid_api_key'];
+ yield [['tags' => ['foo', 'bar', 'baz'], 'tagsMode' => 'all'], [], 'valid_api_key'];
yield [['tags' => ['foo'], 'endDate' => Chronos::parse('2018-12-01')->toAtomString()], [
self::SHORT_URL_SHLINK_WITH_TITLE,
], 'valid_api_key'];
yield [['searchTerm' => 'alejandro'], [
- self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_DOMAIN,
+ self::SHORT_URL_META,
], 'valid_api_key'];
yield [['searchTerm' => 'cool'], [
self::SHORT_URL_SHLINK_WITH_TITLE,
@@ -211,9 +222,9 @@ class ListShortUrlsTest extends ApiTestCase
self::SHORT_URL_CUSTOM_DOMAIN,
], 'valid_api_key'];
yield [[], [
- self::SHORT_URL_SHLINK_WITH_TITLE,
- self::SHORT_URL_META,
self::SHORT_URL_CUSTOM_SLUG,
+ self::SHORT_URL_META,
+ self::SHORT_URL_SHLINK_WITH_TITLE,
], 'author_api_key'];
yield [[], [
self::SHORT_URL_CUSTOM_DOMAIN,
@@ -230,4 +241,30 @@ class ListShortUrlsTest extends ApiTestCase
'totalItems' => $itemsCount,
];
}
+
+ /**
+ * @test
+ * @dataProvider provideInvalidFiltering
+ */
+ public function errorIsReturnedWhenProvidingInvalidValues(array $query, array $expectedInvalidElements): void
+ {
+ $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]);
+ $respPayload = $this->getJsonResponsePayload($resp);
+
+ self::assertEquals(400, $resp->getStatusCode());
+ self::assertEquals([
+ 'invalidElements' => $expectedInvalidElements,
+ 'title' => 'Invalid data',
+ 'type' => 'INVALID_ARGUMENT',
+ 'status' => 400,
+ 'detail' => 'Provided data is not valid',
+ ], $respPayload);
+ }
+
+ public function provideInvalidFiltering(): iterable
+ {
+ yield [['tagsMode' => 'invalid'], ['tagsMode']];
+ yield [['orderBy' => 'invalid'], ['orderBy']];
+ yield [['orderBy' => 'invalid', 'tagsMode' => 'invalid'], ['tagsMode', 'orderBy']];
+ }
}
diff --git a/module/Rest/test-api/Action/ListTagsTest.php b/module/Rest/test-api/Action/ListTagsTest.php
index d82a4f8e..4c627e7c 100644
--- a/module/Rest/test-api/Action/ListTagsTest.php
+++ b/module/Rest/test-api/Action/ListTagsTest.php
@@ -23,60 +23,44 @@ class ListTagsTest extends ApiTestCase
public function provideQueries(): iterable
{
- yield 'admin API key without stats' => ['valid_api_key', [], [
+ yield 'admin API key' => ['valid_api_key', [], [
'data' => ['bar', 'baz', 'foo'],
- ]];
- yield 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [
- 'data' => ['bar', 'baz', 'foo'],
- 'stats' => [
- [
- 'tag' => 'bar',
- 'shortUrlsCount' => 1,
- 'visitsCount' => 2,
- ],
- [
- 'tag' => 'baz',
- 'shortUrlsCount' => 0,
- 'visitsCount' => 0,
- ],
- [
- 'tag' => 'foo',
- 'shortUrlsCount' => 3,
- 'visitsCount' => 5,
- ],
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 3,
+ 'itemsInCurrentPage' => 3,
+ 'totalItems' => 3,
],
]];
-
- yield 'author API key without stats' => ['author_api_key', [], [
- 'data' => ['bar', 'foo'],
- ]];
- yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [
- 'data' => ['bar', 'foo'],
- 'stats' => [
- [
- 'tag' => 'bar',
- 'shortUrlsCount' => 1,
- 'visitsCount' => 2,
- ],
- [
- 'tag' => 'foo',
- 'shortUrlsCount' => 2,
- 'visitsCount' => 5,
- ],
+ yield 'admin api key with pagination' => ['valid_api_key', ['page' => 2, 'itemsPerPage' => 2], [
+ 'data' => ['foo'],
+ 'pagination' => [
+ 'currentPage' => 2,
+ 'pagesCount' => 2,
+ 'itemsPerPage' => 2,
+ 'itemsInCurrentPage' => 1,
+ 'totalItems' => 3,
],
]];
-
- yield 'domain API key without stats' => ['domain_api_key', [], [
- 'data' => ['foo'],
+ yield 'author API key' => ['author_api_key', [], [
+ 'data' => ['bar', 'foo'],
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 2,
+ 'itemsInCurrentPage' => 2,
+ 'totalItems' => 2,
+ ],
]];
- yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [
+ yield 'domain API key' => ['domain_api_key', [], [
'data' => ['foo'],
- 'stats' => [
- [
- 'tag' => 'foo',
- 'shortUrlsCount' => 1,
- 'visitsCount' => 0,
- ],
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 1,
+ 'itemsInCurrentPage' => 1,
+ 'totalItems' => 1,
],
]];
}
diff --git a/module/Rest/test-api/Action/NonOrphanVisitsTest.php b/module/Rest/test-api/Action/NonOrphanVisitsTest.php
new file mode 100644
index 00000000..c53e29cc
--- /dev/null
+++ b/module/Rest/test-api/Action/NonOrphanVisitsTest.php
@@ -0,0 +1,36 @@
+callApiWithKey(self::METHOD_GET, '/visits/non-orphan', [RequestOptions::QUERY => $query]);
+ $payload = $this->getJsonResponsePayload($resp);
+
+ self::assertEquals($totalItems, $payload['visits']['pagination']['totalItems'] ?? Paginator::ALL_ITEMS);
+ self::assertCount($returnedItems, $payload['visits']['data'] ?? []);
+ }
+
+ public function provideQueries(): iterable
+ {
+ yield 'all data' => [[], 7, 7];
+ yield 'middle page' => [['page' => 2, 'itemsPerPage' => 3], 7, 3];
+ yield 'last page' => [['page' => 3, 'itemsPerPage' => 3], 7, 1];
+ yield 'bots excluded' => [['excludeBots' => 'true'], 6, 6];
+ yield 'bots excluded and pagination' => [['excludeBots' => 'true', 'page' => 1, 'itemsPerPage' => 4], 6, 4];
+ yield 'date filter' => [['startDate' => Chronos::now()->addDay()->toAtomString()], 0, 0];
+ }
+}
diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php
new file mode 100644
index 00000000..3b91cbf0
--- /dev/null
+++ b/module/Rest/test-api/Action/TagsStatsTest.php
@@ -0,0 +1,136 @@
+callApiWithKey(self::METHOD_GET, '/tags/stats', [RequestOptions::QUERY => $query], $apiKey);
+ ['tags' => $tags] = $this->getJsonResponsePayload($resp);
+
+ self::assertEquals($expectedStats, $tags['data']);
+ self::assertEquals($expectedPagination, $tags['pagination']);
+ }
+
+ /**
+ * @test
+ * @dataProvider provideQueries
+ */
+ public function expectedListOfTagsIsReturnedForDeprecatedApproach(
+ string $apiKey,
+ array $query,
+ array $expectedStats,
+ array $expectedPagination,
+ ): void {
+ $query['withStats'] = 'true';
+ $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey);
+ ['tags' => $tags] = $this->getJsonResponsePayload($resp);
+
+ self::assertEquals($expectedStats, $tags['stats']);
+ self::assertEquals($expectedPagination, $tags['pagination']);
+ self::assertArrayHasKey('data', $tags);
+ }
+
+ public function provideQueries(): iterable
+ {
+ yield 'admin API key' => ['valid_api_key', [], [
+ [
+ 'tag' => 'bar',
+ 'shortUrlsCount' => 1,
+ 'visitsCount' => 2,
+ ],
+ [
+ 'tag' => 'baz',
+ 'shortUrlsCount' => 0,
+ 'visitsCount' => 0,
+ ],
+ [
+ 'tag' => 'foo',
+ 'shortUrlsCount' => 3,
+ 'visitsCount' => 5,
+ ],
+ ], [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 3,
+ 'itemsInCurrentPage' => 3,
+ 'totalItems' => 3,
+ ]];
+ yield 'admin API key with pagination' => ['valid_api_key', ['page' => 1, 'itemsPerPage' => 2], [
+ [
+ 'tag' => 'bar',
+ 'shortUrlsCount' => 1,
+ 'visitsCount' => 2,
+ ],
+ [
+ 'tag' => 'baz',
+ 'shortUrlsCount' => 0,
+ 'visitsCount' => 0,
+ ],
+ ], [
+ 'currentPage' => 1,
+ 'pagesCount' => 2,
+ 'itemsPerPage' => 2,
+ 'itemsInCurrentPage' => 2,
+ 'totalItems' => 3,
+ ]];
+ yield 'author API key' => ['author_api_key', [], [
+ [
+ 'tag' => 'bar',
+ 'shortUrlsCount' => 1,
+ 'visitsCount' => 2,
+ ],
+ [
+ 'tag' => 'foo',
+ 'shortUrlsCount' => 2,
+ 'visitsCount' => 5,
+ ],
+ ], [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 2,
+ 'itemsInCurrentPage' => 2,
+ 'totalItems' => 2,
+ ]];
+ yield 'author API key with pagination' => ['author_api_key', ['page' => 2, 'itemsPerPage' => 1], [
+ [
+ 'tag' => 'foo',
+ 'shortUrlsCount' => 2,
+ 'visitsCount' => 5,
+ ],
+ ], [
+ 'currentPage' => 2,
+ 'pagesCount' => 2,
+ 'itemsPerPage' => 1,
+ 'itemsInCurrentPage' => 1,
+ 'totalItems' => 2,
+ ]];
+ yield 'domain API key' => ['domain_api_key', [], [
+ [
+ 'tag' => 'foo',
+ 'shortUrlsCount' => 1,
+ 'visitsCount' => 0,
+ ],
+ ], [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 1,
+ 'itemsInCurrentPage' => 1,
+ 'totalItems' => 1,
+ ]];
+ }
+}
diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php
index a51d6a7b..3efbeacb 100644
--- a/module/Rest/test-api/Middleware/CorsTest.php
+++ b/module/Rest/test-api/Middleware/CorsTest.php
@@ -73,7 +73,7 @@ class CorsTest extends ApiTestCase
{
yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE'];
yield 'short URLs route' => ['/short-urls', 'GET,POST'];
- yield 'tags route' => ['/tags', 'GET,POST,PUT,DELETE'];
+ yield 'tags route' => ['/tags', 'GET,PUT,DELETE'];
yield 'health route' => ['/health', 'GET'];
}
}
diff --git a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php b/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php
deleted file mode 100644
index 59c55d84..00000000
--- a/module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php
+++ /dev/null
@@ -1,63 +0,0 @@
-shortUrlService = $this->prophesize(ShortUrlService::class);
- $this->action = new EditShortUrlTagsAction($this->shortUrlService->reveal());
- }
-
- /** @test */
- public function notProvidingTagsReturnsError(): void
- {
- $this->expectException(ValidationException::class);
- $this->action->handle($this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123'));
- }
-
- /** @test */
- public function tagsListIsReturnedIfCorrectShortCodeIsProvided(): void
- {
- $shortCode = 'abc123';
- $this->shortUrlService->updateShortUrl(
- new ShortUrlIdentifier($shortCode),
- Argument::type(ShortUrlEdit::class),
- Argument::type(ApiKey::class),
- )->willReturn(ShortUrl::createEmpty())
- ->shouldBeCalledOnce();
-
- $response = $this->action->handle(
- $this->createRequestWithAPiKey()->withAttribute('shortCode', 'abc123')
- ->withParsedBody(['tags' => []]),
- );
- self::assertEquals(200, $response->getStatusCode());
- }
-
- private function createRequestWithAPiKey(): ServerRequestInterface
- {
- return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create());
- }
-}
diff --git a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php
index 170ccc09..59876b55 100644
--- a/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php
+++ b/module/Rest/test/Action/ShortUrl/ListShortUrlsActionTest.php
@@ -6,7 +6,7 @@ namespace ShlinkioTest\Shlink\Rest\Action\ShortUrl;
use Cake\Chronos\Chronos;
use Laminas\Diactoros\Response\JsonResponse;
-use Laminas\Diactoros\ServerRequest;
+use Laminas\Diactoros\ServerRequestFactory;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
@@ -52,7 +52,8 @@ class ListShortUrlsActionTest extends TestCase
?string $endDate = null,
): void {
$apiKey = ApiKey::create();
- $request = (new ServerRequest())->withQueryParams($query)->withAttribute(ApiKey::class, $apiKey);
+ $request = ServerRequestFactory::fromGlobals()->withQueryParams($query)
+ ->withAttribute(ApiKey::class, $apiKey);
$listShortUrls = $this->service->listShortUrls(ShortUrlsParams::fromRawData([
'page' => $expectedPage,
'searchTerm' => $expectedSearchTerm,
@@ -81,10 +82,10 @@ class ListShortUrlsActionTest extends TestCase
yield [['page' => '8'], 8, null, [], null];
yield [['searchTerm' => $searchTerm = 'foo'], 1, $searchTerm, [], null];
yield [['tags' => $tags = ['foo','bar']], 1, null, $tags, null];
- yield [['orderBy' => $orderBy = 'something'], 1, null, [], $orderBy];
+ yield [['orderBy' => $orderBy = 'longUrl'], 1, null, [], $orderBy];
yield [[
'page' => '2',
- 'orderBy' => $orderBy = 'something',
+ 'orderBy' => $orderBy = 'visits',
'tags' => $tags = ['one', 'two'],
], 2, null, $tags, $orderBy];
yield [
diff --git a/module/Rest/test/Action/Tag/CreateTagsActionTest.php b/module/Rest/test/Action/Tag/CreateTagsActionTest.php
deleted file mode 100644
index f63c0afc..00000000
--- a/module/Rest/test/Action/Tag/CreateTagsActionTest.php
+++ /dev/null
@@ -1,50 +0,0 @@
-tagService = $this->prophesize(TagServiceInterface::class);
- $this->action = new CreateTagsAction($this->tagService->reveal());
- }
-
- /**
- * @test
- * @dataProvider provideTags
- */
- public function processDelegatesIntoService(?array $tags): void
- {
- $request = (new ServerRequest())->withParsedBody(['tags' => $tags]);
- $deleteTags = $this->tagService->createTags($tags ?: [])->willReturn(new ArrayCollection());
-
- $response = $this->action->handle($request);
-
- self::assertEquals(200, $response->getStatusCode());
- $deleteTags->shouldHaveBeenCalled();
- }
-
- public function provideTags(): iterable
- {
- yield 'three tags' => [['foo', 'bar', 'baz']];
- yield 'two tags' => [['some', 'thing']];
- yield 'null tags' => [null];
- yield 'empty tags' => [[]];
- }
-}
diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php
index 8b7378fd..123e4945 100644
--- a/module/Rest/test/Action/Tag/ListTagsActionTest.php
+++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php
@@ -6,17 +6,21 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag;
use Laminas\Diactoros\Response\JsonResponse;
use Laminas\Diactoros\ServerRequestFactory;
+use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Psr\Http\Message\ServerRequestInterface;
+use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Entity\Tag;
use Shlinkio\Shlink\Core\Tag\Model\TagInfo;
use Shlinkio\Shlink\Core\Tag\TagServiceInterface;
use Shlinkio\Shlink\Rest\Action\Tag\ListTagsAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
+use function count;
+
class ListTagsActionTest extends TestCase
{
use ProphecyTrait;
@@ -37,7 +41,10 @@ class ListTagsActionTest extends TestCase
public function returnsBaseDataWhenStatsAreNotRequested(array $query): void
{
$tags = [new Tag('foo'), new Tag('bar')];
- $listTags = $this->tagService->listTags(Argument::type(ApiKey::class))->willReturn($tags);
+ $tagsCount = count($tags);
+ $listTags = $this->tagService->listTags(Argument::any(), Argument::type(ApiKey::class))->willReturn(
+ new Paginator(new ArrayAdapter($tags)),
+ );
/** @var JsonResponse $resp */
$resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query));
@@ -46,6 +53,13 @@ class ListTagsActionTest extends TestCase
self::assertEquals([
'tags' => [
'data' => $tags,
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 10,
+ 'itemsInCurrentPage' => $tagsCount,
+ 'totalItems' => $tagsCount,
+ ],
],
], $payload);
$listTags->shouldHaveBeenCalled();
@@ -62,10 +76,13 @@ class ListTagsActionTest extends TestCase
public function returnsStatsWhenRequested(): void
{
$stats = [
- new TagInfo(new Tag('foo'), 1, 1),
- new TagInfo(new Tag('bar'), 3, 10),
+ new TagInfo('foo', 1, 1),
+ new TagInfo('bar', 3, 10),
];
- $tagsInfo = $this->tagService->tagsInfo(Argument::type(ApiKey::class))->willReturn($stats);
+ $itemsCount = count($stats);
+ $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn(
+ new Paginator(new ArrayAdapter($stats)),
+ );
$req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
/** @var JsonResponse $resp */
@@ -76,6 +93,13 @@ class ListTagsActionTest extends TestCase
'tags' => [
'data' => ['foo', 'bar'],
'stats' => $stats,
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 10,
+ 'itemsInCurrentPage' => $itemsCount,
+ 'totalItems' => $itemsCount,
+ ],
],
], $payload);
$tagsInfo->shouldHaveBeenCalled();
diff --git a/module/Rest/test/Action/Tag/TagsStatsActionTest.php b/module/Rest/test/Action/Tag/TagsStatsActionTest.php
new file mode 100644
index 00000000..2cb3ad64
--- /dev/null
+++ b/module/Rest/test/Action/Tag/TagsStatsActionTest.php
@@ -0,0 +1,72 @@
+tagService = $this->prophesize(TagServiceInterface::class);
+ $this->action = new TagsStatsAction($this->tagService->reveal());
+ }
+
+ /** @test */
+ public function returnsTagsStatsWhenRequested(): void
+ {
+ $stats = [
+ new TagInfo('foo', 1, 1),
+ new TagInfo('bar', 3, 10),
+ ];
+ $itemsCount = count($stats);
+ $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn(
+ new Paginator(new ArrayAdapter($stats)),
+ );
+ $req = $this->requestWithApiKey()->withQueryParams(['withStats' => 'true']);
+
+ /** @var JsonResponse $resp */
+ $resp = $this->action->handle($req);
+ $payload = $resp->getPayload();
+
+ self::assertEquals([
+ 'tags' => [
+ 'data' => $stats,
+ 'pagination' => [
+ 'currentPage' => 1,
+ 'pagesCount' => 1,
+ 'itemsPerPage' => 10,
+ 'itemsInCurrentPage' => $itemsCount,
+ 'totalItems' => $itemsCount,
+ ],
+ ],
+ ], $payload);
+ $tagsInfo->shouldHaveBeenCalled();
+ }
+
+ private function requestWithApiKey(): ServerRequestInterface
+ {
+ return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create());
+ }
+}
diff --git a/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php
new file mode 100644
index 00000000..5b3487f0
--- /dev/null
+++ b/module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php
@@ -0,0 +1,49 @@
+visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class);
+ $this->action = new NonOrphanVisitsAction($this->visitsHelper->reveal());
+ }
+
+ /** @test */
+ public function requestIsHandled(): void
+ {
+ $apiKey = ApiKey::create();
+ $getVisits = $this->visitsHelper->nonOrphanVisits(Argument::type(VisitsParams::class), $apiKey)->willReturn(
+ new Paginator(new ArrayAdapter([])),
+ );
+
+ /** @var JsonResponse $response */
+ $response = $this->action->handle(ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, $apiKey));
+ $payload = $response->getPayload();
+
+ self::assertEquals(200, $response->getStatusCode());
+ self::assertArrayHasKey('visits', $payload);
+ $getVisits->shouldHaveBeenCalledOnce();
+ }
+}
diff --git a/module/Rest/test/Action/Visit/TagVisitsActionTest.php b/module/Rest/test/Action/Visit/TagVisitsActionTest.php
index be3ce914..33907d09 100644
--- a/module/Rest/test/Action/Visit/TagVisitsActionTest.php
+++ b/module/Rest/test/Action/Visit/TagVisitsActionTest.php
@@ -4,7 +4,7 @@ declare(strict_types=1);
namespace ShlinkioTest\Shlink\Rest\Action\Visit;
-use Laminas\Diactoros\ServerRequest;
+use Laminas\Diactoros\ServerRequestFactory;
use Pagerfanta\Adapter\ArrayAdapter;
use PHPUnit\Framework\TestCase;
use Prophecy\Argument;
@@ -30,7 +30,7 @@ class TagVisitsActionTest extends TestCase
}
/** @test */
- public function providingCorrectShortCodeReturnsVisits(): void
+ public function providingCorrectTagReturnsVisits(): void
{
$tag = 'foo';
$apiKey = ApiKey::create();
@@ -39,7 +39,7 @@ class TagVisitsActionTest extends TestCase
);
$response = $this->action->handle(
- (new ServerRequest())->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey),
+ ServerRequestFactory::fromGlobals()->withAttribute('tag', $tag)->withAttribute(ApiKey::class, $apiKey),
);
self::assertEquals(200, $response->getStatusCode());
diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php
index 278d37ff..7ee23076 100644
--- a/module/Rest/test/ApiKey/RoleTest.php
+++ b/module/Rest/test/ApiKey/RoleTest.php
@@ -21,39 +21,50 @@ class RoleTest extends TestCase
* @test
* @dataProvider provideRoles
*/
- public function returnsExpectedSpec(ApiKeyRole $apiKeyRole, bool $inlined, Specification $expected): void
+ public function returnsExpectedSpec(ApiKeyRole $apiKeyRole, Specification $expected): void
{
- self::assertEquals($expected, Role::toSpec($apiKeyRole, $inlined));
+ self::assertEquals($expected, Role::toSpec($apiKeyRole));
}
public function provideRoles(): iterable
{
$apiKey = ApiKey::create();
- yield 'inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), true, Spec::andX()];
- yield 'not inline invalid role' => [new ApiKeyRole('invalid', [], $apiKey), false, Spec::andX()];
- yield 'inline author role' => [
+ yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()];
+ yield 'author role' => [
new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey),
- true,
- Spec::andX(new BelongsToApiKeyInlined($apiKey)),
- ];
- yield 'not inline author role' => [
- new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey),
- false,
new BelongsToApiKey($apiKey),
];
- yield 'inline domain role' => [
- new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '123'], $apiKey),
- true,
- Spec::andX(new BelongsToDomainInlined('123')),
- ];
- yield 'not inline domain role' => [
+ yield 'domain role' => [
new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '456'], $apiKey),
- false,
new BelongsToDomain('456'),
];
}
+ /**
+ * @test
+ * @dataProvider provideInlinedRoles
+ */
+ public function returnsExpectedInlinedSpec(ApiKeyRole $apiKeyRole, Specification $expected): void
+ {
+ self::assertEquals($expected, Role::toInlinedSpec($apiKeyRole));
+ }
+
+ public function provideInlinedRoles(): iterable
+ {
+ $apiKey = ApiKey::create();
+
+ yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()];
+ yield 'author role' => [
+ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey),
+ Spec::andX(new BelongsToApiKeyInlined($apiKey)),
+ ];
+ yield 'domain role' => [
+ new ApiKeyRole(Role::DOMAIN_SPECIFIC, ['domain_id' => '123'], $apiKey),
+ Spec::andX(new BelongsToDomainInlined('123')),
+ ];
+ }
+
/**
* @test
* @dataProvider provideMetasWithDomainId
diff --git a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php
index 1b7730b5..5d80ca17 100644
--- a/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php
+++ b/module/Rest/test/Exception/MissingAuthenticationExceptionTest.php
@@ -14,7 +14,7 @@ class MissingAuthenticationExceptionTest extends TestCase
{
/**
* @test
- * @dataProvider provideExpectedTypes
+ * @dataProvider provideExpectedHeaders
*/
public function exceptionIsProperlyCreatedFromExpectedHeaders(array $expectedHeaders): void
{
@@ -28,13 +28,10 @@ class MissingAuthenticationExceptionTest extends TestCase
$this->assertCommonExceptionShape($e);
self::assertEquals($expectedMessage, $e->getMessage());
self::assertEquals($expectedMessage, $e->getDetail());
- self::assertEquals([
- 'expectedTypes' => $expectedHeaders,
- 'expectedHeaders' => $expectedHeaders,
- ], $e->getAdditionalData());
+ self::assertEquals(['expectedHeaders' => $expectedHeaders], $e->getAdditionalData());
}
- public function provideExpectedTypes(): iterable
+ public function provideExpectedHeaders(): iterable
{
yield [['foo', 'bar']];
yield [['something']];
diff --git a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php
index 98549e70..04c9478d 100644
--- a/module/Rest/test/Middleware/BodyParserMiddlewareTest.php
+++ b/module/Rest/test/Middleware/BodyParserMiddlewareTest.php
@@ -78,35 +78,6 @@ class BodyParserMiddlewareTest extends TestCase
$test = $this;
$body = new Stream('php://temp', 'wr');
$body->write('{"foo": "bar", "bar": ["one", 5]}');
- $request = (new ServerRequest())->withMethod('PUT')
- ->withBody($body)
- ->withHeader('content-type', 'application/json');
- $delegate = $this->prophesize(RequestHandlerInterface::class);
- $process = $delegate->handle(Argument::type(ServerRequestInterface::class))->will(
- function (array $args) use ($test) {
- /** @var ServerRequestInterface $req */
- $req = array_shift($args);
-
- $test->assertEquals([
- 'foo' => 'bar',
- 'bar' => ['one', 5],
- ], $req->getParsedBody());
-
- return new Response();
- },
- );
-
- $this->middleware->process($request, $delegate->reveal());
-
- $process->shouldHaveBeenCalledOnce();
- }
-
- /** @test */
- public function regularRequestsAreUrlDecoded(): void
- {
- $test = $this;
- $body = new Stream('php://temp', 'wr');
- $body->write('foo=bar&bar[]=one&bar[]=5');
$request = (new ServerRequest())->withMethod('PUT')
->withBody($body);
$delegate = $this->prophesize(RequestHandlerInterface::class);