From 1ff241411be161af88fccf53f83ef250aa733661 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Dec 2021 22:21:53 +0100 Subject: [PATCH 001/111] Removed everything that was deprecated --- UPGRADE.md | 39 +++++ bin/helper/mezzio-swoole | 51 ------ build.sh | 3 - config/autoload/geolite2.global.php | 2 +- config/autoload/redirects.global.php | 7 +- config/autoload/url-shortener.global.php | 17 +- config/config.php | 5 +- .../paths/v1_short-urls_{shortCode}.json | 2 +- .../paths/v1_short-urls_{shortCode}_tags.json | 106 ------------ docs/swagger/paths/v1_tags.json | 78 --------- .../paths/{shortCode}_qr-code_{size}.json | 66 -------- docs/swagger/swagger.json | 6 - module/CLI/config/cli.config.php | 1 - module/CLI/config/dependencies.config.php | 2 - .../src/Command/Api/GenerateKeyCommand.php | 8 +- .../CLI/src/Command/Api/ListKeysCommand.php | 8 +- module/CLI/src/Command/BaseCommand.php | 47 ------ .../ShortUrl/CreateShortUrlCommand.php | 59 ++----- .../Command/ShortUrl/ListShortUrlsCommand.php | 14 +- .../CLI/src/Command/Tag/CreateTagCommand.php | 52 ------ .../Util/AbstractWithDateRangeCommand.php | 20 +-- .../ShortUrl/CreateShortUrlCommandTest.php | 4 +- .../ShortUrl/ListShortUrlsCommandTest.php | 7 +- .../test/Command/Tag/CreateTagCommandTest.php | 51 ------ module/Core/config/routes.config.php | 10 -- module/Core/src/Action/Model/QrCodeParams.php | 8 +- .../src/Config/DeprecatedConfigParser.php | 41 ----- .../src/Config/SimplifiedConfigParser.php | 94 ----------- .../src/Exception/DeleteShortUrlException.php | 2 +- module/Core/src/Model/ShortUrlsOrdering.php | 22 +-- module/Core/src/Options/AppOptions.php | 11 +- .../Core/src/Options/UrlShortenerOptions.php | 12 -- .../src/Repository/ShortUrlRepository.php | 17 +- module/Core/src/Tag/TagService.php | 23 +-- module/Core/src/Tag/TagServiceInterface.php | 8 - module/Core/src/Util/TagManagerTrait.php | 37 ---- module/Core/test/Action/QrCodeActionTest.php | 6 - .../Config/DeprecatedConfigParserTest.php | 111 ------------ .../Config/SimplifiedConfigParserTest.php | 158 ------------------ .../Exception/DeleteShortUrlExceptionTest.php | 2 +- .../Core/test/Service/Tag/TagServiceTest.php | 15 -- module/Rest/config/dependencies.config.php | 4 - module/Rest/config/routes.config.php | 2 - .../ShortUrl/EditShortUrlTagsAction.php | 47 ------ .../Rest/src/Action/Tag/CreateTagsAction.php | 35 ---- .../MissingAuthenticationException.php | 5 +- .../src/Middleware/BodyParserMiddleware.php | 35 +--- .../test-api/Action/DeleteShortUrlTest.php | 2 +- .../test-api/Action/EditShortUrlTagsTest.php | 94 ----------- .../test-api/Action/ListShortUrlsTest.php | 8 - .../ShortUrl/EditShortUrlTagsActionTest.php | 63 ------- .../test/Action/Tag/CreateTagsActionTest.php | 50 ------ .../MissingAuthenticationExceptionTest.php | 9 +- .../Middleware/BodyParserMiddlewareTest.php | 29 ---- 54 files changed, 108 insertions(+), 1507 deletions(-) delete mode 100755 bin/helper/mezzio-swoole delete mode 100644 docs/swagger/paths/v1_short-urls_{shortCode}_tags.json delete mode 100644 docs/swagger/paths/{shortCode}_qr-code_{size}.json delete mode 100644 module/CLI/src/Command/BaseCommand.php delete mode 100644 module/CLI/src/Command/Tag/CreateTagCommand.php delete mode 100644 module/CLI/test/Command/Tag/CreateTagCommandTest.php delete mode 100644 module/Core/src/Config/DeprecatedConfigParser.php delete mode 100644 module/Core/src/Config/SimplifiedConfigParser.php delete mode 100644 module/Core/src/Util/TagManagerTrait.php delete mode 100644 module/Core/test/Config/DeprecatedConfigParserTest.php delete mode 100644 module/Core/test/Config/SimplifiedConfigParserTest.php delete mode 100644 module/Rest/src/Action/ShortUrl/EditShortUrlTagsAction.php delete mode 100644 module/Rest/src/Action/Tag/CreateTagsAction.php delete mode 100644 module/Rest/test-api/Action/EditShortUrlTagsTest.php delete mode 100644 module/Rest/test/Action/ShortUrl/EditShortUrlTagsActionTest.php delete mode 100644 module/Rest/test/Action/Tag/CreateTagsActionTest.php diff --git a/UPGRADE.md b/UPGRADE.md index ebe6ad17..815dc2dd 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,44 @@ # 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 pf `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` to replace the first two, and `longUrl` to replace 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`. +* Requests expecting a body no longer support url-encoded payloads. Instead, always use JSON bodies. +* 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. + +### 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. + +### 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. + ## 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/build.sh b/build.sh index c6abbc8a..eb97aef6 100755 --- a/build.sh +++ b/build.sh @@ -36,9 +36,6 @@ ${composerBin} install --no-dev --prefer-dist $composerFlags if [[ $noSwoole ]]; then # If generating a dist not for swoole, uninstall mezzio-swoole ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags -else - # Copy mezzio helper script to vendor (deprecated - Remove with Shlink 3.0.0) - cp "${projectdir}/bin/helper/mezzio-swoole" "./vendor/bin" fi # Delete development files diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index 3d8f0848..64127173 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -9,7 +9,7 @@ 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' => env('GEOLITE_LICENSE_KEY'), ], ]; diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index d2c73884..18f2719e 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -10,10 +10,9 @@ 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' => env('DEFAULT_INVALID_SHORT_URL_REDIRECT'), + 'regular_404' => env('DEFAULT_REGULAR_404_REDIRECT'), + 'base_url' => env('DEFAULT_BASE_URL_REDIRECT'), ], 'url_shortener' => [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index e14ceddb..1e5df0a3 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -12,27 +12,14 @@ return (static function (): array { (int) env('DEFAULT_SHORT_CODES_LENGTH', 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) env('IS_HTTPS_ENABLED', true)) ? 'https' : 'http', + 'hostname' => env('DEFAULT_DOMAIN', ''), ], - '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), diff --git a/config/config.php b/config/config.php index ccb61cbb..c62828f3 100644 --- a/config/config.php +++ b/config/config.php @@ -37,10 +37,7 @@ return (new ConfigAggregator\ConfigAggregator([ new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'), env('APP_ENV') === 'test' ? 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\LaminasConfigProvider('config/params/generated_config.php'), ], 'data/cache/app_config.php', [ - Core\Config\SimplifiedConfigParser::class, Core\Config\BasePathPrefixer::class, - Core\Config\DeprecatedConfigParser::class, ]))->getMergedConfig(); 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..b4fca99c 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -110,84 +110,6 @@ } }, - "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": { - "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" - } - } - } - } - } - } - } - } - }, - "default": { - "description": "Unexpected error.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "../definitions/Error.json" - } - } - } - } - } - }, - "put": { "operationId": "renameTag", "tags": [ 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..8e71f362 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -78,9 +78,6 @@ "/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" @@ -122,9 +119,6 @@ }, "/{shortCode}/qr-code": { "$ref": "paths/{shortCode}_qr-code.json" - }, - "/{shortCode}/qr-code/{size}": { - "$ref": "paths/{shortCode}_qr-code_{size}.json" } } } 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..83e7bc2e 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -52,7 +52,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 +64,14 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand InputOption::VALUE_REQUIRED, 'A comma-separated list of tags to filter results.', ) - ->addOptionWithDeprecatedFallback( + ->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,7 +113,7 @@ 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'); $tags = ! empty($tags) ? explode(',', $tags) : []; $all = $input->getOption('all'); @@ -175,7 +175,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 +195,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/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/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..e7dae690 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -241,7 +241,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, @@ -257,8 +257,9 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], null]; yield [['--order-by' => 'foo'], 'foo']; - yield [['--order-by' => 'foo,ASC'], ['foo' => 'ASC']]; - yield [['--order-by' => 'bar,DESC'], ['bar' => 'DESC']]; + yield [['--order-by' => 'foo,ASC'], 'foo-ASC']; + yield [['--order-by' => 'bar,DESC'], 'bar-DESC']; + yield [['--order-by' => 'baz-DESC'], 'baz-DESC']; } /** @test */ 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/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/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 @@ - ['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/Model/ShortUrlsOrdering.php b/module/Core/src/Model/ShortUrlsOrdering.php index 4184fcc6..2466b571 100644 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ b/module/Core/src/Model/ShortUrlsOrdering.php @@ -8,9 +8,6 @@ use Shlinkio\Shlink\Core\Exception\ValidationException; use function array_pad; use function explode; -use function is_array; -use function is_string; -use function key; final class ShortUrlsOrdering { @@ -41,22 +38,9 @@ final class ShortUrlsOrdering 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]; - } + [$field, $dir] = array_pad(explode('-', $orderBy), 2, null); + $this->orderField = $field; + $this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION; } public function orderField(): ?string 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/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index f760220e..ecbbb590 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -77,16 +77,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/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index fb853b96..e1b9c419 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -56,8 +56,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $fieldName = $orderBy->orderField(); $order = $orderBy->orderDirection(); - // visitsCount and visitCount are deprecated. Only visits should work - if (contains(['visits', 'visitsCount', 'visitCount'], $fieldName)) { + if ($fieldName === 'visits') { // FIXME This query is inefficient. Debug it. $qb->addSelect('COUNT(DISTINCT v) AS totalVisits') ->leftJoin('s.visits', 'v') @@ -67,17 +66,9 @@ 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(); diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 61ed211d..c9248520 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag; -use Doctrine\Common\Collections\Collection; use Doctrine\ORM; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Entity\Tag; @@ -15,14 +14,11 @@ 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\Rest\Entity\ApiKey; class TagService implements TagServiceInterface { - use TagManagerTrait; - public function __construct(private ORM\EntityManagerInterface $em) { } @@ -34,12 +30,10 @@ class TagService implements TagServiceInterface { /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); - /** @var Tag[] $tags */ - $tags = $repo->match(Spec::andX( + return $repo->match(Spec::andX( Spec::orderBy('name'), new WithApiKeySpecsEnsuringJoin($apiKey), )); - return $tags; } /** @@ -67,21 +61,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..a1aa6122 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag; -use Doctrine\Common\Collections\Collection; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; @@ -31,13 +30,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/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/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/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/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/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index 33ae7be0..ed8cba29 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -97,21 +97,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/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 98b385b0..7e48552e 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -30,14 +30,12 @@ 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\Tag\ListTagsAction::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, @@ -76,10 +74,8 @@ return [ Visit\Transformer\OrphanVisitDataTransformer::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\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..4af6304d 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -28,7 +28,6 @@ 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]), @@ -39,7 +38,6 @@ return [ // Tags Action\Tag\ListTagsAction::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/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/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index bb512832..01b5b7bc 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -48,7 +48,7 @@ class DeleteShortUrlTest extends ApiTestCase 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('INVALID_SHORT_URL_DELETION', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Cannot delete short URL', $payload['title']); } 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..e3526756 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -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, 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/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/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); From 8c14526f85cd93af9f309f78fac66d16c2118e4b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 14 Dec 2021 22:30:09 +0100 Subject: [PATCH 002/111] Fixed tests and updated changelog --- CHANGELOG.md | 21 ++++++++++++++++++- .../Repository/ShortUrlRepositoryTest.php | 4 ++-- module/Rest/test-api/Middleware/CorsTest.php | 2 +- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee4a64c6..bd39ed58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* *Nothing* + +### Deprecated +* *Nothing* + +### Removed +* [#1275](https://github.com/shlinkio/shlink/issues/1275) Removed everything that was deprecated. + + See [UPGRADE](UPGRADE.md#from-v2x-to-v3x) doc in order to get details on how to migrate to this version. + +### Fixed +* *Nothing* + + ## [2.10.0] - 2021-12-12 ### Added * [#1163](https://github.com/shlinkio/shlink/issues/1163) Allowed setting not-found redirects for default domain in the same way it's done for any other domain. @@ -852,7 +871,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/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index d4ff42b8..cff341b3 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -128,7 +128,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(2, 2)); $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([ - 'orderBy' => ['visits' => 'DESC'], + 'orderBy' => 'visits-DESC', ])); self::assertCount(3, $result); self::assertSame($bar, $result[0]); @@ -156,7 +156,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([ - 'orderBy' => ['longUrl' => 'ASC'], + 'orderBy' => 'longUrl-ASC', ])); self::assertCount(count($urls), $result); 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']; } } From 203ad7d594a743a361b1f4e61b7e9cffe774ecc8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 16 Dec 2021 21:46:52 +0100 Subject: [PATCH 003/111] Updated dependencies --- CHANGELOG.md | 2 +- composer.json | 12 ++++++------ module/CLI/test/CliTestUtilsTrait.php | 2 ++ 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd39ed58..fad83e95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed -* *Nothing* +* [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 9652af32..a2fbe583 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,7 @@ "mezzio/mezzio": "^3.7", "mezzio/mezzio-fastroute": "^3.3", "mezzio/mezzio-problem-details": "^1.5", - "mezzio/mezzio-swoole": "^3.5", + "mezzio/mezzio-swoole": "^4.0", "mlocati/ip-lib": "^1.17", "monolog/monolog": "^2.3", "nikolaposa/monolog-factory": "^3.1", @@ -54,12 +54,12 @@ "shlinkio/shlink-importer": "^2.5", "shlinkio/shlink-installer": "^6.3", "shlinkio/shlink-ip-geolocation": "^2.2", - "symfony/console": "^5.4", - "symfony/filesystem": "^6.0 || ^5.4", - "symfony/lock": "^6.0 || ^5.4", + "symfony/console": "^6.0", + "symfony/filesystem": "^6.0", + "symfony/lock": "^6.0", "symfony/mercure": "^0.6", - "symfony/process": "^6.0 || ^5.4", - "symfony/string": "^6.0 || ^5.4" + "symfony/process": "^6.0", + "symfony/string": "^6.0" }, "require-dev": { "cebe/php-openapi": "^1.5", 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 { }); From 5c8be4b21f0e745f270662a62cc1c556c9585768 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Dec 2021 18:17:45 +0100 Subject: [PATCH 004/111] Updated logic to handle visits threshold env var so that it is disabled if not provided --- UPGRADE.md | 6 ++++++ bin/install | 12 ------------ bin/set-option | 14 -------------- bin/update | 12 ------------ config/autoload/delete_short_urls.global.php | 16 +++++++++------- 5 files changed, 15 insertions(+), 45 deletions(-) delete mode 100755 bin/install delete mode 100755 bin/set-option delete mode 100755 bin/update diff --git a/UPGRADE.md b/UPGRADE.md index 815dc2dd..fa6c3c7f 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -20,6 +20,10 @@ * `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 @@ -31,6 +35,8 @@ * `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. ### Other changes 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 - [ - '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), + ], -]; + ]; +})(); From 2c6b2b47a45b2841ff1ddd9f4dca1c0e79836fb1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Dec 2021 18:23:27 +0100 Subject: [PATCH 005/111] Updated installer --- composer.json | 2 +- config/autoload/installer.global.php | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index a2fbe583..3f8f256e 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "shlinkio/shlink-config": "^1.4", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "^6.3", + "shlinkio/shlink-installer": "dev-develop#64f8ab2 as 7.0", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 238dea42..9259b061 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -22,7 +22,6 @@ return [ 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, @@ -33,7 +32,7 @@ return [ Option\BasePathConfigOption::class, Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, - Option\RedisServersConfigOption::class, + Option\RedisConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, Option\Mercure\EnableMercureConfigOption::class, Option\Mercure\MercurePublicUrlConfigOption::class, From 970f20275791e331e6e19d29df6b6eb3cffab66b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Dec 2021 18:26:27 +0100 Subject: [PATCH 006/111] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fad83e95..441218f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#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. ### Deprecated * *Nothing* From 277d817429f503c2c3a78ec0ff6446b933262cc6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 18 Dec 2021 18:41:11 +0100 Subject: [PATCH 007/111] Removed API test which is no longer relevant --- .../test-api/Action/DeleteShortUrlTest.php | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 01b5b7bc..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_SHORT_URL_DELETION', $payload['type']); - self::assertEquals($expectedDetail, $payload['detail']); - self::assertEquals('Cannot delete short URL', $payload['title']); - } - /** @test */ public function properShortUrlIsDeletedWhenDomainIsProvided(): void { From 8e1cd67a3d810d97325e79a972137690c7d47796 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jan 2022 18:40:48 +0100 Subject: [PATCH 008/111] Simplified some match expressions --- config/autoload/entity-manager.global.php | 4 ++-- config/autoload/redis.global.php | 4 ++-- module/Core/src/Visit/RequestTracker.php | 7 +++---- module/Rest/src/Service/ApiKeyService.php | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 08427898..c67809d2 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -21,8 +21,8 @@ return (static function (): array { 'mssql' => '1433', default => '3306', }; - $resolveConnection = static fn () => match (true) { - $driver === null || $driver === 'sqlite' => [ + $resolveConnection = static fn () => match ($driver) { + null, 'sqlite' => [ 'driver' => 'pdo_sqlite', 'path' => 'data/database.sqlite', ], diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php index 22101b65..fbcb5846 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -7,8 +7,8 @@ use function Shlinkio\Shlink\Common\env; return (static function (): array { $redisServers = env('REDIS_SERVERS'); - return match (true) { - $redisServers === null => [], + return match ($redisServers) { + null => [], default => [ 'cache' => [ 'redis' => [ 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/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(), }; From e2ed11f960c82ed3840980761ab73c24ede6d9a6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 1 Jan 2022 18:43:41 +0100 Subject: [PATCH 009/111] Updated installer --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index df0f5308..80ad59be 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "shlinkio/shlink-config": "^1.4", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "dev-develop#64f8ab2 as 7.0", + "shlinkio/shlink-installer": "dev-develop#a008036 as 7.0", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", From 81f82d3b7366967aaf956d364a2570349ca03d1c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 3 Jan 2022 18:48:08 +0100 Subject: [PATCH 010/111] Reduced docker image size by ensuring dev native libs are not included in final image --- Dockerfile | 23 +++++++---------------- docker/docker-entrypoint.sh | 6 ++---- 2 files changed, 9 insertions(+), 20 deletions(-) diff --git a/Dockerfile b/Dockerfile index 30ca29e3..5f9bac01 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,23 +11,14 @@ 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 gmp-dev && \ + docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd gmp && \ + 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 gmp # Install sqlsrv driver RUN if [ $(uname -m) == "x86_64" ]; then \ 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 From 4b4f6f32017c5e3c19785a4510ff08fe2aafb99c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 3 Jan 2022 19:10:58 +0100 Subject: [PATCH 011/111] Removed gmp extension as bcmath does the same --- .dockerignore | 1 + Dockerfile | 6 +++--- data/infra/php.Dockerfile | 3 --- data/infra/swoole.Dockerfile | 3 --- 4 files changed, 4 insertions(+), 9 deletions(-) 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/Dockerfile b/Dockerfile index 5f9bac01..c8d6d988 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,13 +12,13 @@ WORKDIR /etc/shlink # Install required PHP extensions RUN \ # 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 gmp-dev && \ - docker-php-ext-install -j"$(nproc)" pdo_mysql pdo_pgsql intl calendar sockets bcmath zip gd gmp && \ + 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 && \ # 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 gmp + apk add --no-cache postgresql icu libzip libpng # Install sqlsrv driver RUN if [ $(uname -m) == "x86_64" ]; then \ diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 96556869..4fbf676e 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -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..dbaa4202 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -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 From aad24389a7c206e2dd8694a48b0fe5a38d046791 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 3 Jan 2022 19:34:36 +0100 Subject: [PATCH 012/111] Slightly reduced docker image size by merging mssql and openswoole installation steps --- Dockerfile | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index c8d6d988..f3de4a41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,24 +20,19 @@ RUN \ 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 . . From a9aa49c2e502d57b8de7cf51fbf3f3ec423b0840 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 3 Jan 2022 19:37:05 +0100 Subject: [PATCH 013/111] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53d4637d..a56edebc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Changed +* [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% 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. From 103af2e2c158d210f6ab94b5d39298cca720ed39 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 12:11:47 +0100 Subject: [PATCH 014/111] Added support for a new tagsMode param when listing short URLs --- module/Core/src/Model/ShortUrlsParams.php | 13 ++++++++ .../Adapter/ShortUrlRepositoryAdapter.php | 2 ++ .../src/Repository/ShortUrlRepository.php | 13 +++++--- .../ShortUrlRepositoryInterface.php | 3 ++ .../Validation/ShortUrlsParamsInputFilter.php | 10 ++++++ .../Repository/ShortUrlRepositoryTest.php | 31 ++++++++++++++----- .../Adapter/ShortUrlRepositoryAdapterTest.php | 6 ++-- 7 files changed, 64 insertions(+), 14 deletions(-) diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index b3761ea8..ac78b807 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -14,11 +14,15 @@ use function Shlinkio\Shlink\Core\parseDateField; final class ShortUrlsParams { 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; + /** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */ + private string $tagsMode = self::TAGS_MODE_ANY; private ShortUrlsOrdering $orderBy; private ?DateRange $dateRange; @@ -63,6 +67,7 @@ final class ShortUrlsParams $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 @@ -94,4 +99,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/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 93b69d33..0be5403b 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -25,6 +25,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface $offset, $this->params->searchTerm(), $this->params->tags(), + $this->params->tagsMode(), $this->params->orderBy(), $this->params->dateRange(), $this->apiKey?->spec(), @@ -36,6 +37,7 @@ class ShortUrlRepositoryAdapter implements AdapterInterface return $this->repository->countList( $this->params->searchTerm(), $this->params->tags(), + $this->params->tagsMode(), $this->params->dateRange(), $this->apiKey?->spec(), ); diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index e1b9c419..88a79e34 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -5,6 +5,7 @@ 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; @@ -15,6 +16,7 @@ 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\Model\ShortUrlsParams; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; @@ -32,11 +34,12 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?int $offset = null, ?string $searchTerm = null, array $tags = [], + string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, ?Specification $spec = null, ): array { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); + $qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $dateRange, $spec); $qb->select('DISTINCT s') ->setMaxResults($limit) ->setFirstResult($offset); @@ -77,10 +80,11 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU public function countList( ?string $searchTerm = null, array $tags = [], + string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, ?DateRange $dateRange = null, ?Specification $spec = null, ): int { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $dateRange, $spec); + $qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $dateRange, $spec); $qb->select('COUNT(DISTINCT s)'); return (int) $qb->getQuery()->getSingleScalarResult(); @@ -89,6 +93,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function createListQueryBuilder( ?string $searchTerm, array $tags, + string $tagsMode, ?DateRange $dateRange, ?Specification $spec, ): QueryBuilder { @@ -139,8 +144,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU { // 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 = <<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); } } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index cff341b3..b7c83a61 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; 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\Resolver\PersistenceShortUrlRelationResolver; @@ -127,22 +128,31 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(2, 2)); - $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([ + $tagsModeAll = ShortUrlsParams::TAGS_MODE_ANY; + $result = $this->repo->findList(null, null, null, [], $tagsModeAll, ShortUrlsOrdering::fromRawData([ 'orderBy' => '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(null, null, null, [], $tagsModeAll, 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(null, [], $tagsModeAll, 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))), + $this->repo->findList(null, null, null, [], $tagsModeAll, null, DateRange::withStartDate( + Chronos::now()->subDays(2), + )), ); - self::assertEquals(2, $this->repo->countList(null, [], DateRange::withStartDate(Chronos::now()->subDays(2)))); + self::assertEquals(2, $this->repo->countList(null, [], $tagsModeAll, DateRange::withStartDate( + Chronos::now()->subDays(2), + ))); } /** @test */ @@ -155,9 +165,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findList(null, null, null, [], ShortUrlsOrdering::fromRawData([ - 'orderBy' => 'longUrl-ASC', - ])); + $result = $this->repo->findList( + null, + null, + null, + [], + ShortUrlsParams::TAGS_MODE_ANY, + ShortUrlsOrdering::fromRawData(['orderBy' => 'longUrl-ASC']), + ); self::assertCount(count($urls), $result); self::assertEquals('a', $result[0]->getLongUrl()); diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 33fdb8f6..99195818 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -46,7 +46,8 @@ class ShortUrlRepositoryAdapterTest extends TestCase $orderBy = $params->orderBy(); $dateRange = $params->dateRange(); - $this->repo->findList(10, 5, $searchTerm, $tags, $orderBy, $dateRange, null)->shouldBeCalledOnce(); + $this->repo->findList(10, 5, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $orderBy, $dateRange, null) + ->shouldBeCalledOnce(); $adapter->getSlice(5, 10); } @@ -70,7 +71,8 @@ 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($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey->spec()) + ->shouldBeCalledOnce(); $adapter->getNbResults(); } From 665a3dbcbf5115e9111e886d1add05b42e36de6f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 12:22:36 +0100 Subject: [PATCH 015/111] Documented tagsMode param for short URLs list --- docs/swagger/paths/v1_short-urls.json | 12 +++++++++- .../src/Repository/ShortUrlRepository.php | 7 +++--- .../ShortUrlRepositoryInterface.php | 5 ++-- .../Repository/ShortUrlRepositoryTest.php | 23 +++++++------------ 4 files changed, 24 insertions(+), 23 deletions(-) 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/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 88a79e34..aaa0bcca 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -16,7 +16,6 @@ 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\Model\ShortUrlsParams; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; @@ -34,7 +33,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?int $offset = null, ?string $searchTerm = null, array $tags = [], - string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, + ?string $tagsMode = null, ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, ?Specification $spec = null, @@ -80,7 +79,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU public function countList( ?string $searchTerm = null, array $tags = [], - string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, + ?string $tagsMode = null, ?DateRange $dateRange = null, ?Specification $spec = null, ): int { @@ -93,7 +92,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function createListQueryBuilder( ?string $searchTerm, array $tags, - string $tagsMode, + ?string $tagsMode, ?DateRange $dateRange, ?Specification $spec, ): QueryBuilder { diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 02f5b5be..c3bd8d7f 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -12,7 +12,6 @@ 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\Model\ShortUrlsParams; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface @@ -22,7 +21,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat ?int $offset = null, ?string $searchTerm = null, array $tags = [], - string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, + ?string $tagsMode = null, ?ShortUrlsOrdering $orderBy = null, ?DateRange $dateRange = null, ?Specification $spec = null, @@ -31,7 +30,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function countList( ?string $searchTerm = null, array $tags = [], - string $tagsMode = ShortUrlsParams::TAGS_MODE_ANY, + ?string $tagsMode = null, ?DateRange $dateRange = null, ?Specification $spec = null, ): int; diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index b7c83a61..69ef7143 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,7 +14,6 @@ use Shlinkio\Shlink\Core\Entity\Visit; 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\Resolver\PersistenceShortUrlRelationResolver; @@ -128,29 +127,28 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(2, 2)); - $tagsModeAll = ShortUrlsParams::TAGS_MODE_ANY; - $result = $this->repo->findList(null, null, null, [], $tagsModeAll, ShortUrlsOrdering::fromRawData([ + $result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([ 'orderBy' => 'visits-DESC', ])); self::assertCount(3, $result); self::assertSame($bar, $result[0]); - $result = $this->repo->findList(null, null, null, [], $tagsModeAll, null, DateRange::withEndDate( + $result = $this->repo->findList(null, null, null, [], null, null, DateRange::withEndDate( Chronos::now()->subDays(2), )); self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(null, [], $tagsModeAll, DateRange::withEndDate( + self::assertEquals(1, $this->repo->countList(null, [], null, DateRange::withEndDate( Chronos::now()->subDays(2), ))); self::assertSame($foo2, $result[0]); self::assertCount( 2, - $this->repo->findList(null, null, null, [], $tagsModeAll, null, DateRange::withStartDate( + $this->repo->findList(null, null, null, [], null, null, DateRange::withStartDate( Chronos::now()->subDays(2), )), ); - self::assertEquals(2, $this->repo->countList(null, [], $tagsModeAll, DateRange::withStartDate( + self::assertEquals(2, $this->repo->countList(null, [], null, DateRange::withStartDate( Chronos::now()->subDays(2), ))); } @@ -165,14 +163,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findList( - null, - null, - null, - [], - ShortUrlsParams::TAGS_MODE_ANY, - ShortUrlsOrdering::fromRawData(['orderBy' => 'longUrl-ASC']), - ); + $result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([ + 'orderBy' => 'longUrl-ASC', + ])); self::assertCount(count($urls), $result); self::assertEquals('a', $result[0]->getLongUrl()); From d8484e777f73008aca2949e8096f275b2a09c661 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 14:23:21 +0100 Subject: [PATCH 016/111] Added logic to actually filter short URLs by any tag or all tags --- .../Command/ShortUrl/ListShortUrlsCommand.php | 10 +++ .../ShortUrl/ListShortUrlsCommandTest.php | 14 +++- .../src/Repository/ShortUrlRepository.php | 22 +++++-- .../Repository/ShortUrlRepositoryTest.php | 66 +++++++++++++++++++ 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 83e7bc2e..0c0daf33 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -64,6 +64,12 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand InputOption::VALUE_REQUIRED, 'A comma-separated list of tags to filter results.', ) + ->addOption( + 'including-all-tags', + 'i', + InputOption::VALUE_NONE, + 'If tags is provided, returns only short URLs having ALL tags.', + ) ->addOption( 'order-by', 'o', @@ -115,6 +121,9 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $page = (int) $input->getOption('page'); $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,6 +134,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $data = [ ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, ShortUrlsParamsInputFilter::TAGS => $tags, + ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, ShortUrlsOrdering::ORDER_BY => $orderBy, ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index e7dae690..97ced44c 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, ]; @@ -269,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/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index aaa0bcca..5fe659f2 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -16,6 +16,7 @@ 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\Model\ShortUrlsParams; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use function array_column; @@ -130,8 +131,10 @@ 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 = $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'); @@ -261,11 +264,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. @@ -277,6 +276,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/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 69ef7143..8cb161d4 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\Visit; 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\Resolver\PersistenceShortUrlRelationResolver; @@ -174,6 +175,71 @@ 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(null, null, null, ['foo', 'bar'])); + self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertCount(1, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'])); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(1, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); + + self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'])); + self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertCount(2, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'])); + self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(2, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + + self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar', 'baz'])); + self::assertCount(5, $this->repo->findList( + null, + null, + null, + ['foo', 'bar', 'baz'], + ShortUrlsParams::TAGS_MODE_ANY, + )); + self::assertCount(0, $this->repo->findList( + null, + null, + null, + ['foo', 'bar', 'baz'], + ShortUrlsParams::TAGS_MODE_ALL, + )); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'])); + self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); + self::assertEquals(0, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); + } + /** @test */ public function shortCodeIsInUseLooksForShortUrlInProperSetOfTables(): void { From 0e25af790d43e21048c49e24e2e224853fab5602 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 14:28:00 +0100 Subject: [PATCH 017/111] Updated changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a56edebc..b0d7c45d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#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. ### Changed * [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% the original size. From 0447aa07fa58d327117ee761cdeda2587460ac0e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 14:34:31 +0100 Subject: [PATCH 018/111] Added more API tests covering the new tagsMode param on short URLs list --- .../test-api/Action/ListShortUrlsTest.php | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e3526756..e5f0ed55 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -189,6 +189,25 @@ class ListShortUrlsTest extends ApiTestCase yield [['tags' => ['bar']], [ self::SHORT_URL_META, ], 'valid_api_key']; + yield [['tags' => ['foo', 'bar']], [ + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'valid_api_key']; + yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, + ], 'valid_api_key']; + yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ + self::SHORT_URL_META, + ], 'valid_api_key']; + yield [['tags' => ['foo', 'bar', 'baz']], [ + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_DOMAIN, + ], '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']; From 9dec05f62df73dacbc648f1a5fe59da34a5f035c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 14:42:31 +0100 Subject: [PATCH 019/111] Added API test covering invalid tagsMode --- .../Rest/test-api/Action/ListShortUrlsTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e5f0ed55..6a140279 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -241,4 +241,21 @@ class ListShortUrlsTest extends ApiTestCase 'totalItems' => $itemsCount, ]; } + + /** @test */ + public function errorIsReturnedWhenProvidingInvalidValues(): void + { + $query = ['tagsMode' => 'invalid']; + $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); + $respPayload = $this->getJsonResponsePayload($resp); + + self::assertEquals(400, $resp->getStatusCode()); + self::assertEquals([ + 'invalidElements' => ['tagsMode'], + 'title' => 'Invalid data', + 'type' => 'INVALID_ARGUMENT', + 'status' => 400, + 'detail' => 'Provided data is not valid', + ], $respPayload); + } } From 44e3f9b49fb6a8f8bd3f8b872febb6a001e8a660 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 14:10:24 +0100 Subject: [PATCH 020/111] Changed default ordering of short URLs, returning newest first --- CHANGELOG.md | 1 + UPGRADE.md | 3 +- .../src/Repository/ShortUrlRepository.php | 5 ++- .../test-api/Action/ListShortUrlsTest.php | 36 +++++++++---------- 4 files changed, 23 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b0d7c45d..407e81d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% 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. ### Deprecated * *Nothing* diff --git a/UPGRADE.md b/UPGRADE.md index fa6c3c7f..53ff49f3 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -6,8 +6,9 @@ * 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 pf `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` to replace the first two, and `longUrl` to replace the last one. +* 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. * 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. diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 5fe659f2..3218f2ac 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -49,9 +49,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $this->processOrderByForList($qb, $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 diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 6a140279..e53c0c11 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, @@ -172,48 +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_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' => ['foo', 'bar'], 'tagsMode' => 'any'], [ - 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' => ['foo', 'bar'], 'tagsMode' => 'all'], [ self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz']], [ - 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' => ['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, @@ -222,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, From 3dc46bc5a312548752e1520f997a3747368827e5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 4 Jan 2022 17:50:41 +0100 Subject: [PATCH 021/111] Updated to latest shlink-common and shlink-config --- composer.json | 12 +++++++++--- config/autoload/delete_short_urls.global.php | 2 +- config/autoload/entity-manager.global.php | 2 +- config/autoload/geolite2.global.php | 2 +- config/autoload/locks.global.php | 2 +- config/autoload/mercure.global.php | 2 +- config/autoload/qr-codes.global.php | 2 +- config/autoload/rabbit.global.php | 2 +- config/autoload/redirects.global.php | 2 +- config/autoload/redis.global.php | 2 +- config/autoload/router.global.php | 2 +- config/autoload/swoole.global.php | 2 +- config/autoload/tracking.global.php | 2 +- config/autoload/url-shortener.global.php | 2 +- config/autoload/webhooks.global.php | 2 +- config/config.php | 2 +- config/test/test_config.global.php | 2 +- 17 files changed, 25 insertions(+), 19 deletions(-) diff --git a/composer.json b/composer.json index 80ad59be..4bc4890e 100644 --- a/composer.json +++ b/composer.json @@ -48,8 +48,8 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "^4.2.1", - "shlinkio/shlink-config": "^1.4", + "shlinkio/shlink-common": "dev-main#5cb4092 as 4.3", + "shlinkio/shlink-config": "^1.5", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", "shlinkio/shlink-installer": "dev-develop#a008036 as 7.0", @@ -192,6 +192,12 @@ }, "config": { "sort-packages": true, - "platform-check": false + "platform-check": false, + "allow-plugins": { + "composer/package-versions-deprecated": true, + "dealerdirect/phpcodesniffer-composer-installer": true, + "infection/extension-installer": true, + "veewee/composer-run-parallel": true + } } } diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php index 49a3e944..a3964e71 100644 --- a/config/autoload/delete_short_urls.global.php +++ b/config/autoload/delete_short_urls.global.php @@ -4,7 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return (static function (): array { $threshold = env('DELETE_SHORT_URL_THRESHOLD'); diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index c67809d2..fbeb5ab6 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -5,7 +5,7 @@ declare(strict_types=1); use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use function Functional\contains; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return (static function (): array { $driver = env('DB_DRIVER'); diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index 64127173..fd11e52a 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index 60054147..16fdbbca 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -7,7 +7,7 @@ use Predis\ClientInterface as PredisClient; use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory; use Symfony\Component\Lock; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; use const Shlinkio\Shlink\LOCAL_LOCK_FACTORY; diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index aff8c6ee..7eb356ab 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -7,7 +7,7 @@ use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\HubInterface; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return (static function (): array { $publicUrl = env('MERCURE_PUBLIC_HUB_URL'); diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 5f528620..7940ad18 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 function Shlinkio\Shlink\Config\env; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index b08dccf2..adf304c8 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -6,7 +6,7 @@ use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Proxy\LazyServiceFactory; use PhpAmqpLib\Connection\AMQPStreamConnection; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 18f2719e..e38b9c25 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 function Shlinkio\Shlink\Config\env; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php index fbcb5846..871ac531 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return (static function (): array { $redisServers = env('REDIS_SERVERS'); diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index a6c6d5f0..55397e27 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -4,7 +4,7 @@ declare(strict_types=1); use Mezzio\Router\FastRouteRouter; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index f7159ed7..d5a6fd55 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; use const Shlinkio\Shlink\MIN_TASK_WORKERS; diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index 26fe4639..2dc23890 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return [ diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 1e5df0a3..d5b4bfe5 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index 585d3eb2..fb946028 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -2,7 +2,7 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; return (static function (): array { $webhooks = env('VISITS_WEBHOOKS'); diff --git a/config/config.php b/config/config.php index c62828f3..330cd836 100644 --- a/config/config.php +++ b/config/config.php @@ -11,7 +11,7 @@ use Mezzio\ProblemDetails; use Mezzio\Swoole; use function class_exists; -use function Shlinkio\Shlink\Common\env; +use function Shlinkio\Shlink\Config\env; use const PHP_SAPI; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 0898c732..bfefab2c 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; From 5c0abb3d96c38daed7c942a311697fa92e0e9f3c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 18:19:29 +0100 Subject: [PATCH 022/111] Created TagsParams class --- .../AbstractInfinitePaginableListParams.php | 41 +++++++++++++++++++ module/Core/src/Model/VisitsParams.php | 38 ++--------------- module/Core/src/Tag/Model/TagsParams.php | 29 +++++++++++++ 3 files changed, 74 insertions(+), 34 deletions(-) create mode 100644 module/Core/src/Model/AbstractInfinitePaginableListParams.php create mode 100644 module/Core/src/Tag/Model/TagsParams.php 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/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/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php new file mode 100644 index 00000000..7fd12348 --- /dev/null +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -0,0 +1,29 @@ +searchTerm; + } +} From 775f58f972d2d5e7176503e923230435a8715007 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 19:12:08 +0100 Subject: [PATCH 023/111] Added support for pagination in tags lists --- composer.json | 2 +- .../CLI/src/Command/Tag/ListTagsCommand.php | 3 +- .../test/Command/Tag/ListTagsCommandTest.php | 9 ++++-- module/Core/src/Tag/TagService.php | 23 +++++++++++---- module/Core/src/Tag/TagServiceInterface.php | 10 ++++--- .../Core/test/Service/Tag/TagServiceTest.php | 9 +++--- module/Rest/src/Action/Tag/ListTagsAction.php | 21 +++++++------- module/Rest/test-api/Action/ListTagsTest.php | 10 +++++++ .../test/Action/Tag/ListTagsActionTest.php | 28 +++++++++++++++++-- 9 files changed, 83 insertions(+), 32 deletions(-) diff --git a/composer.json b/composer.json index 4bc4890e..643c658c 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "dev-main#5cb4092 as 4.3", + "shlinkio/shlink-common": "dev-main#0d476fd as 4.3", "shlinkio/shlink-config": "^1.5", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 9eebe36f..7d21613d 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,7 +39,7 @@ 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', '-', '-']]; } diff --git a/module/CLI/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index f79aa03d..879b2eb7 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -4,9 +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\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\TagServiceInterface; @@ -29,7 +32,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 +44,10 @@ class ListTagsCommandTest extends TestCase /** @test */ public function listOfTagsIsPrinted(): void { - $tagsInfo = $this->tagService->tagsInfo()->willReturn([ + $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([ new TagInfo(new Tag('foo'), 10, 2), new TagInfo(new Tag('bar'), 7, 32), - ]); + ]))); $this->commandTester->execute([]); $output = $this->commandTester->getDisplay(); diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index c9248520..dd6bda7e 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -6,6 +6,8 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\ORM; use Happyr\DoctrineSpecification\Spec; +use Pagerfanta\Adapter\ArrayAdapter; +use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; use Shlinkio\Shlink\Core\Exception\TagConflictException; @@ -14,6 +16,7 @@ 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\Tag\Model\TagsParams; use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -24,26 +27,34 @@ class TagService implements TagServiceInterface } /** - * @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); - return $repo->match(Spec::andX( + $tags = $repo->match(Spec::andX( Spec::orderBy('name'), new WithApiKeySpecsEnsuringJoin($apiKey), )); + + return (new Paginator(new ArrayAdapter($tags))) + ->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); } /** - * @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); + $tagsInfo = $repo->findTagsWithInfo($apiKey); + + return (new Paginator(new ArrayAdapter($tagsInfo))) + ->setMaxPerPage($params->getItemsPerPage()) + ->setCurrentPage($params->getPage()); } /** diff --git a/module/Core/src/Tag/TagServiceInterface.php b/module/Core/src/Tag/TagServiceInterface.php index a1aa6122..284fc341 100644 --- a/module/Core/src/Tag/TagServiceInterface.php +++ b/module/Core/src/Tag/TagServiceInterface.php @@ -4,25 +4,27 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag; +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 diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index ed8cba29..b1b1ed5d 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -16,6 +16,7 @@ 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\TagsParams; use Shlinkio\Shlink\Core\Tag\TagService; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -47,9 +48,9 @@ class TagServiceTest extends TestCase $match = $this->repo->match(Argument::cetera())->willReturn($expected); - $result = $this->service->listTags(); + $result = $this->service->listTags(TagsParams::fromRawData([])); - self::assertEquals($expected, $result); + self::assertEquals($expected, $result->getCurrentPageResults()); $match->shouldHaveBeenCalled(); } @@ -63,9 +64,9 @@ class TagServiceTest extends TestCase $find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected); - $result = $this->service->tagsInfo($apiKey); + $result = $this->service->tagsInfo(TagsParams::fromRawData([]), $apiKey); // TODO Add more cases with params - self::assertEquals($expected, $result); + self::assertEquals($expected, $result->getCurrentPageResults()); $find->shouldHaveBeenCalled(); } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 3d34bd19..c4e0e0de 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]; @@ -28,23 +32,18 @@ class ListTagsAction extends AbstractRestAction $query = $request->getQueryParams(); $withStats = ($query['withStats'] ?? null) === 'true'; $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $params = TagsParams::fromRawData($query); if (! $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()); + $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); + $rawTags = $this->serializePaginator($tagsInfo, null, 'stats'); + $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()->__toString()); - return new JsonResponse([ - 'tags' => [ - 'data' => $data, - 'stats' => $tagsInfo, - ], - ]); + return new JsonResponse(['tags' => $rawTags]); } } diff --git a/module/Rest/test-api/Action/ListTagsTest.php b/module/Rest/test-api/Action/ListTagsTest.php index d82a4f8e..de71182d 100644 --- a/module/Rest/test-api/Action/ListTagsTest.php +++ b/module/Rest/test-api/Action/ListTagsTest.php @@ -7,6 +7,8 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; +use function count; + class ListTagsTest extends ApiTestCase { /** @@ -17,6 +19,14 @@ class ListTagsTest extends ApiTestCase { $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey); $payload = $this->getJsonResponsePayload($resp); + $itemsCount = count($expectedTags['data']); + $expectedTags['pagination'] = [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => $itemsCount, + 'itemsInCurrentPage' => $itemsCount, + 'totalItems' => $itemsCount, + ]; self::assertEquals(['tags' => $expectedTags], $payload); } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 8b7378fd..504f7b4f 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(); @@ -65,7 +79,10 @@ class ListTagsActionTest extends TestCase new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('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(); From 2f42b2d072932d8d58cd0cd91fe10afb731e863d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 19:16:49 +0100 Subject: [PATCH 024/111] Added API tests covering pagination for tags --- module/Rest/test-api/Action/ListTagsTest.php | 88 +++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/module/Rest/test-api/Action/ListTagsTest.php b/module/Rest/test-api/Action/ListTagsTest.php index de71182d..a1dade87 100644 --- a/module/Rest/test-api/Action/ListTagsTest.php +++ b/module/Rest/test-api/Action/ListTagsTest.php @@ -7,8 +7,6 @@ namespace ShlinkioApiTest\Shlink\Rest\Action; use GuzzleHttp\RequestOptions; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; -use function count; - class ListTagsTest extends ApiTestCase { /** @@ -19,14 +17,6 @@ class ListTagsTest extends ApiTestCase { $resp = $this->callApiWithKey(self::METHOD_GET, '/tags', [RequestOptions::QUERY => $query], $apiKey); $payload = $this->getJsonResponsePayload($resp); - $itemsCount = count($expectedTags['data']); - $expectedTags['pagination'] = [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => $itemsCount, - 'itemsInCurrentPage' => $itemsCount, - 'totalItems' => $itemsCount, - ]; self::assertEquals(['tags' => $expectedTags], $payload); } @@ -35,6 +25,23 @@ class ListTagsTest extends ApiTestCase { yield 'admin API key without stats' => ['valid_api_key', [], [ 'data' => ['bar', 'baz', 'foo'], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 3, + 'itemsInCurrentPage' => 3, + 'totalItems' => 3, + ], + ]]; + 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 'admin API key with stats' => ['valid_api_key', ['withStats' => 'true'], [ 'data' => ['bar', 'baz', 'foo'], @@ -55,10 +62,50 @@ class ListTagsTest extends ApiTestCase 'visitsCount' => 5, ], ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 3, + 'itemsInCurrentPage' => 3, + 'totalItems' => 3, + ], + ]]; + yield 'admin API key with pagination and stats' => ['valid_api_key', [ + 'withStats' => 'true', + 'page' => 1, + 'itemsPerPage' => 2, + ], [ + 'data' => ['bar', 'baz'], + 'stats' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'baz', + 'shortUrlsCount' => 0, + 'visitsCount' => 0, + ], + ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 2, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 3, + ], ]]; yield 'author API key without stats' => ['author_api_key', [], [ 'data' => ['bar', 'foo'], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 2, + ], ]]; yield 'author API key with stats' => ['author_api_key', ['withStats' => 'true'], [ 'data' => ['bar', 'foo'], @@ -74,10 +121,24 @@ class ListTagsTest extends ApiTestCase 'visitsCount' => 5, ], ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 2, + ], ]]; yield 'domain API key without stats' => ['domain_api_key', [], [ 'data' => ['foo'], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 1, + ], ]]; yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [ 'data' => ['foo'], @@ -88,6 +149,13 @@ class ListTagsTest extends ApiTestCase 'visitsCount' => 0, ], ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 1, + ], ]]; } } From fd2a2530b129c908fb93cc5becc855bae4013020 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 19:21:02 +0100 Subject: [PATCH 025/111] Documented pagination for tags endpoint --- docs/swagger/paths/v1_tags.json | 50 +++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index b4fca99c..187f184d 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -17,7 +17,7 @@ }, { "name": "withStats", - "description": "Whether you want to include also a list with general stats by tag or not.", + "description": "Whether you want to include also a list with general stats by tag or not. Defaults to false.", "in": "query", "required": false, "schema": { @@ -27,6 +27,33 @@ "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" + } } ], "responses": { @@ -53,6 +80,9 @@ "items": { "$ref": "../definitions/TagInfo.json" } + }, + "pagination": { + "$ref": "../definitions/Pagination.json" } } } @@ -67,7 +97,14 @@ "php", "shlink", "tech" - ] + ], + "pagination": { + "currentPage": 5, + "pagesCount": 10, + "itemsPerPage": 4, + "itemsInCurrentPage": 4, + "totalItems": 38 + } } } }, @@ -89,7 +126,14 @@ "shortUrlsCount": 7, "visitsCount": 1087 } - ] + ], + "pagination": { + "currentPage": 5, + "pagesCount": 5, + "itemsPerPage": 10, + "itemsInCurrentPage": 2, + "totalItems": 42 + } } } } From 11a383b7e5d44392483e5674316b8bb2848c46a5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 19:25:50 +0100 Subject: [PATCH 026/111] Extracted common logic from TagService to a private method --- module/Core/src/Tag/TagService.php | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index dd6bda7e..4da88979 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\ORM; use Happyr\DoctrineSpecification\Spec; +use Pagerfanta\Adapter\AdapterInterface; use Pagerfanta\Adapter\ArrayAdapter; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Tag; @@ -38,9 +39,7 @@ class TagService implements TagServiceInterface new WithApiKeySpecsEnsuringJoin($apiKey), )); - return (new Paginator(new ArrayAdapter($tags))) - ->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + return $this->createPaginator(new ArrayAdapter($tags), $params); } /** @@ -52,7 +51,12 @@ class TagService implements TagServiceInterface $repo = $this->em->getRepository(Tag::class); $tagsInfo = $repo->findTagsWithInfo($apiKey); - return (new Paginator(new ArrayAdapter($tagsInfo))) + return $this->createPaginator(new ArrayAdapter($tagsInfo), $params); + } + + private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator + { + return (new Paginator($adapter)) ->setMaxPerPage($params->getItemsPerPage()) ->setCurrentPage($params->getPage()); } From 6caeb11598e82e93f358499510da7f594c3c8bb4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 22:14:09 +0100 Subject: [PATCH 027/111] Added output logs for swoole during API tests --- bin/test/run-api-tests.sh | 3 +++ config/test/test_config.global.php | 1 + 2 files changed, 4 insertions(+) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 3e8530b6..3f6e27e6 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -4,7 +4,10 @@ export DB_DRIVER=postgres export TEST_ENV=api export GENERATE_COVERAGE=${GENERATE_COVERAGE:-"no"} +# Reset logs rm -rf data/log/api-tests +mkdir data/log/api-tests +touch data/log/api-tests/output.log # Try to stop server just in case it hanged in last execution vendor/bin/laminas mezzio:swoole:stop diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index bfefab2c..41320c89 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -109,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, ], ], From 3dd4e337589c6d4540150e478116f6e43b9f7eef Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 23:30:35 +0100 Subject: [PATCH 028/111] Created DB-level paginator for tags without stats --- module/Core/src/Repository/TagRepository.php | 2 +- .../Adapter/AbstractTagsPaginatorAdapter.php | 33 +++++++++++++++++++ .../Adapter/TagsPaginatorAdapter.php | 21 ++++++++++++ module/Core/src/Tag/TagService.php | 10 ++---- .../Core/test/Service/Tag/TagServiceTest.php | 2 ++ 5 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php create mode 100644 module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index d21122d0..a6871cfc 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -49,7 +49,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito return map( $query->getResult(), - fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), + static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), ); } 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..f35e492d --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -0,0 +1,33 @@ +repo->matchSingleScalarResult(Spec::andX( + // FIXME I don't think using Spec::selectNew is the correct thing here, + // but seems to be the only way to use Spec::COUNT + Spec::selectNew(Tag::class, Spec::COUNT('id', true)), + new WithApiKeySpecsEnsuringJoin($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..23d7af48 --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -0,0 +1,21 @@ +repo->match(Spec::andX( + new WithApiKeySpecsEnsuringJoin($this->apiKey), + Spec::orderBy('name'), + Spec::limit($length), + Spec::offset($offset), + )); + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 4da88979..85955b76 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag; use Doctrine\ORM; -use Happyr\DoctrineSpecification\Spec; use Pagerfanta\Adapter\AdapterInterface; use Pagerfanta\Adapter\ArrayAdapter; use Shlinkio\Shlink\Common\Paginator\Paginator; @@ -18,7 +17,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; 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\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; +use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; use Shlinkio\Shlink\Rest\Entity\ApiKey; class TagService implements TagServiceInterface @@ -34,12 +33,7 @@ class TagService implements TagServiceInterface { /** @var TagRepository $repo */ $repo = $this->em->getRepository(Tag::class); - $tags = $repo->match(Spec::andX( - Spec::orderBy('name'), - new WithApiKeySpecsEnsuringJoin($apiKey), - )); - - return $this->createPaginator(new ArrayAdapter($tags), $params); + return $this->createPaginator(new TagsPaginatorAdapter($repo, $params, $apiKey), $params); } /** diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index b1b1ed5d..d291b3c4 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -47,11 +47,13 @@ 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(0); $result = $this->service->listTags(TagsParams::fromRawData([])); self::assertEquals($expected, $result->getCurrentPageResults()); $match->shouldHaveBeenCalled(); + $count->shouldHaveBeenCalled(); } /** From 4b90cf93d37bf8a53c19d6869c4b134272536c8c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 5 Jan 2022 23:44:14 +0100 Subject: [PATCH 029/111] Created DB-level paginator for tags with stats --- module/Core/src/Repository/TagRepository.php | 12 +++++++++--- .../Core/src/Repository/TagRepositoryInterface.php | 7 ++++++- .../Adapter/AbstractTagsPaginatorAdapter.php | 4 ++-- .../Paginator/Adapter/TagsInfoPaginatorAdapter.php | 13 +++++++++++++ module/Core/src/Tag/TagService.php | 6 ++---- module/Core/test/Service/Tag/TagServiceTest.php | 6 ++++-- 6 files changed, 36 insertions(+), 12 deletions(-) create mode 100644 module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index a6871cfc..de304441 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -32,14 +32,20 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito /** * @return TagInfo[] */ - public function findTagsWithInfo(?ApiKey $apiKey = null): array - { + public function findTagsWithInfo( + ?int $limit = null, + ?int $offset = null, + ?string $searchTerm = null, + ?ApiKey $apiKey = 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'); + ->orderBy('t.name', 'ASC') + ->setMaxResults($limit) + ->setFirstResult($offset); if ($apiKey !== null) { $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); diff --git a/module/Core/src/Repository/TagRepositoryInterface.php b/module/Core/src/Repository/TagRepositoryInterface.php index 924706ff..0158d43d 100644 --- a/module/Core/src/Repository/TagRepositoryInterface.php +++ b/module/Core/src/Repository/TagRepositoryInterface.php @@ -16,7 +16,12 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe /** * @return TagInfo[] */ - public function findTagsWithInfo(?ApiKey $apiKey = null): array; + public function findTagsWithInfo( + ?int $limit = null, + ?int $offset = null, + ?string $searchTerm = null, + ?ApiKey $apiKey = null, + ): array; public function tagExists(string $tag, ?ApiKey $apiKey = null): bool; } diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index f35e492d..f6331f5e 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -24,8 +24,8 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface public function getNbResults(): int { return (int) $this->repo->matchSingleScalarResult(Spec::andX( - // FIXME I don't think using Spec::selectNew is the correct thing here, - // but seems to be the only way to use Spec::COUNT + // FIXME I don't think using Spec::selectNew is the correct thing here, ideally it should be Spec::select, + // but seems to be the only way to use Spec::COUNT(...) Spec::selectNew(Tag::class, Spec::COUNT('id', true)), new WithApiKeySpecsEnsuringJoin($this->apiKey), )); 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..8d64942d --- /dev/null +++ b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php @@ -0,0 +1,13 @@ +repo->findTagsWithInfo($length, $offset, null, $this->apiKey); + } +} diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 85955b76..40eb413f 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\Tag; use Doctrine\ORM; use Pagerfanta\Adapter\AdapterInterface; -use Pagerfanta\Adapter\ArrayAdapter; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Exception\ForbiddenTagOperationException; @@ -17,6 +16,7 @@ use Shlinkio\Shlink\Core\Repository\TagRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Model\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagRenaming; 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; @@ -43,9 +43,7 @@ class TagService implements TagServiceInterface { /** @var TagRepositoryInterface $repo */ $repo = $this->em->getRepository(Tag::class); - $tagsInfo = $repo->findTagsWithInfo($apiKey); - - return $this->createPaginator(new ArrayAdapter($tagsInfo), $params); + return $this->createPaginator(new TagsInfoPaginatorAdapter($repo, $params, $apiKey), $params); } private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index d291b3c4..eac7495a 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -47,7 +47,7 @@ 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(0); + $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); $result = $this->service->listTags(TagsParams::fromRawData([])); @@ -64,12 +64,14 @@ class TagServiceTest extends TestCase { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo($apiKey)->willReturn($expected); + $find = $this->repo->findTagsWithInfo(2, 0, null, $apiKey)->willReturn($expected); + $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); $result = $this->service->tagsInfo(TagsParams::fromRawData([]), $apiKey); // TODO Add more cases with params self::assertEquals($expected, $result->getCurrentPageResults()); $find->shouldHaveBeenCalled(); + $count->shouldHaveBeenCalled(); } /** From e998c8434d009d00ca482c26405702e1d65c7a46 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 09:50:43 +0100 Subject: [PATCH 030/111] Extracted tags filtering params to a DTO --- module/Core/src/Repository/TagRepository.php | 18 ++++----- .../src/Repository/TagRepositoryInterface.php | 8 +--- .../Core/src/Tag/Model/TagsListFiltering.php | 38 +++++++++++++++++++ .../Adapter/TagsInfoPaginatorAdapter.php | 6 ++- .../test-db/Repository/TagRepositoryTest.php | 2 +- .../Core/test/Service/Tag/TagServiceTest.php | 3 +- 6 files changed, 55 insertions(+), 20 deletions(-) create mode 100644 module/Core/src/Tag/Model/TagsListFiltering.php diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index de304441..12db94fc 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -8,6 +8,7 @@ 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\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -32,29 +33,24 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito /** * @return TagInfo[] */ - public function findTagsWithInfo( - ?int $limit = null, - ?int $offset = null, - ?string $searchTerm = null, - ?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') - ->setMaxResults($limit) - ->setFirstResult($offset); + ->setMaxResults($filtering?->limit()) + ->setFirstResult($filtering?->offset()); + $apiKey = $filtering?->apiKey(); if ($apiKey !== null) { $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); } - $query = $qb->getQuery(); - return map( - $query->getResult(), + $qb->getQuery()->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 0158d43d..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,12 +17,7 @@ interface TagRepositoryInterface extends ObjectRepository, EntitySpecificationRe /** * @return TagInfo[] */ - public function findTagsWithInfo( - ?int $limit = null, - ?int $offset = null, - ?string $searchTerm = null, - ?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/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php new file mode 100644 index 00000000..3c4e79d2 --- /dev/null +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -0,0 +1,38 @@ +limit; + } + + public function offset(): ?int + { + return $this->offset; + } + + public function searchTerm(): ?string + { + return $this->searchTerm; + } + + public function apiKey(): ?ApiKey + { + return $this->apiKey; + } +} diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php index 8d64942d..a92a190a 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php @@ -4,10 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Paginator\Adapter; +use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; + class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter { public function getSlice(int $offset, int $length): iterable { - return $this->repo->findTagsWithInfo($length, $offset, null, $this->apiKey); + return $this->repo->findTagsWithInfo( + new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey), + ); } } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 92498d9a..4c6fc3da 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -74,7 +74,7 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); $this->getEntityManager()->flush(); - $result = $this->repo->findTagsWithInfo(); + $result = $this->repo->findTagsWithInfo(); // TODO Test with some filters self::assertCount(4, $result); self::assertEquals(0, $result[0]->shortUrlsCount()); diff --git a/module/Core/test/Service/Tag/TagServiceTest.php b/module/Core/test/Service/Tag/TagServiceTest.php index eac7495a..40d50b36 100644 --- a/module/Core/test/Service/Tag/TagServiceTest.php +++ b/module/Core/test/Service/Tag/TagServiceTest.php @@ -16,6 +16,7 @@ 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; @@ -64,7 +65,7 @@ class TagServiceTest extends TestCase { $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo(2, 0, null, $apiKey)->willReturn($expected); + $find = $this->repo->findTagsWithInfo(new TagsListFiltering(2, 0, null, $apiKey))->willReturn($expected); $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); $result = $this->service->tagsInfo(TagsParams::fromRawData([]), $apiKey); // TODO Add more cases with params From b38b8a336584e541af918d7dc74237c43228fc37 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 10:13:37 +0100 Subject: [PATCH 031/111] Extended TagRepositoryTest, covering filterings on tags --- module/Core/src/Repository/TagRepository.php | 6 ++ .../test-db/Repository/TagRepositoryTest.php | 94 +++++++++++++++---- 2 files changed, 84 insertions(+), 16 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 12db94fc..66b94a33 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -44,6 +44,12 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->setMaxResults($filtering?->limit()) ->setFirstResult($filtering?->offset()); + $searchTerm = $filtering?->searchTerm(); + if ($searchTerm !== null) { + $qb->andWhere($qb->expr()->like('t.name', ':searchPattern')) + ->setParameter('searchPattern', '%' . $searchTerm . '%'); + } + $apiKey = $filtering?->apiKey(); if ($apiKey !== null) { $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 4c6fc3da..156ec022 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -12,6 +12,8 @@ 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\TagInfo; +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; @@ -50,8 +52,11 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals(2, $this->repo->deleteByName($toDelete)); } - /** @test */ - public function properTagsInfoIsReturned(): void + /** + * @test + * @dataProvider provideFilterings + */ + public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void { $names = ['foo', 'bar', 'baz', 'another']; foreach ($names as $name) { @@ -74,24 +79,81 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist(Visit::forValidShortUrl($shortUrl2, Visitor::emptyInstance())); $this->getEntityManager()->flush(); - $result = $this->repo->findTagsWithInfo(); // TODO Test with some filters + $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()); + $asserts($result, $names); + } - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($names[1], $result[1]->tag()->__toString()); + public function provideFilterings(): iterable + { + $noFiltersAsserts = static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(0, $result[0]->shortUrlsCount()); + self::assertEquals(0, $result[0]->visitsCount()); + self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($names[2], $result[2]->tag()->__toString()); + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - self::assertEquals(2, $result[3]->shortUrlsCount()); - self::assertEquals(4, $result[3]->visitsCount()); - self::assertEquals($names[0], $result[3]->tag()->__toString()); + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); + + self::assertEquals(2, $result[3]->shortUrlsCount()); + self::assertEquals(4, $result[3]->visitsCount()); + self::assertEquals($tagNames[0], $result[3]->tag()->__toString()); + }; + + yield 'no filter' => [null, $noFiltersAsserts]; + yield 'empty filter' => [new TagsListFiltering(), $noFiltersAsserts]; + yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(2, $result); + self::assertEquals(0, $result[0]->shortUrlsCount()); + self::assertEquals(0, $result[0]->visitsCount()); + self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); + }]; + yield 'offset' => [new TagsListFiltering(null, 3), static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(1, $result); + self::assertEquals(2, $result[0]->shortUrlsCount()); + self::assertEquals(4, $result[0]->visitsCount()); + self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); + }]; + yield 'limit and offset' => [ + new TagsListFiltering(2, 1), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(2, $result); + self::assertEquals(1, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->visitsCount()); + self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); + }, + ]; + yield 'search term' => [ + new TagsListFiltering(null, null, 'ba'), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(2, $result); + self::assertEquals(1, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->visitsCount()); + self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); + }, + ]; } /** @test */ From 0cf33c61196c7bb57482caf63244344d5c3d1f06 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 10:35:01 +0100 Subject: [PATCH 032/111] Added DB test for TagsPaginator --- .../Adapter/TagsPaginatorAdapter.php | 11 +++- .../Adapter/TagsPaginatorAdapterTest.php | 52 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php index 23d7af48..5b1da6f7 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -11,11 +11,18 @@ class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter { public function getSlice(int $offset, int $length): iterable { - return $this->repo->match(Spec::andX( + $conditions = [ new WithApiKeySpecsEnsuringJoin($this->apiKey), Spec::orderBy('name'), 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/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php new file mode 100644 index 00000000..e0835718 --- /dev/null +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -0,0 +1,52 @@ +repo = $this->getEntityManager()->getRepository(Tag::class); + } + + /** + * @test + * @dataProvider provideFilters + */ + public function expectedListOfTagsIsReturned(?string $searchTerm, int $offset, int $length, int $expected): 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]), null); + + self::assertCount($expected, $adapter->getSlice($offset, $length)); + self::assertEquals(4, $adapter->getNbResults()); + } + + public function provideFilters(): iterable + { + yield [null, 0, 10, 4]; + yield [null, 2, 10, 2]; + yield [null, 1, 3, 3]; + yield [null, 3, 3, 1]; + yield [null, 0, 2, 2]; + yield ['ba', 0, 10, 2]; + yield ['ba', 0, 1, 1]; + yield ['foo', 0, 10, 1]; + yield ['a', 0, 10, 3]; + } +} From af1cf806f0ec0cbc28d6310711a7489e688b1b5f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 10:55:57 +0100 Subject: [PATCH 033/111] Created tag paginator adapter tests --- .../Adapter/TagsInfoPaginatorAdapterTest.php | 48 +++++++++++++++++++ .../Adapter/TagsPaginatorAdapterTest.php | 37 ++++++++++++++ .../test/{Service => }/Tag/TagServiceTest.php | 2 +- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 module/Core/test/Tag/Paginator/Adapter/TagsInfoPaginatorAdapterTest.php create mode 100644 module/Core/test/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php rename module/Core/test/{Service => }/Tag/TagServiceTest.php (99%) 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 99% rename from module/Core/test/Service/Tag/TagServiceTest.php rename to module/Core/test/Tag/TagServiceTest.php index 40d50b36..75b02163 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; From 55591077769a6920183d9b47d6cacc7359e5518d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 11:01:21 +0100 Subject: [PATCH 034/111] Changed namespace for database tests to ShlinkioDbTest --- composer.json | 6 ++---- .../Core/test-db/Domain/Repository/DomainRepositoryTest.php | 2 +- module/Core/test-db/Repository/ShortUrlRepositoryTest.php | 2 +- module/Core/test-db/Repository/TagRepositoryTest.php | 2 +- module/Core/test-db/Repository/VisitRepositoryTest.php | 2 +- .../Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php | 2 +- 6 files changed, 7 insertions(+), 9 deletions(-) diff --git a/composer.json b/composer.json index 643c658c..3b79c514 100644 --- a/composer.json +++ b/composer.json @@ -95,10 +95,8 @@ "ShlinkioTest\\Shlink\\CLI\\": "module/CLI/test", "ShlinkioTest\\Shlink\\Rest\\": "module/Rest/test", "ShlinkioApiTest\\Shlink\\Rest\\": "module/Rest/test-api", - "ShlinkioTest\\Shlink\\Core\\": [ - "module/Core/test", - "module/Core/test-db" - ] + "ShlinkioTest\\Shlink\\Core\\": "module/Core/test", + "ShlinkioDbTest\\Shlink\\Core\\": "module/Core/test-db" }, "files": [ "config/test/constants.php" diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index 382e58dd..b5ca98ea 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; diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 8cb161d4..0001889b 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; diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 156ec022..d8635b96 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -2,7 +2,7 @@ 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; diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index c78583af..475cf374 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; diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index e0835718..62d515ab 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Tag\Paginator\Adapter; +namespace ShlinkioDbTest\Shlink\Core\Tag\Paginator\Adapter; use Shlinkio\Shlink\Core\Entity\Tag; use Shlinkio\Shlink\Core\Repository\TagRepository; From b3863a3e109e29d9e0e4b75fc8007cdbf03518c6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 11:36:08 +0100 Subject: [PATCH 035/111] Improved TagServiceTest, covering tagsInfo method with params --- module/Core/test/Tag/TagServiceTest.php | 46 +++++++++++++++++++++---- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 75b02163..0eb59df0 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -59,20 +59,52 @@ class TagServiceTest extends TestCase /** * @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(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; - $find = $this->repo->findTagsWithInfo(new TagsListFiltering(2, 0, null, $apiKey))->willReturn($expected); + $find = $this->repo->findTagsWithInfo($expectedFiltering)->willReturn($expected); $count = $this->repo->matchSingleScalarResult(Argument::cetera())->willReturn(2); - $result = $this->service->tagsInfo(TagsParams::fromRawData([]), $apiKey); // TODO Add more cases with params + $result = $this->service->tagsInfo($params, $apiKey); self::assertEquals($expected, $result->getCurrentPageResults()); - $find->shouldHaveBeenCalled(); - $count->shouldHaveBeenCalled(); + $find->shouldHaveBeenCalledOnce(); + $count->shouldHaveBeenCalledTimes($countCalls); + } + + public function provideApiKeysAndSearchTerm(): iterable + { + yield 'no API key, no filter' => [ + null, + TagsParams::fromRawData([]), + new TagsListFiltering(2, 0, null, null), + 1, + ]; + yield 'admin API key, no filter' => [ + $apiKey = ApiKey::create(), + TagsParams::fromRawData([]), + new TagsListFiltering(2, 0, null, $apiKey), + 1, + ]; + yield 'no API key, search term' => [ + null, + TagsParams::fromRawData(['searchTerm' => $searchTerm = 'foobar']), + new TagsListFiltering(2, 0, $searchTerm, null), + 1, + ]; + yield 'admin API key, limits' => [ + $apiKey = ApiKey::create(), + TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]), + new TagsListFiltering(1, 0, null, $apiKey), + 0, + ]; } /** From 806ff9daaf552ca7dd8bbb24bc7929c16ea2e959 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 11:40:20 +0100 Subject: [PATCH 036/111] Updated changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 407e81d5..0fb1e3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this 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. + + 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`. + + Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned. + ### Changed * [#1277](https://github.com/shlinkio/shlink/issues/1277) Reduced docker image size to 45% the original size. * [#1268](https://github.com/shlinkio/shlink/issues/1268) Updated dependencies, including symfony/console 6 and mezzio/mezzio-swoole 4. From d00a56bec094ecee6a7686b1ed26c21f9a815ef2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 12:22:05 +0100 Subject: [PATCH 037/111] Fixed query to count tags when a search term is present --- .../Adapter/AbstractTagsPaginatorAdapter.php | 15 ++++++--- .../Adapter/TagsPaginatorAdapterTest.php | 31 +++++++++++-------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index f6331f5e..ba6bc78d 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -23,11 +23,18 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface public function getNbResults(): int { - return (int) $this->repo->matchSingleScalarResult(Spec::andX( - // FIXME I don't think using Spec::selectNew is the correct thing here, ideally it should be Spec::select, - // but seems to be the only way to use Spec::COUNT(...) + $conditions = [ + // FIXME I don't think using Spec::selectNew is the correct thing in this context. + // Ideally it should be Spec::select, but seems to be the only way to use Spec::COUNT(...). Spec::selectNew(Tag::class, Spec::COUNT('id', true)), new WithApiKeySpecsEnsuringJoin($this->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/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index 62d515ab..db3e444a 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -23,8 +23,13 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase * @test * @dataProvider provideFilters */ - public function expectedListOfTagsIsReturned(?string $searchTerm, int $offset, int $length, int $expected): void - { + public function expectedListOfTagsIsReturned( + ?string $searchTerm, + int $offset, + int $length, + int $expectedSliceSize, + int $expectedTotalCount, + ): void { $names = ['foo', 'bar', 'baz', 'another']; foreach ($names as $name) { $this->getEntityManager()->persist(new Tag($name)); @@ -33,20 +38,20 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase $adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData(['searchTerm' => $searchTerm]), null); - self::assertCount($expected, $adapter->getSlice($offset, $length)); - self::assertEquals(4, $adapter->getNbResults()); + self::assertCount($expectedSliceSize, $adapter->getSlice($offset, $length)); + self::assertEquals($expectedTotalCount, $adapter->getNbResults()); } public function provideFilters(): iterable { - yield [null, 0, 10, 4]; - yield [null, 2, 10, 2]; - yield [null, 1, 3, 3]; - yield [null, 3, 3, 1]; - yield [null, 0, 2, 2]; - yield ['ba', 0, 10, 2]; - yield ['ba', 0, 1, 1]; - yield ['foo', 0, 10, 1]; - yield ['a', 0, 10, 3]; + yield [null, 0, 10, 4, 4]; + yield [null, 2, 10, 2, 4]; + yield [null, 1, 3, 3, 4]; + yield [null, 3, 3, 1, 4]; + yield [null, 0, 2, 2, 4]; + yield ['ba', 0, 10, 2, 2]; + yield ['ba', 0, 1, 1, 2]; + yield ['foo', 0, 10, 1, 1]; + yield ['a', 0, 10, 3, 3]; } } From 2b0567b368f00c749b40ee177eaf44b16f3e8f34 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 18:35:50 +0100 Subject: [PATCH 038/111] Fixed typo --- module/Core/src/Repository/VisitRepository.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 5c39c21e..9978dbc0 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -204,7 +204,7 @@ 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); From 107c09604a699d65b4e92cbac31c6b7c5ed46a9a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 6 Jan 2022 19:01:00 +0100 Subject: [PATCH 039/111] Fixed performance issues on list tags endpoint when requesting it with stats --- module/Core/src/Repository/TagRepository.php | 59 ++++++++++++++----- .../test-db/Repository/TagRepositoryTest.php | 4 +- 2 files changed, 45 insertions(+), 18 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 66b94a33..ab440879 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Query\ResultSetMappingBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Entity\Tag; @@ -15,6 +17,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; +use const PHP_INT_MAX; + class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface { public function deleteByName(array $names): int @@ -35,29 +39,52 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ 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') - ->setMaxResults($filtering?->limit()) - ->setFirstResult($filtering?->offset()); + $subQb = $this->createQueryBuilder('t'); + $subQb->select('t.id', 't.name') + ->orderBy('t.name', 'ASC') // TODO Make dynamic + ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset() ?? 0); $searchTerm = $filtering?->searchTerm(); if ($searchTerm !== null) { - $qb->andWhere($qb->expr()->like('t.name', ':searchPattern')) - ->setParameter('searchPattern', '%' . $searchTerm . '%'); + // FIXME This value cannot be added via params, so it needs to be sanitized + $subQb->andWhere($subQb->expr()->like('t.name', '\'%' . $searchTerm . '%\'')); } - $apiKey = $filtering?->apiKey(); - if ($apiKey !== null) { - $this->applySpecification($qb, $apiKey->spec(false, 'shortUrls'), 't'); - } + $subQuery = $subQb->getQuery()->getSQL(); + + // A native query builder needs to be used here because DQL and ORM query builders do not accept + // sub-queries at "from" and "join" level. + // If no sub-query is used, the whole list is loaded even with pagination, making it very inefficient. + $nativeQb = $this->getEntityManager()->getConnection()->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('(' . $subQuery . ')', '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') + ->orderBy('t.name_1', 'ASC'); // TODO Make dynamic + + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); + $rsm->addRootEntityFromClassMetadata(Tag::class, 't'); + $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); + $rsm->addScalarResult('visits_count', 'visitsCount'); + + // TODO Apply API key cond to main query +// $apiKey = $filtering?->apiKey(); +// if ($apiKey !== null) { +// $this->applySpecification($nativeQb, $apiKey->spec(false, 'shortUrls'), 't'); +// } return map( - $qb->getQuery()->getResult(), - static 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[0], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), ); } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index d8635b96..ab4e07ad 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -54,7 +54,7 @@ class TagRepositoryTest extends DatabaseTestCase /** * @test - * @dataProvider provideFilterings + * @dataProvider provideFilters */ public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void { @@ -84,7 +84,7 @@ class TagRepositoryTest extends DatabaseTestCase $asserts($result, $names); } - public function provideFilterings(): iterable + public function provideFilters(): iterable { $noFiltersAsserts = static function (array $result, array $tagNames): void { /** @var TagInfo[] $result */ From a667c957ee6b2f38dd430cd8280db86c72c5f319 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 7 Jan 2022 16:15:47 +0100 Subject: [PATCH 040/111] Added Twitter follow badge to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9aad62d9..e88448ee 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ [![Latest Stable Version](https://img.shields.io/github/release/shlinkio/shlink.svg?style=flat-square)](https://packagist.org/packages/shlinkio/shlink) [![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?logo=docker&style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/) [![License](https://img.shields.io/github/license/shlinkio/shlink.svg?style=flat-square)](https://github.com/shlinkio/shlink/blob/main/LICENSE) +[![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](https://slnk.to/donate) A PHP-based self-hosted URL shortener that can be used to serve shortened URLs under your own custom domain. From 2d861b40773cbfcacb43f5133f60a018557a2058 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 8 Jan 2022 17:25:09 +0100 Subject: [PATCH 041/111] Improved performance when loading paginated tags, by using an ugly compound of native queries and DQL --- CHANGELOG.md | 4 +- module/Core/src/Repository/TagRepository.php | 58 ++++++++++++++----- .../Core/src/Repository/VisitRepository.php | 2 +- 3 files changed, 48 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0fb1e3a2..7ba90574 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this 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. +* [#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`. + 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. diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index ab440879..5793de67 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Repository; -use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Query\ResultSetMappingBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; use Happyr\DoctrineSpecification\Spec; @@ -12,15 +11,24 @@ 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\Entity\ApiKey; use function Functional\map; +use function is_object; +use function method_exists; +use function sprintf; +use function strlen; +use function strpos; +use function substr_replace; use const PHP_INT_MAX; class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface { + private const PARAM_PLACEHOLDER = '?'; + public function deleteByName(array $names): int { if (empty($names)) { @@ -39,6 +47,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { + $conn = $this->getEntityManager()->getConnection(); $subQb = $this->createQueryBuilder('t'); $subQb->select('t.id', 't.name') ->orderBy('t.name', 'ASC') // TODO Make dynamic @@ -47,16 +56,34 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $searchTerm = $filtering?->searchTerm(); if ($searchTerm !== null) { - // FIXME This value cannot be added via params, so it needs to be sanitized - $subQb->andWhere($subQb->expr()->like('t.name', '\'%' . $searchTerm . '%\'')); + $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); } - $subQuery = $subQb->getQuery()->getSQL(); + $apiKey = $filtering?->apiKey(); + if ($apiKey !== null) { + $this->applySpecification($subQb, $apiKey->spec(false, 'shortUrls'), 't'); + } - // A native query builder needs to be used here because DQL and ORM query builders do not accept + $subQuery = $subQb->getQuery(); + $subQuerySql = $subQuery->getSQL(); + + // Sadly, we need to manually interpolate the params in the query replacing the placeholders, as this is going + // to be used as a sub-query in a native query. There's no need to sanitize, though. + foreach ($subQuery->getParameters() as $param) { + $value = $param->getValue(); + $pos = strpos($subQuerySql, self::PARAM_PLACEHOLDER); + $subQuerySql = substr_replace( + $subQuerySql, + sprintf('\'%s\'', is_object($value) && method_exists($value, 'getId') ? $value->getId() : $value), + $pos === false ? -1 : $pos, + strlen(self::PARAM_PLACEHOLDER), + ); + } + + // 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 = $this->getEntityManager()->getConnection()->createQueryBuilder(); + $nativeQb = $conn->createQueryBuilder(); $nativeQb ->select( 't.id_0 AS id', @@ -64,24 +91,29 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 'COUNT(DISTINCT s.id) AS short_urls_count', 'COUNT(DISTINCT v.id) AS visits_count', ) - ->from('(' . $subQuery . ')', 't') + ->from('(' . $subQuerySql . ')', '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') ->orderBy('t.name_1', 'ASC'); // TODO Make dynamic + // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates + $apiKey?->mapRoles(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, + }); + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Tag::class, 't'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); $rsm->addScalarResult('visits_count', 'visitsCount'); - // TODO Apply API key cond to main query -// $apiKey = $filtering?->apiKey(); -// if ($apiKey !== null) { -// $this->applySpecification($nativeQb, $apiKey->spec(false, 'shortUrls'), 't'); -// } - return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), static fn (array $row) => new TagInfo($row[0], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 9978dbc0..3a8440a7 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -210,7 +210,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ->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(); From 2abcaf02e22a4ede2e4d2d3abebf1d904f5dfa00 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 11:23:27 +0100 Subject: [PATCH 042/111] Standardized ordering field handling and added validation for short URLs list --- composer.json | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 3 +- .../ShortUrl/ListShortUrlsCommandTest.php | 8 +-- module/Core/src/Model/Ordering.php | 35 +++++++++++ module/Core/src/Model/ShortUrlsOrdering.php | 60 ------------------- module/Core/src/Model/ShortUrlsParams.php | 7 ++- .../src/Repository/ShortUrlRepository.php | 9 +-- .../ShortUrlRepositoryInterface.php | 4 +- .../Validation/ShortUrlsParamsInputFilter.php | 3 + .../Repository/ShortUrlRepositoryTest.php | 10 +--- .../Adapter/ShortUrlRepositoryAdapterTest.php | 10 ++-- .../ShortUrl/ListShortUrlsActionTest.php | 9 +-- 12 files changed, 68 insertions(+), 92 deletions(-) create mode 100644 module/Core/src/Model/Ordering.php delete mode 100644 module/Core/src/Model/ShortUrlsOrdering.php diff --git a/composer.json b/composer.json index a5ca2bd1..2522f158 100644 --- a/composer.json +++ b/composer.json @@ -48,7 +48,7 @@ "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "^4.3", + "shlinkio/shlink-common": "dev-main#cbcff58 as 4.4", "shlinkio/shlink-config": "^1.5", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 0c0daf33..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; @@ -135,7 +134,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ShortUrlsParamsInputFilter::SEARCH_TERM => $searchTerm, ShortUrlsParamsInputFilter::TAGS => $tags, ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, - ShortUrlsOrdering::ORDER_BY => $orderBy, + ShortUrlsParamsInputFilter::ORDER_BY => $orderBy, ShortUrlsParamsInputFilter::START_DATE => $startDate?->toAtomString(), ShortUrlsParamsInputFilter::END_DATE => $endDate?->toAtomString(), ]; diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 97ced44c..38d3bcd3 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -263,10 +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' => 'baz-DESC'], 'baz-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 */ diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php new file mode 100644 index 00000000..95fb6a14 --- /dev/null +++ b/module/Core/src/Model/Ordering.php @@ -0,0 +1,35 @@ +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 2466b571..00000000 --- a/module/Core/src/Model/ShortUrlsOrdering.php +++ /dev/null @@ -1,60 +0,0 @@ -validateAndInit($query); - - return $instance; - } - - /** - * @throws ValidationException - */ - private function validateAndInit(array $data): void - { - $orderBy = $data[self::ORDER_BY] ?? null; - if ($orderBy === null) { - return; - } - - [$field, $dir] = array_pad(explode('-', $orderBy), 2, null); - $this->orderField = $field; - $this->orderDirection = $dir ?? self::DEFAULT_ORDER_DIRECTION; - } - - 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 ac78b807..9abfd10f 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -13,6 +13,7 @@ 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'; @@ -23,7 +24,7 @@ final class ShortUrlsParams private array $tags; /** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */ private string $tagsMode = self::TAGS_MODE_ANY; - private ShortUrlsOrdering $orderBy; + private Ordering $orderBy; private ?DateRange $dateRange; private function __construct() @@ -63,7 +64,7 @@ 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 ); @@ -90,7 +91,7 @@ final class ShortUrlsParams return $this->tags; } - public function orderBy(): ShortUrlsOrdering + public function orderBy(): Ordering { return $this->orderBy; } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 3218f2ac..57aa480c 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -13,9 +13,9 @@ 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\Importer\Model\ImportedShlinkUrl; @@ -35,7 +35,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ?string $searchTerm = null, array $tags = [], ?string $tagsMode = null, - ?ShortUrlsOrdering $orderBy = null, + ?Ordering $orderBy = null, ?DateRange $dateRange = null, ?Specification $spec = null, ): array { @@ -53,13 +53,14 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU 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(); if ($fieldName === 'visits') { - // FIXME This query is inefficient. Debug it. + // 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') diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index c3bd8d7f..c6f8df60 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -9,9 +9,9 @@ use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterfa use Happyr\DoctrineSpecification\Specification\Specification; 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\Importer\Model\ImportedShlinkUrl; interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificationRepositoryInterface @@ -22,7 +22,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat ?string $searchTerm = null, array $tags = [], ?string $tagsMode = null, - ?ShortUrlsOrdering $orderBy = null, + ?Ordering $orderBy = null, ?DateRange $dateRange = null, ?Specification $spec = null, ): array; diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php index 9223037f..6c0443aa 100644 --- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php @@ -21,6 +21,7 @@ class ShortUrlsParamsInputFilter extends InputFilter 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) { @@ -46,5 +47,7 @@ class ShortUrlsParamsInputFilter extends InputFilter 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); + + $this->add($this->createOrderByInput(self::ORDER_BY, ShortUrlsParams::ORDERABLE_FIELDS)); } } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 0001889b..5bf34437 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -11,9 +11,9 @@ 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; @@ -128,9 +128,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertCount(1, $this->repo->findList(2, 2)); - $result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([ - 'orderBy' => 'visits-DESC', - ])); + $result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['visits', 'DESC'])); self::assertCount(3, $result); self::assertSame($bar, $result[0]); @@ -164,9 +162,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findList(null, null, null, [], null, ShortUrlsOrdering::fromRawData([ - 'orderBy' => 'longUrl-ASC', - ])); + $result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['longUrl', 'ASC'])); self::assertCount(count($urls), $result); self::assertEquals('a', $result[0]->getLongUrl()); diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 99195818..3d4b01ae 100644 --- a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -82,11 +82,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/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 [ From ff75b3cd1f1ea972e6e81c58bbbb4bcb27fab97b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 11:28:32 +0100 Subject: [PATCH 043/111] Enhanced test covering list short URLs with invalid params --- .../Core/src/Exception/ValidationException.php | 3 +++ .../Rest/test-api/Action/ListShortUrlsTest.php | 17 +++++++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) 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/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index e53c0c11..b28a0b5d 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -242,20 +242,29 @@ class ListShortUrlsTest extends ApiTestCase ]; } - /** @test */ - public function errorIsReturnedWhenProvidingInvalidValues(): void + /** + * @test + * @dataProvider provideInvalidFiltering + */ + public function errorIsReturnedWhenProvidingInvalidValues(array $query, array $expectedInvalidElements): void { - $query = ['tagsMode' => 'invalid']; $resp = $this->callApiWithKey(self::METHOD_GET, '/short-urls', [RequestOptions::QUERY => $query]); $respPayload = $this->getJsonResponsePayload($resp); self::assertEquals(400, $resp->getStatusCode()); self::assertEquals([ - 'invalidElements' => ['tagsMode'], + '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']]; + } } From 1b51a1aeddd75e34e104bc53302305841af8b0c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 13:31:08 +0100 Subject: [PATCH 044/111] Added ordering support for tags list when not requesting stats --- .../Core/src/Tag/Model/TagsListFiltering.php | 12 +++++++ module/Core/src/Tag/Model/TagsParams.php | 24 +++++++++++-- .../Adapter/TagsInfoPaginatorAdapter.php | 2 +- .../Adapter/TagsPaginatorAdapter.php | 5 ++- .../Adapter/TagsPaginatorAdapterTest.php | 36 ++++++++++++------- module/Core/test/Tag/TagServiceTest.php | 16 ++++----- module/Rest/src/Action/Tag/ListTagsAction.php | 5 ++- 7 files changed, 73 insertions(+), 27 deletions(-) diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php index 3c4e79d2..8f078788 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Model; +use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Rest\Entity\ApiKey; final class TagsListFiltering @@ -12,10 +13,16 @@ final class TagsListFiltering private ?int $limit = null, private ?int $offset = null, private ?string $searchTerm = null, + private ?Ordering $orderBy = null, private ?ApiKey $apiKey = null, ) { } + public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self + { + return new self($limit, $offset, $params->searchTerm(), $params->orderBy(), $apiKey); + } + public function limit(): ?int { return $this->limit; @@ -31,6 +38,11 @@ final class TagsListFiltering 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 index 7fd12348..3f40debe 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -5,11 +5,19 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Tag\Model; use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams; +use Shlinkio\Shlink\Core\Model\Ordering; + +use function Shlinkio\Shlink\Common\parseOrderBy; final class TagsParams extends AbstractInfinitePaginableListParams { - private function __construct(private ?string $searchTerm, ?int $page, ?int $itemsPerPage) - { + private function __construct( + private ?string $searchTerm, + private Ordering $orderBy, + private bool $withStats, + ?int $page, + ?int $itemsPerPage, + ) { parent::__construct($page, $itemsPerPage); } @@ -17,6 +25,8 @@ final class TagsParams extends AbstractInfinitePaginableListParams { return new self( $query['searchTerm'] ?? null, + Ordering::fromTuple(isset($query['orderBy']) ? parseOrderBy($query['orderBy']) : [null, null]), + ($query['withStats'] ?? null) === 'true', isset($query['page']) ? (int) $query['page'] : null, isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); @@ -26,4 +36,14 @@ final class TagsParams extends AbstractInfinitePaginableListParams { return $this->searchTerm; } + + public function orderBy(): Ordering + { + return $this->orderBy; + } + + public function withStats(): bool + { + return $this->withStats; + } } diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php index a92a190a..c2917200 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsInfoPaginatorAdapter.php @@ -11,7 +11,7 @@ class TagsInfoPaginatorAdapter extends AbstractTagsPaginatorAdapter public function getSlice(int $offset, int $length): iterable { return $this->repo->findTagsWithInfo( - new TagsListFiltering($length, $offset, $this->params->searchTerm(), $this->apiKey), + 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 index 5b1da6f7..d6bc0b7b 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -13,7 +13,10 @@ class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter { $conditions = [ new WithApiKeySpecsEnsuringJoin($this->apiKey), - Spec::orderBy('name'), + Spec::orderBy( + 'name', // Ordering by other fields makes no sense here + $this->params->orderBy()->orderDirection(), + ), Spec::limit($length), Spec::offset($offset), ]; diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index db3e444a..f8cd2e8e 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -10,6 +10,8 @@ use Shlinkio\Shlink\Core\Tag\Model\TagsParams; use Shlinkio\Shlink\Core\Tag\Paginator\Adapter\TagsPaginatorAdapter; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; +use function Functional\map; + class TagsPaginatorAdapterTest extends DatabaseTestCase { private TagRepository $repo; @@ -25,9 +27,10 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase */ public function expectedListOfTagsIsReturned( ?string $searchTerm, + ?string $orderBy, int $offset, int $length, - int $expectedSliceSize, + array $expectedTags, int $expectedTotalCount, ): void { $names = ['foo', 'bar', 'baz', 'another']; @@ -36,22 +39,31 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - $adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData(['searchTerm' => $searchTerm]), null); + $adapter = new TagsPaginatorAdapter($this->repo, TagsParams::fromRawData([ + 'searchTerm' => $searchTerm, + 'orderBy' => $orderBy, + ]), null); - self::assertCount($expectedSliceSize, $adapter->getSlice($offset, $length)); + $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, 0, 10, 4, 4]; - yield [null, 2, 10, 2, 4]; - yield [null, 1, 3, 3, 4]; - yield [null, 3, 3, 1, 4]; - yield [null, 0, 2, 2, 4]; - yield ['ba', 0, 10, 2, 2]; - yield ['ba', 0, 1, 1, 2]; - yield ['foo', 0, 10, 1, 1]; - yield ['a', 0, 10, 3, 3]; + 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, 'name-DESC', 0, 10, ['foo', 'baz', 'bar', 'another'], 4]; + yield [null, 'name-ASC', 0, 10, ['another', 'bar', 'baz', 'foo'], 4]; + yield [null, 'name-DESC', 0, 2, ['foo', 'baz'], 4]; + yield ['ba', 'name-DESC', 0, 1, ['baz'], 2]; } } diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index 0eb59df0..c3efc4b5 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -83,26 +83,26 @@ class TagServiceTest extends TestCase { yield 'no API key, no filter' => [ null, - TagsParams::fromRawData([]), - new TagsListFiltering(2, 0, null, null), + $params = TagsParams::fromRawData([]), + TagsListFiltering::fromRangeAndParams(2, 0, $params, null), 1, ]; yield 'admin API key, no filter' => [ $apiKey = ApiKey::create(), - TagsParams::fromRawData([]), - new TagsListFiltering(2, 0, null, $apiKey), + $params = TagsParams::fromRawData([]), + TagsListFiltering::fromRangeAndParams(2, 0, $params, $apiKey), 1, ]; yield 'no API key, search term' => [ null, - TagsParams::fromRawData(['searchTerm' => $searchTerm = 'foobar']), - new TagsListFiltering(2, 0, $searchTerm, null), + $params = TagsParams::fromRawData(['searchTerm' => 'foobar']), + TagsListFiltering::fromRangeAndParams(2, 0, $params, null), 1, ]; yield 'admin API key, limits' => [ $apiKey = ApiKey::create(), - TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]), - new TagsListFiltering(1, 0, null, $apiKey), + $params = TagsParams::fromRawData(['page' => 1, 'itemsPerPage' => 1]), + TagsListFiltering::fromRangeAndParams(1, 0, $params, $apiKey), 0, ]; } diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index c4e0e0de..ecf379cb 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -30,11 +30,10 @@ class ListTagsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { $query = $request->getQueryParams(); - $withStats = ($query['withStats'] ?? null) === 'true'; - $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $params = TagsParams::fromRawData($query); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - if (! $withStats) { + if (! $params->withStats()) { return new JsonResponse([ 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), ]); From 95d8d3ef7264cced3daf48888a795044fe500f68 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 13:38:59 +0100 Subject: [PATCH 045/111] Added ordering by name support for tags list with stats --- docs/swagger/paths/v1_tags.json | 13 ++++++++ module/Core/src/Repository/TagRepository.php | 4 +-- .../test-db/Repository/TagRepositoryTest.php | 33 +++++++++++++++++-- .../Adapter/TagsPaginatorAdapterTest.php | 8 ++--- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 187f184d..1e36e112 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -54,6 +54,19 @@ "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": { diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 5793de67..0182908c 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -50,7 +50,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $conn = $this->getEntityManager()->getConnection(); $subQb = $this->createQueryBuilder('t'); $subQb->select('t.id', 't.name') - ->orderBy('t.name', 'ASC') // TODO Make dynamic + ->orderBy('t.name', $filtering?->orderBy()?->orderDirection() ?? 'ASC') // TODO Make filed dynamic ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset() ?? 0); @@ -96,7 +96,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->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') - ->orderBy('t.name_1', 'ASC'); // TODO Make dynamic + ->orderBy('t.name_1', $filtering?->orderBy()?->orderDirection() ?? 'ASC'); // TODO Make field dynamic // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) { diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index ab4e07ad..5ac1f3ac 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -8,6 +8,7 @@ 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; @@ -86,7 +87,7 @@ class TagRepositoryTest extends DatabaseTestCase public function provideFilters(): iterable { - $noFiltersAsserts = static function (array $result, array $tagNames): void { + $defaultAsserts = static function (array $result, array $tagNames): void { /** @var TagInfo[] $result */ self::assertCount(4, $result); self::assertEquals(0, $result[0]->shortUrlsCount()); @@ -106,8 +107,8 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals($tagNames[0], $result[3]->tag()->__toString()); }; - yield 'no filter' => [null, $noFiltersAsserts]; - yield 'empty filter' => [new TagsListFiltering(), $noFiltersAsserts]; + yield 'no filter' => [null, $defaultAsserts]; + yield 'empty filter' => [new TagsListFiltering(), $defaultAsserts]; yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void { /** @var TagInfo[] $result */ self::assertCount(2, $result); @@ -154,6 +155,32 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); }, ]; + yield 'ASC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'ASC'])), + $defaultAsserts, + ]; + yield 'DESC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(0, $result[3]->shortUrlsCount()); + self::assertEquals(0, $result[3]->visitsCount()); + self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[1], $result[2]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); + + self::assertEquals(2, $result[0]->shortUrlsCount()); + self::assertEquals(4, $result[0]->visitsCount()); + self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); + }, + ]; } /** @test */ diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index f8cd2e8e..d906f80c 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -61,9 +61,9 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase 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, 'name-DESC', 0, 10, ['foo', 'baz', 'bar', 'another'], 4]; - yield [null, 'name-ASC', 0, 10, ['another', 'bar', 'baz', 'foo'], 4]; - yield [null, 'name-DESC', 0, 2, ['foo', 'baz'], 4]; - yield ['ba', 'name-DESC', 0, 1, ['baz'], 2]; + 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]; } } From d5851bbb6abdd387283f057545ec2719040aee2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 17:24:07 +0100 Subject: [PATCH 046/111] Created TagsStats endpoint --- docs/swagger/paths/v1_tags.json | 66 +++------ docs/swagger/paths/v2_tags_stats.json | 123 +++++++++++++++++ docs/swagger/swagger.json | 3 + module/Rest/config/dependencies.config.php | 2 + module/Rest/config/routes.config.php | 1 + module/Rest/src/Action/Tag/ListTagsAction.php | 1 + .../Rest/src/Action/Tag/TagsStatsAction.php | 36 +++++ module/Rest/test-api/Action/TagsStatsTest.php | 127 ++++++++++++++++++ 8 files changed, 310 insertions(+), 49 deletions(-) create mode 100644 docs/swagger/paths/v2_tags_stats.json create mode 100644 module/Rest/src/Action/Tag/TagsStatsAction.php create mode 100644 module/Rest/test-api/Action/TagsStatsTest.php diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 1e36e112..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. Defaults to false.", + "deprecated": true, + "description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.", "in": "query", "required": false, "schema": { @@ -101,53 +102,20 @@ } } }, - "examples": { - "Without stats": { - "value": { - "tags": { - "data": [ - "games", - "php", - "shlink", - "tech" - ], - "pagination": { - "currentPage": 5, - "pagesCount": 10, - "itemsPerPage": 4, - "itemsInCurrentPage": 4, - "totalItems": 38 - } - } - } - }, - "With stats": { - "value": { - "tags": { - "data": [ - "games", - "shlink" - ], - "stats": [ - { - "tag": "games", - "shortUrlsCount": 10, - "visitsCount": 521 - }, - { - "tag": "shlink", - "shortUrlsCount": 7, - "visitsCount": 1087 - } - ], - "pagination": { - "currentPage": 5, - "pagesCount": 5, - "itemsPerPage": 10, - "itemsInCurrentPage": 2, - "totalItems": 42 - } - } + "example": { + "tags": { + "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..bd745fd0 --- /dev/null +++ b/docs/swagger/paths/v2_tags_stats.json @@ -0,0 +1,123 @@ +{ + "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.", + "required": false, + "schema": { + "type": "string", + "enum": [ + "tag-ASC", + "tag-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/swagger.json b/docs/swagger/swagger.json index 8e71f362..f04510d0 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -82,6 +82,9 @@ "/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" diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 7e48552e..e7d99a85 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -35,6 +35,7 @@ return [ Action\Visit\GlobalVisitsAction::class => ConfigAbstractFactory::class, Action\Visit\OrphanVisitsAction::class => ConfigAbstractFactory::class, Action\Tag\ListTagsAction::class => ConfigAbstractFactory::class, + Action\Tag\TagsStatsAction::class => ConfigAbstractFactory::class, Action\Tag\DeleteTagsAction::class => ConfigAbstractFactory::class, Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, @@ -75,6 +76,7 @@ return [ ], Action\ShortUrl\ListShortUrlsAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\Tag\ListTagsAction::class => [TagService::class], + Action\Tag\TagsStatsAction::class => [TagService::class], Action\Tag\DeleteTagsAction::class => [TagService::class], Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 4af6304d..49d9f107 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -37,6 +37,7 @@ return [ // Tags Action\Tag\ListTagsAction::getRouteDef(), + Action\Tag\TagsStatsAction::getRouteDef(), Action\Tag\DeleteTagsAction::getRouteDef(), Action\Tag\UpdateTagAction::getRouteDef(), diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index ecf379cb..bad8f62e 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -39,6 +39,7 @@ class ListTagsAction extends AbstractRestAction ]); } + // 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()->__toString()); diff --git a/module/Rest/src/Action/Tag/TagsStatsAction.php b/module/Rest/src/Action/Tag/TagsStatsAction.php new file mode 100644 index 00000000..eeb149db --- /dev/null +++ b/module/Rest/src/Action/Tag/TagsStatsAction.php @@ -0,0 +1,36 @@ +getQueryParams(); + $params = TagsParams::fromRawData($query); + $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); + $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); + + return new JsonResponse(['tags' => $this->serializePaginator($tagsInfo)]); + } +} diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php new file mode 100644 index 00000000..a97a5974 --- /dev/null +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -0,0 +1,127 @@ +callApiWithKey(self::METHOD_GET, '/tags/stats', [RequestOptions::QUERY => $query], $apiKey); + $payload = $this->getJsonResponsePayload($resp); + + self::assertEquals(['tags' => $expectedTags], $payload); + } + + public function provideQueries(): iterable + { + yield 'admin API key' => ['valid_api_key', [], [ + 'data' => [ + [ + '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 'admin API key with pagination' => ['valid_api_key', ['page' => 1, 'itemsPerPage' => 2], [ + 'data' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'baz', + 'shortUrlsCount' => 0, + 'visitsCount' => 0, + ], + ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 2, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 3, + ], + ]]; + yield 'author API key' => ['author_api_key', [], [ + 'data' => [ + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, + ], + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, + ], + ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 2, + ], + ]]; + yield 'author API key with pagination' => ['author_api_key', ['page' => 2, 'itemsPerPage' => 1], [ + 'data' => [ + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, + ], + ], + 'pagination' => [ + 'currentPage' => 2, + 'pagesCount' => 2, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 2, + ], + ]]; + yield 'domain API key' => ['domain_api_key', [], [ + 'data' => [ + [ + 'tag' => 'foo', + 'shortUrlsCount' => 1, + 'visitsCount' => 0, + ], + ], + 'pagination' => [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 1, + ], + ]]; + } +} From a6b1647f27b5f94b4fb7b0d0946ddc5dfae80f2f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 17:37:00 +0100 Subject: [PATCH 047/111] Created TagStatsActionTest --- CHANGELOG.md | 2 + module/Rest/src/Action/Tag/ListTagsAction.php | 3 +- .../Rest/src/Action/Tag/TagsStatsAction.php | 3 +- .../test/Action/Tag/TagsStatsActionTest.php | 73 +++++++++++++++++++ 4 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 module/Rest/test/Action/Tag/TagsStatsActionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index e81dc273..19b50747 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned. +* [#1315](https://github.com/shlinkio/shlink/issues/1315) Included new `GET /tags/stats` endpoint, which effectively deprecates `GET /tags?withStats=true`. + ### 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. diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index bad8f62e..ba25ffe5 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -29,8 +29,7 @@ class ListTagsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $query = $request->getQueryParams(); - $params = TagsParams::fromRawData($query); + $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); if (! $params->withStats()) { diff --git a/module/Rest/src/Action/Tag/TagsStatsAction.php b/module/Rest/src/Action/Tag/TagsStatsAction.php index eeb149db..cec8edd6 100644 --- a/module/Rest/src/Action/Tag/TagsStatsAction.php +++ b/module/Rest/src/Action/Tag/TagsStatsAction.php @@ -26,8 +26,7 @@ class TagsStatsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $query = $request->getQueryParams(); - $params = TagsParams::fromRawData($query); + $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); diff --git a/module/Rest/test/Action/Tag/TagsStatsActionTest.php b/module/Rest/test/Action/Tag/TagsStatsActionTest.php new file mode 100644 index 00000000..3f98b64e --- /dev/null +++ b/module/Rest/test/Action/Tag/TagsStatsActionTest.php @@ -0,0 +1,73 @@ +tagService = $this->prophesize(TagServiceInterface::class); + $this->action = new TagsStatsAction($this->tagService->reveal()); + } + + /** @test */ + public function returnsTagsStatsWhenRequested(): void + { + $stats = [ + new TagInfo(new Tag('foo'), 1, 1), + new TagInfo(new Tag('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()); + } +} From acfc5a4676140c3b7ff2485e96cd51232eb62b9c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 17:38:45 +0100 Subject: [PATCH 048/111] Updated changelog --- CHANGELOG.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19b50747..aad20edc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Additionally, the endpoint also supports filtering by `searchTerm` query param. When provided, only tags matching it will be returned. -* [#1315](https://github.com/shlinkio/shlink/issues/1315) Included new `GET /tags/stats` endpoint, which effectively deprecates `GET /tags?withStats=true`. - ### 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. @@ -29,10 +27,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1300](https://github.com/shlinkio/shlink/issues/1300) Changed default ordering for short URLs list, returning always from newest to oldest. ### Deprecated -* *Nothing* +* [#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. +* [#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. From 3b359cfc4f975e2c7a97ce065a0de3b9b618c09c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 17:47:19 +0100 Subject: [PATCH 049/111] Reduced amount of duplicated code in API tests --- module/Rest/test-api/Action/ListTagsTest.php | 100 +--------- module/Rest/test-api/Action/TagsStatsTest.php | 181 +++++++++--------- 2 files changed, 98 insertions(+), 183 deletions(-) diff --git a/module/Rest/test-api/Action/ListTagsTest.php b/module/Rest/test-api/Action/ListTagsTest.php index a1dade87..4c627e7c 100644 --- a/module/Rest/test-api/Action/ListTagsTest.php +++ b/module/Rest/test-api/Action/ListTagsTest.php @@ -23,7 +23,7 @@ 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'], 'pagination' => [ 'currentPage' => 1, @@ -43,61 +43,7 @@ class ListTagsTest extends ApiTestCase 'totalItems' => 3, ], ]]; - 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 'admin API key with pagination and stats' => ['valid_api_key', [ - 'withStats' => 'true', - 'page' => 1, - 'itemsPerPage' => 2, - ], [ - 'data' => ['bar', 'baz'], - 'stats' => [ - [ - 'tag' => 'bar', - 'shortUrlsCount' => 1, - 'visitsCount' => 2, - ], - [ - 'tag' => 'baz', - 'shortUrlsCount' => 0, - 'visitsCount' => 0, - ], - ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 2, - 'itemsPerPage' => 2, - 'itemsInCurrentPage' => 2, - 'totalItems' => 3, - ], - ]]; - - yield 'author API key without stats' => ['author_api_key', [], [ + yield 'author API key' => ['author_api_key', [], [ 'data' => ['bar', 'foo'], 'pagination' => [ 'currentPage' => 1, @@ -107,30 +53,7 @@ class ListTagsTest extends ApiTestCase 'totalItems' => 2, ], ]]; - 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, - ], - ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 2, - 'itemsInCurrentPage' => 2, - 'totalItems' => 2, - ], - ]]; - - yield 'domain API key without stats' => ['domain_api_key', [], [ + yield 'domain API key' => ['domain_api_key', [], [ 'data' => ['foo'], 'pagination' => [ 'currentPage' => 1, @@ -140,22 +63,5 @@ class ListTagsTest extends ApiTestCase 'totalItems' => 1, ], ]]; - yield 'domain API key with stats' => ['domain_api_key', ['withStats' => 'true'], [ - '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/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php index a97a5974..3b91cbf0 100644 --- a/module/Rest/test-api/Action/TagsStatsTest.php +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -13,115 +13,124 @@ class TagsStatsTest extends ApiTestCase * @test * @dataProvider provideQueries */ - public function expectedListOfTagsIsReturned(string $apiKey, array $query, array $expectedTags): void - { + public function expectedListOfTagsIsReturned( + string $apiKey, + array $query, + array $expectedStats, + array $expectedPagination, + ): void { $resp = $this->callApiWithKey(self::METHOD_GET, '/tags/stats', [RequestOptions::QUERY => $query], $apiKey); - $payload = $this->getJsonResponsePayload($resp); + ['tags' => $tags] = $this->getJsonResponsePayload($resp); - self::assertEquals(['tags' => $expectedTags], $payload); + 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', [], [ - 'data' => [ - [ - 'tag' => 'bar', - 'shortUrlsCount' => 1, - 'visitsCount' => 2, - ], - [ - 'tag' => 'baz', - 'shortUrlsCount' => 0, - 'visitsCount' => 0, - ], - [ - 'tag' => 'foo', - 'shortUrlsCount' => 3, - 'visitsCount' => 5, - ], + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 3, - 'itemsInCurrentPage' => 3, - 'totalItems' => 3, + [ + '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], [ - 'data' => [ - [ - 'tag' => 'bar', - 'shortUrlsCount' => 1, - 'visitsCount' => 2, - ], - [ - 'tag' => 'baz', - 'shortUrlsCount' => 0, - 'visitsCount' => 0, - ], + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 2, - 'itemsPerPage' => 2, - 'itemsInCurrentPage' => 2, - 'totalItems' => 3, + [ + 'tag' => 'baz', + 'shortUrlsCount' => 0, + 'visitsCount' => 0, ], + ], [ + 'currentPage' => 1, + 'pagesCount' => 2, + 'itemsPerPage' => 2, + 'itemsInCurrentPage' => 2, + 'totalItems' => 3, ]]; yield 'author API key' => ['author_api_key', [], [ - 'data' => [ - [ - 'tag' => 'bar', - 'shortUrlsCount' => 1, - 'visitsCount' => 2, - ], - [ - 'tag' => 'foo', - 'shortUrlsCount' => 2, - 'visitsCount' => 5, - ], + [ + 'tag' => 'bar', + 'shortUrlsCount' => 1, + 'visitsCount' => 2, ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 2, - 'itemsInCurrentPage' => 2, - 'totalItems' => 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], [ - 'data' => [ - [ - 'tag' => 'foo', - 'shortUrlsCount' => 2, - 'visitsCount' => 5, - ], - ], - 'pagination' => [ - 'currentPage' => 2, - 'pagesCount' => 2, - 'itemsPerPage' => 1, - 'itemsInCurrentPage' => 1, - 'totalItems' => 2, + [ + 'tag' => 'foo', + 'shortUrlsCount' => 2, + 'visitsCount' => 5, ], + ], [ + 'currentPage' => 2, + 'pagesCount' => 2, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 2, ]]; yield 'domain API key' => ['domain_api_key', [], [ - 'data' => [ - [ - 'tag' => 'foo', - 'shortUrlsCount' => 1, - 'visitsCount' => 0, - ], - ], - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 1, - 'itemsInCurrentPage' => 1, - 'totalItems' => 1, + [ + 'tag' => 'foo', + 'shortUrlsCount' => 1, + 'visitsCount' => 0, ], + ], [ + 'currentPage' => 1, + 'pagesCount' => 1, + 'itemsPerPage' => 1, + 'itemsInCurrentPage' => 1, + 'totalItems' => 1, ]]; } } From e47c90c645e22abeddef4bded42f3e80dbbc104a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 9 Jan 2022 21:02:23 +0100 Subject: [PATCH 050/111] Simplified how the custom slugs are processed, allowing more characters in the process --- composer.json | 1 - config/constants.php | 1 - .../src/Util/CocurSymfonySluggerBridge.php | 22 ------------------- .../src/Validation/ShortUrlInputFilter.php | 13 +++++------ module/Core/test/Model/ShortUrlMetaTest.php | 14 +++++++++++- 5 files changed, 18 insertions(+), 33 deletions(-) delete mode 100644 module/Core/src/Util/CocurSymfonySluggerBridge.php diff --git a/composer.json b/composer.json index 2522f158..f12e41fb 100644 --- a/composer.json +++ b/composer.json @@ -17,7 +17,6 @@ "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^2.3", - "cocur/slugify": "^4.0", "doctrine/migrations": "^3.3", "doctrine/orm": "^2.10", "endroid/qr-code": "^4.4", 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/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/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index 2497f85d..96896e94 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/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 9a5eac72..ec2d314e 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -30,34 +30,43 @@ 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::LONG_URL => 'foo', ShortUrlInputFilter::CUSTOM_SLUG => '/', ]]; yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', ShortUrlInputFilter::CUSTOM_SLUG => '', ]]; yield [[ + ShortUrlInputFilter::LONG_URL => 'foo', ShortUrlInputFilter::CUSTOM_SLUG => ' ', ]]; yield [[ @@ -92,12 +101,15 @@ 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 ['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 ['谷歌', '谷歌']; From 41d3826c1afa2f7e2d46e8b8bc5c19002577b15b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 10:43:20 +0100 Subject: [PATCH 051/111] Ensured bars are replaced by dashes in custom slugs --- module/Core/src/Validation/ShortUrlInputFilter.php | 2 +- module/Core/test/Model/ShortUrlMetaTest.php | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index 96896e94..6cd578fb 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -77,7 +77,7 @@ class ShortUrlInputFilter extends InputFilter // empty, is by using the deprecated setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new Filter\Callback( - static fn (mixed $value) => is_string($value) ? str_replace([' ', '/'], ['-', ''], $value) : $value, + static fn (mixed $value) => is_string($value) ? str_replace([' ', '/'], '-', $value) : $value, )); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index ec2d314e..1933b3b6 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -57,10 +57,6 @@ class ShortUrlMetaTest extends TestCase ShortUrlInputFilter::LONG_URL => 'foo', ShortUrlInputFilter::SHORT_CODE_LENGTH => 3, ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'foo', - ShortUrlInputFilter::CUSTOM_SLUG => '/', - ]]; yield [[ ShortUrlInputFilter::LONG_URL => 'foo', ShortUrlInputFilter::CUSTOM_SLUG => '', @@ -107,6 +103,7 @@ class ShortUrlMetaTest extends TestCase 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']; From 45de3f01287506ff3f4a495e41d2e72282629cba Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 11:13:16 +0100 Subject: [PATCH 052/111] Ensured emojis in short URLs are not URL-encoded --- .../ShortUrl/Helper/ShortUrlStringifier.php | 18 ++++++++---------- .../Helper/ShortUrlStringifierTest.php | 12 ++++++++++++ 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index 1ec36677..d238f2fc 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/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'], '', From b941ee9aa963396967afe2fdc2d89bda38baadd7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 12:05:01 +0100 Subject: [PATCH 053/111] Removed usage of deprecated methods from migrations --- config/autoload/entity-manager.local.php.dist | 1 + data/migrations/Version20160819142757.php | 26 +++++++++---------- data/migrations/Version20160820191203.php | 3 ++- data/migrations/Version20171021093246.php | 3 ++- data/migrations/Version20171022064541.php | 3 ++- data/migrations/Version20180801183328.php | 3 ++- data/migrations/Version20180913205455.php | 3 ++- data/migrations/Version20180915110857.php | 3 ++- data/migrations/Version20181020060559.php | 5 ++-- data/migrations/Version20181020065148.php | 3 ++- data/migrations/Version20181110175521.php | 3 ++- data/migrations/Version20190824075137.php | 3 ++- data/migrations/Version20190930165521.php | 3 ++- data/migrations/Version20191001201532.php | 3 ++- data/migrations/Version20191020074522.php | 3 ++- data/migrations/Version20200105165647.php | 14 +++++----- data/migrations/Version20200106215144.php | 3 ++- data/migrations/Version20200110182849.php | 9 +++++-- data/migrations/Version20200323190014.php | 5 ++-- data/migrations/Version20200503170404.php | 3 ++- data/migrations/Version20201023090929.php | 3 ++- data/migrations/Version20201102113208.php | 3 ++- data/migrations/Version20210102174433.php | 3 ++- data/migrations/Version20210118153932.php | 3 ++- data/migrations/Version20210202181026.php | 3 ++- data/migrations/Version20210207100807.php | 3 ++- data/migrations/Version20210306165711.php | 3 ++- data/migrations/Version20210522051601.php | 3 ++- data/migrations/Version20210522124633.php | 3 ++- data/migrations/Version20210720143824.php | 3 ++- data/migrations/Version20211002072605.php | 3 ++- 31 files changed, 84 insertions(+), 51 deletions(-) diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index 0624aa51..ef5cabf8 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' => 'utf8', ], ], diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php index 70831eb9..6db246a6 100644 --- a/data/migrations/Version20160819142757.php +++ b/data/migrations/Version20160819142757.php @@ -5,45 +5,43 @@ 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 get_class; + /** * 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 = get_class($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 ($platformClass) { + MySQLPlatform::class => $column->setPlatformOption('collation', 'utf8_bin'), + 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); } } From ce47d8c5912b6275efa2747d5973ac843a119efc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 13:04:16 +0100 Subject: [PATCH 054/111] Added full support for emojis --- config/autoload/entity-manager.global.php | 9 ++- config/test/test_config.global.php | 2 + data/migrations/Version20160819142757.php | 12 +-- data/migrations/Version20220110113313.php | 73 +++++++++++++++++++ data/migrations_template.txt | 3 +- .../test-api/Action/CreateShortUrlTest.php | 19 ++++- 6 files changed, 107 insertions(+), 11 deletions(-) create mode 100644 data/migrations/Version20220110113313.php diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index fbeb5ab6..19113c22 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -21,6 +21,13 @@ return (static function (): array { 'mssql' => '1433', default => '3306', }; + $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', @@ -34,7 +41,7 @@ return (static function (): array { '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', + 'charset' => $resolveCharset(), ], }; diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index fff7e00a..89807b26 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -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', ], }; }; diff --git a/data/migrations/Version20160819142757.php b/data/migrations/Version20160819142757.php index 6db246a6..aeb1eb16 100644 --- a/data/migrations/Version20160819142757.php +++ b/data/migrations/Version20160819142757.php @@ -11,7 +11,7 @@ use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; -use function get_class; +use function is_subclass_of; /** * Auto-generated Migration: Please modify to your needs! @@ -24,13 +24,15 @@ class Version20160819142757 extends AbstractMigration */ public function up(Schema $schema): void { - $platformClass = get_class($this->connection->getDatabasePlatform()); + $platformClass = $this->connection->getDatabasePlatform(); $table = $schema->getTable('short_urls'); $column = $table->getColumn('short_code'); - match ($platformClass) { - MySQLPlatform::class => $column->setPlatformOption('collation', 'utf8_bin'), - SqlitePlatform::class => $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, }; } 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/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 { From f4dd27ca3fe2bac3923150865fd24961a129ad83 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 13:05:40 +0100 Subject: [PATCH 055/111] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aad20edc..dee37617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### 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. From 5b3c6f7752c8142da062bf8320c0227c2ce7b4f7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 13:09:24 +0100 Subject: [PATCH 056/111] Fixed charset in local entity manager config --- config/autoload/entity-manager.local.php.dist | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/autoload/entity-manager.local.php.dist b/config/autoload/entity-manager.local.php.dist index ef5cabf8..4a0750a5 100644 --- a/config/autoload/entity-manager.local.php.dist +++ b/config/autoload/entity-manager.local.php.dist @@ -11,7 +11,7 @@ return [ 'driver' => 'pdo_mysql', 'host' => 'shlink_db_mysql', 'dbname' => 'shlink', - 'charset' => 'utf8', + 'charset' => 'utf8mb4', ], ], From 34512da2fbd18c3ebca7263e7d6416c08acd0c35 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 13:21:12 +0100 Subject: [PATCH 057/111] Fixed indentation --- module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php index d238f2fc..3cc98786 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlStringifier.php @@ -18,9 +18,9 @@ class ShortUrlStringifier implements ShortUrlStringifierInterface public function stringify(ShortUrl $shortUrl): string { $uriWithoutShortCode = (new Uri())->withScheme($this->domainConfig['schema'] ?? 'http') - ->withHost($this->resolveDomain($shortUrl)) - ->withPath($this->basePath) - ->__toString(); + ->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()); From 2ed475fc7691ecee9795d26bd940e90f932c59ec Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 14:37:44 +0100 Subject: [PATCH 058/111] Ensure database fields are created with proper charset and collation in MySQL --- .../Shlinkio.Shlink.Core.Entity.Domain.php | 8 ++++---- .../Shlinkio.Shlink.Core.Entity.ShortUrl.php | 8 ++++---- .../Shlinkio.Shlink.Core.Entity.Tag.php | 2 +- .../Shlinkio.Shlink.Core.Entity.Visit.php | 6 +++--- ...inkio.Shlink.Core.Entity.VisitLocation.php | 2 +- module/Core/functions/functions.php | 20 ++++++++++--------- 6 files changed, 24 insertions(+), 22 deletions(-) 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/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, + }; +} From 154431e86ca751018f6e51609d1ae1a14169d183 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 15:15:16 +0100 Subject: [PATCH 059/111] Updated to infection 0.26 --- composer.json | 2 +- infection-api.json | 1 + infection-db.json | 1 + infection.json | 5 +++-- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index f12e41fb..a297e7ae 100644 --- a/composer.json +++ b/composer.json @@ -65,7 +65,7 @@ "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.3.0", "eaglewu/swoole-ide-helper": "dev-master", - "infection/infection": "^0.25.4", + "infection/infection": "^0.26", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.2", "phpstan/phpstan-doctrine": "^1.0", 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..df5a9749 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": { + "badge": "develop" } }, "tmpDir": "build/infection-unit/temp", From 629f8ece7a32ab8dc5d3bdeb5e434fb2e48b4d4e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 17:10:36 +0100 Subject: [PATCH 060/111] Updated to latest docker images and openswoole --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/publish-release.yml | 2 +- .github/workflows/publish-swagger-spec.yml | 2 +- Dockerfile | 4 ++-- data/infra/php.Dockerfile | 2 +- data/infra/swoole.Dockerfile | 4 ++-- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 051be99f..3e078f2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,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 +44,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 +79,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 +114,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/Dockerfile b/Dockerfile index f3de4a41..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" diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 4fbf676e..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 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index dbaa4202..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 From 632a19ceebfea88af694ee746c34e0ddd951c4fc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 17:12:09 +0100 Subject: [PATCH 061/111] Updated changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dee37617..31f225b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#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. ### Deprecated * [#1315](https://github.com/shlinkio/shlink/issues/1315) Deprecated `GET /tags?withStats=true` endpoint. Use `GET /tags/stats` instead. From db4ef328b11c9d03ae3a15412277105e697291dd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 20:26:33 +0100 Subject: [PATCH 062/111] Renamed some visits paginator adapters for consistency --- .../Adapter/OrphanVisitsPaginatorAdapter.php | 2 +- .../Paginator/Adapter/ShortUrlRepositoryAdapter.php | 2 +- ...Adapter.php => ShortUrlVisitsPaginatorAdapter.php} | 4 ++-- ...natorAdapter.php => TagVisitsPaginatorAdapter.php} | 4 ++-- module/Core/src/Repository/VisitRepository.php | 11 +++++------ .../Core/src/Repository/VisitRepositoryInterface.php | 5 +++++ module/Core/src/Visit/VisitsStatsHelper.php | 8 ++++---- .../Adapter/VisitsForTagPaginatorAdapterTest.php | 6 +++--- .../Paginator/Adapter/VisitsPaginatorAdapterTest.php | 6 +++--- 9 files changed, 26 insertions(+), 22 deletions(-) rename module/Core/src/Paginator/Adapter/{VisitsPaginatorAdapter.php => ShortUrlVisitsPaginatorAdapter.php} (89%) rename module/Core/src/Paginator/Adapter/{VisitsForTagPaginatorAdapter.php => TagVisitsPaginatorAdapter.php} (89%) diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 18c2c435..0a7ce3db 100644 --- a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -23,7 +23,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/ShortUrlRepositoryAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php index 0be5403b..cf54e1b5 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php @@ -18,7 +18,7 @@ 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, diff --git a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php similarity index 89% rename from module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php rename to module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 9ff13e3c..9f7ba6e0 100644 --- a/module/Core/src/Paginator/Adapter/VisitsPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; -class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter +class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { public function __construct( private VisitRepositoryInterface $visitRepository, @@ -21,7 +21,7 @@ class VisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter ) { } - public function getSlice($offset, $length): array // phpcs:ignore + public function getSlice(int $offset, int $length): iterable { return $this->visitRepository->findVisitsByShortCode( $this->identifier, diff --git a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php b/module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php similarity index 89% rename from module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php rename to module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 20af1598..e9687f78 100644 --- a/module/Core/src/Paginator/Adapter/VisitsForTagPaginatorAdapter.php +++ b/module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -10,7 +10,7 @@ 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,7 +20,7 @@ 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, diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 3a8440a7..d4faa836 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -53,10 +53,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); } @@ -189,11 +186,13 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo 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()))); } } diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 28f1e9a8..29360ddd 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -52,5 +52,10 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countOrphanVisits(VisitsCountFiltering $filtering): int; +// /** +// * @return Visit[] +// */ +// public function findExistingVisits(VisitsListFiltering $filtering): array; + public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 8138d170..2f888115 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -15,8 +15,8 @@ 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\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; +use Shlinkio\Shlink\Core\Paginator\Adapter\TagVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Repository\TagRepository; use Shlinkio\Shlink\Core\Repository\VisitRepository; @@ -62,7 +62,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** @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, $spec), $params); } /** @@ -80,7 +80,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); } /** diff --git a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index c92e21c6..2f0da796 100644 --- a/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -9,7 +9,7 @@ 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\Paginator\Adapter\TagVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; @@ -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/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 413ae1cd..66ff77ac 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -10,7 +10,7 @@ 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\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; @@ -64,9 +64,9 @@ 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([]), From 7f4ada9c4b10905e247061c2178cbb24ef32ed0d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 21:43:32 +0100 Subject: [PATCH 063/111] Created method in VisitRepository to fetch all non-orphan visits --- module/Core/src/Repository/TagRepository.php | 4 +- .../Core/src/Repository/VisitRepository.php | 51 ++++++++++++------- .../Repository/VisitRepositoryInterface.php | 8 +-- .../Repository/VisitRepositoryTest.php | 48 ++++++++++++++++- 4 files changed, 86 insertions(+), 25 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 0182908c..1aa35603 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -60,9 +60,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito } $apiKey = $filtering?->apiKey(); - if ($apiKey !== null) { - $this->applySpecification($subQb, $apiKey->spec(false, 'shortUrls'), 't'); - } + $this->applySpecification($subQb, $apiKey?->spec(false, 'shortUrls'), 't'); $subQuery = $subQb->getQuery(); $subQuerySql = $subQuery->getSQL(); diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index d4faa836..4e83c03e 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -104,8 +104,7 @@ 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->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 @@ -139,8 +138,7 @@ 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') @@ -152,25 +150,15 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->spec(), 'v'); + $this->applySpecification($qb, $filtering->spec(), 'v'); // FIXME This is actually binding arguments 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()); } @@ -179,11 +167,40 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return (int) $this->matchSingleScalarResult(new CountOfOrphanVisits($filtering)); } + /** + * @return Visit[] + */ + public function findNonOrphanVisits(VisitsListFiltering $filtering): array + { + $qb = $this->createAllVisitsQueryBuilder($filtering); + $qb->andWhere($qb->expr()->isNotNull('v.shortUrl')); + + $this->applySpecification($qb, $filtering->spec()); + + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + } + public function countVisits(?ApiKey $apiKey = null): int { return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); } + 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 strictly 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(); diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index 29360ddd..a3c6497c 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -52,10 +52,10 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification public function countOrphanVisits(VisitsCountFiltering $filtering): int; -// /** -// * @return Visit[] -// */ -// public function findExistingVisits(VisitsListFiltering $filtering): array; + /** + * @return Visit[] + */ + public function findNonOrphanVisits(VisitsListFiltering $filtering): array; public function countVisits(?ApiKey $apiKey = null): int; } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 475cf374..0f90fc6a 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -29,6 +29,9 @@ 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 { @@ -388,6 +391,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 +475,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--; From f0fd9470460a28d537e08cefd9a8202e9f3c381f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 22:16:33 +0100 Subject: [PATCH 064/111] Moved existing paginator adapters that are related with visits to the Visits namespace --- .../Paginator/Adapter/OrphanVisitsPaginatorAdapter.php | 3 ++- .../Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php | 3 ++- .../Paginator/Adapter/TagVisitsPaginatorAdapter.php | 3 ++- module/Core/src/Visit/VisitsStatsHelper.php | 6 +++--- .../Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php | 4 ++-- .../Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php | 4 ++-- .../Paginator/Adapter/VisitsPaginatorAdapterTest.php | 4 ++-- 7 files changed, 15 insertions(+), 12 deletions(-) rename module/Core/src/{ => Visit}/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php (87%) rename module/Core/src/{ => Visit}/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php (91%) rename module/Core/src/{ => Visit}/Paginator/Adapter/TagVisitsPaginatorAdapter.php (90%) rename module/Core/test/{ => Visit}/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php (93%) rename module/Core/test/{ => Visit}/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php (93%) rename module/Core/test/{ => Visit}/Paginator/Adapter/VisitsPaginatorAdapterTest.php (94%) diff --git a/module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php similarity index 87% rename from module/Core/src/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 0a7ce3db..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; diff --git a/module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php similarity index 91% rename from module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 9f7ba6e0..0d33b867 100644 --- a/module/Core/src/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -2,11 +2,12 @@ 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; diff --git a/module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php similarity index 90% rename from module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php rename to module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index e9687f78..f71c1df3 100644 --- a/module/Core/src/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.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; diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 2f888115..d0463d91 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -14,14 +14,14 @@ 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\ShortUrlVisitsPaginatorAdapter; -use Shlinkio\Shlink\Core\Paginator\Adapter\TagVisitsPaginatorAdapter; 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\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; 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/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php similarity index 93% rename from module/Core/test/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 2f0da796..cad1090b 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\TagVisitsPaginatorAdapter; 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; diff --git a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php similarity index 94% rename from module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php index 66ff77ac..bc9ca967 100644 --- a/module/Core/test/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.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\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\VisitsParams; -use Shlinkio\Shlink\Core\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; 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; From cff9cd5fb8f45daac9052e1c90d25ce5c8512f9b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 10 Jan 2022 22:23:00 +0100 Subject: [PATCH 065/111] Documented endpoint to get all non-orphan visits --- docs/swagger/paths/v2_visits_non-orphan.json | 146 +++++++++++++++++++ docs/swagger/swagger.json | 3 + 2 files changed, 149 insertions(+) create mode 100644 docs/swagger/paths/v2_visits_non-orphan.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/swagger.json b/docs/swagger/swagger.json index f04510d0..3730b527 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.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" From 976b07cd61379ba9ed97909b66768f9947c72224 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 12 Jan 2022 20:48:42 +0100 Subject: [PATCH 066/111] Updated to doctrine 2.11 --- composer.json | 2 +- module/Core/src/Repository/ShortUrlRepository.php | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index a297e7ae..3213403b 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^2.3", "doctrine/migrations": "^3.3", - "doctrine/orm": "^2.10", + "doctrine/orm": "^2.11", "endroid/qr-code": "^4.4", "geoip2/geoip2": "^2.12", "guzzlehttp/guzzle": "^7.4", diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index 57aa480c..e59b7f31 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -192,6 +192,9 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $this->doShortCodeIsInUse($identifier, $spec, LockMode::PESSIMISTIC_WRITE); } + /** + * @param LockMode::PESSIMISTIC_WRITE|null $lockMode + */ private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool { $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); From 0d37eb65c90d4bfd0e8d3f9f5b660ade5370e838 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 11 Jan 2022 19:05:53 +0100 Subject: [PATCH 067/111] Used PhpFileProvider to load installer generated config --- config/config.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.php b/config/config.php index 330cd836..33c78896 100644 --- a/config/config.php +++ b/config/config.php @@ -37,7 +37,7 @@ return (new ConfigAggregator\ConfigAggregator([ new ConfigAggregator\PhpFileProvider('config/autoload/{{,*.}global,{,*.}local}.php'), env('APP_ENV') === 'test' ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') - : new ConfigAggregator\LaminasConfigProvider('config/params/generated_config.php'), + : new ConfigAggregator\PhpFileProvider('config/params/generated_config.php'), ], 'data/cache/app_config.php', [ Core\Config\BasePathPrefixer::class, ]))->getMergedConfig(); From c6f16b055807057426089c2b015f26abb7230bf6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 11:34:17 +0100 Subject: [PATCH 068/111] Updated to latest installer with support for env vars --- composer.json | 2 +- config/autoload/entity-manager.global.php | 2 +- config/autoload/installer.global.php | 6 ++---- config/config.php | 17 +++++++++++++++-- module/Core/functions/functions.php | 19 +++++++++++++++++++ module/Core/src/Options/TrackingOptions.php | 12 +++++++----- 6 files changed, 45 insertions(+), 13 deletions(-) diff --git a/composer.json b/composer.json index 3213403b..7901dde3 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "shlinkio/shlink-config": "^1.5", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "dev-develop#a008036 as 7.0", + "shlinkio/shlink-installer": "dev-develop#ba32503 as 7.0", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index 19113c22..c83db2a8 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -38,7 +38,7 @@ return (static function (): array { 'dbname' => env('DB_NAME', 'shlink'), 'user' => env('DB_USER'), 'password' => env('DB_PASSWORD'), - 'host' => env('DB_HOST', $driver === 'postgres' ? env('DB_UNIX_SOCKET') : null), + 'host' => env('DB_HOST', env('DB_UNIX_SOCKET')), 'port' => env('DB_PORT', $resolveDefaultPort()), 'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null, 'charset' => $resolveCharset(), diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 9259b061..81f9941a 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -18,8 +18,6 @@ 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\Visit\VisitsWebhooksConfigOption::class, @@ -27,12 +25,12 @@ return [ 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\RedisConfigOption::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/config.php b/config/config.php index 33c78896..33cf602b 100644 --- a/config/config.php +++ b/config/config.php @@ -12,12 +12,25 @@ use Mezzio\Swoole; use function class_exists; use function Shlinkio\Shlink\Config\env; +use function Shlinkio\Shlink\Core\putNotYetDefinedEnv; use const PHP_SAPI; $isCli = PHP_SAPI === 'cli'; +$isTestEnv = env('APP_ENV') === 'test'; return (new ConfigAggregator\ConfigAggregator([ + ! $isTestEnv + ? new ConfigAggregator\ArrayProvider((new ConfigAggregator\ConfigAggregator([ + new ConfigAggregator\PhpFileProvider('config/params/generated_config.php'), + ], null, [function (array $generatedConfig) { + foreach ($generatedConfig as $envVar => $value) { + putNotYetDefinedEnv($envVar, $value); + } + + return []; + }]))->getMergedConfig()) + : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, @@ -35,9 +48,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') - : new ConfigAggregator\PhpFileProvider('config/params/generated_config.php'), + : new ConfigAggregator\ArrayProvider([]), ], 'data/cache/app_config.php', [ Core\Config\BasePathPrefixer::class, ]))->getMergedConfig(); diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 567fde47..870f84c4 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -13,9 +13,13 @@ use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use function Functional\reduce_left; +use function implode; use function is_array; +use function is_scalar; use function print_r; +use function putenv; use function Shlinkio\Shlink\Common\buildDateRange; +use function Shlinkio\Shlink\Config\env; use function sprintf; use function str_repeat; @@ -116,3 +120,18 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll default => $field, }; } + +function putNotYetDefinedEnv(string $key, mixed $value): void +{ + $isArray = is_array($value); + if (!($isArray || is_scalar($value)) || env($key) !== null) { + return; + } + + $normalizedValue = $isArray ? implode(',', $value) : match ($value) { + true => 'true', + false => 'false', + default => $value, + }; + putenv(sprintf('%s=%s', $key, $normalizedValue)); +} 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)), + }; } } From 91192a8a8f39099855e571688e8c03fe2cadf700 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 16:06:24 +0100 Subject: [PATCH 069/111] Updated to latest shlink-installer and shlink-config, ensuring env vars are properly loaded --- composer.json | 4 ++-- config/config.php | 12 ++---------- module/Core/functions/functions.php | 19 ------------------- 3 files changed, 4 insertions(+), 31 deletions(-) diff --git a/composer.json b/composer.json index 7901dde3..41389109 100644 --- a/composer.json +++ b/composer.json @@ -48,10 +48,10 @@ "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", "shlinkio/shlink-common": "dev-main#cbcff58 as 4.4", - "shlinkio/shlink-config": "^1.5", + "shlinkio/shlink-config": "dev-main#483cf8a as 1.6", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "dev-develop#ba32503 as 7.0", + "shlinkio/shlink-installer": "dev-develop#3ca7ec5 as 7.0", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", diff --git a/config/config.php b/config/config.php index 33cf602b..0adb0208 100644 --- a/config/config.php +++ b/config/config.php @@ -9,10 +9,10 @@ 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\Config\env; -use function Shlinkio\Shlink\Core\putNotYetDefinedEnv; use const PHP_SAPI; @@ -21,15 +21,7 @@ $isTestEnv = env('APP_ENV') === 'test'; return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv - ? new ConfigAggregator\ArrayProvider((new ConfigAggregator\ConfigAggregator([ - new ConfigAggregator\PhpFileProvider('config/params/generated_config.php'), - ], null, [function (array $generatedConfig) { - foreach ($generatedConfig as $envVar => $value) { - putNotYetDefinedEnv($envVar, $value); - } - - return []; - }]))->getMergedConfig()) + ? new EnvVarLoaderProvider('config/params/generated_config.php') : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 870f84c4..567fde47 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -13,13 +13,9 @@ use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use function Functional\reduce_left; -use function implode; use function is_array; -use function is_scalar; use function print_r; -use function putenv; use function Shlinkio\Shlink\Common\buildDateRange; -use function Shlinkio\Shlink\Config\env; use function sprintf; use function str_repeat; @@ -120,18 +116,3 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll default => $field, }; } - -function putNotYetDefinedEnv(string $key, mixed $value): void -{ - $isArray = is_array($value); - if (!($isArray || is_scalar($value)) || env($key) !== null) { - return; - } - - $normalizedValue = $isArray ? implode(',', $value) : match ($value) { - true => 'true', - false => 'false', - default => $value, - }; - putenv(sprintf('%s=%s', $key, $normalizedValue)); -} From a1366f0ef14ea5eae03fe968d543f6c32c8fef80 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 16:52:48 +0100 Subject: [PATCH 070/111] Exposed port 8888 on php container for experimentation --- docker-compose.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 3d552f9a..5e2a3bd6 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 From 199d976e3d050478d656cc9bfeb91f77920e7e41 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 16:55:57 +0100 Subject: [PATCH 071/111] Updated changelog and upgrading doc --- CHANGELOG.md | 1 + UPGRADE.md | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f225b5..0a33bd5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#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. ### Deprecated * [#1315](https://github.com/shlinkio/shlink/issues/1315) Deprecated `GET /tags?withStats=true` endpoint. Use `GET /tags/stats` instead. diff --git a/UPGRADE.md b/UPGRADE.md index 53ff49f3..7988276d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -38,6 +38,7 @@ * `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 From f53305c404025bd67e896f1b315c716d3cd6753e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 17:17:22 +0100 Subject: [PATCH 072/111] Added ADR for the changes to load env vars on top of installer config --- ...-have-precedence-over-installer-options.md | 50 +++++++++++++++++++ docs/adr/README.md | 1 + 2 files changed, 51 insertions(+) create mode 100644 docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md 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..63231bc1 --- /dev/null +++ b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md @@ -0,0 +1,50 @@ +# 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. + +### 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) From 545da96d15b8aeee81cef76bfd6a0181911c1d23 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 15 Jan 2022 17:21:36 +0100 Subject: [PATCH 073/111] Updated env vars ADR --- ...nv-vars-behavior-to-have-precedence-over-installer-options.md | 1 + 1 file changed, 1 insertion(+) 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 index 63231bc1..df11538c 100644 --- 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 @@ -34,6 +34,7 @@ Then Shlink reads this as the first config file, which sets the values as env va * 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 From 60c0ca3ae5098924aa8fc9381b721c08801057c0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 10:56:37 +0100 Subject: [PATCH 074/111] Changed VisitsCountFiltering and VisitsListFiltering so that they encapsulate an ApiKey instead of a Spec --- module/Core/src/Repository/VisitRepository.php | 12 +++++++----- .../Core/src/Repository/VisitRepositoryInterface.php | 1 + .../Adapter/ShortUrlVisitsPaginatorAdapter.php | 8 ++++---- .../Paginator/Adapter/TagVisitsPaginatorAdapter.php | 4 ++-- .../src/Visit/Persistence/VisitsCountFiltering.php | 8 ++++---- .../src/Visit/Persistence/VisitsListFiltering.php | 6 +++--- module/Core/src/Visit/VisitsStatsHelper.php | 9 +++++---- .../Core/test-db/Repository/VisitRepositoryTest.php | 8 ++++---- .../Adapter/VisitsForTagPaginatorAdapterTest.php | 2 +- .../Paginator/Adapter/VisitsPaginatorAdapterTest.php | 4 ++-- 10 files changed, 33 insertions(+), 29 deletions(-) diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 4e83c03e..b8b4688c 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -104,10 +104,10 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->spec())?->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 + // 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)); @@ -150,7 +150,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->spec(), 'v'); // FIXME This is actually binding arguments + $this->applySpecification($qb, $filtering->apiKey()?->spec(true), 'v'); return $qb; } @@ -175,11 +175,13 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNotNull('v.shortUrl')); - $this->applySpecification($qb, $filtering->spec()); + $this->applySpecification($qb, $filtering->apiKey()?->spec(true)); return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } + // TODO This should support counting in a date range or excluding bots + // TODO Rename to countNonOrphanVisits public function countVisits(?ApiKey $apiKey = null): int { return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); @@ -188,7 +190,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo 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 strictly provided by the caller, it's reasonably safe + // Since they are not provided by the caller, it's reasonably safe $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v'); diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index a3c6497c..d541e20e 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -12,6 +12,7 @@ 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; diff --git a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 0d33b867..2e47fbf8 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -4,13 +4,13 @@ declare(strict_types=1); 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 ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter { @@ -18,7 +18,7 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap private VisitRepositoryInterface $visitRepository, private ShortUrlIdentifier $identifier, private VisitsParams $params, - private ?Specification $spec, + private ?ApiKey $apiKey, ) { } @@ -29,7 +29,7 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap new VisitsListFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->spec, + $this->apiKey, $length, $offset, ), @@ -43,7 +43,7 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap new VisitsCountFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->spec, + $this->apiKey, ), ); } diff --git a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index f71c1df3..162b6cba 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -28,7 +28,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter new VisitsListFiltering( $this->params->getDateRange(), $this->params->excludeBots(), - $this->apiKey?->spec(true), + $this->apiKey, $length, $offset, ), @@ -42,7 +42,7 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter 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..c0389ef6 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -4,15 +4,15 @@ 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, ) { } @@ -26,8 +26,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/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index d0463d91..91f4d4fa 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -51,18 +51,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 ShortUrlVisitsPaginatorAdapter($repo, $identifier, $params, $spec), $params); + return $this->createPaginator( + new ShortUrlVisitsPaginatorAdapter($repo, $identifier, $params, $apiKey), + $params, + ); } /** diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 0f90fc6a..390b1707 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -192,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), )); } diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index cad1090b..442e7128 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -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++) { diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php index bc9ca967..41b873df 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php @@ -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++) { @@ -70,7 +70,7 @@ class VisitsPaginatorAdapterTest extends TestCase $this->repo->reveal(), new ShortUrlIdentifier(''), VisitsParams::fromRawData([]), - $apiKey?->spec(), + $apiKey, ); } } From 61618250ecca54cc631fe2aec237a9f35a8147bf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 11:15:39 +0100 Subject: [PATCH 075/111] Renamed countVisits to countNonOrphanVisits, and updated its signature to expect a VisitsCountFiltering DTO --- module/Core/src/Repository/VisitRepository.php | 6 ++---- module/Core/src/Repository/VisitRepositoryInterface.php | 3 +-- .../Core/src/Visit/Persistence/VisitsCountFiltering.php | 5 +++++ module/Core/src/Visit/VisitsStatsHelper.php | 2 +- module/Core/test-db/Repository/VisitRepositoryTest.php | 8 ++++---- module/Core/test/Visit/VisitsStatsHelperTest.php | 2 +- 6 files changed, 14 insertions(+), 12 deletions(-) diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index b8b4688c..6c48a318 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -16,7 +16,6 @@ use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; 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; @@ -181,10 +180,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } // TODO This should support counting in a date range or excluding bots - // TODO Rename to countNonOrphanVisits - public function countVisits(?ApiKey $apiKey = null): int + public function countNonOrphanVisits(VisitsCountFiltering $filtering): int { - return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($apiKey)); + return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($filtering->apiKey())); } private function createAllVisitsQueryBuilder(VisitsListFiltering $filtering): QueryBuilder diff --git a/module/Core/src/Repository/VisitRepositoryInterface.php b/module/Core/src/Repository/VisitRepositoryInterface.php index d541e20e..3d480c01 100644 --- a/module/Core/src/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Repository/VisitRepositoryInterface.php @@ -10,7 +10,6 @@ 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 @@ -58,5 +57,5 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification */ public function findNonOrphanVisits(VisitsListFiltering $filtering): array; - public function countVisits(?ApiKey $apiKey = null): int; + public function countNonOrphanVisits(VisitsCountFiltering $filtering): int; } diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index c0389ef6..140ec9b9 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -16,6 +16,11 @@ class VisitsCountFiltering ) { } + public static function withApiKey(?ApiKey $apiKey): self + { + return new self(null, false, $apiKey); + } + public function dateRange(): ?DateRange { return $this->dateRange; diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 91f4d4fa..25f5c82e 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -37,7 +37,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()), ); } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 390b1707..a7981ea1 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -297,10 +297,10 @@ 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->countOrphanVisits(new VisitsCountFiltering())); self::assertEquals(3, $this->repo->countOrphanVisits(new VisitsCountFiltering(null, true))); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index ab76bbf1..7d9a06b9 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, ); From 4a3e04ced9b9d6a99318ac3f28694b3ce26e82d2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 11:44:12 +0100 Subject: [PATCH 076/111] Added tests covering count non orphan visits with different combinations of filters --- .../Core/src/Repository/VisitRepository.php | 5 +-- .../src/Visit/Spec/CountOfNonOrphanVisits.php | 39 +++++++++++++++++++ .../src/Visit/Spec/CountOfShortUrlVisits.php | 27 ------------- .../Repository/VisitRepositoryTest.php | 10 +++++ 4 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php delete mode 100644 module/Core/src/Visit/Spec/CountOfShortUrlVisits.php diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 6c48a318..67b4256f 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -14,8 +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 const PHP_INT_MAX; @@ -179,10 +179,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } - // TODO This should support counting in a date range or excluding bots public function countNonOrphanVisits(VisitsCountFiltering $filtering): int { - return (int) $this->matchSingleScalarResult(new CountOfShortUrlVisits($filtering->apiKey())); + return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering)); } private function createAllVisitsQueryBuilder(VisitsListFiltering $filtering): QueryBuilder 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/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index a7981ea1..950bfc8a 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -301,6 +301,16 @@ class VisitRepositoryTest extends DatabaseTestCase 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))); } From 8b79eee081af497b1876486322f6dc6e5b448f09 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 12:08:11 +0100 Subject: [PATCH 077/111] Updated changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a33bd5b..46e47408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this 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. From fe1fa7689aa0f37ead473f5f29c1578988b805c4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 12:24:02 +0100 Subject: [PATCH 078/111] Created endpoint to list non-orphan visits --- .../NonOrphanVisitsPaginatorAdapter.php | 42 ++++++++++++++++ module/Core/src/Visit/VisitsStatsHelper.php | 9 ++++ .../src/Visit/VisitsStatsHelperInterface.php | 5 ++ module/Rest/config/dependencies.config.php | 2 + module/Rest/config/routes.config.php | 1 + .../Action/Visit/NonOrphanVisitsAction.php | 37 ++++++++++++++ .../test-api/Action/NonOrphanVisitsTest.php | 36 ++++++++++++++ .../Visit/NonOrphanVisitsActionTest.php | 49 +++++++++++++++++++ .../test/Action/Visit/TagVisitsActionTest.php | 6 +-- 9 files changed, 184 insertions(+), 3 deletions(-) create mode 100644 module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php create mode 100644 module/Rest/src/Action/Visit/NonOrphanVisitsAction.php create mode 100644 module/Rest/test-api/Action/NonOrphanVisitsTest.php create mode 100644 module/Rest/test/Action/Visit/NonOrphanVisitsActionTest.php 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/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 25f5c82e..914a9c5b 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -19,6 +19,7 @@ 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; @@ -95,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/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index e7d99a85..5f0d5c05 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -34,6 +34,7 @@ return [ 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, @@ -74,6 +75,7 @@ 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\Tag\ListTagsAction::class => [TagService::class], Action\Tag\TagsStatsAction::class => [TagService::class], diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php index 49d9f107..16f83149 100644 --- a/module/Rest/config/routes.config.php +++ b/module/Rest/config/routes.config.php @@ -34,6 +34,7 @@ return [ Action\Visit\TagVisitsAction::getRouteDef(), Action\Visit\GlobalVisitsAction::getRouteDef(), Action\Visit\OrphanVisitsAction::getRouteDef(), + Action\Visit\NonOrphanVisitsAction::getRouteDef(), // Tags Action\Tag\ListTagsAction::getRouteDef(), 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/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/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()); From 7c1f705e6422ba4d08d97a19fd3c117aa889c0ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 12:29:36 +0100 Subject: [PATCH 079/111] Created NonOrphanVisitsPaginatorAdapter test --- .../NonOrphanVisitsPaginatorAdapterTest.php | 79 +++++++++++++++++++ ...=> ShortUrlVisitsPaginatorAdapterTest.php} | 2 +- .../Core/test/Visit/VisitsStatsHelperTest.php | 19 +++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php rename module/Core/test/Visit/Paginator/Adapter/{VisitsPaginatorAdapterTest.php => ShortUrlVisitsPaginatorAdapterTest.php} (97%) 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/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php similarity index 97% rename from module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php rename to module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index 41b873df..04e17bc6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -16,7 +16,7 @@ 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; diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 7d9a06b9..731697e6 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -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(); + } } From bfb54189b8e231528570a9877cf46db7a8aec8e7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 15:34:07 +0100 Subject: [PATCH 080/111] Moved some config to the proper namespace, now that config is no longer part of the public contract --- config/autoload/redirects.global.php | 3 +- config/autoload/webhooks.global.php | 5 +-- module/Core/config/dependencies.config.php | 6 ++- module/Core/src/Options/RedirectOptions.php | 45 +++++++++++++++++++ .../Core/src/Options/UrlShortenerOptions.php | 34 -------------- module/Core/src/Options/WebhookOptions.php | 10 ++--- .../Core/src/Util/RedirectResponseHelper.php | 4 +- .../NotifyVisitToWebHooksTest.php | 2 +- .../test/Util/RedirectResponseHelperTest.php | 6 +-- 9 files changed, 63 insertions(+), 52 deletions(-) create mode 100644 module/Core/src/Options/RedirectOptions.php diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index e38b9c25..20cde8eb 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -15,8 +15,7 @@ return [ 'base_url' => env('DEFAULT_BASE_URL_REDIRECT'), ], - 'url_shortener' => [ - // TODO Move these options to their own config namespace. Maybe "redirects". + '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), ], diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index fb946028..6bbbfbda 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -9,9 +9,8 @@ return (static function (): array { return [ - 'url_shortener' => [ - // TODO Move these options to their own config namespace - 'visits_webhooks' => $webhooks === null ? [] : explode(',', $webhooks), + 'visits_webhooks' => [ + 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), 'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false), ], diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index fdfecef9..90931c11 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, @@ -86,10 +87,11 @@ 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, @@ -123,7 +125,7 @@ return [ 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'], 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/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index ecbbb590..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; 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/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/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/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); } From 77fee1390fd5b3925741faf585d5d37f6c0b90f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 15:41:20 +0100 Subject: [PATCH 081/111] Renamed class to a more appropriate name --- module/Core/config/dependencies.config.php | 8 ++++---- module/Core/src/Importer/ImportedLinksProcessor.php | 4 ++-- ...{ShortCodeHelper.php => ShortCodeUniquenessHelper.php} | 2 +- ...terface.php => ShortCodeUniquenessHelperInterface.php} | 2 +- module/Core/src/Service/UrlShortener.php | 4 ++-- module/Core/test/Importer/ImportedLinksProcessorTest.php | 4 ++-- ...deHelperTest.php => ShortCodeUniquenessHelperTest.php} | 8 ++++---- module/Core/test/Service/UrlShortenerTest.php | 4 ++-- 8 files changed, 18 insertions(+), 18 deletions(-) rename module/Core/src/Service/ShortUrl/{ShortCodeHelper.php => ShortCodeUniquenessHelper.php} (90%) rename module/Core/src/Service/ShortUrl/{ShortCodeHelperInterface.php => ShortCodeUniquenessHelperInterface.php} (72%) rename module/Core/test/Service/ShortUrl/{ShortCodeHelperTest.php => ShortCodeUniquenessHelperTest.php} (91%) diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 90931c11..516ad8a1 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -33,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, @@ -97,7 +97,7 @@ return [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, - Service\ShortUrl\ShortCodeHelper::class, + Service\ShortUrl\ShortCodeUniquenessHelper::class, ], Visit\VisitsTracker::class => [ 'em', @@ -120,7 +120,7 @@ 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], @@ -165,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/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/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/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/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/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/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( From 492eba3a8ba20c9e42dbddd6da58a9bae4369d81 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 16 Jan 2022 15:54:22 +0100 Subject: [PATCH 082/111] Fixed duplicated slashes generated in path when doing not-found redirects with placeholders --- module/Core/src/Config/NotFoundRedirectResolver.php | 5 ++++- module/Core/test/Config/NotFoundRedirectResolverTest.php | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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/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', ]; } From b4c52116b4b45504a08ae89485dc613df965b0ac Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Jan 2022 07:41:33 +0100 Subject: [PATCH 083/111] Enabled stryker report for infection --- infection.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infection.json b/infection.json index df5a9749..9a4f7f00 100644 --- a/infection.json +++ b/infection.json @@ -11,7 +11,7 @@ "summary": "build/infection-unit/summary-log.txt", "debug": "build/infection-unit/debug-log.txt", "stryker": { - "badge": "develop" + "report": "develop" } }, "tmpDir": "build/infection-unit/temp", From 0727c7bdfb476273a735880e6ae6be2f1e3827da Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Jan 2022 19:12:50 +0100 Subject: [PATCH 084/111] Updated readme file --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index e88448ee..d07dc696 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ [![Twitter](https://img.shields.io/twitter/follow/shlinkio?color=blue&label=follow&logo=twitter&style=flat-square)](https://twitter.com/shlinkio) [![Paypal donate](https://img.shields.io/badge/Donate-paypal-blue.svg?style=flat-square&logo=paypal&colorA=aaaaaa)](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 @@ -27,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. @@ -61,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. @@ -73,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 From 661b07e12fd35e3ca5fa72e947b419010d5b4aad Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Jan 2022 20:10:41 +0100 Subject: [PATCH 085/111] Refactored ShortUrlRepository to wrap args into DTOs --- module/Core/src/Model/Ordering.php | 5 + .../src/Repository/ShortUrlRepository.php | 53 +++---- .../ShortUrlRepositoryInterface.php | 24 +-- module/Core/src/Service/ShortUrlService.php | 2 +- .../Adapter/ShortUrlRepositoryAdapter.php | 21 +-- .../Persistence/ShortUrlsCountFiltering.php | 51 +++++++ .../Persistence/ShortUrlsListFiltering.php | 55 +++++++ .../Repository/ShortUrlRepositoryTest.php | 143 ++++++++++++------ .../Adapter/ShortUrlRepositoryAdapterTest.php | 16 +- 9 files changed, 251 insertions(+), 119 deletions(-) rename module/Core/src/{ => ShortUrl}/Paginator/Adapter/ShortUrlRepositoryAdapter.php (53%) create mode 100644 module/Core/src/ShortUrl/Persistence/ShortUrlsCountFiltering.php create mode 100644 module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php rename module/Core/test/{ => ShortUrl}/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php (81%) diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index 95fb6a14..6112dde7 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -18,6 +18,11 @@ final class Ordering return new self($field, $dir ?? self::DEFAULT_DIR); } + public static function emptyInstance(): self + { + return self::fromTuple([null, null]); + } + public function orderField(): ?string { return $this->field; diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index e59b7f31..f644d0d1 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -11,12 +11,13 @@ 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\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; @@ -26,27 +27,18 @@ 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 = [], - ?string $tagsMode = null, - ?Ordering $orderBy = null, - ?DateRange $dateRange = null, - ?Specification $spec = null, - ): array { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $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 explicit order by, fallback to dateCreated-DESC @@ -77,30 +69,21 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU return $qb->getQuery()->getResult(); } - public function countList( - ?string $searchTerm = null, - array $tags = [], - ?string $tagsMode = null, - ?DateRange $dateRange = null, - ?Specification $spec = null, - ): int { - $qb = $this->createListQueryBuilder($searchTerm, $tags, $tagsMode, $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, - ?string $tagsMode, - ?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); @@ -110,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 @@ -131,13 +116,13 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // Filter by tags if provided if (! empty($tags)) { - $tagsMode = $tagsMode ?? ShortUrlsParams::TAGS_MODE_ANY; + $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; } diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index c6f8df60..4a8d04df 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -7,34 +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\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +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 = [], - ?string $tagsMode = null, - ?Ordering $orderBy = null, - ?DateRange $dateRange = null, - ?Specification $spec = null, - ): array; + public function findList(ShortUrlsListFiltering $filtering): array; - public function countList( - ?string $searchTerm = null, - array $tags = [], - ?string $tagsMode = null, - ?DateRange $dateRange = null, - ?Specification $spec = null, - ): int; + public function countList(ShortUrlsCountFiltering $filtering): int; + // TODO Use ShortUrlIdentifier here public function findOneWithDomainFallback(string $shortCode, ?string $domain = null): ?ShortUrl; public function findOne(ShortUrlIdentifier $identifier, ?Specification $spec = null): ?ShortUrl; 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/Paginator/Adapter/ShortUrlRepositoryAdapter.php b/module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php similarity index 53% rename from module/Core/src/Paginator/Adapter/ShortUrlRepositoryAdapter.php rename to module/Core/src/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapter.php index cf54e1b5..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 @@ -21,25 +23,12 @@ class ShortUrlRepositoryAdapter implements AdapterInterface public function getSlice(int $offset, int $length): iterable { return $this->repository->findList( - $length, - $offset, - $this->params->searchTerm(), - $this->params->tags(), - $this->params->tagsMode(), - $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->tagsMode(), - $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/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index 5bf34437..ed7cd0ab 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -17,6 +17,8 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; 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; @@ -85,7 +87,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase } $this->getEntityManager()->flush(); - self::assertEquals($count, $this->repo->countList()); + self::assertEquals($count, $this->repo->countList(new ShortUrlsCountFiltering())); } /** @test */ @@ -112,44 +114,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, [], null, Ordering::fromTuple(['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, null, DateRange::withEndDate( - Chronos::now()->subDays(2), - )); - self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(null, [], null, DateRange::withEndDate( - Chronos::now()->subDays(2), - ))); - self::assertSame($foo2, $result[0]); - - self::assertCount( - 2, - $this->repo->findList(null, null, null, [], null, null, DateRange::withStartDate( + $result = $this->repo->findList( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withEndDate( Chronos::now()->subDays(2), )), ); - self::assertEquals(2, $this->repo->countList(null, [], null, DateRange::withStartDate( + self::assertCount(1, $result); + 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( + 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 */ @@ -162,7 +169,9 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - $result = $this->repo->findList(null, null, null, [], null, Ordering::fromTuple(['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()); @@ -202,38 +211,86 @@ class ShortUrlRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'])); - self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); - self::assertCount(1, $this->repo->findList(null, null, null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); - self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'])); - self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY)); - self::assertEquals(1, $this->repo->countList(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL)); - - self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'])); - self::assertCount(4, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); - self::assertCount(2, $this->repo->findList(null, null, null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); - self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'])); - self::assertEquals(4, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); - self::assertEquals(2, $this->repo->countList(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); - - self::assertCount(5, $this->repo->findList(null, null, null, ['foo', 'bar', 'baz'])); 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( + ))); + 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), )); - self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'])); - self::assertEquals(5, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY)); - self::assertEquals(0, $this->repo->countList(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL)); } /** @test */ diff --git a/module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php similarity index 81% rename from module/Core/test/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php rename to module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 3d4b01ae..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,8 +48,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase $orderBy = $params->orderBy(); $dateRange = $params->dateRange(); - $this->repo->findList(10, 5, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $orderBy, $dateRange, null) - ->shouldBeCalledOnce(); + $this->repo->findList( + new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange), + )->shouldBeCalledOnce(); $adapter->getSlice(5, 10); } @@ -71,8 +74,9 @@ class ShortUrlRepositoryAdapterTest extends TestCase $adapter = new ShortUrlRepositoryAdapter($this->repo->reveal(), $params, $apiKey); $dateRange = $params->dateRange(); - $this->repo->countList($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey->spec()) - ->shouldBeCalledOnce(); + $this->repo->countList( + new ShortUrlsCountFiltering($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey), + )->shouldBeCalledOnce(); $adapter->getNbResults(); } From dc430bae10bd40c09a26516c9041ca4d4795ca98 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 17 Jan 2022 20:21:35 +0100 Subject: [PATCH 086/111] Refactored method in ShortUrlsRepository --- CHANGELOG.md | 1 + .../src/Repository/ShortUrlRepository.php | 6 ++-- .../ShortUrlRepositoryInterface.php | 3 +- .../src/Service/ShortUrl/ShortUrlResolver.php | 2 +- .../Repository/ShortUrlRepositoryTest.php | 31 ++++++++++++------- .../Service/ShortUrl/ShortUrlResolverTest.php | 8 +++-- 6 files changed, 31 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46e47408..7eb2fa25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#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. diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index f644d0d1..9852c530 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -127,7 +127,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU 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 @@ -146,8 +146,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $query = $this->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 diff --git a/module/Core/src/Repository/ShortUrlRepositoryInterface.php b/module/Core/src/Repository/ShortUrlRepositoryInterface.php index 4a8d04df..cfc36e0e 100644 --- a/module/Core/src/Repository/ShortUrlRepositoryInterface.php +++ b/module/Core/src/Repository/ShortUrlRepositoryInterface.php @@ -20,8 +20,7 @@ interface ShortUrlRepositoryInterface extends ObjectRepository, EntitySpecificat public function countList(ShortUrlsCountFiltering $filtering): int; - // TODO Use ShortUrlIdentifier here - 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/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/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index ed7cd0ab..30b95774 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -57,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 */ 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); From d39f3b42659267ea64c6f56e4e9d13c813fcff05 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Jan 2022 19:38:53 +0100 Subject: [PATCH 087/111] Enhanced TagRepositoryTest and replaced inlined quoting by doctrine connection quoting --- .../Core/src/Repository/VisitRepository.php | 2 +- .../ShortUrl/Spec/BelongsToApiKeyInlined.php | 3 +- .../ShortUrl/Spec/BelongsToDomainInlined.php | 3 +- .../test-db/Repository/TagRepositoryTest.php | 31 +++++++++++++++++-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 67b4256f..befd104d 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -142,7 +142,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $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')); 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/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 5ac1f3ac..d030bd03 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -63,19 +63,27 @@ class TagRepositoryTest extends DatabaseTestCase 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())); $this->getEntityManager()->flush(); @@ -181,6 +189,23 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); }, ]; + yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( + ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), + )), static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(3, $result); + self::assertEquals(1, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->visitsCount()); + self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[0], $result[2]->tag()->__toString()); + }]; } /** @test */ From 9e9621e7b2654276fcb5d9729071094c883fa869 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Jan 2022 20:06:32 +0100 Subject: [PATCH 088/111] Standardized how inlined or regular specs are applied to query builders --- module/Core/src/Repository/TagRepository.php | 21 +-------- .../Core/src/Repository/VisitRepository.php | 6 +-- module/Rest/src/ApiKey/Role.php | 27 ++++++----- .../Spec/WithApiKeySpecsEnsuringJoin.php | 9 ++-- module/Rest/src/Entity/ApiKey.php | 10 +++- module/Rest/test/ApiKey/RoleTest.php | 47 ++++++++++++------- 6 files changed, 61 insertions(+), 59 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 1aa35603..f19e8917 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -16,12 +16,6 @@ use Shlinkio\Shlink\Rest\ApiKey\Spec\WithApiKeySpecsEnsuringJoin; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Functional\map; -use function is_object; -use function method_exists; -use function sprintf; -use function strlen; -use function strpos; -use function substr_replace; use const PHP_INT_MAX; @@ -60,24 +54,11 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito } $apiKey = $filtering?->apiKey(); - $this->applySpecification($subQb, $apiKey?->spec(false, 'shortUrls'), 't'); + $this->applySpecification($subQb, new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrls', true), 't'); $subQuery = $subQb->getQuery(); $subQuerySql = $subQuery->getSQL(); - // Sadly, we need to manually interpolate the params in the query replacing the placeholders, as this is going - // to be used as a sub-query in a native query. There's no need to sanitize, though. - foreach ($subQuery->getParameters() as $param) { - $value = $param->getValue(); - $pos = strpos($subQuerySql, self::PARAM_PLACEHOLDER); - $subQuerySql = substr_replace( - $subQuerySql, - sprintf('\'%s\'', is_object($value) && method_exists($value, 'getId') ? $value->getId() : $value), - $pos === false ? -1 : $pos, - strlen(self::PARAM_PLACEHOLDER), - ); - } - // 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. diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index befd104d..b43d676d 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -105,7 +105,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); $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 + // 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') @@ -149,7 +149,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo } $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->apiKey()?->spec(true), 'v'); + $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); return $qb; } @@ -174,7 +174,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNotNull('v.shortUrl')); - $this->applySpecification($qb, $filtering->apiKey()?->spec(true)); + $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec()); return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); } 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..56f64a6d 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,8 +11,11 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - public function __construct(private ?ApiKey $apiKey, private string $fieldToJoin = 'shortUrls') - { + public function __construct( + private ?ApiKey $apiKey, + private string $fieldToJoin = 'shortUrls', + private bool $inlined = false, + ) { parent::__construct(); } @@ -20,7 +23,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->inlined ? $this->apiKey->inlinedSpec() : $this->apiKey->spec($this->fieldToJoin), ); } } 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/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 From d0546a2ea2817eab0f77ecc2edfbdfece1d9b710 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 18 Jan 2022 20:14:24 +0100 Subject: [PATCH 089/111] Split spec to join ApiKey spec with short URLs, into inlined and regular versions --- module/Core/src/Repository/TagRepository.php | 5 ++-- .../Spec/WithApiKeySpecsEnsuringJoin.php | 9 +++---- .../WithInlinedApiKeySpecsEnsuringJoin.php | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 module/Rest/src/ApiKey/Spec/WithInlinedApiKeySpecsEnsuringJoin.php diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index f19e8917..8e6e2080 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -13,6 +13,7 @@ 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\map; @@ -21,8 +22,6 @@ use const PHP_INT_MAX; class TagRepository extends EntitySpecificationRepository implements TagRepositoryInterface { - private const PARAM_PLACEHOLDER = '?'; - public function deleteByName(array $names): int { if (empty($names)) { @@ -54,7 +53,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito } $apiKey = $filtering?->apiKey(); - $this->applySpecification($subQb, new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrls', true), 't'); + $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey, 'shortUrls'), 't'); $subQuery = $subQb->getQuery(); $subQuerySql = $subQuery->getSQL(); diff --git a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php index 56f64a6d..1f8c2fd3 100644 --- a/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php +++ b/module/Rest/src/ApiKey/Spec/WithApiKeySpecsEnsuringJoin.php @@ -11,11 +11,8 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class WithApiKeySpecsEnsuringJoin extends BaseSpecification { - public function __construct( - private ?ApiKey $apiKey, - private string $fieldToJoin = 'shortUrls', - private bool $inlined = false, - ) { + public function __construct(private ?ApiKey $apiKey, private string $fieldToJoin = 'shortUrls') + { parent::__construct(); } @@ -23,7 +20,7 @@ class WithApiKeySpecsEnsuringJoin extends BaseSpecification { return $this->apiKey === null || $this->apiKey->isAdmin() ? Spec::andX() : Spec::andX( Spec::join($this->fieldToJoin, 's'), - $this->inlined ? $this->apiKey->inlinedSpec() : $this->apiKey->spec($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(), + ); + } +} From 747dac531db2bd96e8d10d9b523dc2eea34a27d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 20 Jan 2022 20:16:37 +0100 Subject: [PATCH 090/111] Added a more strict way to handle valid and expected env vars --- config/autoload/delete_short_urls.global.php | 4 +- config/autoload/entity-manager.global.php | 16 +- config/autoload/geolite2.global.php | 4 +- config/autoload/locks.global.php | 5 +- config/autoload/mercure.global.php | 9 +- config/autoload/qr-codes.global.php | 16 +- config/autoload/rabbit.global.php | 15 +- config/autoload/redirects.global.php | 14 +- config/autoload/redis.global.php | 6 +- config/autoload/router.global.php | 5 +- config/autoload/swoole.global.php | 8 +- config/autoload/tracking.global.php | 18 +-- config/autoload/url-shortener.global.php | 12 +- config/autoload/webhooks.global.php | 7 +- config/config.php | 2 +- module/Core/src/Config/EnvVars.php | 156 +++++++++++++++++++ 16 files changed, 228 insertions(+), 69 deletions(-) create mode 100644 module/Core/src/Config/EnvVars.php diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php index a3964e71..3d562f78 100644 --- a/config/autoload/delete_short_urls.global.php +++ b/config/autoload/delete_short_urls.global.php @@ -4,10 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use function Shlinkio\Shlink\Config\env; +use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $threshold = env('DELETE_SHORT_URL_THRESHOLD'); + $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD()->loadFromEnv(); return [ diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index c83db2a8..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\Config\env; return (static function (): array { - $driver = env('DB_DRIVER'); + $driver = EnvVars::DB_DRIVER()->loadFromEnv(); $isMysqlCompatible = contains(['maria', 'mysql'], $driver); $resolveDriver = static fn () => match ($driver) { @@ -35,12 +35,12 @@ return (static function (): array { ], default => [ 'driver' => $resolveDriver(), - 'dbname' => env('DB_NAME', 'shlink'), - 'user' => env('DB_USER'), - 'password' => env('DB_PASSWORD'), - 'host' => env('DB_HOST', env('DB_UNIX_SOCKET')), - 'port' => env('DB_PORT', $resolveDefaultPort()), - 'unix_socket' => $isMysqlCompatible ? env('DB_UNIX_SOCKET') : null, + '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/geolite2.global.php b/config/autoload/geolite2.global.php index fd11e52a..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\Config\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'), + 'license_key' => EnvVars::GEOLITE_LICENSE_KEY()->loadFromEnv(), ], ]; diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index 16fdbbca..8b018d1e 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\Config\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() ? 'local_lock_store' : 'redis_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 7eb356ab..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\Config\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 7940ad18..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\Config\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 adf304c8..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\Config\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 20cde8eb..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\Config\env; +use Shlinkio\Shlink\Core\Config\EnvVars; use const Shlinkio\Shlink\DEFAULT_REDIRECT_CACHE_LIFETIME; use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; @@ -10,14 +10,16 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; return [ 'not_found_redirects' => [ - 'invalid_short_url' => env('DEFAULT_INVALID_SHORT_URL_REDIRECT'), - 'regular_404' => env('DEFAULT_REGULAR_404_REDIRECT'), - 'base_url' => env('DEFAULT_BASE_URL_REDIRECT'), + '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(), ], '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), + '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 7af209d6..6bb1961e 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -2,10 +2,10 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Config\env; +use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $redisServers = env('REDIS_SERVERS'); + $redisServers = EnvVars::REDIS_SERVERS()->loadFromEnv(); return match ($redisServers) { null => [], @@ -14,7 +14,7 @@ return (static function (): array { 'default_lifetime' => 86400, // 24h '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 55397e27..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\Config\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 d5a6fd55..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\Config\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 2dc23890..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\Config\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 d5b4bfe5..25de914a 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -2,14 +2,14 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Config\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, ); @@ -17,12 +17,12 @@ return (static function (): array { 'url_shortener' => [ 'domain' => [ - 'schema' => ((bool) env('IS_HTTPS_ENABLED', true)) ? 'https' : 'http', - 'hostname' => env('DEFAULT_DOMAIN', ''), + 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http', + 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), ], '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 6bbbfbda..8e768e39 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -2,16 +2,17 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Config\env; +use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $webhooks = env('VISITS_WEBHOOKS'); + $webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv(); return [ 'visits_webhooks' => [ 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), - 'notify_orphan_visits_to_webhooks' => (bool) env('NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS', false), + '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 0adb0208..3dad2105 100644 --- a/config/config.php +++ b/config/config.php @@ -21,7 +21,7 @@ $isTestEnv = env('APP_ENV') === 'test'; return (new ConfigAggregator\ConfigAggregator([ ! $isTestEnv - ? new EnvVarLoaderProvider('config/params/generated_config.php') + ? new EnvVarLoaderProvider('config/params/generated_config.php', Core\Config\EnvVars::cases()) : new ConfigAggregator\ArrayProvider([]), Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php new file mode 100644 index 00000000..e3771e4c --- /dev/null +++ b/module/Core/src/Config/EnvVars.php @@ -0,0 +1,156 @@ +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; + } +} From 7202605fc89dd925b1795765ce7f747281d63ad7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 20 Jan 2022 20:40:34 +0100 Subject: [PATCH 091/111] Created EnvVarsTest --- module/Core/test/Config/EnvVarsTest.php | 139 ++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 module/Core/test/Config/EnvVarsTest.php 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']; + } +} From bef17ff76de211fa011bf2c417beb49e3f68ff31 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 20 Jan 2022 20:56:38 +0100 Subject: [PATCH 092/111] Fixed inverted condition when determining locks --- config/autoload/locks.global.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index 8b018d1e..bdbdb8e5 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -24,7 +24,7 @@ return [ LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class, ], 'aliases' => [ - 'lock_store' => EnvVars::REDIS_SERVERS()->existsInEnv() ? '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, From a198484ab6a548fb03340d36672f9cf30acb61df Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 19:21:30 +0100 Subject: [PATCH 093/111] Updated test utils lib --- composer.json | 2 +- module/Core/src/Repository/TagRepository.php | 2 +- module/Core/test-db/Domain/Repository/DomainRepositoryTest.php | 2 +- module/Core/test-db/Repository/ShortUrlRepositoryTest.php | 2 +- module/Core/test-db/Repository/TagRepositoryTest.php | 2 +- module/Core/test-db/Repository/VisitRepositoryTest.php | 2 +- .../test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/composer.json b/composer.json index 41389109..89d9e14d 100644 --- a/composer.json +++ b/composer.json @@ -74,7 +74,7 @@ "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.2.0", - "shlinkio/shlink-test-utils": "^2.5", + "shlinkio/shlink-test-utils": "^3.0", "symfony/var-dumper": "^6.0", "veewee/composer-run-parallel": "^1.1" }, diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 8e6e2080..cc952796 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -53,7 +53,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito } $apiKey = $filtering?->apiKey(); - $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey, 'shortUrls'), 't'); + $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); $subQuery = $subQb->getQuery(); $subQuerySql = $subQuery->getSQL(); diff --git a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php index b5ca98ea..3f69e7d9 100644 --- a/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php +++ b/module/Core/test-db/Domain/Repository/DomainRepositoryTest.php @@ -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 30b95774..4ad89629 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -33,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()); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index d030bd03..9bcfaf2b 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -27,7 +27,7 @@ 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()); diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index 950bfc8a..c23bd8aa 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -38,7 +38,7 @@ 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()); diff --git a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php index d906f80c..7e75aa22 100644 --- a/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php +++ b/module/Core/test-db/Tag/Paginator/Adapter/TagsPaginatorAdapterTest.php @@ -16,7 +16,7 @@ class TagsPaginatorAdapterTest extends DatabaseTestCase { private TagRepository $repo; - protected function beforeEach(): void + protected function setUp(): void { $this->repo = $this->getEntityManager()->getRepository(Tag::class); } From 33a6c9fda70ebd9c2386f73b8769216622504ec5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 19:52:25 +0100 Subject: [PATCH 094/111] Added support to order tags with stats by short URLs or visits count. In a non-performant way --- module/Core/src/Repository/TagRepository.php | 28 ++++- .../test-db/Repository/TagRepositoryTest.php | 107 +++++++++++++++++- 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index cc952796..7e69dcc9 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -16,6 +16,7 @@ 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; @@ -40,12 +41,19 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { + $orderBy = $filtering?->orderBy(); + $orderField = $orderBy?->orderField(); + $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); + $conn = $this->getEntityManager()->getConnection(); $subQb = $this->createQueryBuilder('t'); - $subQb->select('t.id', 't.name') - ->orderBy('t.name', $filtering?->orderBy()?->orderDirection() ?? 'ASC') // TODO Make filed dynamic - ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset() ?? 0); + $subQb->select('t.id', 't.name'); + + if (! $orderMainQuery) { + $subQb->orderBy('t.name', $orderBy?->orderDirection() ?? 'ASC') + ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset() ?? 0); + } $searchTerm = $filtering?->searchTerm(); if ($searchTerm !== null) { @@ -74,7 +82,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->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') - ->orderBy('t.name_1', $filtering?->orderBy()?->orderDirection() ?? 'ASC'); // TODO Make field dynamic + ->orderBy('t.name_1', $orderBy?->orderDirection() ?? 'ASC'); // TODO Make field dynamic // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) { @@ -87,6 +95,16 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito default => $nativeQb, }); + if ($orderMainQuery) { + $nativeQb + ->orderBy( + $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', + $orderBy?->orderDirection() ?? 'ASC', + ) + ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset() ?? 0); + } + $rsm = new ResultSetMappingBuilder($this->getEntityManager()); $rsm->addRootEntityFromClassMetadata(Tag::class, 't'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 9bcfaf2b..0ddd04ab 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -86,6 +86,15 @@ class TagRepositoryTest extends DatabaseTestCase $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'], null), $this->relationResolver), + ); + $this->getEntityManager()->flush(); $result = $this->repo->findTagsWithInfo($filtering); @@ -102,7 +111,7 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals(0, $result[0]->visitsCount()); self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->shortUrlsCount()); self::assertEquals(3, $result[1]->visitsCount()); self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); @@ -124,7 +133,7 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals(0, $result[0]->visitsCount()); self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->shortUrlsCount()); self::assertEquals(3, $result[1]->visitsCount()); self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); }]; @@ -140,7 +149,7 @@ class TagRepositoryTest extends DatabaseTestCase static function (array $result, array $tagNames): void { /** @var TagInfo[] $result */ self::assertCount(2, $result); - self::assertEquals(1, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->shortUrlsCount()); self::assertEquals(3, $result[0]->visitsCount()); self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); @@ -154,7 +163,7 @@ class TagRepositoryTest extends DatabaseTestCase static function (array $result, array $tagNames): void { /** @var TagInfo[] $result */ self::assertCount(2, $result); - self::assertEquals(1, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->shortUrlsCount()); self::assertEquals(3, $result[0]->visitsCount()); self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); @@ -176,7 +185,7 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals(0, $result[3]->visitsCount()); self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); - self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->shortUrlsCount()); self::assertEquals(3, $result[2]->visitsCount()); self::assertEquals($tagNames[1], $result[2]->tag()->__toString()); @@ -189,6 +198,94 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); }, ]; + yield 'short URLs count ASC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'ASC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(0, $result[0]->shortUrlsCount()); + self::assertEquals(0, $result[0]->visitsCount()); + self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); + + self::assertEquals(1, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); + + self::assertEquals(2, $result[2]->shortUrlsCount()); + self::assertEquals(4, $result[2]->visitsCount()); + self::assertEquals($tagNames[0], $result[2]->tag()->__toString()); + + self::assertEquals(3, $result[3]->shortUrlsCount()); + self::assertEquals(3, $result[3]->visitsCount()); + self::assertEquals($tagNames[1], $result[3]->tag()->__toString()); + }, + ]; + yield 'short URLs count DESC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['shortUrlsCount', 'DESC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(3, $result[0]->shortUrlsCount()); + self::assertEquals(3, $result[0]->visitsCount()); + self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); + + self::assertEquals(2, $result[1]->shortUrlsCount()); + self::assertEquals(4, $result[1]->visitsCount()); + self::assertEquals($tagNames[0], $result[1]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); + + self::assertEquals(0, $result[3]->shortUrlsCount()); + self::assertEquals(0, $result[3]->visitsCount()); + self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); + }, + ]; + yield 'visits count ASC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'ASC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(0, $result[0]->shortUrlsCount()); + self::assertEquals(0, $result[0]->visitsCount()); + self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); + + self::assertEquals(3, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); + + self::assertEquals(2, $result[3]->shortUrlsCount()); + self::assertEquals(4, $result[3]->visitsCount()); + self::assertEquals($tagNames[0], $result[3]->tag()->__toString()); + }, + ]; + yield 'visits count DESC ordering' => [ + new TagsListFiltering(null, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(4, $result); + self::assertEquals(2, $result[0]->shortUrlsCount()); + self::assertEquals(4, $result[0]->visitsCount()); + self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); + + self::assertEquals(3, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); + + self::assertEquals(1, $result[2]->shortUrlsCount()); + self::assertEquals(3, $result[2]->visitsCount()); + self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); + + self::assertEquals(0, $result[3]->shortUrlsCount()); + self::assertEquals(0, $result[3]->visitsCount()); + self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); + }, + ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), static function (array $result, array $tagNames): void { From afca66d6557dcbe6b73eb29b9c44be6a4a6d7498 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 19:58:56 +0100 Subject: [PATCH 095/111] Added tests covering tags info with counted ordering and limit --- docker-compose.yml | 2 +- .../Core/test-db/Repository/TagRepositoryTest.php | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5e2a3bd6..739c0079 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,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/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 0ddd04ab..30cb6256 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -286,6 +286,20 @@ class TagRepositoryTest extends DatabaseTestCase self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); }, ]; + yield 'visits count DESC ordering and limit' => [ + new TagsListFiltering(2, null, null, Ordering::fromTuple(['visitsCount', 'DESC'])), + static function (array $result, array $tagNames): void { + /** @var TagInfo[] $result */ + self::assertCount(2, $result); + self::assertEquals(2, $result[0]->shortUrlsCount()); + self::assertEquals(4, $result[0]->visitsCount()); + self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); + + self::assertEquals(3, $result[1]->shortUrlsCount()); + self::assertEquals(3, $result[1]->visitsCount()); + self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); + }, + ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), static function (array $result, array $tagNames): void { From d5606114cd2b321bd7ce0875ea5503fe77415313 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 20:02:52 +0100 Subject: [PATCH 096/111] Documented new ordering fields supported on tags list --- docs/swagger/paths/v2_tags_stats.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/swagger/paths/v2_tags_stats.json b/docs/swagger/paths/v2_tags_stats.json index bd745fd0..48e5690f 100644 --- a/docs/swagger/paths/v2_tags_stats.json +++ b/docs/swagger/paths/v2_tags_stats.json @@ -51,7 +51,11 @@ "type": "string", "enum": [ "tag-ASC", - "tag-DESC" + "tag-DESC", + "shortUrlsCount-ASC", + "shortUrlsCount-DESC", + "visitsCount-ASC", + "visitsCount-DESC" ] } } From 361e864415a0926f96a24be07c8c5fee8d0e7625 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 20:12:16 +0100 Subject: [PATCH 097/111] Added fallback ordering to tags list --- module/Core/src/Repository/TagRepository.php | 1 + 1 file changed, 1 insertion(+) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 7e69dcc9..c0ab0d2e 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -101,6 +101,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', $orderBy?->orderDirection() ?? 'ASC', ) + ->addOrderBy('t.name_1', 'ASC') // In case of same amount, order by tag too ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset() ?? 0); } From 6b409b06cc52ee9bc32a9dae754c82b97ea5e230 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 22:04:53 +0100 Subject: [PATCH 098/111] Simplified TagRepository test for tags info list, making it more predictable --- .../test-db/Repository/TagRepositoryTest.php | 273 +++++------------- 1 file changed, 78 insertions(+), 195 deletions(-) diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 30cb6256..50129e54 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -13,7 +13,6 @@ 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\TagInfo; use Shlinkio\Shlink\Core\Tag\Model\TagsListFiltering; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -21,6 +20,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\TestUtils\DbTest\DatabaseTestCase; use function array_chunk; +use function count; class TagRepositoryTest extends DatabaseTestCase { @@ -57,7 +57,7 @@ class TagRepositoryTest extends DatabaseTestCase * @test * @dataProvider provideFilters */ - public function properTagsInfoIsReturned(?TagsListFiltering $filtering, callable $asserts): void + public function properTagsInfoIsReturned(?TagsListFiltering $filtering, array $expectedList): void { $names = ['foo', 'bar', 'baz', 'another']; foreach ($names as $name) { @@ -92,231 +92,114 @@ class TagRepositoryTest extends DatabaseTestCase ShortUrl::fromMeta($metaWithTags(['bar'], null), $this->relationResolver), ); $this->getEntityManager()->persist( - ShortUrl::fromMeta($metaWithTags(['bar'], null), $this->relationResolver), + ShortUrl::fromMeta($metaWithTags(['bar'], $apiKey), $this->relationResolver), ); $this->getEntityManager()->flush(); $result = $this->repo->findTagsWithInfo($filtering); - $asserts($result, $names); + 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()->__toString()); + } } public function provideFilters(): iterable { - $defaultAsserts = static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(0, $result[0]->shortUrlsCount()); - self::assertEquals(0, $result[0]->visitsCount()); - self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - - self::assertEquals(3, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); - - self::assertEquals(2, $result[3]->shortUrlsCount()); - self::assertEquals(4, $result[3]->visitsCount()); - self::assertEquals($tagNames[0], $result[3]->tag()->__toString()); - }; - - yield 'no filter' => [null, $defaultAsserts]; - yield 'empty filter' => [new TagsListFiltering(), $defaultAsserts]; - yield 'limit' => [new TagsListFiltering(2), static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(2, $result); - self::assertEquals(0, $result[0]->shortUrlsCount()); - self::assertEquals(0, $result[0]->visitsCount()); - self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - - self::assertEquals(3, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - }]; - yield 'offset' => [new TagsListFiltering(null, 3), static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(1, $result); - self::assertEquals(2, $result[0]->shortUrlsCount()); - self::assertEquals(4, $result[0]->visitsCount()); - self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); - }]; - yield 'limit and offset' => [ - new TagsListFiltering(2, 1), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(2, $result); - self::assertEquals(3, $result[0]->shortUrlsCount()); - self::assertEquals(3, $result[0]->visitsCount()); - self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); - - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); - }, + $defaultList = [ + ['another', 0, 0], + ['bar', 3, 3], + ['baz', 1, 3], + ['foo', 2, 4], ]; - yield 'search term' => [ - new TagsListFiltering(null, null, 'ba'), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(2, $result); - self::assertEquals(3, $result[0]->shortUrlsCount()); - self::assertEquals(3, $result[0]->visitsCount()); - self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[2], $result[1]->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'])), - $defaultAsserts, - ]; - yield 'DESC ordering' => [ - new TagsListFiltering(null, null, null, Ordering::fromTuple(['tag', 'DESC'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(0, $result[3]->shortUrlsCount()); - self::assertEquals(0, $result[3]->visitsCount()); - self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); - - self::assertEquals(3, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[1], $result[2]->tag()->__toString()); - - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); - - self::assertEquals(2, $result[0]->shortUrlsCount()); - self::assertEquals(4, $result[0]->visitsCount()); - self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); - }, + $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'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(0, $result[0]->shortUrlsCount()); - self::assertEquals(0, $result[0]->visitsCount()); - self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); - - self::assertEquals(2, $result[2]->shortUrlsCount()); - self::assertEquals(4, $result[2]->visitsCount()); - self::assertEquals($tagNames[0], $result[2]->tag()->__toString()); - - self::assertEquals(3, $result[3]->shortUrlsCount()); - self::assertEquals(3, $result[3]->visitsCount()); - self::assertEquals($tagNames[1], $result[3]->tag()->__toString()); - }, + [ + ['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'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(3, $result[0]->shortUrlsCount()); - self::assertEquals(3, $result[0]->visitsCount()); - self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); - - self::assertEquals(2, $result[1]->shortUrlsCount()); - self::assertEquals(4, $result[1]->visitsCount()); - self::assertEquals($tagNames[0], $result[1]->tag()->__toString()); - - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); - - self::assertEquals(0, $result[3]->shortUrlsCount()); - self::assertEquals(0, $result[3]->visitsCount()); - self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); - }, + [ + ['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'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(0, $result[0]->shortUrlsCount()); - self::assertEquals(0, $result[0]->visitsCount()); - self::assertEquals($tagNames[3], $result[0]->tag()->__toString()); - - self::assertEquals(3, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); - - self::assertEquals(2, $result[3]->shortUrlsCount()); - self::assertEquals(4, $result[3]->visitsCount()); - self::assertEquals($tagNames[0], $result[3]->tag()->__toString()); - }, + [ + ['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'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(4, $result); - self::assertEquals(2, $result[0]->shortUrlsCount()); - self::assertEquals(4, $result[0]->visitsCount()); - self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); - - self::assertEquals(3, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[2], $result[2]->tag()->__toString()); - - self::assertEquals(0, $result[3]->shortUrlsCount()); - self::assertEquals(0, $result[3]->visitsCount()); - self::assertEquals($tagNames[3], $result[3]->tag()->__toString()); - }, + [ + ['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'])), - static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(2, $result); - self::assertEquals(2, $result[0]->shortUrlsCount()); - self::assertEquals(4, $result[0]->visitsCount()); - self::assertEquals($tagNames[0], $result[0]->tag()->__toString()); - - self::assertEquals(3, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[1], $result[1]->tag()->__toString()); - }, + [ + ['foo', 2, 4], + ['bar', 3, 3], + ], ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), - )), static function (array $result, array $tagNames): void { - /** @var TagInfo[] $result */ - self::assertCount(3, $result); - self::assertEquals(1, $result[0]->shortUrlsCount()); - self::assertEquals(3, $result[0]->visitsCount()); - self::assertEquals($tagNames[1], $result[0]->tag()->__toString()); - - self::assertEquals(1, $result[1]->shortUrlsCount()); - self::assertEquals(3, $result[1]->visitsCount()); - self::assertEquals($tagNames[2], $result[1]->tag()->__toString()); - - self::assertEquals(1, $result[2]->shortUrlsCount()); - self::assertEquals(3, $result[2]->visitsCount()); - self::assertEquals($tagNames[0], $result[2]->tag()->__toString()); - }]; + )), [ + ['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 */ From 1c9ce0ede0265338f4045dacf9d46bb61407fd87 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 21 Jan 2022 22:22:55 +0100 Subject: [PATCH 099/111] Fixed default/fallback tags with stats ordering --- module/Core/src/Repository/TagRepository.php | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index c0ab0d2e..ab9f1738 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -43,6 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito { $orderBy = $filtering?->orderBy(); $orderField = $orderBy?->orderField(); + $orderDir = $orderBy?->orderDirection(); $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); $conn = $this->getEntityManager()->getConnection(); @@ -50,7 +51,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $subQb->select('t.id', 't.name'); if (! $orderMainQuery) { - $subQb->orderBy('t.name', $orderBy?->orderDirection() ?? 'ASC') + $subQb->orderBy('t.name', $orderDir ?? 'ASC') ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) ->setFirstResult($filtering?->offset() ?? 0); } @@ -81,8 +82,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->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') - ->orderBy('t.name_1', $orderBy?->orderDirection() ?? 'ASC'); // TODO Make field dynamic + ->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(fn (string $roleName, array $meta) => match ($roleName) { @@ -99,13 +99,15 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $nativeQb ->orderBy( $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', - $orderBy?->orderDirection() ?? 'ASC', + $orderDir ?? 'ASC', ) - ->addOrderBy('t.name_1', 'ASC') // In case of same amount, order by tag too ->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->addRootEntityFromClassMetadata(Tag::class, 't'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); From dd6bcd68ccfad7620f0d01dbb1eb831d51c1472d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 22 Jan 2022 20:36:50 +0100 Subject: [PATCH 100/111] Removed not-needed extra line --- module/Core/src/Repository/TagRepository.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index ab9f1738..dc3b2e65 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -41,9 +41,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderBy = $filtering?->orderBy(); - $orderField = $orderBy?->orderField(); - $orderDir = $orderBy?->orderDirection(); + $orderField = $filtering?->orderBy()?->orderField(); + $orderDir = $filtering?->orderBy()?->orderDirection(); $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); $conn = $this->getEntityManager()->getConnection(); From 8adb6596fb48d2ff99311d3c0259798c755b53af Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 09:37:02 +0100 Subject: [PATCH 101/111] Refactored TagInfo to wrap the raw tag name instead of a Tag entity --- module/CLI/src/Command/Tag/ListTagsCommand.php | 3 +-- module/CLI/test/Command/Tag/ListTagsCommandTest.php | 5 ++--- module/Core/src/Repository/TagRepository.php | 4 ++-- module/Core/src/Tag/Model/TagInfo.php | 5 ++--- module/Core/test-db/Repository/TagRepositoryTest.php | 2 +- module/Core/test/Tag/TagServiceTest.php | 2 +- module/Rest/src/Action/Tag/ListTagsAction.php | 2 +- module/Rest/test/Action/Tag/ListTagsActionTest.php | 4 ++-- module/Rest/test/Action/Tag/TagsStatsActionTest.php | 5 ++--- 9 files changed, 14 insertions(+), 18 deletions(-) diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 7d21613d..9c7269fa 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -46,8 +46,7 @@ class ListTagsCommand extends Command 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/test/Command/Tag/ListTagsCommandTest.php b/module/CLI/test/Command/Tag/ListTagsCommandTest.php index 879b2eb7..499442d0 100644 --- a/module/CLI/test/Command/Tag/ListTagsCommandTest.php +++ b/module/CLI/test/Command/Tag/ListTagsCommandTest.php @@ -10,7 +10,6 @@ use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\CLI\Command\Tag\ListTagsCommand; 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 ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -45,8 +44,8 @@ class ListTagsCommandTest extends TestCase public function listOfTagsIsPrinted(): void { $tagsInfo = $this->tagService->tagsInfo(Argument::any())->willReturn(new Paginator(new ArrayAdapter([ - new TagInfo(new Tag('foo'), 10, 2), - new TagInfo(new Tag('bar'), 7, 32), + new TagInfo('foo', 10, 2), + new TagInfo('bar', 7, 32), ]))); $this->commandTester->execute([]); diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index dc3b2e65..66aebae3 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -108,13 +108,13 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $nativeQb->addOrderBy('t.name_1', $orderMainQuery || $orderDir === null ? 'ASC' : $orderDir); $rsm = new ResultSetMappingBuilder($this->getEntityManager()); - $rsm->addRootEntityFromClassMetadata(Tag::class, 't'); + $rsm->addScalarResult('name', 'tag'); $rsm->addScalarResult('short_urls_count', 'shortUrlsCount'); $rsm->addScalarResult('visits_count', 'visitsCount'); return map( $this->getEntityManager()->createNativeQuery($nativeQb->getSQL(), $rsm)->getResult(), - static fn (array $row) => new TagInfo($row[0], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), + static fn (array $row) => new TagInfo($row['tag'], (int) $row['shortUrlsCount'], (int) $row['visitsCount']), ); } 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/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index 50129e54..fe544376 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -103,7 +103,7 @@ class TagRepositoryTest extends DatabaseTestCase 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()->__toString()); + self::assertEquals($tag, $result[$index]->tag()); } } diff --git a/module/Core/test/Tag/TagServiceTest.php b/module/Core/test/Tag/TagServiceTest.php index c3efc4b5..8c301f0f 100644 --- a/module/Core/test/Tag/TagServiceTest.php +++ b/module/Core/test/Tag/TagServiceTest.php @@ -67,7 +67,7 @@ class TagServiceTest extends TestCase TagsListFiltering $expectedFiltering, int $countCalls, ): void { - $expected = [new TagInfo(new Tag('foo'), 1, 1), new TagInfo(new Tag('bar'), 3, 10)]; + $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); diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index ba25ffe5..ab81400c 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -41,7 +41,7 @@ class ListTagsAction extends AbstractRestAction // 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()->__toString()); + $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()); return new JsonResponse(['tags' => $rawTags]); } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 504f7b4f..123e4945 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -76,8 +76,8 @@ 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), ]; $itemsCount = count($stats); $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn( diff --git a/module/Rest/test/Action/Tag/TagsStatsActionTest.php b/module/Rest/test/Action/Tag/TagsStatsActionTest.php index 3f98b64e..2cb3ad64 100644 --- a/module/Rest/test/Action/Tag/TagsStatsActionTest.php +++ b/module/Rest/test/Action/Tag/TagsStatsActionTest.php @@ -13,7 +13,6 @@ 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\TagsStatsAction; @@ -38,8 +37,8 @@ class TagsStatsActionTest extends TestCase public function returnsTagsStatsWhenRequested(): 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), ]; $itemsCount = count($stats); $tagsInfo = $this->tagService->tagsInfo(Argument::any(), Argument::type(ApiKey::class))->willReturn( From cdb18a5baf6ee9efa043f904e19ed350dc27e85f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 10:48:38 +0100 Subject: [PATCH 102/111] Documented performance issue when sorting by visits or short URLs count --- docs/swagger/paths/v2_tags_stats.json | 2 +- module/Core/src/Model/Ordering.php | 3 +++ module/Core/src/Repository/TagRepository.php | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/swagger/paths/v2_tags_stats.json b/docs/swagger/paths/v2_tags_stats.json index 48e5690f..91771335 100644 --- a/docs/swagger/paths/v2_tags_stats.json +++ b/docs/swagger/paths/v2_tags_stats.json @@ -45,7 +45,7 @@ { "name": "orderBy", "in": "query", - "description": "To determine how to order the results.", + "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", diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index 6112dde7..bd648227 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -12,6 +12,9 @@ final class Ordering { } + /** + * @param array{string|null, string|null} $props + */ public static function fromTuple(array $props): self { [$field, $dir] = $props; diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 66aebae3..1ee5404b 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -84,7 +84,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito ->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(fn (string $roleName, array $meta) => match ($roleName) { + $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))), ), From 0e6790cdabd1608c45f6c3f931b586677516615b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 11:29:53 +0100 Subject: [PATCH 103/111] Replaced references to regular swoole by openswoole --- CONTRIBUTING.md | 6 +++--- README.md | 6 +++--- build.sh | 4 ++-- config/autoload/common.global.php | 4 ++-- data/infra/examples/shlink-daemon-logrotate.conf | 4 ++-- data/infra/examples/shlink-daemon.sh | 16 ++++++++-------- docker/README.md | 2 +- 7 files changed, 21 insertions(+), 21 deletions(-) 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/README.md b/README.md index d07dc696..df9a04d8 100644 --- a/README.md +++ b/README.md @@ -37,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 @@ -51,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. diff --git a/build.sh b/build.sh index eb97aef6..e274210a 100755 --- a/build.sh +++ b/build.sh @@ -10,7 +10,7 @@ fi version=$1 noSwoole=$2 phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') -[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_swoole" +[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole" distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist" builtContent="./build/${distId}" projectdir=$(pwd) @@ -34,7 +34,7 @@ ${composerBin} self-update ${composerBin} install --no-dev --prefer-dist $composerFlags if [[ $noSwoole ]]; then - # If generating a dist not for swoole, uninstall mezzio-swoole + # If generating a dist not for openswoole, uninstall mezzio-swoole ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags fi diff --git a/config/autoload/common.global.php b/config/autoload/common.global.php index 2bc4c2db..b35807fd 100644 --- a/config/autoload/common.global.php +++ b/config/autoload/common.global.php @@ -8,8 +8,8 @@ return [ 'debug' => 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/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/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 From 299f9f3a109576b0145b0be84da941a3778f2dfd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 11:36:05 +0100 Subject: [PATCH 104/111] Documented support on swoole being dropped --- CHANGELOG.md | 6 ++++++ UPGRADE.md | 1 + 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eb2fa25..8cb4d5e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this 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* diff --git a/UPGRADE.md b/UPGRADE.md index 7988276d..0d79cff1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -46,6 +46,7 @@ * 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 From 3ace4952e6d7aaa2be394d4c3ac652568f3a334a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 11:37:46 +0100 Subject: [PATCH 105/111] Changed swoole with openswoole in issue templates --- .github/ISSUE_TEMPLATE/Bug.md | 2 +- .github/ISSUE_TEMPLATE/Question_Support.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 11c6c9a2b89773360d94cd957872e07b3828b66e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 23 Jan 2022 17:45:13 +0100 Subject: [PATCH 106/111] Removed unneeded lines --- composer.json | 2 +- module/Core/src/Repository/TagRepository.php | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/composer.json b/composer.json index 1e54bf3a..ba5f968b 100644 --- a/composer.json +++ b/composer.json @@ -64,8 +64,8 @@ "cebe/php-openapi": "^1.5", "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.3.0", - "eaglewu/swoole-ide-helper": "dev-master", "infection/infection": "^0.26", + "openswoole/ide-helper": "~4.9.1", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.2", "phpstan/phpstan-doctrine": "^1.0", diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index 1ee5404b..aa24e0a1 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -63,9 +63,6 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $apiKey = $filtering?->apiKey(); $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); - $subQuery = $subQb->getQuery(); - $subQuerySql = $subQuery->getSQL(); - // 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. @@ -77,7 +74,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 'COUNT(DISTINCT s.id) AS short_urls_count', 'COUNT(DISTINCT v.id) AS visits_count', ) - ->from('(' . $subQuerySql . ')', 't') + ->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')) From 25ac7c31c40dd0873c6604e74e636ba90de29f68 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 25 Jan 2022 20:39:31 +0100 Subject: [PATCH 107/111] Minor doc improvements --- .github/workflows/ci.yml | 1 + CHANGELOG.md | 2 +- UPGRADE.md | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3e078f2f..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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 059b5782..741f02a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#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 + 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. diff --git a/UPGRADE.md b/UPGRADE.md index 0d79cff1..2289a314 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -4,12 +4,12 @@ ### 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 pf `INVALID_SHORTCODE_DELETION`. +* 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. +* 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. From 9df80e5bec4c0f4f48bff61441c344c91ea77eb5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 27 Jan 2022 20:56:52 +0100 Subject: [PATCH 108/111] Added explicit versions for shlink dependencies --- composer.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/composer.json b/composer.json index ba5f968b..496e961e 100644 --- a/composer.json +++ b/composer.json @@ -48,10 +48,10 @@ "pugx/shortid-php": "^1.0", "ramsey/uuid": "^4.2", "shlinkio/shlink-common": "^4.4", - "shlinkio/shlink-config": "dev-main#483cf8a as 1.6", + "shlinkio/shlink-config": "^1.6", "shlinkio/shlink-event-dispatcher": "^2.3", "shlinkio/shlink-importer": "^2.5", - "shlinkio/shlink-installer": "dev-develop#3ca7ec5 as 7.0", + "shlinkio/shlink-installer": "^7.0", "shlinkio/shlink-ip-geolocation": "^2.2", "symfony/console": "^6.0", "symfony/filesystem": "^6.0", From 9766231d41b2dfa33b7b3e04a3db5fd3d5f72681 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 27 Jan 2022 20:59:05 +0100 Subject: [PATCH 109/111] Added v3.0.0 to changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 741f02a8..9307b497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [Unreleased] +## [3.0.0] ### 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. From 93de62f81dee33805e9efb8de97e3357cac3f56c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 28 Jan 2022 13:06:56 +0100 Subject: [PATCH 110/111] Fixed typo in UPGRADE.md --- UPGRADE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UPGRADE.md b/UPGRADE.md index 2289a314..bce1bdde 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -7,7 +7,7 @@ * 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 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: From c8f55f9c057d32435d5c4f13333f5338b530301b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 28 Jan 2022 16:00:40 +0100 Subject: [PATCH 111/111] Added release date for Shlink 3.0.0 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9307b497..fede80b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). -## [3.0.0] +## [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.