From 0f894dcdfee1815b10c717eac4d80d1275f9bbfc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 4 Feb 2024 14:16:42 +0100 Subject: [PATCH 01/97] Replace request-id middleware dependency with userland code --- composer.json | 19 ++++---- config/autoload/logger.global.php | 7 +-- .../autoload/middleware-pipeline.global.php | 2 +- config/autoload/request_id.global.php | 44 ------------------- .../test-api/Middleware/RequestIdTest.php | 30 +++++++++++++ 5 files changed, 44 insertions(+), 58 deletions(-) delete mode 100644 config/autoload/request_id.global.php create mode 100644 module/Rest/test-api/Middleware/RequestIdTest.php diff --git a/composer.json b/composer.json index 7c336b05..c309aeef 100644 --- a/composer.json +++ b/composer.json @@ -36,16 +36,15 @@ "league/uri": "^6.8", "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", - "mezzio/mezzio-fastroute": "^3.10", + "mezzio/mezzio-fastroute": "^3.11", "mezzio/mezzio-problem-details": "^1.13", - "mezzio/mezzio-swoole": "^4.7", + "mezzio/mezzio-swoole": "^4.8", "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", - "php-middleware/request-id": "^4.1", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "^5.7.1", + "shlinkio/shlink-common": "dev-main#a309824 as 6.0", "shlinkio/shlink-config": "^2.5", "shlinkio/shlink-event-dispatcher": "^3.1", "shlinkio/shlink-importer": "^5.2.1", @@ -56,11 +55,11 @@ "spiral/roadrunner-cli": "^2.5", "spiral/roadrunner-http": "^3.1", "spiral/roadrunner-jobs": "^4.0", - "symfony/console": "^6.3", - "symfony/filesystem": "^6.3", - "symfony/lock": "^6.3", - "symfony/process": "^6.3", - "symfony/string": "^6.3" + "symfony/console": "^6.4", + "symfony/filesystem": "^6.4", + "symfony/lock": "^6.4", + "symfony/process": "^6.4", + "symfony/string": "^6.4" }, "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", @@ -76,7 +75,7 @@ "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.8.1", - "symfony/var-dumper": "^6.3", + "symfony/var-dumper": "^6.4", "veewee/composer-run-parallel": "^1.3" }, "conflict": { diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 01ec40ab..9e5e3160 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -7,20 +7,21 @@ namespace Shlinkio\Shlink; use Laminas\ServiceManager\Factory\InvokableFactory; use Monolog\Level; use Monolog\Logger; -use PhpMiddleware\RequestId; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Shlinkio\Shlink\Common\Logger\LoggerFactory; use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; +use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { $common = [ 'level' => Level::Info->value, - 'processors' => [RequestId\MonologProcessor::class], - 'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%', + 'processors' => [RequestIdMiddleware::class], + 'line_format' => + '[%datetime%] [%extra.' . RequestIdMiddleware::ATTRIBUTE . '%] %channel%.%level_name% - %message%', ]; return [ diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index cb8045e9..072937ec 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -7,10 +7,10 @@ namespace Shlinkio\Shlink; use Laminas\Stratigility\Middleware\ErrorHandler; use Mezzio\ProblemDetails; use Mezzio\Router; -use PhpMiddleware\RequestId\RequestIdMiddleware; use RKA\Middleware\IpAddress; use Shlinkio\Shlink\Common\Middleware\AccessLogMiddleware; use Shlinkio\Shlink\Common\Middleware\ContentLengthMiddleware; +use Shlinkio\Shlink\Common\Middleware\RequestIdMiddleware; return [ diff --git a/config/autoload/request_id.global.php b/config/autoload/request_id.global.php deleted file mode 100644 index 5525849a..00000000 --- a/config/autoload/request_id.global.php +++ /dev/null @@ -1,44 +0,0 @@ - [ - 'allow_override' => true, - 'header_name' => 'X-Request-Id', - ], - - 'dependencies' => [ - 'factories' => [ - RequestId\Generator\RamseyUuid4StaticGenerator::class => InvokableFactory::class, - RequestId\RequestIdProviderFactory::class => ConfigAbstractFactory::class, - RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class, - RequestId\MonologProcessor::class => ConfigAbstractFactory::class, - ], - 'delegators' => [ - RequestId\MonologProcessor::class => [ - BackwardsCompatibleMonologProcessorDelegator::class, - ], - ], - ], - - ConfigAbstractFactory::class => [ - RequestId\RequestIdProviderFactory::class => [ - RequestId\Generator\RamseyUuid4StaticGenerator::class, - 'config.request_id.allow_override', - 'config.request_id.header_name', - ], - RequestId\RequestIdMiddleware::class => [ - RequestId\RequestIdProviderFactory::class, - 'config.request_id.header_name', - ], - RequestId\MonologProcessor::class => [RequestId\RequestIdMiddleware::class], - ], - -]; diff --git a/module/Rest/test-api/Middleware/RequestIdTest.php b/module/Rest/test-api/Middleware/RequestIdTest.php new file mode 100644 index 00000000..bdc390a7 --- /dev/null +++ b/module/Rest/test-api/Middleware/RequestIdTest.php @@ -0,0 +1,30 @@ +callApi('GET', '/health'); + self::assertTrue($response->hasHeader('X-Request-Id')); + } + + #[Test] + public function keepsProvidedRequestId(): void + { + $response = $this->callApi('GET', '/health', [ + RequestOptions::HEADERS => [ + 'X-Request-Id' => 'foobar', + ], + ]); + self::assertEquals('foobar', $response->hasHeader('X-Request-Id')); + } +} From 7c3e3442c2a201ba0c97455434dc7109c540e0ed Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 4 Feb 2024 14:18:16 +0100 Subject: [PATCH 02/97] Update changelog --- CHANGELOG.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81281802..566ae9dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ 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 +* [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.7.3] - 2024-01-04 ### Added * *Nothing* From aa242eba25766d1f976fa52094f43049badf96b0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 5 Feb 2024 22:13:59 +0100 Subject: [PATCH 03/97] Remove League\Uri from ShortUrlRedirectionBuilder --- .../Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index c322f195..a6df5558 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; use GuzzleHttp\Psr7\Query; +use Laminas\Diactoros\Uri; use Laminas\Stdlib\ArrayUtils; -use League\Uri\Uri; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; @@ -27,7 +27,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ): string { $currentQuery = $request->getQueryParams(); $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - $uri = Uri::createFromString($shortUrl->longUrlForDevice($device)); + $uri = new Uri($shortUrl->longUrlForDevice($device)); $shouldForwardQuery = $shortUrl->forwardQuery(); return $uri From e014cfa72a99198f8e109d7143fe326cff9c6acd Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 5 Feb 2024 22:44:23 +0100 Subject: [PATCH 04/97] Remove dependency on league/uri --- CHANGELOG.md | 1 + composer.json | 1 - .../src/Config/NotFoundRedirectResolver.php | 24 ++++++++----------- .../Helper/ShortUrlRedirectionBuilder.php | 6 ++--- module/Core/test-api/Action/RedirectTest.php | 2 ++ .../Config/NotFoundRedirectResolverTest.php | 24 +++++++++---------- 6 files changed, 28 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 566ae9dc..1a0b0c9f 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 * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. +* [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index c309aeef..0ea76443 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,6 @@ "laminas/laminas-inputfilter": "^2.27", "laminas/laminas-servicemanager": "^3.21", "laminas/laminas-stdlib": "^3.17", - "league/uri": "^6.8", "matomo/matomo-php-tracker": "^3.2", "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.11", diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index ce5401d2..cfb09c8e 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -4,8 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use League\Uri\Exceptions\SyntaxError; -use League\Uri\Uri; +use Laminas\Diactoros\Exception\InvalidArgumentException; +use Laminas\Diactoros\Uri; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\UriInterface; use Psr\Log\LoggerInterface; @@ -51,8 +51,8 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface private function resolvePlaceholders(UriInterface $currentUri, string $redirectUrl): string { try { - $redirectUri = Uri::createFromString($redirectUrl); - } catch (SyntaxError $e) { + $redirectUri = new Uri($redirectUrl); + } catch (InvalidArgumentException $e) { $this->logger->warning('It was not possible to parse "{url}" as a valid URL: {e}', [ 'e' => $e, 'url' => $redirectUrl, @@ -63,26 +63,22 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface $path = $currentUri->getPath(); $domain = $currentUri->getAuthority(); - $replacePlaceholderForPattern = static fn (string $pattern, string $replace, ?string $value): string|null => - $value === null ? null : str_replace($pattern, $replace, $value); - $replacePlaceholders = static function ( callable $modifier, - ?string $value, + string $value, ) use ( - $replacePlaceholderForPattern, $path, $domain, - ): string|null { - $value = $replacePlaceholderForPattern($modifier(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value); - return $replacePlaceholderForPattern($modifier(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value); + ): string { + $value = str_replace(urlencode(self::DOMAIN_PLACEHOLDER), $modifier($domain), $value); + return str_replace(urlencode(self::ORIGINAL_PATH_PLACEHOLDER), $modifier($path), $value); }; $replacePlaceholdersInPath = static function (string $path) use ($replacePlaceholders): string { $result = $replacePlaceholders(static fn (mixed $v) => $v, $path); - return str_replace('//', '/', $result ?? ''); + return str_replace('//', '/', $result); }; - $replacePlaceholdersInQuery = static fn (?string $query): string|null => $replacePlaceholders( + $replacePlaceholdersInQuery = static fn (string $query): string => $replacePlaceholders( urlencode(...), $query, ); diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php index a6df5558..bbf616f7 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlRedirectionBuilder.php @@ -36,9 +36,9 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface ->__toString(); } - private function resolveQuery(Uri $uri, array $currentQuery): ?string + private function resolveQuery(Uri $uri, array $currentQuery): string { - $hardcodedQuery = Query::parse($uri->getQuery() ?? ''); + $hardcodedQuery = Query::parse($uri->getQuery()); $disableTrackParam = $this->trackingOptions->disableTrackParam; if ($disableTrackParam !== null) { @@ -48,7 +48,7 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface // We want to merge preserving numeric keys, as some params might be numbers $mergedQuery = ArrayUtils::merge($hardcodedQuery, $currentQuery, true); - return empty($mergedQuery) ? null : Query::build($mergedQuery); + return Query::build($mergedQuery); } private function resolvePath(Uri $uri, ?string $extraPath): string diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index f3edcbe4..bbcc6fec 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -18,6 +18,8 @@ class RedirectTest extends ApiTestCase public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void { $response = $this->callShortUrl('def456', $userAgent); + + self::assertEquals(302, $response->getStatusCode()); self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); } diff --git a/module/Core/test/Config/NotFoundRedirectResolverTest.php b/module/Core/test/Config/NotFoundRedirectResolverTest.php index 0b943099..5ef5db2b 100644 --- a/module/Core/test/Config/NotFoundRedirectResolverTest.php +++ b/module/Core/test/Config/NotFoundRedirectResolverTest.php @@ -57,8 +57,14 @@ class NotFoundRedirectResolverTest extends TestCase yield 'base URL with trailing slash' => [ $uri = new Uri('/'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'baseUrl'), - 'baseUrl', + new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + 'https://example.com/baseUrl', + ]; + yield 'base URL without trailing slash' => [ + $uri = new Uri(''), + self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), + new NotFoundRedirectOptions(baseUrl: 'https://example.com/baseUrl'), + 'https://example.com/baseUrl', ]; yield 'base URL with domain placeholder' => [ $uri = new Uri('https://s.test'), @@ -72,17 +78,11 @@ class NotFoundRedirectResolverTest extends TestCase new NotFoundRedirectOptions(baseUrl: 'https://redirect-here.com/?domain={DOMAIN}'), 'https://redirect-here.com/?domain=s.test', ]; - yield 'base URL without trailing slash' => [ - $uri = new Uri(''), - self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(baseUrl: 'baseUrl'), - 'baseUrl', - ]; yield 'regular 404' => [ $uri = new Uri('/foo/bar'), self::notFoundType(ServerRequestFactory::fromGlobals()->withUri($uri)), - new NotFoundRedirectOptions(regular404: 'regular404'), - 'regular404', + new NotFoundRedirectOptions(regular404: 'https://example.com/regular404'), + 'https://example.com/regular404', ]; yield 'regular 404 with path placeholder in query' => [ $uri = new Uri('/foo/bar'), @@ -101,8 +101,8 @@ class NotFoundRedirectResolverTest extends TestCase yield 'invalid short URL' => [ new Uri('/foo'), self::notFoundType(self::requestForRoute(RedirectAction::class)), - new NotFoundRedirectOptions(invalidShortUrl: 'invalidShortUrl'), - 'invalidShortUrl', + new NotFoundRedirectOptions(invalidShortUrl: 'https://example.com/invalidShortUrl'), + 'https://example.com/invalidShortUrl', ]; yield 'invalid short URL with path placeholder' => [ new Uri('/foo'), From a8611f5d80ee591b1c42375384ee95311c18c21f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 09:54:59 +0100 Subject: [PATCH 05/97] Support loading env vars from secret files --- CHANGELOG.md | 2 +- bin/test/run-api-tests.sh | 6 ++++-- module/Core/src/Config/EnvVars.php | 23 ++++++++++++++++++++++- module/Core/test/Config/EnvVarsTest.php | 10 ++++++++++ module/Core/test/DB_PASSWORD.env | 1 + 5 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 module/Core/test/DB_PASSWORD.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a0b0c9f..3093f52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added -* *Nothing* +* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image. ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index b22a974e..6a1cfb46 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -20,6 +20,8 @@ echo 'Starting server...' [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d [ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \ -o=http.address=0.0.0.0:9999 \ + -o=http.pool.debug=false \ + -o=jobs.pool.debug=false \ -o=logs.encoding=json \ -o=logs.channels.http.encoding=json \ -o=logs.channels.server.encoding=json \ @@ -29,10 +31,10 @@ echo 'Starting server...' sleep 2 # Let's give the server a couple of seconds to start vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* -testsExitCode=$? +TESTS_EXIT_CODE=$? [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999 # Exit this script with the same code as the tests. If tests failed, this script has to fail -exit $testsExitCode +exit $TESTS_EXIT_CODE diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index ff64838b..40f311e9 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,7 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; +use function file_get_contents; +use function is_file; use function Shlinkio\Shlink\Config\env; +use function Shlinkio\Shlink\Config\parseEnvVar; +use function sprintf; enum EnvVars: string { @@ -77,7 +81,24 @@ enum EnvVars: string public function loadFromEnv(mixed $default = null): mixed { - return env($this->value, $default); + return env($this->value) ?? $this->loadFromFileEnv() ?? $default; + } + + /** + * Checks if an equivalent environment variable exists with the `_FILE` suffix. If so, it loads its value as a file, + * reads it, and returns its contents. + * This is useful when loading Shlink with docker compose and using secrets. + * See https://docs.docker.com/compose/use-secrets/ + */ + private function loadFromFileEnv(): string|int|bool|null + { + $file = env(sprintf('%s_FILE', $this->value)); + if ($file === null || ! is_file($file)) { + return null; + } + + $content = file_get_contents($file); + return $content ? parseEnvVar($content) : null; } public function existsInEnv(): bool diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index 0b012051..dd83393b 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -17,12 +17,16 @@ class EnvVarsTest extends TestCase { putenv(EnvVars::BASE_PATH->value . '=the_base_path'); putenv(EnvVars::DB_NAME->value . '=shlink'); + + $envFilePath = __DIR__ . '/../DB_PASSWORD.env'; + putenv(EnvVars::DB_PASSWORD->value . '_FILE=' . $envFilePath); } protected function tearDown(): void { putenv(EnvVars::BASE_PATH->value . '='); putenv(EnvVars::DB_NAME->value . '='); + putenv(EnvVars::DB_PASSWORD->value . '_FILE='); } #[Test, DataProvider('provideExistingEnvVars')] @@ -54,4 +58,10 @@ class EnvVarsTest extends TestCase yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; } + + #[Test] + public function fallsBackToReadEnvVarsFromFile(): void + { + self::assertEquals('this_is_the_password', EnvVars::DB_PASSWORD->loadFromEnv()); + } } diff --git a/module/Core/test/DB_PASSWORD.env b/module/Core/test/DB_PASSWORD.env new file mode 100644 index 00000000..d5b7bed8 --- /dev/null +++ b/module/Core/test/DB_PASSWORD.env @@ -0,0 +1 @@ +this_is_the_password From 46acf4de1c5183d8467bac08dea12a8923af8ef8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 13:57:16 +0100 Subject: [PATCH 06/97] Support filtering orphan visits by type in VisitRepository --- .../Core/src/Visit/Model/OrphanVisitType.php | 12 +++ module/Core/src/Visit/Model/VisitType.php | 6 +- .../Adapter/OrphanVisitsPaginatorAdapter.php | 8 +- .../OrphanVisitsCountFiltering.php | 21 +++++ .../Persistence/OrphanVisitsListFiltering.php | 23 ++++++ .../Persistence/VisitsCountFiltering.php | 5 -- .../src/Visit/Repository/VisitRepository.php | 15 +++- .../Repository/VisitRepositoryInterface.php | 6 +- .../src/Visit/Spec/CountOfOrphanVisits.php | 8 +- module/Core/src/Visit/VisitsStatsHelper.php | 7 +- .../Visit/Repository/VisitRepositoryTest.php | 80 ++++++++++++------- .../OrphanVisitsPaginatorAdapterTest.php | 18 ++--- .../Core/test/Visit/VisitsStatsHelperTest.php | 6 +- 13 files changed, 154 insertions(+), 61 deletions(-) create mode 100644 module/Core/src/Visit/Model/OrphanVisitType.php create mode 100644 module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php create mode 100644 module/Core/src/Visit/Persistence/OrphanVisitsListFiltering.php diff --git a/module/Core/src/Visit/Model/OrphanVisitType.php b/module/Core/src/Visit/Model/OrphanVisitType.php new file mode 100644 index 00000000..4150a959 --- /dev/null +++ b/module/Core/src/Visit/Model/OrphanVisitType.php @@ -0,0 +1,12 @@ +value; + case BASE_URL = OrphanVisitType::BASE_URL->value; + case REGULAR_404 = OrphanVisitType::REGULAR_404->value; } diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index e871d125..1f38ab6a 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -6,8 +6,8 @@ namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter; use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -22,7 +22,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte protected function doCount(): int { - return $this->repo->countOrphanVisits(new VisitsCountFiltering( + return $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, @@ -31,7 +31,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte public function getSlice(int $offset, int $length): iterable { - return $this->repo->findOrphanVisits(new VisitsListFiltering( + return $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, diff --git a/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php new file mode 100644 index 00000000..88676df8 --- /dev/null +++ b/module/Core/src/Visit/Persistence/OrphanVisitsCountFiltering.php @@ -0,0 +1,21 @@ +apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { return []; @@ -146,10 +148,17 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); + + // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later + if ($filtering->type) { + $conn = $this->getEntityManager()->getConnection(); + $qb->andWhere($qb->expr()->eq('v.type', $conn->quote($filtering->type->value))); + } + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } - public function countOrphanVisits(VisitsCountFiltering $filtering): int + public function countOrphanVisits(OrphanVisitsCountFiltering $filtering): int { if ($filtering->apiKey?->hasRole(Role::NO_ORPHAN_VISITS)) { return 0; @@ -176,7 +185,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo return (int) $this->matchSingleScalarResult(new CountOfNonOrphanVisits($filtering)); } - private function createAllVisitsQueryBuilder(VisitsListFiltering $filtering): QueryBuilder + private function createAllVisitsQueryBuilder(VisitsListFiltering|OrphanVisitsListFiltering $filtering): QueryBuilder { // Parameters in this query need to be inlined, not bound, as we need to use it as sub-query later // Since they are not provided by the caller, it's reasonably safe diff --git a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php index 4e53db2b..9904181b 100644 --- a/module/Core/src/Visit/Repository/VisitRepositoryInterface.php +++ b/module/Core/src/Visit/Repository/VisitRepositoryInterface.php @@ -8,6 +8,8 @@ use Doctrine\Persistence\ObjectRepository; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepositoryInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; @@ -37,9 +39,9 @@ interface VisitRepositoryInterface extends ObjectRepository, EntitySpecification /** * @return Visit[] */ - public function findOrphanVisits(VisitsListFiltering $filtering): array; + public function findOrphanVisits(OrphanVisitsListFiltering $filtering): array; - public function countOrphanVisits(VisitsCountFiltering $filtering): int; + public function countOrphanVisits(OrphanVisitsCountFiltering $filtering): int; /** * @return Visit[] diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index 106350c6..9d9cab56 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -8,11 +8,11 @@ use Happyr\DoctrineSpecification\Spec; use Happyr\DoctrineSpecification\Specification\BaseSpecification; use Happyr\DoctrineSpecification\Specification\Specification; use Shlinkio\Shlink\Core\Spec\InDateRange; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; class CountOfOrphanVisits extends BaseSpecification { - public function __construct(private VisitsCountFiltering $filtering) + public function __construct(private readonly OrphanVisitsCountFiltering $filtering) { parent::__construct(); } @@ -28,6 +28,10 @@ class CountOfOrphanVisits extends BaseSpecification $conditions[] = Spec::eq('potentialBot', false); } + if ($this->filtering->type) { + $conditions[] = Spec::eq('type', $this->filtering->type->value); + } + return Spec::countOf(Spec::andX(...$conditions)); } } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index bdd2fd3b..23ea8fc2 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -25,6 +25,7 @@ use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\NonOrphanVisitsPaginatorAdapter use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\ShortUrlVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\TagVisitsPaginatorAdapter; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; @@ -42,13 +43,13 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface $visitsRepo = $this->em->getRepository(Visit::class); return new VisitsStats( - nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), - orphanVisitsTotal: $visitsRepo->countOrphanVisits(VisitsCountFiltering::withApiKey($apiKey)), + nonOrphanVisitsTotal: $visitsRepo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey)), + orphanVisitsTotal: $visitsRepo->countOrphanVisits(new OrphanVisitsCountFiltering(apiKey: $apiKey)), nonOrphanVisitsNonBots: $visitsRepo->countNonOrphanVisits( new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), orphanVisitsNonBots: $visitsRepo->countOrphanVisits( - new VisitsCountFiltering(excludeBots: true, apiKey: $apiKey), + new OrphanVisitsCountFiltering(excludeBots: true, apiKey: $apiKey), ), ); } diff --git a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php index cca71a14..90496e1e 100644 --- a/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Visit/Repository/VisitRepositoryTest.php @@ -15,7 +15,10 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; @@ -305,10 +308,12 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); 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(0, $this->repo->countOrphanVisits(VisitsCountFiltering::withApiKey($noOrphanVisitsApiKey))); + self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey1))); + self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $apiKey2))); + self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(apiKey: $domainApiKey))); + self::assertEquals(0, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + apiKey: $noOrphanVisitsApiKey, + ))); self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); @@ -319,8 +324,8 @@ class VisitRepositoryTest extends DatabaseTestCase 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))); + self::assertEquals(4, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(3, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(excludeBots: true))); } #[Test] @@ -353,27 +358,36 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertCount(0, $this->repo->findOrphanVisits(new VisitsListFiltering(apiKey: $noOrphanVisitsApiKey))); - self::assertCount(18, $this->repo->findOrphanVisits(new VisitsListFiltering())); - self::assertCount(15, $this->repo->findOrphanVisits(new VisitsListFiltering(null, true))); - self::assertCount(5, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 5))); - self::assertCount(10, $this->repo->findOrphanVisits(new VisitsListFiltering(null, false, null, 15, 8))); - self::assertCount(9, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::since(Chronos::parse('2020-01-04')), - false, - null, - 15, + self::assertCount(0, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + apiKey: $noOrphanVisitsApiKey, ))); - self::assertCount(2, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), - false, - null, - 6, - 4, + self::assertCount(18, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering())); + self::assertCount(15, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(excludeBots: true))); + self::assertCount(5, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(limit: 5))); + self::assertCount(10, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering(limit: 15, offset: 8))); + self::assertCount(9, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::since(Chronos::parse('2020-01-04')), + limit: 15, ))); - self::assertCount(3, $this->repo->findOrphanVisits(new VisitsListFiltering( + self::assertCount(2, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + limit: 6, + offset: 4, + ))); + self::assertCount(2, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + type: OrphanVisitType::INVALID_SHORT_URL, + ))); + self::assertCount(3, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( DateRange::until(Chronos::parse('2020-01-01')), ))); + self::assertCount(6, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + type: OrphanVisitType::REGULAR_404, + ))); + self::assertCount(4, $this->repo->findOrphanVisits(new OrphanVisitsListFiltering( + type: OrphanVisitType::BASE_URL, + limit: 4, + ))); } #[Test] @@ -400,17 +414,27 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::allTime()))); + self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering())); + self::assertEquals(18, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering(DateRange::allTime()))); self::assertEquals(9, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), + new OrphanVisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), )); - self::assertEquals(6, $this->repo->countOrphanVisits(new VisitsCountFiltering( + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), ))); self::assertEquals(3, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), + new OrphanVisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), )); + self::assertEquals(2, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + dateRange: DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + type: OrphanVisitType::BASE_URL, + ))); + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + type: OrphanVisitType::INVALID_SHORT_URL, + ))); + self::assertEquals(6, $this->repo->countOrphanVisits(new OrphanVisitsCountFiltering( + type: OrphanVisitType::REGULAR_404, + ))); } #[Test] diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 04e3f84c..623482a6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -12,8 +12,8 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; -use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -38,7 +38,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $this->repo->expects($this->once())->method('countOrphanVisits')->with( - new VisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), + new OrphanVisitsCountFiltering($this->params->dateRange, apiKey: $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -55,12 +55,12 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; - $this->repo->expects($this->once())->method('findOrphanVisits')->with(new VisitsListFiltering( - $this->params->dateRange, - $this->params->excludeBots, - $this->apiKey, - $limit, - $offset, + $this->repo->expects($this->once())->method('findOrphanVisits')->with(new OrphanVisitsListFiltering( + dateRange: $this->params->dateRange, + excludeBots: $this->params->excludeBots, + apiKey: $this->apiKey, + limit: $limit, + offset: $offset, ))->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index dd11fdef..3af7b739 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -26,6 +26,8 @@ use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; +use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\VisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; @@ -251,10 +253,10 @@ class VisitsStatsHelperTest extends TestCase $list = array_map(static fn () => Visit::forBasePath(Visitor::emptyInstance()), range(0, 3)); $repo = $this->createMock(VisitRepository::class); $repo->expects($this->once())->method('countOrphanVisits')->with( - $this->isInstanceOf(VisitsCountFiltering::class), + $this->isInstanceOf(OrphanVisitsCountFiltering::class), )->willReturn(count($list)); $repo->expects($this->once())->method('findOrphanVisits')->with( - $this->isInstanceOf(VisitsListFiltering::class), + $this->isInstanceOf(OrphanVisitsListFiltering::class), )->willReturn($list); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); From 48a8290e920f1e9aa9c31b0c170d3655adca0974 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 17:51:42 +0100 Subject: [PATCH 07/97] Allow type filter property for orphan visits list --- docker-compose.yml | 2 +- docs/swagger/paths/v2_visits_orphan.json | 58 +++++++++++++++++++ .../Command/Visit/GetOrphanVisitsCommand.php | 4 +- .../src/Visit/Model/OrphanVisitsParams.php | 53 +++++++++++++++++ module/Core/src/Visit/Model/VisitsParams.php | 2 +- .../Adapter/OrphanVisitsPaginatorAdapter.php | 6 +- module/Core/src/Visit/VisitsStatsHelper.php | 3 +- .../src/Visit/VisitsStatsHelperInterface.php | 3 +- .../OrphanVisitsPaginatorAdapterTest.php | 6 +- .../Core/test/Visit/VisitsStatsHelperTest.php | 3 +- .../src/Action/Visit/OrphanVisitsAction.php | 4 +- .../Action/Visit/OrphanVisitsActionTest.php | 16 ++++- 12 files changed, 144 insertions(+), 16 deletions(-) create mode 100644 module/Core/src/Visit/Model/OrphanVisitsParams.php diff --git a/docker-compose.yml b/docker-compose.yml index f33693ad..71194fbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -199,7 +199,7 @@ services: shlink_swagger_ui: container_name: shlink_swagger_ui - image: swaggerapi/swagger-ui:v5.10.3 + image: swaggerapi/swagger-ui:v5.11.3 ports: - "8005:8080" volumes: diff --git a/docs/swagger/paths/v2_visits_orphan.json b/docs/swagger/paths/v2_visits_orphan.json index fe799934..df2ee0cd 100644 --- a/docs/swagger/paths/v2_visits_orphan.json +++ b/docs/swagger/paths/v2_visits_orphan.json @@ -55,6 +55,16 @@ "type": "string", "enum": ["true"] } + }, + { + "name": "type", + "in": "query", + "description": "The type of visits to return. All visits are returned when not provided.", + "required": false, + "schema": { + "type": "string", + "enum": ["invalid_short_url", "base_url", "regular_404"] + } } ], "security": [ @@ -137,6 +147,54 @@ } } }, + "400": { + "description": "Provided query arguments are invalid.", + "content": { + "application/problem+json": { + "schema": { + "type": "object", + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["invalidElements"], + "properties": { + "invalidElements": { + "type": "array", + "items": { + "type": "string", + "enum": ["type"] + } + } + } + } + ] + }, + "examples": { + "API v3 and newer": { + "value": { + "title": "Invalid data", + "type": "https://shlink.io/api/error/invalid-data", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["type"] + } + }, + "Previous to API v3": { + "value": { + "title": "Invalid data", + "type": "INVALID_ARGUMENT", + "detail": "Provided data is not valid", + "status": 400, + "invalidElements": ["type"] + } + } + } + } + } + }, "default": { "description": "Unexpected error.", "content": { diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 618a35cd..3b89e339 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\CLI\Command\Visit; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Symfony\Component\Console\Input\InputInterface; class GetOrphanVisitsCommand extends AbstractVisitsListCommand @@ -23,7 +23,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator { - return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange)); + return $this->visitsHelper->orphanVisits(new OrphanVisitsParams($dateRange)); } /** diff --git a/module/Core/src/Visit/Model/OrphanVisitsParams.php b/module/Core/src/Visit/Model/OrphanVisitsParams.php new file mode 100644 index 00000000..45efd6bb --- /dev/null +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -0,0 +1,53 @@ +dateRange, + page: $visitsParams->page, + itemsPerPage: $visitsParams->itemsPerPage, + excludeBots: $visitsParams->excludeBots, + type: $type !== null ? self::parseType($type) : null, + ); + } + + private static function parseType(string $type): OrphanVisitType + { + try { + return OrphanVisitType::from($type); + } catch (ValueError) { + throw ValidationException::fromArray([ + 'type' => sprintf( + '%s is not a valid orphan visit type. Expected one of ["%s"]', + $type, + implode('", "', enumValues(OrphanVisitType::class)), + ), + ]); + } + } +} diff --git a/module/Core/src/Visit/Model/VisitsParams.php b/module/Core/src/Visit/Model/VisitsParams.php index 90ca4770..10713131 100644 --- a/module/Core/src/Visit/Model/VisitsParams.php +++ b/module/Core/src/Visit/Model/VisitsParams.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Model\AbstractInfinitePaginableListParams; use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; -final class VisitsParams extends AbstractInfinitePaginableListParams +class VisitsParams extends AbstractInfinitePaginableListParams { public readonly DateRange $dateRange; diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 1f38ab6a..863460a1 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -5,7 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Visit\Paginator\Adapter; use Shlinkio\Shlink\Core\Paginator\Adapter\AbstractCacheableCountPaginatorAdapter; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; @@ -15,7 +15,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte { public function __construct( private readonly VisitRepositoryInterface $repo, - private readonly VisitsParams $params, + private readonly OrphanVisitsParams $params, private readonly ?ApiKey $apiKey, ) { } @@ -26,6 +26,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, + type: $this->params->type, )); } @@ -35,6 +36,7 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte dateRange: $this->params->dateRange, excludeBots: $this->params->excludeBots, apiKey: $this->apiKey, + type: $this->params->type, limit: $length, offset: $offset, )); diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 23ea8fc2..480435c1 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -18,6 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\DomainVisitsPaginatorAdapter; @@ -117,7 +118,7 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator + public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator { /** @var VisitRepositoryInterface $repo */ $repo = $this->em->getRepository(Visit::class); diff --git a/module/Core/src/Visit/VisitsStatsHelperInterface.php b/module/Core/src/Visit/VisitsStatsHelperInterface.php index 71173553..265174ed 100644 --- a/module/Core/src/Visit/VisitsStatsHelperInterface.php +++ b/module/Core/src/Visit/VisitsStatsHelperInterface.php @@ -10,6 +10,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -43,7 +44,7 @@ interface VisitsStatsHelperInterface /** * @return Visit[]|Paginator */ - public function orphanVisits(VisitsParams $params, ?ApiKey $apiKey = null): Paginator; + public function orphanVisits(OrphanVisitsParams $params, ?ApiKey $apiKey = null): Paginator; /** * @return Visit[]|Paginator diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 623482a6..3e50faf0 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -9,8 +9,8 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Paginator\Adapter\OrphanVisitsPaginatorAdapter; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsCountFiltering; use Shlinkio\Shlink\Core\Visit\Persistence\OrphanVisitsListFiltering; @@ -21,13 +21,13 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { private OrphanVisitsPaginatorAdapter $adapter; private MockObject & VisitRepositoryInterface $repo; - private VisitsParams $params; + private OrphanVisitsParams $params; private ApiKey $apiKey; protected function setUp(): void { $this->repo = $this->createMock(VisitRepositoryInterface::class); - $this->params = VisitsParams::fromRawData([]); + $this->params = OrphanVisitsParams::fromRawData([]); $this->apiKey = ApiKey::create(); $this->adapter = new OrphanVisitsPaginatorAdapter($this->repo, $this->params, $this->apiKey); diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index 3af7b739..b023bc1c 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -23,6 +23,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\Model\VisitsStats; @@ -260,7 +261,7 @@ class VisitsStatsHelperTest extends TestCase )->willReturn($list); $this->em->expects($this->once())->method('getRepository')->with(Visit::class)->willReturn($repo); - $paginator = $this->helper->orphanVisits(new VisitsParams()); + $paginator = $this->helper->orphanVisits(new OrphanVisitsParams()); self::assertEquals($list, ArrayUtils::iteratorToArray($paginator->getCurrentPageResults())); } diff --git a/module/Rest/src/Action/Visit/OrphanVisitsAction.php b/module/Rest/src/Action/Visit/OrphanVisitsAction.php index c7adf3a1..57244197 100644 --- a/module/Rest/src/Action/Visit/OrphanVisitsAction.php +++ b/module/Rest/src/Action/Visit/OrphanVisitsAction.php @@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtilsTrait; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; @@ -29,7 +29,7 @@ class OrphanVisitsAction extends AbstractRestAction public function handle(ServerRequestInterface $request): ResponseInterface { - $params = VisitsParams::fromRawData($request->getQueryParams()); + $params = OrphanVisitsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); $visits = $this->visitsHelper->orphanVisits($params, $apiKey); diff --git a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php index da660d0e..efa14caa 100644 --- a/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/OrphanVisitsActionTest.php @@ -12,9 +12,10 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; +use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Visit\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\Rest\Action\Visit\OrphanVisitsAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -41,7 +42,7 @@ class OrphanVisitsActionTest extends TestCase $visitor = Visitor::emptyInstance(); $visits = [Visit::forInvalidShortUrl($visitor), Visit::forRegularNotFound($visitor)]; $this->visitsHelper->expects($this->once())->method('orphanVisits')->with( - $this->isInstanceOf(VisitsParams::class), + $this->isInstanceOf(OrphanVisitsParams::class), )->willReturn(new Paginator(new ArrayAdapter($visits))); $visitsAmount = count($visits); $this->orphanVisitTransformer->expects($this->exactly($visitsAmount))->method('transform')->with( @@ -57,4 +58,15 @@ class OrphanVisitsActionTest extends TestCase self::assertCount($visitsAmount, $payload['visits']['data']); self::assertEquals(200, $response->getStatusCode()); } + + #[Test] + public function exceptionIsThrownIfInvalidDataIsProvided(): void + { + $this->expectException(ValidationException::class); + $this->action->handle( + ServerRequestFactory::fromGlobals() + ->withAttribute(ApiKey::class, ApiKey::create()) + ->withQueryParams(['type' => 'invalidType']), + ); + } } From f17b641d46708fbd676040557b48d8bdeaa5edaa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 18:19:28 +0100 Subject: [PATCH 08/97] Allow filtering orphan visits by type from the CLI --- .../Command/Visit/GetOrphanVisitsCommand.php | 15 +++++++++++++-- .../Visit/GetOrphanVisitsCommandTest.php | 17 ++++++++++++----- module/Core/functions/functions.php | 9 +++++++++ .../Core/src/Visit/Model/OrphanVisitsParams.php | 7 +++---- 4 files changed, 37 insertions(+), 11 deletions(-) diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index 3b89e339..7beae19a 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -8,7 +8,12 @@ use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +use function Shlinkio\Shlink\Core\enumToString; +use function sprintf; class GetOrphanVisitsCommand extends AbstractVisitsListCommand { @@ -18,12 +23,18 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand { $this ->setName(self::NAME) - ->setDescription('Returns the list of orphan visits.'); + ->setDescription('Returns the list of orphan visits.') + ->addOption('type', 't', InputOption::VALUE_REQUIRED, sprintf( + 'Return visits only with this type. One of %s', + enumToString(OrphanVisitType::class), + )); } protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator { - return $this->visitsHelper->orphanVisits(new OrphanVisitsParams($dateRange)); + $rawType = $input->getOption('type'); + $type = $rawType !== null ? OrphanVisitType::from($rawType) : null; + return $this->visitsHelper->orphanVisits(new OrphanVisitsParams(dateRange: $dateRange, type: $type)); } /** diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php index b90e6af6..a9e2a50c 100644 --- a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -6,12 +6,15 @@ namespace ShlinkioTest\Shlink\CLI\Command\Visit; use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\Visit\GetOrphanVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Entity\VisitLocation; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitsParams; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\Core\Visit\Model\Visitor; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -30,16 +33,20 @@ class GetOrphanVisitsCommandTest extends TestCase } #[Test] - public function outputIsProperlyGenerated(): void + #[TestWith([[], false])] + #[TestWith([['--type' => OrphanVisitType::BASE_URL->value], true])] + public function outputIsProperlyGenerated(array $args, bool $includesType): void { $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), ); - $this->visitsHelper->expects($this->once())->method('orphanVisits')->withAnyParameters()->willReturn( - new Paginator(new ArrayAdapter([$visit])), - ); + $this->visitsHelper->expects($this->once())->method('orphanVisits')->with($this->callback( + fn (OrphanVisitsParams $param) => ( + ($includesType && $param->type !== null) || (!$includesType && $param->type === null) + ), + ))->willReturn(new Paginator(new ArrayAdapter([$visit]))); - $this->commandTester->execute([]); + $this->commandTester->execute($args); $output = $this->commandTester->getDisplay(); self::assertEquals( diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index d07bc9e2..f26cb84f 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -20,6 +20,7 @@ use function array_keys; use function array_map; use function array_reduce; use function date_default_timezone_get; +use function implode; use function is_array; use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; @@ -182,3 +183,11 @@ function enumValues(string $enum): array $cache[$enum] = array_map(static fn (BackedEnum $type) => (string) $type->value, $enum::cases()) ); } + +/** + * @param class-string $enum + */ +function enumToString(string $enum): string +{ + return sprintf('["%s"]', implode('", "', enumValues($enum))); +} diff --git a/module/Core/src/Visit/Model/OrphanVisitsParams.php b/module/Core/src/Visit/Model/OrphanVisitsParams.php index 45efd6bb..0fb2e99b 100644 --- a/module/Core/src/Visit/Model/OrphanVisitsParams.php +++ b/module/Core/src/Visit/Model/OrphanVisitsParams.php @@ -6,8 +6,7 @@ use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Exception\ValidationException; use ValueError; -use function implode; -use function Shlinkio\Shlink\Core\enumValues; +use function Shlinkio\Shlink\Core\enumToString; use function sprintf; final class OrphanVisitsParams extends VisitsParams @@ -43,9 +42,9 @@ final class OrphanVisitsParams extends VisitsParams } catch (ValueError) { throw ValidationException::fromArray([ 'type' => sprintf( - '%s is not a valid orphan visit type. Expected one of ["%s"]', + '%s is not a valid orphan visit type. Expected one of %s', $type, - implode('", "', enumValues(OrphanVisitType::class)), + enumToString(OrphanVisitType::class), ), ]); } From 430883987a092b124a725364e2c2d72bf50dadfa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 18:26:19 +0100 Subject: [PATCH 09/97] Add API test for type-filtering in orphan visits list --- .../Rest/test-api/Action/OrphanVisitsTest.php | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/module/Rest/test-api/Action/OrphanVisitsTest.php b/module/Rest/test-api/Action/OrphanVisitsTest.php index 2c8b2479..cf7cee0f 100644 --- a/module/Rest/test-api/Action/OrphanVisitsTest.php +++ b/module/Rest/test-api/Action/OrphanVisitsTest.php @@ -8,6 +8,7 @@ use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\Common\Paginator\Paginator; +use Shlinkio\Shlink\Core\Visit\Model\OrphanVisitType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class OrphanVisitsTest extends ApiTestCase @@ -68,6 +69,23 @@ class OrphanVisitsTest extends ApiTestCase 1, [self::REGULAR_NOT_FOUND], ]; + yield 'base_url only' => [['type' => OrphanVisitType::BASE_URL->value], 1, 1, [self::BASE_URL]]; + yield 'regular_404 only' => [['type' => OrphanVisitType::REGULAR_404->value], 1, 1, [self::REGULAR_NOT_FOUND]]; + yield 'invalid_short_url only' => [ + ['type' => OrphanVisitType::INVALID_SHORT_URL->value], + 1, + 1, + [self::INVALID_SHORT_URL], + ]; + } + + #[Test] + public function errorIsReturnedForInvalidType(): void + { + $resp = $this->callApiWithKey(self::METHOD_GET, '/visits/orphan', [ + RequestOptions::QUERY => ['type' => 'invalid'], + ]); + self::assertEquals(400, $resp->getStatusCode()); } #[Test] From 14a0db1f34a730c083838a8d63a0c1d2cd540c28 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 10 Feb 2024 18:28:54 +0100 Subject: [PATCH 10/97] Update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3093f52b..c5798e4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added * [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image. +* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type. + + This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. From f2a7b687a9928d39cc31b660bb29240505c63a1c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 11 Feb 2024 11:22:48 +0100 Subject: [PATCH 11/97] Update docker images to PHP 8.3 --- .github/actions/ci-setup/action.yml | 2 +- .github/workflows/ci-db-tests.yml | 3 +-- .github/workflows/ci-mutation-tests.yml | 3 +-- .github/workflows/ci-tests.yml | 3 +-- .github/workflows/ci.yml | 5 ++--- .github/workflows/publish-release.yml | 2 +- .github/workflows/publish-swagger-spec.yml | 2 +- Dockerfile | 6 +++--- build.sh | 3 +-- data/infra/examples/nginx-vhost.conf | 2 +- data/infra/php.Dockerfile | 6 +++--- data/infra/roadrunner.Dockerfile | 6 +++--- data/infra/swoole.Dockerfile | 8 ++++---- 13 files changed, 23 insertions(+), 28 deletions(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 054575eb..19df378a 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -43,5 +43,5 @@ runs: ini-values: pcov.directory=module - name: Install dependencies if: ${{ inputs.install-deps == 'yes' }} - run: composer install --no-interaction --prefer-dist ${{ inputs.php-version == '8.3' && '--ignore-platform-reqs' || '' }} + run: composer install --no-interaction --prefer-dist shell: bash diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index cc653315..f2aaefb9 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} env: LC_ALL: C steps: @@ -28,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0, pdo_sqlsrv-5.11.1 + php-extensions: openswoole-22.1.2, pdo_sqlsrv-5.12.0 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index d0d18c15..64da4adc 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -14,13 +14,12 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 + php-extensions: openswoole-22.1.2 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 77c055bf..48cc56ef 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,7 +14,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} steps: - uses: actions/checkout@v4 - name: Start postgres database server @@ -26,7 +25,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 + php-extensions: openswoole-22.1.2 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcbe0a48..c6b08e29 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 + php-extensions: openswoole-22.1.2 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -60,7 +60,6 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - continue-on-error: ${{ matrix.php-version == '8.3' }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: @@ -70,7 +69,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole ${{ matrix.php-version == '8.3' && '--ignore-platform-reqs' || '' }} + - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole - run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:api:rr diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 3fe1a1a4..76d04f94 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,7 +17,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 + php-extensions: openswoole-22.1.2 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 2ecf8d49..aa7e5e93 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.0 + php-extensions: openswoole-22.1.2 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/Dockerfile b/Dockerfile index 0916b10b..2616323a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM php:8.2-alpine3.17 as base +FROM php:8.3-alpine3.19 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} @@ -7,8 +7,8 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ARG SHLINK_USER_ID='root' ENV SHLINK_USER_ID ${SHLINK_USER_ID} -ENV OPENSWOOLE_VERSION 22.1.0 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV OPENSWOOLE_VERSION 22.1.2 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 ENV LC_ALL 'C' diff --git a/build.sh b/build.sh index db607172..f91ab408 100755 --- a/build.sh +++ b/build.sh @@ -31,8 +31,7 @@ cd "${builtContent}" # Install dependencies echo "Installing dependencies with $composerBin..." -# Deprecated. Do not ignore PHP platform req for Shlink v4.0.0 -composerFlags="--optimize-autoloader --no-progress --no-interaction --ignore-platform-req=php+" +composerFlags="--optimize-autoloader --no-progress --no-interaction" ${composerBin} self-update ${composerBin} install --no-dev --prefer-dist $composerFlags diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 0cd3ff4b..b7a5d4fa 100644 --- a/data/infra/examples/nginx-vhost.conf +++ b/data/infra/examples/nginx-vhost.conf @@ -11,7 +11,7 @@ server { location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; - fastcgi_pass unix:/var/run/php/php8.2-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 14c99f95..20732e3f 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.2-fpm-alpine3.17 +FROM php:8.3-fpm-alpine3.19 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV APCU_VERSION 5.1.23 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/roadrunner.Dockerfile b/data/infra/roadrunner.Dockerfile index 0e91d491..33768eda 100644 --- a/data/infra/roadrunner.Dockerfile +++ b/data/infra/roadrunner.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.2-alpine3.17 +FROM php:8.3-alpine3.19 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV APCU_VERSION 5.1.23 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 72536c75..4bc60385 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.2-alpine3.17 +FROM php:8.3-alpine3.19 MAINTAINER Alejandro Celaya -ENV APCU_VERSION 5.1.21 +ENV APCU_VERSION 5.1.23 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 22.1.0 -ENV PDO_SQLSRV_VERSION 5.11.1 +ENV OPENSWOOLE_VERSION 22.1.2 +ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 From 7a6bfed44589cec7435a27cc65bf113574cdaf30 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 11 Feb 2024 11:23:34 +0100 Subject: [PATCH 12/97] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5798e4c..7d09a142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. * [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. +* [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3. ### Deprecated * *Nothing* From ad3805a560e826a31b7c1542de7c8501715e8393 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 12 Feb 2024 20:29:40 +0100 Subject: [PATCH 13/97] Removed deprecated features --- Dockerfile | 17 +-- build.sh | 21 +-- config/autoload/installer.global.php | 2 - .../autoload/middleware-pipeline.global.php | 1 - config/autoload/rabbit.global.php | 3 - config/autoload/url-shortener.global.php | 2 +- config/autoload/webhooks.global.php | 20 --- config/constants.php | 5 +- docker/docker-entrypoint.sh | 15 +- docs/async-api/async-api.json | 5 - docs/swagger/definitions/ShortUrl.json | 6 - docs/swagger/definitions/TagInfo.json | 7 +- docs/swagger/definitions/VisitStats.json | 12 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- module/Core/config/dependencies.config.php | 3 - .../Core/config/event_dispatcher.config.php | 15 -- module/Core/src/Config/EnvVars.php | 6 - .../Helper/EnabledListenerChecker.php | 15 +- .../EventDispatcher/NotifyVisitToWebHooks.php | 101 ------------ .../RabbitMq/NotifyVisitToRabbitMq.php | 35 ----- module/Core/src/Options/RabbitMqOptions.php | 6 +- module/Core/src/Options/WebhookOptions.php | 41 ----- .../Core/src/ShortUrl/Model/ShortUrlMode.php | 6 - .../Transformer/ShortUrlDataTransformer.php | 3 - module/Core/src/Tag/Model/OrderableField.php | 14 +- module/Core/src/Tag/Model/TagInfo.php | 11 +- module/Core/src/Tag/Model/TagsParams.php | 3 - .../Core/src/Tag/Repository/TagRepository.php | 2 +- module/Core/src/Visit/Model/VisitsStats.php | 10 +- module/Core/test-api/Action/QrCodeTest.php | 8 +- .../Tag/Repository/TagRepositoryTest.php | 7 - module/Core/test/Action/QrCodeActionTest.php | 2 +- .../Helper/EnabledListenerCheckerTest.php | 24 --- .../NotifyVisitToWebHooksTest.php | 144 ------------------ .../PublishingUpdatesGeneratorTest.php | 2 - .../RabbitMq/NotifyVisitToRabbitMqTest.php | 49 +----- .../test/ShortUrl/Model/ShortUrlModeTest.php | 28 ---- module/Rest/config/dependencies.config.php | 1 - module/Rest/src/Action/Tag/ListTagsAction.php | 18 +-- module/Rest/src/ApiKey/Role.php | 8 +- module/Rest/src/Entity/ApiKey.php | 2 +- module/Rest/src/Entity/ApiKeyRole.php | 16 -- ...wardsCompatibleProblemDetailsException.php | 99 ------------ ...ckwardsCompatibleProblemDetailsHandler.php | 30 ---- .../test-api/Action/CreateShortUrlTest.php | 18 +-- .../test-api/Action/DeleteShortUrlTest.php | 6 +- .../Rest/test-api/Action/DeleteTagsTest.php | 4 +- .../test-api/Action/DomainRedirectsTest.php | 2 +- .../Rest/test-api/Action/DomainVisitsTest.php | 6 +- .../Rest/test-api/Action/EditShortUrlTest.php | 6 +- .../Rest/test-api/Action/GlobalVisitsTest.php | 6 +- .../test-api/Action/ListShortUrlsTest.php | 8 +- module/Rest/test-api/Action/RenameTagTest.php | 2 +- .../test-api/Action/ResolveShortUrlTest.php | 2 +- .../test-api/Action/ShortUrlVisitsTest.php | 2 +- .../Action/SingleStepCreateShortUrlTest.php | 2 +- module/Rest/test-api/Action/TagVisitsTest.php | 2 +- module/Rest/test-api/Action/TagsStatsTest.php | 25 --- module/Rest/test-api/Action/UpdateTagTest.php | 10 +- .../Rest/test-api/Action/VisitStatsTest.php | 6 - .../Middleware/AuthenticationTest.php | 10 +- .../test/Action/Tag/ListTagsActionTest.php | 48 +----- ...sCompatibleProblemDetailsExceptionTest.php | 113 -------------- ...rdsCompatibleProblemDetailsHandlerTest.php | 74 --------- 64 files changed, 96 insertions(+), 1083 deletions(-) delete mode 100644 config/autoload/webhooks.global.php delete mode 100644 module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php delete mode 100644 module/Core/src/Options/WebhookOptions.php delete mode 100644 module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php delete mode 100644 module/Core/test/ShortUrl/Model/ShortUrlModeTest.php delete mode 100644 module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php delete mode 100644 module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php delete mode 100644 module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php delete mode 100644 module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php diff --git a/Dockerfile b/Dockerfile index 2616323a..34d6d7ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,6 @@ ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} ARG SHLINK_USER_ID='root' ENV SHLINK_USER_ID ${SHLINK_USER_ID} -ENV OPENSWOOLE_VERSION 22.1.2 ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 @@ -26,13 +25,8 @@ RUN \ apk del .dev-deps && \ apk add --no-cache postgresql icu libzip libpng -# Install openswoole and sqlsrv driver for x86_64 builds +# Install sqlsrv driver for x86_64 builds RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} unixodbc-dev && \ - if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ - # Openswoole is deprecated. Remove in v4.0.0 - pecl install openswoole-${OPENSWOOLE_VERSION} && \ - docker-php-ext-enable openswoole ; \ - fi; \ if [ $(uname -m) == "x86_64" ]; then \ wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ @@ -47,14 +41,7 @@ FROM base as builder COPY . . COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ - # FIXME Ignoring ext-openswoole platform req, as it makes install fail with roadrunner, even though it's a dev dependency and we are passing --no-dev - php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \ - if [ "$SHLINK_RUNTIME" == 'openswoole' ]; then \ - # Openswoole is deprecated. Remove in v4.0.0 - php composer.phar remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction ; \ - elif [ "$SHLINK_RUNTIME" == 'rr' ]; then \ - php composer.phar remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole ; \ - fi; \ + php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ php composer.phar clear-cache && \ rm -r docker composer.* && \ sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php diff --git a/build.sh b/build.sh index f91ab408..7b77295f 100755 --- a/build.sh +++ b/build.sh @@ -1,18 +1,15 @@ #!/usr/bin/env bash set -e -if [ "$#" -lt 1 ] || [ "$#" -gt 2 ] || ([ "$#" == 2 ] && [ "$2" != "--no-swoole" ]); then +if [ "$#" -lt 1 ]; then echo "Usage:" >&2 - echo " $0 {version} [--no-swoole]" >&2 + echo " $0 {version}" >&2 exit 1 fi version=$1 -noSwoole=$2 phpVersion=$(php -r 'echo PHP_MAJOR_VERSION . "." . PHP_MINOR_VERSION;') -# Openswoole is deprecated. Remove in v4.0.0 -[[ $noSwoole ]] && swooleSuffix="" || swooleSuffix="_openswoole" -distId="shlink${version}_php${phpVersion}${swooleSuffix}_dist" +distId="shlink${version}_php${phpVersion}_dist" builtContent="./build/${distId}" projectdir=$(pwd) [[ -f ./composer.phar ]] && composerBin='./composer.phar' || composerBin='composer' @@ -31,18 +28,8 @@ cd "${builtContent}" # Install dependencies echo "Installing dependencies with $composerBin..." -composerFlags="--optimize-autoloader --no-progress --no-interaction" ${composerBin} self-update -${composerBin} install --no-dev --prefer-dist $composerFlags - -if [[ $noSwoole ]]; then - # If generating a dist not for openswoole, uninstall mezzio-swoole - ${composerBin} remove mezzio/mezzio-swoole --with-all-dependencies --update-no-dev $composerFlags -else - # Deprecated. Remove in Shlink v4.0.0 - # If generating a dist for openswoole, uninstall RoadRunner - ${composerBin} remove spiral/roadrunner spiral/roadrunner-jobs spiral/roadrunner-cli spiral/roadrunner-http --with-all-dependencies --update-no-dev $composerFlags -fi +${composerBin} install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction # Delete development files echo 'Deleting dev files...' diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 45f92153..affb0897 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -21,8 +21,6 @@ return [ Option\Database\DatabaseUnixSocketConfigOption::class, Option\UrlShortener\ShortDomainHostConfigOption::class, Option\UrlShortener\ShortDomainSchemaConfigOption::class, - Option\Visit\VisitsWebhooksConfigOption::class, - Option\Visit\OrphanVisitsWebhooksConfigOption::class, Option\Redirect\BaseUrlRedirectConfigOption::class, Option\Redirect\InvalidShortUrlRedirectConfigOption::class, Option\Redirect\Regular404RedirectConfigOption::class, diff --git a/config/autoload/middleware-pipeline.global.php b/config/autoload/middleware-pipeline.global.php index 072937ec..99f71bce 100644 --- a/config/autoload/middleware-pipeline.global.php +++ b/config/autoload/middleware-pipeline.global.php @@ -47,7 +47,6 @@ return [ 'rest' => [ 'path' => '/rest', 'middleware' => [ - Rest\Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class, Router\Middleware\ImplicitOptionsMiddleware::class, Rest\Middleware\BodyParserMiddleware::class, Rest\Middleware\AuthenticationMiddleware::class, diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index bf9591e5..fd8cda68 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -14,9 +14,6 @@ return [ 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), - - // Deprecated - 'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false), ], ]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 2a121bee..2816577d 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -14,7 +14,7 @@ return (static function (): array { MIN_SHORT_CODES_LENGTH, ); $modeFromEnv = EnvVars::SHORT_URL_MODE->loadFromEnv(ShortUrlMode::STRICT->value); - $mode = ShortUrlMode::tryDeprecated($modeFromEnv) ?? ShortUrlMode::STRICT; + $mode = ShortUrlMode::tryFrom($modeFromEnv) ?? ShortUrlMode::STRICT; return [ diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php deleted file mode 100644 index e72c4904..00000000 --- a/config/autoload/webhooks.global.php +++ /dev/null @@ -1,20 +0,0 @@ -loadFromEnv(); - - return [ - - 'visits_webhooks' => [ - 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), - 'notify_orphan_visits_to_webhooks' => - (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false), - ], - - ]; -})(); diff --git a/config/constants.php b/config/constants.php index f08c135c..7b263262 100644 --- a/config/constants.php +++ b/config/constants.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Core\Util\RedirectStatus; const DEFAULT_DELETE_SHORT_URL_THRESHOLD = 15; const DEFAULT_SHORT_CODES_LENGTH = 5; const MIN_SHORT_CODES_LENGTH = 4; -const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; // Deprecated. Default to 307 for Shlink v4 +const DEFAULT_REDIRECT_STATUS_CODE = RedirectStatus::STATUS_302; const DEFAULT_REDIRECT_CACHE_LIFETIME = 30; const LOCAL_LOCK_FACTORY = 'Shlinkio\Shlink\LocalLockFactory'; const TITLE_TAG_VALUE = '/]*>(.*?)<\/title>/i'; // Matches the value inside a html title tag @@ -19,6 +19,5 @@ const DEFAULT_QR_CODE_MARGIN = 0; const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; -// Deprecated. Shlink 4.0.0 should change default value to `true` -const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = false; +const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; const MIN_TASK_WORKERS = 4; diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 6c95bee2..faa506a9 100644 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -20,19 +20,6 @@ fi php vendor/bin/shlink-installer init ${flags} -# Periodically run visit:locate every hour, if ENABLE_PERIODIC_VISIT_LOCATE=true was provided and running as root -# FIXME: ENABLE_PERIODIC_VISIT_LOCATE is deprecated. Remove cron support in Shlink 4.0.0 -if [ "${ENABLE_PERIODIC_VISIT_LOCATE}" = "true" ] && [ "${SHLINK_USER_ID}" = "root" ]; then - echo "Configuring periodic visit location..." - echo "0 * * * * php /etc/shlink/bin/cli visit:locate -q" > /etc/crontabs/root - /usr/sbin/crond & -fi - -if [ "$SHLINK_RUNTIME" = 'openswoole' ]; then - # Openswoole is deprecated. Remove in Shlink 4.0.0 - # When restarting the container, openswoole might think it is already in execution - # This forces the app to be started every second until the exit code is 0 - until php vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done -elif [ "$SHLINK_RUNTIME" = 'rr' ]; then +if [ "$SHLINK_RUNTIME" = 'rr' ]; then ./bin/rr serve -c config/roadrunner/.rr.yml fi diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index d45dae2b..d3177b98 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -122,11 +122,6 @@ "visitsSummary": { "$ref": "#/components/schemas/VisitsSummary" }, - "visitsCount": { - "deprecated": true, - "type": "integer", - "description": "The number of visits that this short URL has received." - }, "tags": { "type": "array", "items": { diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 98fd9c87..8a420e9b 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -6,7 +6,6 @@ "longUrl", "deviceLongUrls", "dateCreated", - "visitsCount", "visitsSummary", "tags", "meta", @@ -36,11 +35,6 @@ "format": "date-time", "description": "The date in which the short URL was created in ISO format." }, - "visitsCount": { - "deprecated": true, - "type": "integer", - "description": "**[DEPRECATED]** Use `visitsSummary.total` instead." - }, "visitsSummary": { "$ref": "./VisitsSummary.json" }, diff --git a/docs/swagger/definitions/TagInfo.json b/docs/swagger/definitions/TagInfo.json index 41de1068..27658c95 100644 --- a/docs/swagger/definitions/TagInfo.json +++ b/docs/swagger/definitions/TagInfo.json @@ -1,6 +1,6 @@ { "type": "object", - "required": ["tag", "shortUrlsCount", "visitsSummary", "visitsCount"], + "required": ["tag", "shortUrlsCount", "visitsSummary"], "properties": { "tag": { "type": "string", @@ -12,11 +12,6 @@ }, "visitsSummary": { "$ref": "./VisitsSummary.json" - }, - "visitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use visitsSummary.total instead" } } } diff --git a/docs/swagger/definitions/VisitStats.json b/docs/swagger/definitions/VisitStats.json index 2ed24375..a1d8ce19 100644 --- a/docs/swagger/definitions/VisitStats.json +++ b/docs/swagger/definitions/VisitStats.json @@ -1,22 +1,12 @@ { "type": "object", - "required": ["nonOrphanVisits", "orphanVisits", "visitsCount", "orphanVisitsCount"], + "required": ["nonOrphanVisits", "orphanVisits"], "properties": { "nonOrphanVisits": { "$ref": "./VisitsSummary.json" }, "orphanVisits": { "$ref": "./VisitsSummary.json" - }, - "visitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use nonOrphanVisits.total instead" - }, - "orphanVisitsCount": { - "deprecated": true, - "type": "number", - "description": "**[DEPRECATED]** Use orphanVisits.total instead" } } } diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index c9497daf..297c435e 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -218,7 +218,7 @@ class ListShortUrlsCommand extends Command 'Short URL' => $pickProp('shortUrl'), 'Long URL' => $pickProp('longUrl'), 'Date created' => $pickProp('dateCreated'), - 'Visits count' => $pickProp('visitsCount'), + 'Visits count' => static fn (array $shortUrl) => $shortUrl['visitsSummary']->total, ]; if ($input->getOption('show-tags')) { $columnsMap['Tags'] = static fn (array $shortUrl): string => implode(', ', $shortUrl['tags']); diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 591fcc79..6b6be190 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -31,7 +31,6 @@ return [ Options\TrackingOptions::class => [ValinorConfigFactory::class, 'config.tracking'], Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], - Options\WebhookOptions::class => ConfigAbstractFactory::class, ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class, @@ -113,8 +112,6 @@ return [ Domain\DomainService::class, ], - Options\WebhookOptions::class => ['config.visits_webhooks'], - ShortUrl\UrlShortener::class => [ ShortUrl\Helper\ShortUrlTitleResolutionHelper::class, 'em', diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 1a81d8ed..012b8e12 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -37,7 +37,6 @@ return (static function (): array { EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, - EventDispatcher\NotifyVisitToWebHooks::class, EventDispatcher\UpdateGeoLiteDb::class, ], EventDispatcher\Event\ShortUrlCreated::class => [ @@ -66,7 +65,6 @@ return (static function (): array { EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\Matomo\SendVisitToMatomo::class => ConfigAbstractFactory::class, EventDispatcher\LocateUnlocatedVisits::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, @@ -104,9 +102,6 @@ return (static function (): array { EventDispatcher\LocateUnlocatedVisits::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], - EventDispatcher\NotifyVisitToWebHooks::class => [ - EventDispatcher\CloseDbConnectionEventListenerDelegator::class, - ], ], ], @@ -119,14 +114,6 @@ return (static function (): array { EventDispatcherInterface::class, ], EventDispatcher\LocateUnlocatedVisits::class => [VisitLocator::class, VisitToLocationHelper::class], - EventDispatcher\NotifyVisitToWebHooks::class => [ - 'httpClient', - 'em', - 'Logger_Shlink', - Options\WebhookOptions::class, - ShortUrl\Transformer\ShortUrlDataTransformer::class, - Options\AppOptions::class, - ], EventDispatcher\Mercure\NotifyVisitToMercure::class => [ MercureHubPublishingHelper::class, EventDispatcher\PublishingUpdatesGenerator::class, @@ -144,7 +131,6 @@ return (static function (): array { EventDispatcher\PublishingUpdatesGenerator::class, 'em', 'Logger_Shlink', - Visit\Transformer\OrphanVisitDataTransformer::class, Options\RabbitMqOptions::class, ], EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ @@ -187,7 +173,6 @@ return (static function (): array { Options\RabbitMqOptions::class, 'config.redis.pub_sub_enabled', MercureOptions::class, - Options\WebhookOptions::class, GeoLite2Options::class, MatomoOptions::class, ], diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 40f311e9..4a32b7c3 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -36,8 +36,6 @@ enum EnvVars: string case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; case RABBITMQ_USE_SSL = 'RABBITMQ_USE_SSL'; - /** @deprecated */ - case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; case MATOMO_ENABLED = 'MATOMO_ENABLED'; case MATOMO_BASE_URL = 'MATOMO_BASE_URL'; case MATOMO_SITE_ID = 'MATOMO_SITE_ID'; @@ -74,10 +72,6 @@ enum EnvVars: string case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; case TIMEZONE = 'TIMEZONE'; case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; - /** @deprecated */ - case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; - /** @deprecated */ - case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; public function loadFromEnv(mixed $default = null): mixed { diff --git a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php index 269aed76..ad4c8070 100644 --- a/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php +++ b/module/Core/src/EventDispatcher/Helper/EnabledListenerChecker.php @@ -8,19 +8,17 @@ use Shlinkio\Shlink\Common\Mercure\MercureOptions; use Shlinkio\Shlink\Core\EventDispatcher; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\EventDispatcher\Listener\EnabledListenerCheckerInterface; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; -class EnabledListenerChecker implements EnabledListenerCheckerInterface +readonly class EnabledListenerChecker implements EnabledListenerCheckerInterface { public function __construct( - private readonly RabbitMqOptions $rabbitMqOptions, - private readonly bool $redisPubSubEnabled, - private readonly MercureOptions $mercureOptions, - private readonly WebhookOptions $webhookOptions, - private readonly GeoLite2Options $geoLiteOptions, - private readonly MatomoOptions $matomoOptions, + private RabbitMqOptions $rabbitMqOptions, + private bool $redisPubSubEnabled, + private MercureOptions $mercureOptions, + private GeoLite2Options $geoLiteOptions, + private MatomoOptions $matomoOptions, ) { } @@ -38,7 +36,6 @@ class EnabledListenerChecker implements EnabledListenerCheckerInterface EventDispatcher\Mercure\NotifyVisitToMercure::class, EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => $this->mercureOptions->isEnabled(), EventDispatcher\Matomo\SendVisitToMatomo::class => $this->matomoOptions->enabled, - EventDispatcher\NotifyVisitToWebHooks::class => $this->webhookOptions->hasWebhooks(), EventDispatcher\UpdateGeoLiteDb::class => $this->geoLiteOptions->hasLicenseKey(), default => false, // Any unknown async listener should not be enabled by default }; diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php deleted file mode 100644 index 028c3c13..00000000 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ /dev/null @@ -1,101 +0,0 @@ -webhookOptions->hasWebhooks()) { - return; - } - - $visitId = $shortUrlLocated->visitId; - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if ($visit->isOrphan() && ! $this->webhookOptions->notifyOrphanVisits()) { - return; - } - - $requestOptions = $this->buildRequestOptions($visit); - $requestPromises = $this->performRequests($requestOptions, $visitId); - - // Wait for all the promises to finish, ignoring rejections, as in those cases we only want to log the error. - Utils::settle($requestPromises)->wait(); - } - - private function buildRequestOptions(Visit $visit): array - { - $payload = ['visit' => $visit->jsonSerialize()]; - $shortUrl = $visit->getShortUrl(); - if ($shortUrl !== null) { - $payload['shortUrl'] = $this->transformer->transform($shortUrl); - } - - return [ - RequestOptions::TIMEOUT => 10, - RequestOptions::JSON => $payload, - RequestOptions::HEADERS => ['User-Agent' => $this->appOptions->__toString()], - ]; - } - - /** - * @param Promise[] $requestOptions - */ - private function performRequests(array $requestOptions, string $visitId): array - { - return array_map( - fn (string $webhook): PromiseInterface => $this->httpClient - ->requestAsync(RequestMethodInterface::METHOD_POST, $webhook, $requestOptions) - ->otherwise(fn (Throwable $e) => $this->logWebhookFailure($webhook, $visitId, $e)), - $this->webhookOptions->webhooks(), - ); - } - - private function logWebhookFailure(string $webhook, string $visitId, Throwable $e): void - { - $this->logger->warning('Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', [ - 'visitId' => $visitId, - 'webhook' => $webhook, - 'e' => $e, - ]); - } -} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php index ed5b08e0..ddc4221c 100644 --- a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -6,15 +6,11 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use Psr\Log\LoggerInterface; -use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; -use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\EventDispatcher\Async\AbstractNotifyVisitListener; use Shlinkio\Shlink\Core\EventDispatcher\Async\RemoteSystem; use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; -use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Visit\Entity\Visit; class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener { @@ -23,42 +19,11 @@ class NotifyVisitToRabbitMq extends AbstractNotifyVisitListener PublishingUpdatesGeneratorInterface $updatesGenerator, EntityManagerInterface $em, LoggerInterface $logger, - private readonly DataTransformerInterface $orphanVisitTransformer, private readonly RabbitMqOptions $options, ) { parent::__construct($rabbitMqHelper, $updatesGenerator, $em, $logger); } - /** - * @return Update[] - */ - protected function determineUpdatesForVisit(Visit $visit): array - { - // Once the two deprecated cases below have been removed, make parent method private - if (! $this->options->legacyVisitsPublishing) { - return parent::determineUpdatesForVisit($visit); - } - - // This was defined incorrectly. - // According to the spec, both the visit and the short URL it belongs to, should be published. - // The shape should be ['visit' => [...], 'shortUrl' => ?[...]] - // However, this would be a breaking change, so we need a flag that determines the shape of the payload. - return $visit->isOrphan() - ? [ - Update::forTopicAndPayload( - Topic::NEW_ORPHAN_VISIT->value, - $this->orphanVisitTransformer->transform($visit), - ), - ] - : [ - Update::forTopicAndPayload(Topic::NEW_VISIT->value, $visit->jsonSerialize()), - Update::forTopicAndPayload( - Topic::newShortUrlVisit($visit->getShortUrl()?->getShortCode()), - $visit->jsonSerialize(), - ), - ]; - } - protected function isEnabled(): bool { return $this->options->enabled; diff --git a/module/Core/src/Options/RabbitMqOptions.php b/module/Core/src/Options/RabbitMqOptions.php index cc25f3bf..308dff2a 100644 --- a/module/Core/src/Options/RabbitMqOptions.php +++ b/module/Core/src/Options/RabbitMqOptions.php @@ -4,12 +4,10 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; -final class RabbitMqOptions +final readonly class RabbitMqOptions { public function __construct( - public readonly bool $enabled = false, - /** @deprecated */ - public readonly bool $legacyVisitsPublishing = false, + public bool $enabled = false, ) { } } diff --git a/module/Core/src/Options/WebhookOptions.php b/module/Core/src/Options/WebhookOptions.php deleted file mode 100644 index 7196fd0c..00000000 --- a/module/Core/src/Options/WebhookOptions.php +++ /dev/null @@ -1,41 +0,0 @@ -webhooks; - } - - public function hasWebhooks(): bool - { - return ! empty($this->webhooks); - } - - protected function setWebhooks(array $webhooks): void - { - $this->webhooks = $webhooks; - } - - public function notifyOrphanVisits(): bool - { - return $this->notifyOrphanVisitsToWebhooks; - } - - protected function setNotifyOrphanVisitsToWebhooks(bool $notifyOrphanVisitsToWebhooks): void - { - $this->notifyOrphanVisitsToWebhooks = $notifyOrphanVisitsToWebhooks; - } -} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlMode.php b/module/Core/src/ShortUrl/Model/ShortUrlMode.php index d359e8cc..19886657 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlMode.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlMode.php @@ -6,10 +6,4 @@ enum ShortUrlMode: string { case STRICT = 'strict'; case LOOSE = 'loose'; - - /** @deprecated */ - public static function tryDeprecated(string $mode): ?self - { - return $mode === 'loosely' ? self::LOOSE : self::tryFrom($mode); - } } diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index a6641998..d0661504 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -39,9 +39,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface $shortUrl->getVisitsCount(), $shortUrl->nonBotVisitsCount(), ), - - // Deprecated - 'visitsCount' => $shortUrl->getVisitsCount(), ]; } diff --git a/module/Core/src/Tag/Model/OrderableField.php b/module/Core/src/Tag/Model/OrderableField.php index b7a9509f..39092e4d 100644 --- a/module/Core/src/Tag/Model/OrderableField.php +++ b/module/Core/src/Tag/Model/OrderableField.php @@ -10,15 +10,13 @@ enum OrderableField: string case SHORT_URLS_COUNT = 'shortUrlsCount'; case VISITS = 'visits'; case NON_BOT_VISITS = 'nonBotVisits'; - /** @deprecated Use VISITS instead */ - case VISITS_COUNT = 'visitsCount'; - public static function toSnakeCaseValidField(?string $field): self + public static function toValidField(?string $field): self { - $parsed = $field !== null ? self::tryFrom($field) : self::TAG; - return match ($parsed) { - self::VISITS_COUNT, null => self::VISITS, - default => $parsed, - }; + if ($field === null) { + return self::TAG; + } + + return self::tryFrom($field) ?? self::TAG; } } diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 4c0018b2..504181ec 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -7,13 +7,13 @@ namespace Shlinkio\Shlink\Core\Tag\Model; use JsonSerializable; use Shlinkio\Shlink\Core\Visit\Model\VisitsSummary; -final class TagInfo implements JsonSerializable +final readonly class TagInfo implements JsonSerializable { - public readonly VisitsSummary $visitsSummary; + public VisitsSummary $visitsSummary; public function __construct( - public readonly string $tag, - public readonly int $shortUrlsCount, + public string $tag, + public int $shortUrlsCount, int $visitsCount, ?int $nonBotVisitsCount = null, ) { @@ -36,9 +36,6 @@ final class TagInfo implements JsonSerializable 'tag' => $this->tag, 'shortUrlsCount' => $this->shortUrlsCount, 'visitsSummary' => $this->visitsSummary, - - // Deprecated - 'visitsCount' => $this->visitsSummary->total, ]; } } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 3b1d84b2..422f9da1 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -14,8 +14,6 @@ final class TagsParams extends AbstractInfinitePaginableListParams private function __construct( public readonly ?string $searchTerm, public readonly Ordering $orderBy, - /** @deprecated */ - public readonly bool $withStats, ?int $page, ?int $itemsPerPage, ) { @@ -27,7 +25,6 @@ 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, ); diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index ce8b1f76..7f07e867 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -43,7 +43,7 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderField = OrderableField::toSnakeCaseValidField($filtering?->orderBy?->field); + $orderField = OrderableField::toValidField($filtering?->orderBy?->field); $orderDir = $filtering?->orderBy?->direction ?? 'ASC'; $apiKey = $filtering?->apiKey; $conn = $this->getEntityManager()->getConnection(); diff --git a/module/Core/src/Visit/Model/VisitsStats.php b/module/Core/src/Visit/Model/VisitsStats.php index adac34eb..22f05bd4 100644 --- a/module/Core/src/Visit/Model/VisitsStats.php +++ b/module/Core/src/Visit/Model/VisitsStats.php @@ -6,10 +6,10 @@ namespace Shlinkio\Shlink\Core\Visit\Model; use JsonSerializable; -final class VisitsStats implements JsonSerializable +final readonly class VisitsStats implements JsonSerializable { - private readonly VisitsSummary $nonOrphanVisitsSummary; - private readonly VisitsSummary $orphanVisitsSummary; + private VisitsSummary $nonOrphanVisitsSummary; + private VisitsSummary $orphanVisitsSummary; public function __construct( int $nonOrphanVisitsTotal, @@ -32,10 +32,6 @@ final class VisitsStats implements JsonSerializable return [ 'nonOrphanVisits' => $this->nonOrphanVisitsSummary, 'orphanVisits' => $this->orphanVisitsSummary, - - // Deprecated - 'visitsCount' => $this->nonOrphanVisitsSummary->total, - 'orphanVisitsCount' => $this->orphanVisitsSummary->total, ]; } } diff --git a/module/Core/test-api/Action/QrCodeTest.php b/module/Core/test-api/Action/QrCodeTest.php index 955e6c7e..21fd5147 100644 --- a/module/Core/test-api/Action/QrCodeTest.php +++ b/module/Core/test-api/Action/QrCodeTest.php @@ -10,7 +10,7 @@ use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; class QrCodeTest extends ApiTestCase { #[Test] - public function returnsNotFoundWhenShortUrlIsNotEnabled(): void + public function returnsQrCodeEvenIfShortUrlIsNotEnabled(): void { // The QR code successfully resolves at first $response = $this->callShortUrl('custom/qr-code'); @@ -20,8 +20,8 @@ class QrCodeTest extends ApiTestCase $this->callShortUrl('custom'); $this->callShortUrl('custom'); - // After 2 visits, the QR code should return a 404 - $response = $this->callShortUrl('custom/qr-code'); - self::assertEquals(404, $response->getStatusCode()); + // After 2 visits, the short URL returns a 404, but the QR code should still work + self::assertEquals(404, $this->callShortUrl('custom')->getStatusCode()); + self::assertEquals(200, $this->callShortUrl('custom/qr-code')->getStatusCode()); } } diff --git a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php index 6cccf199..77077142 100644 --- a/module/Core/test-db/Tag/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Tag/Repository/TagRepositoryTest.php @@ -197,13 +197,6 @@ class TagRepositoryTest extends DatabaseTestCase ['another', 0, 0, 0], ], ]; - yield 'visits count DESC ordering and limit' => [ - new TagsListFiltering(2, null, null, Ordering::fromTuple([OrderableField::VISITS_COUNT->value, 'DESC'])), - [ - ['foo', 2, 4, 3], - ['bar', 3, 3, 2], - ], - ]; yield 'api key' => [new TagsListFiltering(null, null, null, null, ApiKey::fromMeta( ApiKeyMeta::withRoles(RoleDefinition::forAuthoredShortUrls()), )), [ diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 9a89ff47..98e1e375 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -265,7 +265,7 @@ class QrCodeActionTest extends TestCase $this->urlResolver, new ShortUrlStringifier(['domain' => 's.test']), new NullLogger(), - $options ?? new QrCodeOptions(), + $options ?? new QrCodeOptions(enabledForDisabledShortUrls: false), ); } } diff --git a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php index 00f78fe4..cebde437 100644 --- a/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php +++ b/module/Core/test/EventDispatcher/Helper/EnabledListenerCheckerTest.php @@ -12,7 +12,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\Helper\EnabledListenerChecker; use Shlinkio\Shlink\Core\EventDispatcher\Matomo\SendVisitToMatomo; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyNewShortUrlToMercure; use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToWebHooks; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq; use Shlinkio\Shlink\Core\EventDispatcher\RabbitMq\NotifyVisitToRabbitMq; use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis; @@ -20,7 +19,6 @@ use Shlinkio\Shlink\Core\EventDispatcher\RedisPubSub\NotifyVisitToRedis; use Shlinkio\Shlink\Core\EventDispatcher\UpdateGeoLiteDb; use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Options\RabbitMqOptions; -use Shlinkio\Shlink\Core\Options\WebhookOptions; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; class EnabledListenerCheckerTest extends TestCase @@ -41,7 +39,6 @@ class EnabledListenerCheckerTest extends TestCase [NotifyVisitToMercure::class], [NotifyNewShortUrlToMercure::class], [SendVisitToMatomo::class], - [NotifyVisitToWebHooks::class], [UpdateGeoLiteDb::class], ]; } @@ -68,7 +65,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -79,7 +75,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => true, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -90,18 +85,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, - NotifyVisitToWebHooks::class => false, - UpdateGeoLiteDb::class => false, - 'unknown' => false, - ]]; - yield 'Webhooks' => [self::checker(webhooksEnabled: true), [ - NotifyVisitToRabbitMq::class => false, - NotifyNewShortUrlToRabbitMq::class => false, - NotifyVisitToRedis::class => false, - NotifyNewShortUrlToRedis::class => false, - NotifyVisitToMercure::class => false, - NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -112,7 +95,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; @@ -124,7 +106,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, SendVisitToMatomo::class => true, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -135,7 +116,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyNewShortUrlToRedis::class => false, NotifyVisitToMercure::class => false, NotifyNewShortUrlToMercure::class => false, - NotifyVisitToWebHooks::class => false, UpdateGeoLiteDb::class => false, 'unknown' => false, ]]; @@ -143,7 +123,6 @@ class EnabledListenerCheckerTest extends TestCase rabbitMqEnabled: true, redisPubSubEnabled: true, mercureEnabled: true, - webhooksEnabled: true, geoLiteEnabled: true, matomoEnabled: true, ), [ @@ -154,7 +133,6 @@ class EnabledListenerCheckerTest extends TestCase NotifyVisitToMercure::class => true, NotifyNewShortUrlToMercure::class => true, SendVisitToMatomo::class => true, - NotifyVisitToWebHooks::class => true, UpdateGeoLiteDb::class => true, 'unknown' => false, ]]; @@ -164,7 +142,6 @@ class EnabledListenerCheckerTest extends TestCase bool $rabbitMqEnabled = false, bool $redisPubSubEnabled = false, bool $mercureEnabled = false, - bool $webhooksEnabled = false, bool $geoLiteEnabled = false, bool $matomoEnabled = false, ): EnabledListenerChecker { @@ -172,7 +149,6 @@ class EnabledListenerCheckerTest extends TestCase new RabbitMqOptions(enabled: $rabbitMqEnabled), $redisPubSubEnabled, new MercureOptions(publicHubUrl: $mercureEnabled ? 'the-url' : null), - new WebhookOptions(['webhooks' => $webhooksEnabled ? ['foo', 'bar'] : []]), new GeoLite2Options(licenseKey: $geoLiteEnabled ? 'the-key' : null), new MatomoOptions(enabled: $matomoEnabled), ); diff --git a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php b/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php deleted file mode 100644 index 8b9c10ac..00000000 --- a/module/Core/test/EventDispatcher/NotifyVisitToWebHooksTest.php +++ /dev/null @@ -1,144 +0,0 @@ -httpClient = $this->createMock(ClientInterface::class); - $this->em = $this->createMock(EntityManagerInterface::class); - $this->logger = $this->createMock(LoggerInterface::class); - } - - #[Test] - public function emptyWebhooksMakeNoFurtherActions(): void - { - $this->em->expects($this->never())->method('find'); - - $this->createListener([])(new VisitLocated('1')); - } - - #[Test] - public function invalidVisitDoesNotPerformAnyRequest(): void - { - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn(null); - $this->httpClient->expects($this->never())->method('requestAsync'); - $this->logger->expects($this->once())->method('warning')->with( - 'Tried to notify webhooks for visit with id "{visitId}", but it does not exist.', - ['visitId' => '1'], - ); - - $this->createListener(['foo', 'bar'])(new VisitLocated('1')); - } - - #[Test] - public function orphanVisitDoesNotPerformAnyRequestWhenDisabled(): void - { - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn( - Visit::forBasePath(Visitor::emptyInstance()), - ); - $this->httpClient->expects($this->never())->method('requestAsync'); - $this->logger->expects($this->never())->method('warning'); - - $this->createListener(['foo', 'bar'], false)(new VisitLocated('1')); - } - - #[Test, DataProvider('provideVisits')] - public function expectedRequestsArePerformedToWebhooks(Visit $visit, array $expectedResponseKeys): void - { - $webhooks = ['foo', 'invalid', 'bar', 'baz']; - $invalidWebhooks = ['invalid', 'baz']; - - $this->em->expects($this->once())->method('find')->with(Visit::class, '1')->willReturn($visit); - $this->httpClient->expects($this->exactly(count($webhooks)))->method('requestAsync')->with( - RequestMethodInterface::METHOD_POST, - $this->istype('string'), - $this->callback(function (array $requestOptions) use ($expectedResponseKeys) { - Assert::assertArrayHasKey(RequestOptions::HEADERS, $requestOptions); - Assert::assertArrayHasKey(RequestOptions::JSON, $requestOptions); - Assert::assertArrayHasKey(RequestOptions::TIMEOUT, $requestOptions); - Assert::assertEquals(10, $requestOptions[RequestOptions::TIMEOUT]); - Assert::assertEquals(['User-Agent' => 'Shlink:v1.2.3'], $requestOptions[RequestOptions::HEADERS]); - - $json = $requestOptions[RequestOptions::JSON]; - Assert::assertCount(count($expectedResponseKeys), $json); - foreach ($expectedResponseKeys as $key) { - Assert::assertArrayHasKey($key, $json); - } - - return true; - }), - )->willReturnCallback(function ($_, $webhook) use ($invalidWebhooks) { - $shouldReject = contains($webhook, $invalidWebhooks); - return $shouldReject ? new RejectedPromise(new Exception('')) : new FulfilledPromise(''); - }); - $this->logger->expects($this->exactly(count($invalidWebhooks)))->method('warning')->with( - 'Failed to notify visit with id "{visitId}" to webhook "{webhook}". {e}', - $this->callback(function (array $extra): bool { - Assert::assertArrayHasKey('webhook', $extra); - Assert::assertArrayHasKey('visitId', $extra); - Assert::assertArrayHasKey('e', $extra); - - return true; - }), - ); - - $this->createListener($webhooks)(new VisitLocated('1')); - } - - public static function provideVisits(): iterable - { - yield 'regular visit' => [ - Visit::forValidShortUrl(ShortUrl::createFake(), Visitor::emptyInstance()), - ['shortUrl', 'visit'], - ]; - yield 'orphan visit' => [Visit::forBasePath(Visitor::emptyInstance()), ['visit']]; - } - - private function createListener(array $webhooks, bool $notifyOrphanVisits = true): NotifyVisitToWebHooks - { - return new NotifyVisitToWebHooks( - $this->httpClient, - $this->em, - $this->logger, - new WebhookOptions( - ['webhooks' => $webhooks, 'notify_orphan_visits_to_webhooks' => $notifyOrphanVisits], - ), - new ShortUrlDataTransformer(new ShortUrlStringifier([])), - new AppOptions('Shlink', '1.2.3'), - ); - } -} diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 9d28f2cd..c5ebb1a8 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -53,7 +53,6 @@ class PublishingUpdatesGeneratorTest extends TestCase 'longUrl' => 'https://longUrl', 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'visitsCount' => 0, 'tags' => [], 'meta' => [ 'validSince' => null, @@ -128,7 +127,6 @@ class PublishingUpdatesGeneratorTest extends TestCase 'longUrl' => 'https://longUrl', 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), - 'visitsCount' => 0, 'tags' => [], 'meta' => [ 'validSince' => null, diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php index e722bf25..7386169f 100644 --- a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -7,7 +7,6 @@ namespace ShlinkioTest\Shlink\Core\EventDispatcher\RabbitMq; use Doctrine\ORM\EntityManagerInterface; use DomainException; use Exception; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; @@ -24,7 +23,6 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Throwable; use function array_walk; @@ -132,9 +130,8 @@ class NotifyVisitToRabbitMqTest extends TestCase yield [new DomainException('DomainException Error')]; } - #[Test, DataProvider('provideLegacyPayloads')] + #[Test, DataProvider('providePayloads')] public function expectedPayloadIsPublishedDependingOnConfig( - bool $legacy, Visit $visit, callable $setup, callable $expect, @@ -144,44 +141,12 @@ class NotifyVisitToRabbitMqTest extends TestCase $setup($this->updatesGenerator); $expect($this->helper, $this->updatesGenerator); - ($this->listener(new RabbitMqOptions(true, $legacy)))(new VisitLocated($visitId)); + ($this->listener())(new VisitLocated($visitId)); } - public static function provideLegacyPayloads(): iterable + public static function providePayloads(): iterable { - yield 'legacy non-orphan visit' => [ - true, - $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), - static fn () => null, - function (MockObject & PublishingHelperInterface $helper) use ($visit): void { - $helper->method('publishUpdate')->with(self::callback(function (Update $update) use ($visit): bool { - $payload = $update->payload; - Assert::assertEquals($payload, $visit->jsonSerialize()); - Assert::assertArrayNotHasKey('visitedUrl', $payload); - Assert::assertArrayNotHasKey('type', $payload); - Assert::assertArrayNotHasKey('visit', $payload); - Assert::assertArrayNotHasKey('shortUrl', $payload); - - return true; - })); - }, - ]; - yield 'legacy orphan visit' => [ - true, - Visit::forBasePath(Visitor::emptyInstance()), - static fn () => null, - function (MockObject & PublishingHelperInterface $helper): void { - $helper->method('publishUpdate')->with(self::callback(function (Update $update): bool { - $payload = $update->payload; - Assert::assertArrayHasKey('visitedUrl', $payload); - Assert::assertArrayHasKey('type', $payload); - - return true; - })); - }, - ]; - yield 'non-legacy non-orphan visit' => [ - false, + yield 'non-orphan visit' => [ Visit::forValidShortUrl(ShortUrl::withLongUrl('https://longUrl'), Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); @@ -195,8 +160,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $helper->expects(self::exactly(2))->method('publishUpdate')->with(self::isInstanceOf(Update::class)); }, ]; - yield 'non-legacy orphan visit' => [ - false, + yield 'orphan visit' => [ Visit::forBasePath(Visitor::emptyInstance()), function (MockObject & PublishingUpdatesGeneratorInterface $updatesGenerator): void { $update = Update::forTopicAndPayload('', []); @@ -217,8 +181,7 @@ class NotifyVisitToRabbitMqTest extends TestCase $this->updatesGenerator, $this->em, $this->logger, - new OrphanVisitDataTransformer(), - $options ?? new RabbitMqOptions(enabled: true, legacyVisitsPublishing: false), + $options ?? new RabbitMqOptions(enabled: true), ); } } diff --git a/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php b/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php deleted file mode 100644 index f2ca7cce..00000000 --- a/module/Core/test/ShortUrl/Model/ShortUrlModeTest.php +++ /dev/null @@ -1,28 +0,0 @@ - ['invalid', null]; - yield 'foo' => ['foo', null]; - yield 'loose' => ['loose', ShortUrlMode::LOOSE]; - yield 'loosely' => ['loosely', ShortUrlMode::LOOSE]; - yield 'strict' => ['strict', ShortUrlMode::STRICT]; - } -} diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index acca571d..67343e27 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -55,7 +55,6 @@ return [ Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class => ConfigAbstractFactory::class, Middleware\ShortUrl\OverrideDomainMiddleware::class => ConfigAbstractFactory::class, Middleware\Mercure\NotConfiguredMercureErrorHandler::class => ConfigAbstractFactory::class, - Middleware\ErrorHandler\BackwardsCompatibleProblemDetailsHandler::class => InvokableFactory::class, ], ], diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index 9674d5bc..13898584 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -8,14 +8,11 @@ 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; -use function array_map; - class ListTagsAction extends AbstractRestAction { use PagerfantaUtilsTrait; @@ -32,17 +29,8 @@ class ListTagsAction extends AbstractRestAction $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - if (! $params->withStats) { - return new JsonResponse([ - 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), - ]); - } - - // 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, dataProp: 'stats'); - $rawTags['data'] = array_map(static fn (TagInfo $info) => $info->tag, [...$tagsInfo]); - - return new JsonResponse(['tags' => $rawTags]); + return new JsonResponse([ + 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), + ]); } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index dd2d8ae7..4f3685db 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -40,8 +40,8 @@ enum Role: string public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { - return match ($role->role()) { - self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey(), $context), + return match ($role->role) { + self::AUTHORED_SHORT_URLS => new BelongsToApiKey($role->apiKey, $context), self::DOMAIN_SPECIFIC => new BelongsToDomain(self::domainIdFromMeta($role->meta()), $context), default => Spec::andX(), }; @@ -49,8 +49,8 @@ enum Role: string public static function toInlinedSpec(ApiKeyRole $role): Specification { - return match ($role->role()) { - self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey())), + return match ($role->role) { + self::AUTHORED_SHORT_URLS => Spec::andX(new BelongsToApiKeyInlined($role->apiKey)), self::DOMAIN_SPECIFIC => Spec::andX(new BelongsToDomainInlined(self::domainIdFromMeta($role->meta()))), default => Spec::andX(), }; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index dae30de0..9ad3fcf4 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -156,7 +156,7 @@ class ApiKey extends AbstractEntity */ public function mapRoles(callable $fun): array { - return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role(), $role->meta()))->getValues(); + return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role, $role->meta()))->getValues(); } public function registerRole(RoleDefinition $roleDefinition): void diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 6fadb839..5053b74d 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -13,22 +13,6 @@ class ApiKeyRole extends AbstractEntity { } - /** - * @deprecated Use property access directly - */ - public function role(): Role - { - return $this->role; - } - - /** - * @deprecated Use property access directly - */ - public function apiKey(): ApiKey - { - return $this->apiKey; - } - public function meta(): array { return $this->meta; diff --git a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php b/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php deleted file mode 100644 index 8cfb918c..00000000 --- a/module/Rest/src/Exception/BackwardsCompatibleProblemDetailsException.php +++ /dev/null @@ -1,99 +0,0 @@ -getMessage(), $e->getCode(), $e); - } - - public static function fromProblemDetails(ProblemDetailsExceptionInterface $e): self - { - return new self($e); - } - - public function getStatus(): int - { - return $this->e->getStatus(); - } - - public function getType(): string - { - return $this->remapType($this->e->getType()); - } - - public function getTitle(): string - { - return $this->e->getTitle(); - } - - public function getDetail(): string - { - return $this->e->getDetail(); - } - - public function getAdditionalData(): array - { - return $this->e->getAdditionalData(); - } - - public function toArray(): array - { - return $this->remapTypeInArray($this->e->toArray()); - } - - public function jsonSerialize(): array - { - return $this->remapTypeInArray($this->e->jsonSerialize()); - } - - private function remapTypeInArray(array $wrappedArray): array - { - if (! isset($wrappedArray['type'])) { - return $wrappedArray; - } - - return [...$wrappedArray, 'type' => $this->remapType($wrappedArray['type'])]; - } - - private function remapType(string $wrappedType): string - { - $segments = explode('/', $wrappedType); - $lastSegment = end($segments); - - return match ($lastSegment) { - ValidationException::ERROR_CODE => 'INVALID_ARGUMENT', - DeleteShortUrlException::ERROR_CODE => 'INVALID_SHORT_URL_DELETION', - DomainNotFoundException::ERROR_CODE => 'DOMAIN_NOT_FOUND', - ForbiddenTagOperationException::ERROR_CODE => 'FORBIDDEN_OPERATION', - InvalidUrlException::ERROR_CODE => 'INVALID_URL', - NonUniqueSlugException::ERROR_CODE => 'INVALID_SLUG', - ShortUrlNotFoundException::ERROR_CODE => 'INVALID_SHORTCODE', - TagConflictException::ERROR_CODE => 'TAG_CONFLICT', - TagNotFoundException::ERROR_CODE => 'TAG_NOT_FOUND', - MercureException::ERROR_CODE => 'MERCURE_NOT_CONFIGURED', - MissingAuthenticationException::ERROR_CODE => 'INVALID_AUTHORIZATION', - VerifyAuthenticationException::ERROR_CODE => 'INVALID_API_KEY', - default => $wrappedType, - }; - } -} diff --git a/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php b/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php deleted file mode 100644 index c099ad70..00000000 --- a/module/Rest/src/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandler.php +++ /dev/null @@ -1,30 +0,0 @@ -handle($request); - } catch (ProblemDetailsExceptionInterface $e) { - $version = $request->getAttribute('version') ?? '2'; - throw version_compare($version, '3', '>=') - ? $e - : BackwardsCompatibleProblemDetailsException::fromProblemDetails($e); - } - } -} diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 01592129..efd70666 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -19,7 +19,7 @@ class CreateShortUrlTest extends ApiTestCase #[Test] public function createsNewShortUrlWhenOnlyLongUrlIsProvided(): void { - $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'visitsCount', 'tags']; + $expectedKeys = ['shortCode', 'shortUrl', 'longUrl', 'dateCreated', 'tags']; [$statusCode, $payload] = $this->createShortUrl(); self::assertEquals(self::STATUS_OK, $statusCode); @@ -48,7 +48,7 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); self::assertEquals($detail, $payload['detail']); - self::assertEquals('INVALID_SLUG', $payload['type']); + self::assertEquals('https://shlink.io/api/error/non-unique-slug', $payload['type']); self::assertEquals('Invalid custom slug', $payload['title']); self::assertEquals($slug, $payload['customSlug']); @@ -70,8 +70,8 @@ class CreateShortUrlTest extends ApiTestCase public static function provideDuplicatedSlugApiVersions(): iterable { - yield ['1', 'INVALID_SLUG']; - yield ['2', 'INVALID_SLUG']; + yield ['1', 'https://shlink.io/api/error/non-unique-slug']; + yield ['2', 'https://shlink.io/api/error/non-unique-slug']; yield ['3', 'https://shlink.io/api/error/non-unique-slug']; } @@ -241,7 +241,7 @@ class CreateShortUrlTest extends ApiTestCase public static function provideInvalidUrls(): iterable { - yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'INVALID_URL']; + yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'https://shlink.io/api/error/invalid-url']; yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url']; } @@ -264,18 +264,18 @@ class CreateShortUrlTest extends ApiTestCase public static function provideInvalidArgumentApiVersions(): iterable { - yield 'missing long url v2' => [[], '2', 'INVALID_ARGUMENT']; + yield 'missing long url v2' => [[], '2', 'https://shlink.io/api/error/invalid-data']; yield 'missing long url v3' => [[], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'empty long url v2' => [['longUrl' => null], '2', 'INVALID_ARGUMENT']; + yield 'empty long url v2' => [['longUrl' => null], '2', 'https://shlink.io/api/error/invalid-data']; yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'INVALID_ARGUMENT']; + yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'https://shlink.io/api/error/invalid-data']; yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3', 'https://shlink.io/api/error/invalid-data']; yield 'empty device long url v2' => [[ 'longUrl' => 'foo', 'deviceLongUrls' => [ 'android' => null, ], - ], '2', 'INVALID_ARGUMENT']; + ], '2', 'https://shlink.io/api/error/invalid-data']; yield 'empty device long url v3' => [[ 'longUrl' => 'foo', 'deviceLongUrls' => [ diff --git a/module/Rest/test-api/Action/DeleteShortUrlTest.php b/module/Rest/test-api/Action/DeleteShortUrlTest.php index 7bd3dfea..06848c48 100644 --- a/module/Rest/test-api/Action/DeleteShortUrlTest.php +++ b/module/Rest/test-api/Action/DeleteShortUrlTest.php @@ -31,7 +31,7 @@ class DeleteShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); @@ -52,8 +52,8 @@ class DeleteShortUrlTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield ['1', 'INVALID_SHORTCODE']; - yield ['2', 'INVALID_SHORTCODE']; + yield ['1', 'https://shlink.io/api/error/short-url-not-found']; + yield ['2', 'https://shlink.io/api/error/short-url-not-found']; yield ['3', 'https://shlink.io/api/error/short-url-not-found']; } diff --git a/module/Rest/test-api/Action/DeleteTagsTest.php b/module/Rest/test-api/Action/DeleteTagsTest.php index b04fbaf5..a269d2db 100644 --- a/module/Rest/test-api/Action/DeleteTagsTest.php +++ b/module/Rest/test-api/Action/DeleteTagsTest.php @@ -30,8 +30,8 @@ class DeleteTagsTest extends ApiTestCase public static function provideNonAdminApiKeys(): iterable { - yield 'author' => ['author_api_key', '2', 'FORBIDDEN_OPERATION']; - yield 'domain' => ['domain_api_key', '2', 'FORBIDDEN_OPERATION']; + yield 'author' => ['author_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation']; + yield 'domain' => ['domain_api_key', '2', 'https://shlink.io/api/error/forbidden-tag-operation']; yield 'version 3' => ['domain_api_key', '3', 'https://shlink.io/api/error/forbidden-tag-operation']; } } diff --git a/module/Rest/test-api/Action/DomainRedirectsTest.php b/module/Rest/test-api/Action/DomainRedirectsTest.php index bc78d035..d97092d6 100644 --- a/module/Rest/test-api/Action/DomainRedirectsTest.php +++ b/module/Rest/test-api/Action/DomainRedirectsTest.php @@ -21,7 +21,7 @@ class DomainRedirectsTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals('Provided data is not valid', $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } diff --git a/module/Rest/test-api/Action/DomainVisitsTest.php b/module/Rest/test-api/Action/DomainVisitsTest.php index 2c1d1d2e..3a06257b 100644 --- a/module/Rest/test-api/Action/DomainVisitsTest.php +++ b/module/Rest/test-api/Action/DomainVisitsTest.php @@ -49,7 +49,7 @@ class DomainVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('DOMAIN_NOT_FOUND', $payload['type']); + self::assertEquals('https://shlink.io/api/error/domain-not-found', $payload['type']); self::assertEquals(sprintf('Domain with authority "%s" could not be found', $domain), $payload['detail']); self::assertEquals('Domain not found', $payload['title']); self::assertEquals($domain, $payload['authority']); @@ -73,8 +73,8 @@ class DomainVisitsTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield ['1', 'DOMAIN_NOT_FOUND']; - yield ['2', 'DOMAIN_NOT_FOUND']; + yield ['1', 'https://shlink.io/api/error/domain-not-found']; + yield ['2', 'https://shlink.io/api/error/domain-not-found']; yield ['3', 'https://shlink.io/api/error/domain-not-found']; } } diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index a55fb066..89055adb 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -96,7 +96,7 @@ class EditShortUrlTest extends ApiTestCase public static function provideLongUrls(): iterable { yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null]; - yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'INVALID_URL']; + yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'https://shlink.io/api/error/invalid-url']; } #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] @@ -112,7 +112,7 @@ class EditShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); @@ -131,7 +131,7 @@ class EditShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } diff --git a/module/Rest/test-api/Action/GlobalVisitsTest.php b/module/Rest/test-api/Action/GlobalVisitsTest.php index 657f16a6..30c880d5 100644 --- a/module/Rest/test-api/Action/GlobalVisitsTest.php +++ b/module/Rest/test-api/Action/GlobalVisitsTest.php @@ -17,10 +17,8 @@ class GlobalVisitsTest extends ApiTestCase $payload = $this->getJsonResponsePayload($resp); self::assertArrayHasKey('visits', $payload); - self::assertArrayHasKey('visitsCount', $payload['visits']); - self::assertArrayHasKey('orphanVisitsCount', $payload['visits']); - self::assertEquals($expectedVisits, $payload['visits']['visitsCount']); - self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisitsCount']); + self::assertEquals($expectedVisits, $payload['visits']['nonOrphanVisits']['total']); + self::assertEquals($expectedOrphanVisits, $payload['visits']['orphanVisits']['total']); } public static function provideApiKeys(): iterable diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index bb6296f7..3591ea60 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -20,7 +20,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/abc123', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2018-05-01T00:00:00+00:00', - 'visitsCount' => 3, 'visitsSummary' => [ 'total' => 3, 'nonBots' => 3, @@ -42,7 +41,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/ghi789', 'longUrl' => 'https://shlink.io/documentation/', 'dateCreated' => '2018-05-01T00:00:00+00:00', - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 2, @@ -64,7 +62,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://some-domain.com/custom-with-domain', 'longUrl' => 'https://google.com', 'dateCreated' => '2018-10-20T00:00:00+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -88,7 +85,6 @@ class ListShortUrlsTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2017/12/09' . '/acmailer-7-0-the-most-important-release-in-a-long-time/', 'dateCreated' => '2019-01-01T00:00:10+00:00', - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -110,7 +106,6 @@ class ListShortUrlsTest extends ApiTestCase 'shortUrl' => 'http://s.test/custom', 'longUrl' => 'https://shlink.io', 'dateCreated' => '2019-01-01T00:00:20+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -134,7 +129,6 @@ class ListShortUrlsTest extends ApiTestCase 'https://blog.alejandrocelaya.com/2019/04/27' . '/considerations-to-properly-use-open-source-software-projects/', 'dateCreated' => '2019-01-01T00:00:30+00:00', - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -310,7 +304,7 @@ class ListShortUrlsTest extends ApiTestCase self::assertEquals([ 'invalidElements' => $expectedInvalidElements, 'title' => 'Invalid data', - 'type' => 'INVALID_ARGUMENT', + 'type' => 'https://shlink.io/api/error/invalid-data', 'status' => 400, 'detail' => 'Provided data is not valid', ], $respPayload); diff --git a/module/Rest/test-api/Action/RenameTagTest.php b/module/Rest/test-api/Action/RenameTagTest.php index e401da1d..35a3e1b2 100644 --- a/module/Rest/test-api/Action/RenameTagTest.php +++ b/module/Rest/test-api/Action/RenameTagTest.php @@ -24,7 +24,7 @@ class RenameTagTest extends ApiTestCase self::assertEquals(self::STATUS_FORBIDDEN, $resp->getStatusCode()); self::assertEquals(self::STATUS_FORBIDDEN, $payload['status']); - self::assertEquals('FORBIDDEN_OPERATION', $payload['type']); + self::assertEquals('https://shlink.io/api/error/forbidden-tag-operation', $payload['type']); self::assertEquals('You are not allowed to rename tags', $payload['detail']); self::assertEquals('Forbidden tag operation', $payload['title']); } diff --git a/module/Rest/test-api/Action/ResolveShortUrlTest.php b/module/Rest/test-api/Action/ResolveShortUrlTest.php index c10abc74..0c0ce5ec 100644 --- a/module/Rest/test-api/Action/ResolveShortUrlTest.php +++ b/module/Rest/test-api/Action/ResolveShortUrlTest.php @@ -58,7 +58,7 @@ class ResolveShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); diff --git a/module/Rest/test-api/Action/ShortUrlVisitsTest.php b/module/Rest/test-api/Action/ShortUrlVisitsTest.php index 6a7e6a7e..8db002c4 100644 --- a/module/Rest/test-api/Action/ShortUrlVisitsTest.php +++ b/module/Rest/test-api/Action/ShortUrlVisitsTest.php @@ -34,7 +34,7 @@ class ShortUrlVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('INVALID_SHORTCODE', $payload['type']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Short URL not found', $payload['title']); self::assertEquals($shortCode, $payload['shortCode']); diff --git a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php index faed281d..038e3f38 100644 --- a/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php +++ b/module/Rest/test-api/Action/SingleStepCreateShortUrlTest.php @@ -38,7 +38,7 @@ class SingleStepCreateShortUrlTest extends ApiTestCase self::assertEquals(self::STATUS_UNAUTHORIZED, $resp->getStatusCode()); self::assertEquals(self::STATUS_UNAUTHORIZED, $payload['status']); - self::assertEquals('INVALID_AUTHORIZATION', $payload['type']); + self::assertEquals('https://shlink.io/api/error/missing-authentication', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid authorization', $payload['title']); } diff --git a/module/Rest/test-api/Action/TagVisitsTest.php b/module/Rest/test-api/Action/TagVisitsTest.php index fc54c111..c51f02fb 100644 --- a/module/Rest/test-api/Action/TagVisitsTest.php +++ b/module/Rest/test-api/Action/TagVisitsTest.php @@ -53,7 +53,7 @@ class TagVisitsTest extends ApiTestCase self::assertEquals(self::STATUS_NOT_FOUND, $resp->getStatusCode()); self::assertEquals(self::STATUS_NOT_FOUND, $payload['status']); - self::assertEquals('TAG_NOT_FOUND', $payload['type']); + self::assertEquals('https://shlink.io/api/error/tag-not-found', $payload['type']); self::assertEquals(sprintf('Tag with name "%s" could not be found', $tag), $payload['detail']); self::assertEquals('Tag not found', $payload['title']); } diff --git a/module/Rest/test-api/Action/TagsStatsTest.php b/module/Rest/test-api/Action/TagsStatsTest.php index 4ee94d42..9bf01474 100644 --- a/module/Rest/test-api/Action/TagsStatsTest.php +++ b/module/Rest/test-api/Action/TagsStatsTest.php @@ -25,29 +25,12 @@ class TagsStatsTest extends ApiTestCase 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 static function provideQueries(): iterable { yield 'admin API key' => ['valid_api_key', [], [ [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -57,7 +40,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'baz', 'shortUrlsCount' => 0, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -67,7 +49,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 3, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -85,7 +66,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -95,7 +75,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'baz', 'shortUrlsCount' => 0, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, @@ -113,7 +92,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'bar', 'shortUrlsCount' => 1, - 'visitsCount' => 2, 'visitsSummary' => [ 'total' => 2, 'nonBots' => 1, @@ -123,7 +101,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 2, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -141,7 +118,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 2, - 'visitsCount' => 5, 'visitsSummary' => [ 'total' => 5, 'nonBots' => 4, @@ -159,7 +135,6 @@ class TagsStatsTest extends ApiTestCase [ 'tag' => 'foo', 'shortUrlsCount' => 1, - 'visitsCount' => 0, 'visitsSummary' => [ 'total' => 0, 'nonBots' => 0, diff --git a/module/Rest/test-api/Action/UpdateTagTest.php b/module/Rest/test-api/Action/UpdateTagTest.php index 96b8ed62..3bced135 100644 --- a/module/Rest/test-api/Action/UpdateTagTest.php +++ b/module/Rest/test-api/Action/UpdateTagTest.php @@ -23,7 +23,7 @@ class UpdateTagTest extends ApiTestCase self::assertEquals(self::STATUS_BAD_REQUEST, $resp->getStatusCode()); self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals('INVALID_ARGUMENT', $payload['type']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); self::assertEquals($expectedDetail, $payload['detail']); self::assertEquals('Invalid data', $payload['title']); } @@ -55,8 +55,8 @@ class UpdateTagTest extends ApiTestCase public static function provideTagNotFoundApiVersions(): iterable { - yield 'version 1' => ['1', 'TAG_NOT_FOUND']; - yield 'version 2' => ['2', 'TAG_NOT_FOUND']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-not-found']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-not-found']; yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-not-found']; } @@ -80,8 +80,8 @@ class UpdateTagTest extends ApiTestCase public static function provideTagConflictsApiVersions(): iterable { - yield 'version 1' => ['1', 'TAG_CONFLICT']; - yield 'version 2' => ['2', 'TAG_CONFLICT']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/tag-conflict']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/tag-conflict']; yield 'version 3' => ['3', 'https://shlink.io/api/error/tag-conflict']; } diff --git a/module/Rest/test-api/Action/VisitStatsTest.php b/module/Rest/test-api/Action/VisitStatsTest.php index 10a4de0c..2adf5a6a 100644 --- a/module/Rest/test-api/Action/VisitStatsTest.php +++ b/module/Rest/test-api/Action/VisitStatsTest.php @@ -32,8 +32,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 7, - 'orphanVisitsCount' => 3, ]]; yield 'domain-only API key' => ['domain_api_key', [ 'nonOrphanVisits' => [ @@ -46,8 +44,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 0, - 'orphanVisitsCount' => 3, ]]; yield 'author API key' => ['author_api_key', [ 'nonOrphanVisits' => [ @@ -60,8 +56,6 @@ class VisitStatsTest extends ApiTestCase 'nonBots' => 2, 'bots' => 1, ], - 'visitsCount' => 5, - 'orphanVisitsCount' => 3, ]]; } } diff --git a/module/Rest/test-api/Middleware/AuthenticationTest.php b/module/Rest/test-api/Middleware/AuthenticationTest.php index d086f6a6..1c164c85 100644 --- a/module/Rest/test-api/Middleware/AuthenticationTest.php +++ b/module/Rest/test-api/Middleware/AuthenticationTest.php @@ -29,8 +29,8 @@ class AuthenticationTest extends ApiTestCase public static function provideApiVersions(): iterable { - yield 'version 1' => ['1', 'INVALID_AUTHORIZATION']; - yield 'version 2' => ['2', 'INVALID_AUTHORIZATION']; + yield 'version 1' => ['1', 'https://shlink.io/api/error/missing-authentication']; + yield 'version 2' => ['2', 'https://shlink.io/api/error/missing-authentication']; yield 'version 3' => ['3', 'https://shlink.io/api/error/missing-authentication']; } @@ -58,9 +58,9 @@ class AuthenticationTest extends ApiTestCase public static function provideInvalidApiKeys(): iterable { - yield 'key which does not exist' => ['invalid', '2', 'INVALID_API_KEY']; - yield 'key which is expired' => ['expired_api_key', '2', 'INVALID_API_KEY']; - yield 'key which is disabled' => ['disabled_api_key', '2', 'INVALID_API_KEY']; + yield 'key which does not exist' => ['invalid', '2', 'https://shlink.io/api/error/invalid-api-key']; + yield 'key which is expired' => ['expired_api_key', '2', 'https://shlink.io/api/error/invalid-api-key']; + yield 'key which is disabled' => ['disabled_api_key', '2', 'https://shlink.io/api/error/invalid-api-key']; yield 'version 3' => ['disabled_api_key', '3', 'https://shlink.io/api/error/invalid-api-key']; } } diff --git a/module/Rest/test/Action/Tag/ListTagsActionTest.php b/module/Rest/test/Action/Tag/ListTagsActionTest.php index 447e8331..a63041dd 100644 --- a/module/Rest/test/Action/Tag/ListTagsActionTest.php +++ b/module/Rest/test/Action/Tag/ListTagsActionTest.php @@ -7,14 +7,12 @@ namespace ShlinkioTest\Shlink\Rest\Action\Tag; use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\ServerRequestFactory; use Pagerfanta\Adapter\ArrayAdapter; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Core\Tag\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; @@ -32,8 +30,8 @@ class ListTagsActionTest extends TestCase $this->action = new ListTagsAction($this->tagService); } - #[Test, DataProvider('provideNoStatsQueries')] - public function returnsBaseDataWhenStatsAreNotRequested(array $query): void + #[Test] + public function returnsBaseDataWhenStatsAreNotRequested(): void { $tags = [new Tag('foo'), new Tag('bar')]; $tagsCount = count($tags); @@ -43,7 +41,7 @@ class ListTagsActionTest extends TestCase )->willReturn(new Paginator(new ArrayAdapter($tags))); /** @var JsonResponse $resp */ - $resp = $this->action->handle($this->requestWithApiKey()->withQueryParams($query)); + $resp = $this->action->handle($this->requestWithApiKey()); $payload = $resp->getPayload(); self::assertEquals([ @@ -60,46 +58,6 @@ class ListTagsActionTest extends TestCase ], $payload); } - public static function provideNoStatsQueries(): iterable - { - yield 'no query' => [[]]; - yield 'withStats is false' => [['withStats' => 'withStats']]; - yield 'withStats is something else' => [['withStats' => 'foo']]; - } - - #[Test] - public function returnsStatsWhenRequested(): void - { - $stats = [ - new TagInfo('foo', 1, 1), - new TagInfo('bar', 3, 10), - ]; - $itemsCount = count($stats); - $this->tagService->expects($this->once())->method('tagsInfo')->with( - $this->anything(), - $this->isInstanceOf(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' => ['foo', 'bar'], - 'stats' => $stats, - 'pagination' => [ - 'currentPage' => 1, - 'pagesCount' => 1, - 'itemsPerPage' => 10, - 'itemsInCurrentPage' => $itemsCount, - 'totalItems' => $itemsCount, - ], - ], - ], $payload); - } - private function requestWithApiKey(): ServerRequestInterface { return ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); diff --git a/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php b/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php deleted file mode 100644 index e51a5ac1..00000000 --- a/module/Rest/test/Exception/BackwardsCompatibleProblemDetailsExceptionTest.php +++ /dev/null @@ -1,113 +0,0 @@ -type; - } - - public function getTitle(): string - { - return 'title'; - } - - public function getDetail(): string - { - return 'detail'; - } - - public function getAdditionalData(): array - { - return []; - } - - public function toArray(): array - { - return ['type' => $this->type]; - } - - public function jsonSerialize(): array - { - return ['type' => $this->type]; - } - }; - $e = BackwardsCompatibleProblemDetailsException::fromProblemDetails($original); - - self::assertEquals($e->getType(), $expectedType); - self::assertEquals($e->toArray(), ['type' => $expectedType]); - self::assertEquals($e->jsonSerialize(), ['type' => $expectedType]); - - self::assertEquals($original->getTitle(), $e->getTitle()); - self::assertEquals($original->getDetail(), $e->getDetail()); - self::assertEquals($original->getAdditionalData(), $e->getAdditionalData()); - - if ($expectSameType) { - self::assertEquals($original->getType(), $e->getType()); - self::assertEquals($original->toArray(), $e->toArray()); - self::assertEquals($original->jsonSerialize(), $e->jsonSerialize()); - } else { - self::assertNotEquals($original->getType(), $e->getType()); - self::assertNotEquals($original->toArray(), $e->toArray()); - self::assertNotEquals($original->jsonSerialize(), $e->jsonSerialize()); - } - } - - public static function provideTypes(): iterable - { - yield ['foo', 'foo', true]; - yield ['bar', 'bar', true]; - yield [ValidationException::ERROR_CODE, 'INVALID_ARGUMENT']; - yield [DeleteShortUrlException::ERROR_CODE, 'INVALID_SHORT_URL_DELETION']; - yield [DomainNotFoundException::ERROR_CODE, 'DOMAIN_NOT_FOUND']; - yield [ForbiddenTagOperationException::ERROR_CODE, 'FORBIDDEN_OPERATION']; - yield [InvalidUrlException::ERROR_CODE, 'INVALID_URL']; - yield [NonUniqueSlugException::ERROR_CODE, 'INVALID_SLUG']; - yield [ShortUrlNotFoundException::ERROR_CODE, 'INVALID_SHORTCODE']; - yield [TagConflictException::ERROR_CODE, 'TAG_CONFLICT']; - yield [TagNotFoundException::ERROR_CODE, 'TAG_NOT_FOUND']; - yield [MercureException::ERROR_CODE, 'MERCURE_NOT_CONFIGURED']; - yield [MissingAuthenticationException::ERROR_CODE, 'INVALID_AUTHORIZATION']; - yield [VerifyAuthenticationException::ERROR_CODE, 'INVALID_API_KEY']; - } -} diff --git a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php b/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php deleted file mode 100644 index 78862980..00000000 --- a/module/Rest/test/Middleware/ErrorHandler/BackwardsCompatibleProblemDetailsHandlerTest.php +++ /dev/null @@ -1,74 +0,0 @@ -handler = new BackwardsCompatibleProblemDetailsHandler(); - } - - /** - * @param class-string $expectedException - */ - #[Test, DataProvider('provideExceptions')] - public function expectedExceptionIsThrownBasedOnTheRequestVersion( - ServerRequestInterface $request, - Throwable $thrownException, - string $expectedException, - ): void { - $handler = $this->createMock(RequestHandlerInterface::class); - $handler->expects($this->once())->method('handle')->with($request)->willThrowException($thrownException); - - $this->expectException($expectedException); - - $this->handler->process($request, $handler); - } - - public static function provideExceptions(): iterable - { - $baseRequest = ServerRequestFactory::fromGlobals(); - - yield 'no version' => [ - $baseRequest, - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 1' => [ - $baseRequest->withAttribute('version', '1'), - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 2' => [ - $baseRequest->withAttribute('version', '2'), - ValidationException::fromArray([]), - BackwardsCompatibleProblemDetailsException::class, - ]; - yield 'version 3' => [ - $baseRequest->withAttribute('version', '3'), - ValidationException::fromArray([]), - ValidationException::class, - ]; - yield 'version 4' => [ - $baseRequest->withAttribute('version', '3'), - ValidationException::fromArray([]), - ValidationException::class, - ]; - } -} From 13ec27039d2219cd734a0b3b42e96fd1fe9d4674 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Feb 2024 08:55:22 +0100 Subject: [PATCH 14/97] Ensure non-root user in Dockerfile --- .github/workflows/publish-docker-image.yml | 8 -------- Dockerfile | 9 ++++----- 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/.github/workflows/publish-docker-image.yml b/.github/workflows/publish-docker-image.yml index ee9276fd..a57ebe41 100644 --- a/.github/workflows/publish-docker-image.yml +++ b/.github/workflows/publish-docker-image.yml @@ -15,13 +15,6 @@ jobs: - runtime: 'rr' tag-suffix: 'roadrunner' platforms: 'linux/arm64/v8,linux/amd64' - - runtime: 'openswoole' - tag-suffix: 'openswoole' - platforms: 'linux/arm/v7,linux/arm64/v8,linux/amd64' - - runtime: 'rr' - tag-suffix: 'non-root' - platforms: 'linux/arm64/v8,linux/amd64' - user-id: '1001' uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main secrets: inherit with: @@ -31,4 +24,3 @@ jobs: tags-suffix: ${{ matrix.tag-suffix }} extra-build-args: | SHLINK_RUNTIME=${{ matrix.runtime }} - SHLINK_USER_ID=${{ matrix.user-id && matrix.user-id || 'root' }} diff --git a/Dockerfile b/Dockerfile index 34d6d7ef..4ad94dc9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,9 +4,8 @@ ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=rr ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ARG SHLINK_USER_ID='root' -ENV SHLINK_USER_ID ${SHLINK_USER_ID} +ENV USER_ID '1001' ENV PDO_SQLSRV_VERSION 5.12.0 ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 @@ -41,7 +40,7 @@ FROM base as builder COPY . . COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ - php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ + php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \ php composer.phar clear-cache && \ rm -r docker composer.* && \ sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php @@ -51,7 +50,7 @@ RUN apk add --no-cache git && \ FROM base LABEL maintainer="Alejandro Celaya " -COPY --from=builder --chown=${SHLINK_USER_ID} /etc/shlink . +COPY --from=builder --chown=${USER_ID} /etc/shlink . RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink && \ if [ "$SHLINK_RUNTIME" == 'rr' ]; then \ php ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr ; \ @@ -65,6 +64,6 @@ COPY docker/docker-entrypoint.sh docker-entrypoint.sh COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php COPY docker/config/php.ini ${PHP_INI_DIR}/conf.d/ -USER ${SHLINK_USER_ID} +USER ${USER_ID} ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"] From f2edb54b8b427a804ae4fd31e68277774aa3a0d0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Feb 2024 09:27:54 +0100 Subject: [PATCH 15/97] Document changes from v3 to v4 --- UPGRADE.md | 40 +++++++++++++++++++++++++++++++++ docs/swagger/paths/v1_tags.json | 21 ----------------- 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 6bef9dbc..0af19f77 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,45 @@ # Upgrading +## From v3.x to v4.x + +### General + +* Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner. +* Dist files for swoole/openswoole are no longer published. +* Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms. + +### Changes in URL shortener + +* The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead. +* QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations. + If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. + +### Changes in REST API + +* REST API v1/v2 now behave like v3. This only affects error codes, which are now proper URIs. + * `INVALID_ARGUMENT` -> `https://shlink.io/api/error/invalid-data` + * `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion` + * `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found` + * `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation` + * `INVALID_URL` -> `https://shlink.io/api/error/invalid-url` + * `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug` + * `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found` + * `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict` + * `TAG_NOT_FOUND` -> `https://shlink.io/api/error/tag-not-found` + * `MERCURE_NOT_CONFIGURED` -> `https://shlink.io/api/error/mercure-not-configured` + * `INVALID_AUTHORIZATION` -> `https://shlink.io/api/error/missing-authentication` + * `INVALID_API_KEY` -> `https://shlink.io/api/error/invalid-api-key` +* Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it. +* It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead. +* It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead. + +### Changes in Docker image + +* Since openswoole is no longer supported, there are no longer image tags suffixed with `openswoole`. You should migrate to the default or `roadrunner` ones. +* The `non-root` docker tag is no longer published, as all docker images are now running without super-user permissions. +* Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically. + This was not really needed in the docker image, as visits are located on the fly. + ## From v2.x to v3.x ### Changes in REST API diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index 0e77cf3c..b4f6ef89 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -15,20 +15,6 @@ { "$ref": "../parameters/version.json" }, - { - "name": "withStats", - "deprecated": true, - "description": "**[Deprecated]** Use [GET /tags/stats](#/Tags/tagsWithStats) endpoint to get tags with their stats.", - "in": "query", - "required": false, - "schema": { - "type": "string", - "enum": [ - "true", - "false" - ] - } - }, { "name": "page", "in": "query", @@ -88,13 +74,6 @@ "type": "string" } }, - "stats": { - "description": "The tag stats will be returned only if the withStats param was provided with value 'true'", - "type": "array", - "items": { - "$ref": "../definitions/TagInfo.json" - } - }, "pagination": { "$ref": "../definitions/Pagination.json" } From cf355b0b69db323408e50a3719cff4e1ab0807b0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 13 Feb 2024 22:46:25 +0100 Subject: [PATCH 16/97] Update shlink-common and shlink-installer --- UPGRADE.md | 5 +++++ composer.json | 4 ++-- config/autoload/cache.global.php | 1 - config/autoload/installer.global.php | 1 - module/Core/src/Config/EnvVars.php | 1 - 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 0af19f77..f76be20b 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -40,6 +40,11 @@ * Due to previous point, it is no longer possible to pass `ENABLE_PERIODIC_VISIT_LOCATE=true` in order to configure a cron job that locates visits periodically. This was not really needed in the docker image, as visits are located on the fly. +### Changes in integrations + +* Credentials in redis URLs should now be URL-encoded, as they are unconditionally url-decoded before being used. Previously, it was possible to customize this behavior via `REDIS_DECODE_CREDENTIALS=true|false`. +* Providing redis URIs in the form of `tcp://password@6.6.6.6:6379` is no longer supported. If you want to provide password with no username, do `tcp://:password@6.6.6.6:6379` instead. + ## From v2.x to v3.x ### Changes in REST API diff --git a/composer.json b/composer.json index 0ea76443..ed0cb3ae 100644 --- a/composer.json +++ b/composer.json @@ -43,11 +43,11 @@ "pagerfanta/core": "^3.8", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#a309824 as 6.0", + "shlinkio/shlink-common": "dev-main#3f6b243 as 6.0", "shlinkio/shlink-config": "^2.5", "shlinkio/shlink-event-dispatcher": "^3.1", "shlinkio/shlink-importer": "^5.2.1", - "shlinkio/shlink-installer": "^8.7", + "shlinkio/shlink-installer": "dev-develop#9f0d7e5 as 9.0", "shlinkio/shlink-ip-geolocation": "^3.4", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.2", diff --git a/config/autoload/cache.global.php b/config/autoload/cache.global.php index 30db2c0a..94a9a183 100644 --- a/config/autoload/cache.global.php +++ b/config/autoload/cache.global.php @@ -11,7 +11,6 @@ return (static function (): array { 'redis' => [ 'servers' => $redisServers, 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), - 'decode_credentials' => (bool) EnvVars::REDIS_DECODE_CREDENTIALS->loadFromEnv(false), ], ]; diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index affb0897..a3b477af 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -31,7 +31,6 @@ return [ Option\Worker\TaskWorkerNumConfigOption::class, Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, - Option\Redis\RedisDecodeCredentialsConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, Option\Redis\RedisPubSubConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 4a32b7c3..14a850c9 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -24,7 +24,6 @@ enum EnvVars: string case CACHE_NAMESPACE = 'CACHE_NAMESPACE'; case REDIS_SERVERS = 'REDIS_SERVERS'; case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; - case REDIS_DECODE_CREDENTIALS = 'REDIS_DECODE_CREDENTIALS'; case REDIS_PUB_SUB_ENABLED = 'REDIS_PUB_SUB_ENABLED'; case MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; case MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; From 720db64a03f403e997081ddef81bbcad988b4c4c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 6 Feb 2024 08:53:57 +0100 Subject: [PATCH 17/97] Switch to RoadRunner as default API test runtime --- bin/test/run-api-tests.sh | 2 +- composer.json | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 6a1cfb46..d8ed270a 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -2,7 +2,7 @@ export APP_ENV=test export TEST_ENV=api -export TEST_RUNTIME="${TEST_RUNTIME:-"openswoole"}" # Openswoole is deprecated. Remove in v4.0.0 +export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # Openswoole is deprecated. Remove in v4.0.0 export DB_DRIVER="${DB_DRIVER:-"postgres"}" export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" diff --git a/composer.json b/composer.json index ed0cb3ae..05767381 100644 --- a/composer.json +++ b/composer.json @@ -50,10 +50,10 @@ "shlinkio/shlink-installer": "dev-develop#9f0d7e5 as 9.0", "shlinkio/shlink-ip-geolocation": "^3.4", "shlinkio/shlink-json": "^1.1", - "spiral/roadrunner": "^2023.2", - "spiral/roadrunner-cli": "^2.5", - "spiral/roadrunner-http": "^3.1", - "spiral/roadrunner-jobs": "^4.0", + "spiral/roadrunner": "^2023.3", + "spiral/roadrunner-cli": "^2.6", + "spiral/roadrunner-http": "^3.3", + "spiral/roadrunner-jobs": "^4.3", "symfony/console": "^6.4", "symfony/filesystem": "^6.4", "symfony/lock": "^6.4", From d949b54ef43d0281a7bd7d6d032ce408d7fb8d33 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Feb 2024 23:37:10 +0100 Subject: [PATCH 18/97] Switch to roadrunner as the default runtime for API tests --- .github/workflows/ci-tests.yml | 5 ++++ .github/workflows/ci.yml | 12 ++++----- .gitignore | 2 +- .rr.test.yml | 49 ++++++++++++++++++++++++++++++++++ bin/test/run-api-tests.sh | 8 +----- composer.json | 2 +- 6 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 .rr.test.yml diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 48cc56ef..440ca0b2 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -14,6 +14,8 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - uses: actions/checkout@v4 - name: Start postgres database server @@ -27,6 +29,9 @@ jobs: php-version: ${{ matrix.php-version }} php-extensions: openswoole-22.1.2 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} + - name: Download RoadRunner binary + if: ${{ inputs.test-group == 'api' }} + run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v4 if: ${{ matrix.php-version == '8.2' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6b08e29..016daf0a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,28 +50,26 @@ jobs: with: test-group: cli - openswoole-api-tests: + roadrunner-api-tests: uses: './.github/workflows/ci-tests.yml' with: test-group: api - roadrunner-api-tests: + openswoole-api-tests: runs-on: ubuntu-22.04 strategy: matrix: php-version: ['8.2', '8.3'] - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # rr get-binary picks this env automatically steps: - uses: actions/checkout@v4 - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-version }} + extensions: openswoole-22.1.2 tools: composer - - run: composer install --no-interaction --prefer-dist --ignore-platform-req=ext-openswoole - - run: ./vendor/bin/rr get --no-interaction --no-config --location bin/ && chmod +x bin/rr - - run: composer test:api:rr + - run: composer install --no-interaction --prefer-dist + - run: composer test:api:openswoole sqlite-db-tests: uses: './.github/workflows/ci-db-tests.yml' diff --git a/.gitignore b/.gitignore index b07b73d1..04c8ed56 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ .idea bin/rr -config/roadrunner/.pid +.pid build !docker/build composer.lock diff --git a/.rr.test.yml b/.rr.test.yml new file mode 100644 index 00000000..6925555b --- /dev/null +++ b/.rr.test.yml @@ -0,0 +1,49 @@ +version: '3' + +############################################################################################### +# It's important to keep this file in the project root, otherwise code coverage does not work # +# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 # +############################################################################################### + +rpc: + listen: tcp://127.0.0.1:6001 + +server: + command: 'php ./bin/roadrunner-worker.php' + +http: + address: '0.0.0.0:9999' + middleware: ['static'] + static: + dir: './public' + forbid: ['.php', '.htaccess'] + pool: + num_workers: 1 + debug: false + +jobs: + pool: + num_workers: 1 + debug: false + timeout: 300 + consume: ['shlink'] + pipelines: + shlink: + driver: memory + config: + priority: 10 + prefetch: 10 + +logs: + encoding: json + mode: development + channels: + http: + mode: 'off' # Disable logging as Shlink handles it internally + server: + encoding: json + level: info + metrics: + level: panic + jobs: + level: panic diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index d8ed270a..c90ae041 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -18,13 +18,7 @@ touch $OUTPUT_LOGS echo 'Starting server...' [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.dev.yml \ - -o=http.address=0.0.0.0:9999 \ - -o=http.pool.debug=false \ - -o=jobs.pool.debug=false \ - -o=logs.encoding=json \ - -o=logs.channels.http.encoding=json \ - -o=logs.channels.server.encoding=json \ +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=.rr.test.yml \ -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & diff --git a/composer.json b/composer.json index 05767381..dabd4d0c 100644 --- a/composer.json +++ b/composer.json @@ -130,7 +130,7 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:api:rr": "TEST_RUNTIME=rr bin/test/run-api-tests.sh", + "test:api:openswoole": "TEST_RUNTIME=openswoole bin/test/run-api-tests.sh", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", From 60176060cb0c323311ff001e18a6846802cfc161 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 15 Feb 2024 23:42:03 +0100 Subject: [PATCH 19/97] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d09a142..cf38bf96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. * [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. * [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3. +* [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. ### Deprecated * *Nothing* From 11176317176b9f8b61c53957dd97246b06c50a01 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 00:01:35 +0100 Subject: [PATCH 20/97] Move rr tests config back to the config/roadrunner folder --- bin/test/run-api-tests.sh | 2 +- .rr.test.yml => config/roadrunner/.rr.test.yml | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) rename .rr.test.yml => config/roadrunner/.rr.test.yml (84%) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index c90ae041..b4e38467 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -18,7 +18,7 @@ touch $OUTPUT_LOGS echo 'Starting server...' [ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=.rr.test.yml \ +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.test.yml -w . \ -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & diff --git a/.rr.test.yml b/config/roadrunner/.rr.test.yml similarity index 84% rename from .rr.test.yml rename to config/roadrunner/.rr.test.yml index 6925555b..f3e8bb78 100644 --- a/.rr.test.yml +++ b/config/roadrunner/.rr.test.yml @@ -1,9 +1,9 @@ version: '3' -############################################################################################### -# It's important to keep this file in the project root, otherwise code coverage does not work # -# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 # -############################################################################################### +############################################################################################ +# Routes here need to be relative to the project root, as API tests are run with `-w .` # +# See https://github.com/orgs/roadrunner-server/discussions/1440#discussioncomment-8486186 # +############################################################################################ rpc: listen: tcp://127.0.0.1:6001 From 96ed7cae0d994ad2f5490946895eeb8a845f82a5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 23:02:46 +0100 Subject: [PATCH 21/97] Drop support for openswoole --- .github/DISCUSSION_TEMPLATE/help-wanted.yml | 4 +- .github/ISSUE_TEMPLATE/Bug.yml | 4 +- .github/workflows/ci-db-tests.yml | 2 +- .github/workflows/ci-mutation-tests.yml | 1 - .github/workflows/ci-tests.yml | 1 - .github/workflows/ci.yml | 23 +---- .github/workflows/publish-release.yml | 9 +- .github/workflows/publish-swagger-spec.yml | 1 - CHANGELOG.md | 2 +- CONTRIBUTING.md | 6 +- Dockerfile | 2 +- README.md | 5 +- bin/test/run-api-tests.sh | 5 +- composer.json | 9 +- config/autoload/common.global.php | 2 +- config/autoload/logger.global.php | 11 --- config/autoload/router.global.php | 2 +- config/autoload/swoole.global.php | 34 -------- config/autoload/swoole.local.php.dist | 13 --- config/autoload/url-shortener.local.php.dist | 2 - config/config.php | 10 --- config/container.php | 11 --- config/test/test_config.global.php | 15 ---- .../examples/shlink-daemon-logrotate.conf | 13 --- data/infra/examples/shlink-daemon.sh | 54 ------------ data/infra/swoole.Dockerfile | 85 ------------------- data/infra/swoole_proxy_vhost.conf | 14 --- docker-compose.override.yml.dist | 6 -- docker-compose.yml | 38 --------- docker/README.md | 2 +- indocker | 4 +- .../Core/config/event_dispatcher.config.php | 3 +- module/Core/src/ShortUrl/UrlShortener.php | 2 +- 33 files changed, 26 insertions(+), 369 deletions(-) delete mode 100644 config/autoload/swoole.global.php delete mode 100644 config/autoload/swoole.local.php.dist delete mode 100644 data/infra/examples/shlink-daemon-logrotate.conf delete mode 100644 data/infra/examples/shlink-daemon.sh delete mode 100644 data/infra/swoole.Dockerfile delete mode 100644 data/infra/swoole_proxy_vhost.conf diff --git a/.github/DISCUSSION_TEMPLATE/help-wanted.yml b/.github/DISCUSSION_TEMPLATE/help-wanted.yml index 1283f43d..08444522 100644 --- a/.github/DISCUSSION_TEMPLATE/help-wanted.yml +++ b/.github/DISCUSSION_TEMPLATE/help-wanted.yml @@ -20,10 +20,8 @@ body: options: - Self-hosted Apache - Self-hosted nginx - - Self-hosted openswoole - Self-hosted RoadRunner - - Openswoole Docker image - - RoadRunner Docker image + - Docker image - Other (explain in summary) - type: dropdown validations: diff --git a/.github/ISSUE_TEMPLATE/Bug.yml b/.github/ISSUE_TEMPLATE/Bug.yml index 1f715088..2fce5cf4 100644 --- a/.github/ISSUE_TEMPLATE/Bug.yml +++ b/.github/ISSUE_TEMPLATE/Bug.yml @@ -22,10 +22,8 @@ body: options: - Self-hosted Apache - Self-hosted nginx - - Self-hosted openswoole - Self-hosted RoadRunner - - Openswoole Docker image - - RoadRunner Docker image + - Docker image - Other (explain in summary) - type: dropdown validations: diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index f2aaefb9..dd797e83 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -27,7 +27,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2, pdo_sqlsrv-5.12.0 + php-extensions: pdo_sqlsrv-5.12.0 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index 64da4adc..c34902d3 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -19,7 +19,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v4 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 440ca0b2..ea26ccd7 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -27,7 +27,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - name: Download RoadRunner binary if: ${{ inputs.test-group == 'api' }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 016daf0a..c3289dc1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -50,27 +49,11 @@ jobs: with: test-group: cli - roadrunner-api-tests: + api-tests: uses: './.github/workflows/ci-tests.yml' with: test-group: api - openswoole-api-tests: - runs-on: ubuntu-22.04 - strategy: - matrix: - php-version: ['8.2', '8.3'] - steps: - - uses: actions/checkout@v4 - - run: docker-compose -f docker-compose.yml -f docker-compose.ci.yml up -d shlink_db_postgres - - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php-version }} - extensions: openswoole-22.1.2 - tools: composer - - run: composer install --no-interaction --prefer-dist - - run: composer test:api:openswoole - sqlite-db-tests: uses: './.github/workflows/ci-db-tests.yml' with: @@ -112,7 +95,7 @@ jobs: api-mutation-tests: needs: - - openswoole-api-tests + - api-tests uses: './.github/workflows/ci-mutation-tests.yml' with: test-group: api @@ -127,7 +110,7 @@ jobs: upload-coverage: needs: - unit-tests - - openswoole-api-tests + - api-tests - cli-tests - sqlite-db-tests runs-on: ubuntu-22.04 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 76d04f94..7875c07b 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -11,22 +11,17 @@ jobs: strategy: matrix: php-version: ['8.2', '8.3'] - swoole: ['yes', 'no'] steps: - uses: actions/checkout@v4 - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - - if: ${{ matrix.swoole == 'yes' }} - run: ./build.sh ${GITHUB_REF#refs/tags/v} - - if: ${{ matrix.swoole == 'no' }} - run: ./build.sh ${GITHUB_REF#refs/tags/v} --no-swoole + - run: ./build.sh ${GITHUB_REF#refs/tags/v} - uses: actions/upload-artifact@v4 with: - name: dist-files-${{ matrix.php-version }}-${{ matrix.swoole }} + name: dist-files-${{ matrix.php-version }} path: build publish: diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index aa7e5e93..beebf57f 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,6 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-22.1.2 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index cf38bf96..92036903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Removed -* *Nothing* +* [#1908](https://github.com/shlinkio/shlink/issues/1908) Remove support for openswoole (and swoole). ### Fixed * *Nothing* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b2f36d2..e9a77964 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 `8800` through RoadRunner, `8080` through openswoole and `8000` through nginx+php-fpm. +Once you finish this, you will have the project exposed in ports `8800` through RoadRunner and `8000` through nginx+php-fpm. > 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 git-ignored assets, like logs, caches, lock files, GeoLite DB files, etc. It's the only location where Shlink may need to write at runtime. * `docs`: Any project documentation is stored here, like API spec definitions or architectural decision records. * `module`: Contains a sub-folder 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 RoadRunner or openswoole. +* `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 RoadRunner. ## 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 RoadRunner or openswoole, 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 RoadRunner, and test it from the outside by interacting with the REST API. These are the best tests to catch regressions, and to verify everything behaves as expected. diff --git a/Dockerfile b/Dockerfile index 4ad94dc9..4251b3e4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,7 +40,7 @@ FROM base as builder COPY . . COPY --from=composer:2 /usr/bin/composer ./composer.phar RUN apk add --no-cache git && \ - php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction --ignore-platform-req=ext-openswoole && \ + php composer.phar install --no-dev --prefer-dist --optimize-autoloader --no-progress --no-interaction && \ php composer.phar clear-cache && \ rm -r docker composer.* && \ sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php diff --git a/README.md b/README.md index 7e84d5ae..2a2d3f6c 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,11 @@ First, make sure the host where you are going to run shlink fulfills these requi * PHP 8.2 or 8.3 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. - * apcu extension is recommended if you don't plan to use openswoole. + * apcu extension is recommended if you don't plan to use RoadRunner. * 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, MicrosoftSQL or SQLite. * You will also need the corresponding pdo variation for the database you are planning to use: `pdo_mysql`, `pdo_pgsql`, `pdo_sqlsrv` or `pdo_sqlite`. -* The [openswoole](https://openswoole.com/) PHP extension (if you plan to serve Shlink with openswoole) or the web server of your choice with PHP integration (like Apache or Nginx). ### Download @@ -53,7 +52,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 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. Finally, decompress the file in the location of your choice. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index b4e38467..18ad2dd5 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -2,7 +2,7 @@ export APP_ENV=test export TEST_ENV=api -export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # Openswoole is deprecated. Remove in v4.0.0 +export TEST_RUNTIME="${TEST_RUNTIME:-"rr"}" # rr is the only runtime currently supported export DB_DRIVER="${DB_DRIVER:-"postgres"}" export GENERATE_COVERAGE="${GENERATE_COVERAGE:-"no"}" @@ -13,11 +13,9 @@ mkdir data/log/api-tests touch $OUTPUT_LOGS # Try to stop server just in case it hanged in last execution -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f echo 'Starting server...' -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:start -d [ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.test.yml -w . \ -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ @@ -27,7 +25,6 @@ sleep 2 # Let's give the server a couple of seconds to start vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* TESTS_EXIT_CODE=$? -[ "$TEST_RUNTIME" = 'openswoole' ] && vendor/bin/laminas mezzio:swoole:stop [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999 # Exit this script with the same code as the tests. If tests failed, this script has to fail diff --git a/composer.json b/composer.json index dabd4d0c..20da872a 100644 --- a/composer.json +++ b/composer.json @@ -37,17 +37,16 @@ "mezzio/mezzio": "^3.17", "mezzio/mezzio-fastroute": "^3.11", "mezzio/mezzio-problem-details": "^1.13", - "mezzio/mezzio-swoole": "^4.8", "mlocati/ip-lib": "^1.18", "mobiledetect/mobiledetectlib": "^4.8", "pagerfanta/core": "^3.8", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#3f6b243 as 6.0", - "shlinkio/shlink-config": "^2.5", + "shlinkio/shlink-common": "dev-main#2323ff3 as 6.0", + "shlinkio/shlink-config": "dev-main#6b287b3 as 2.6", "shlinkio/shlink-event-dispatcher": "^3.1", "shlinkio/shlink-importer": "^5.2.1", - "shlinkio/shlink-installer": "dev-develop#9f0d7e5 as 9.0", + "shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0", "shlinkio/shlink-ip-geolocation": "^3.4", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", @@ -64,7 +63,6 @@ "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", "infection/infection": "^0.27", - "openswoole/ide-helper": "~22.0.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", @@ -130,7 +128,6 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:api:openswoole": "TEST_RUNTIME=openswoole bin/test/run-api-tests.sh", "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", diff --git a/config/autoload/common.global.php b/config/autoload/common.global.php index 19404d8c..c7db57f1 100644 --- a/config/autoload/common.global.php +++ b/config/autoload/common.global.php @@ -8,7 +8,7 @@ return [ 'debug' => false, - // Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console + // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // commands don't generate a cache file that's then used by php-fpm web executions ConfigAggregator::ENABLE_CACHE => PHP_SAPI !== 'cli', diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index 9e5e3160..67b737ae 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -53,16 +53,5 @@ return (static function (): array { ], ], - // Deprecated. Remove in Shlink 4.0.0 - 'mezzio-swoole' => [ - 'swoole-http-server' => [ - 'logger' => [ - // Let's disable mezio-swoole access logging, so that we can provide our own implementation, - // consistent for roadrunner and openswoole - 'logger-name' => NullLogger::class, - ], - ], - ], - ]; })(); diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index 831a7523..d13bf7d4 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -11,7 +11,7 @@ return [ 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'fastroute' => [ - // Disabling config cache for cli, ensures it's never used for openswoole/RoadRunner, and also that console + // Disabling config cache for cli, ensures it's never used for RoadRunner, and also that console // commands don't generate a cache file that's then used by php-fpm web executions FastRouteRouter::CONFIG_CACHE_ENABLED => PHP_SAPI !== 'cli', FastRouteRouter::CONFIG_CACHE_FILE => 'data/cache/fastroute_cached_routes.php', diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php deleted file mode 100644 index 494e3cf2..00000000 --- a/config/autoload/swoole.global.php +++ /dev/null @@ -1,34 +0,0 @@ -loadFromEnv(16); - - return [ - - 'mezzio-swoole' => [ - // Setting this to true can have unexpected behaviors when running several concurrent slow DB queries - 'enable_coroutine' => false, - - 'swoole-http-server' => [ - 'host' => '0.0.0.0', - 'port' => (int) EnvVars::PORT->loadFromEnv(8080), - 'process-name' => 'shlink', - - 'options' => [ - ...getOpenswooleConfigFromEnv(), - 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), - 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), - ], - ], - ], - - ]; -})(); diff --git a/config/autoload/swoole.local.php.dist b/config/autoload/swoole.local.php.dist deleted file mode 100644 index f30b3610..00000000 --- a/config/autoload/swoole.local.php.dist +++ /dev/null @@ -1,13 +0,0 @@ - [ - 'hot-code-reload' => [ - 'enable' => true, - ], - ], - -]; diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 2d129625..0b2e9db1 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -2,7 +2,6 @@ declare(strict_types=1); -use function Shlinkio\Shlink\Config\runningInOpenswoole; use function Shlinkio\Shlink\Config\runningInRoadRunner; return [ @@ -12,7 +11,6 @@ return [ 'schema' => 'http', 'hostname' => sprintf('localhost:%s', match (true) { runningInRoadRunner() => '8800', - runningInOpenswoole() => '8080', default => '8000', }), ], diff --git a/config/config.php b/config/config.php index a52ade5a..78fc542a 100644 --- a/config/config.php +++ b/config/config.php @@ -8,19 +8,12 @@ use Laminas\ConfigAggregator; 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\Config\openswooleIsInstalled; -use function Shlinkio\Shlink\Config\runningInRoadRunner; use function Shlinkio\Shlink\Core\enumValues; -use const PHP_SAPI; - $isTestEnv = env('APP_ENV') === 'test'; -$enableSwoole = PHP_SAPI === 'cli' && openswooleIsInstalled() && ! runningInRoadRunner(); return (new ConfigAggregator\ConfigAggregator( providers: [ @@ -30,9 +23,6 @@ return (new ConfigAggregator\ConfigAggregator( Mezzio\ConfigProvider::class, Mezzio\Router\ConfigProvider::class, Mezzio\Router\FastRouteRouter\ConfigProvider::class, - $enableSwoole && class_exists(Swoole\ConfigProvider::class) - ? Swoole\ConfigProvider::class - : new ConfigAggregator\ArrayProvider([]), ProblemDetails\ConfigProvider::class, Diactoros\ConfigProvider::class, Common\ConfigProvider::class, diff --git a/config/container.php b/config/container.php index e7574fe6..5d263173 100644 --- a/config/container.php +++ b/config/container.php @@ -12,17 +12,6 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; -// Workaround to make this compatible with both openswoole 22 and earlier versions. -// Openswoole support is deprecated. Remove in v4.0.0 -if (! function_exists('swoole_set_process_name')) { - // phpcs:disable - function swoole_set_process_name(string $name): void - { - OpenSwoole\Util::setProcessName($name); - } - // phpcs:enable -} - // This is one of the first files loaded. Configure the timezone here date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get())); diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 8ae64d7a..0fed88ee 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -32,7 +32,6 @@ use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function sprintf; -use function sys_get_temp_dir; use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_PORT; @@ -136,20 +135,6 @@ return [ ], ], - 'mezzio-swoole' => [ - 'enable_coroutine' => false, - 'swoole-http-server' => [ - 'host' => API_TESTS_HOST, - 'port' => API_TESTS_PORT, - '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, - ], - ], - ], - 'routes' => !$isApiTest ? [] : [ [ 'name' => 'dump_coverage', diff --git a/data/infra/examples/shlink-daemon-logrotate.conf b/data/infra/examples/shlink-daemon-logrotate.conf deleted file mode 100644 index 2a11ed0b..00000000 --- a/data/infra/examples/shlink-daemon-logrotate.conf +++ /dev/null @@ -1,13 +0,0 @@ -/var/log/shlink/shlink_openswoole.log { - su root root - daily - missingok - rotate 120 - compress - delaycompress - notifempty - create 0640 root root - postrotate - /etc/init.d/shlink_openswoole restart - endscript -} diff --git a/data/infra/examples/shlink-daemon.sh b/data/infra/examples/shlink-daemon.sh deleted file mode 100644 index c32590f9..00000000 --- a/data/infra/examples/shlink-daemon.sh +++ /dev/null @@ -1,54 +0,0 @@ -#!/bin/bash -### BEGIN INIT INFO -# 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 openswoole -### END INIT INFO - -SCRIPT=/path/to/shlink/vendor/bin/laminas\ mezzio:swoole:start -RUNAS=root - -PIDFILE=/var/run/shlink_openswoole.pid -LOGDIR=/var/log/shlink -LOGFILE=${LOGDIR}/shlink_openswoole.log - -start() { - if [[ -f "$PIDFILE" ]] && kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with openswoole already running' >&2 - return 1 - fi - echo 'Starting shlink with openswoole' >&2 - mkdir -p "$LOGDIR" - touch "$LOGFILE" - local CMD="$SCRIPT &> \"$LOGFILE\" & echo \$!" - su -c "$CMD" $RUNAS > "$PIDFILE" - echo 'Shlink started' >&2 -} - -stop() { - if [[ ! -f "$PIDFILE" ]] || ! kill -0 $(cat "$PIDFILE"); then - echo 'Shlink with openswoole not running' >&2 - return 1 - fi - echo 'Stopping shlink with openswoole' >&2 - kill -15 $(cat "$PIDFILE") && rm -f "$PIDFILE" - echo 'Shlink stopped' >&2 -} - -case "$1" in - start) - start - ;; - stop) - stop - ;; - restart) - stop - start - ;; - *) - echo "Usage: $0 {start|stop|restart}" -esac diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile deleted file mode 100644 index 4bc60385..00000000 --- a/data/infra/swoole.Dockerfile +++ /dev/null @@ -1,85 +0,0 @@ -FROM php:8.3-alpine3.19 -MAINTAINER Alejandro Celaya - -ENV APCU_VERSION 5.1.23 -ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 22.1.2 -ENV PDO_SQLSRV_VERSION 5.12.0 -ENV MS_ODBC_DOWNLOAD 'b/9/f/b9f3cce4-3925-46d4-9f46-da08869c6486' -ENV MS_ODBC_SQL_VERSION 18_18.1.1.1 - -RUN apk update - -# Install common php extensions -RUN docker-php-ext-install pdo_mysql -RUN docker-php-ext-install calendar - -RUN apk add --no-cache oniguruma-dev -RUN docker-php-ext-install mbstring - -RUN apk add --no-cache sqlite-libs -RUN apk add --no-cache sqlite-dev -RUN docker-php-ext-install pdo_sqlite - -RUN apk add --no-cache icu-dev -RUN docker-php-ext-install intl - -RUN apk add --no-cache libzip-dev zlib-dev -RUN docker-php-ext-install zip - -RUN apk add --no-cache libpng-dev -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 --virtual .phpize-deps $PHPIZE_DEPS linux-headers && \ - docker-php-ext-install sockets && \ - apk del .phpize-deps -RUN docker-php-ext-install bcmath - -# Install APCu extension -ADD https://pecl.php.net/get/apcu-$APCU_VERSION.tgz /tmp/apcu.tar.gz -RUN mkdir -p /usr/src/php/ext/apcu \ - && tar xf /tmp/apcu.tar.gz -C /usr/src/php/ext/apcu --strip-components=1 \ - && docker-php-ext-configure apcu \ - && docker-php-ext-install apcu \ - && rm /tmp/apcu.tar.gz \ - && rm /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini \ - && echo extension=apcu.so > /usr/local/etc/php/conf.d/20-php-ext-apcu.ini - -# Install inotify extension -ADD https://pecl.php.net/get/inotify-$INOTIFY_VERSION.tgz /tmp/inotify.tar.gz -RUN mkdir -p /usr/src/php/ext/inotify \ - && tar xf /tmp/inotify.tar.gz -C /usr/src/php/ext/inotify --strip-components=1 \ - && docker-php-ext-configure inotify \ - && docker-php-ext-install inotify \ - && rm /tmp/inotify.tar.gz - -# Install openswoole, pcov and mssql driver -RUN wget https://download.microsoft.com/download/${MS_ODBC_DOWNLOAD}/msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --allow-untrusted msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk && \ - apk add --no-cache --virtual .phpize-deps $PHPIZE_DEPS unixodbc-dev && \ - pecl install openswoole-${OPENSWOOLE_VERSION} pdo_sqlsrv-${PDO_SQLSRV_VERSION} pcov && \ - docker-php-ext-enable openswoole pdo_sqlsrv pcov && \ - apk del .phpize-deps && \ - rm msodbcsql${MS_ODBC_SQL_VERSION}-1_amd64.apk - -# Install composer -COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer - -# Make home directory writable by anyone -RUN chmod 777 /home - -VOLUME /home/shlink -WORKDIR /home/shlink - -# Expose openswoole port -EXPOSE 8080 - -CMD \ - # Install dependencies if the vendor dir does not exist - if [[ ! -d "./vendor" ]]; then /usr/local/bin/composer install ; fi && \ - # When restarting the container, openswoole might think it is already in execution - # This forces the app to be started every second until the exit code is 0 - until php ./vendor/bin/laminas mezzio:swoole:start; do sleep 1 ; done diff --git a/data/infra/swoole_proxy_vhost.conf b/data/infra/swoole_proxy_vhost.conf deleted file mode 100644 index af31b1ea..00000000 --- a/data/infra/swoole_proxy_vhost.conf +++ /dev/null @@ -1,14 +0,0 @@ -server { - listen 80 default_server; - - error_log /home/shlink/www/data/infra/nginx/swoole_proxy.error.log; - - location / { - proxy_http_version 1.1; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_pass http://shlink_swoole:8080; - proxy_read_timeout 90s; - } -} diff --git a/docker-compose.override.yml.dist b/docker-compose.override.yml.dist index 1c5409c6..a3af3546 100644 --- a/docker-compose.override.yml.dist +++ b/docker-compose.override.yml.dist @@ -7,12 +7,6 @@ services: - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro - shlink_swoole: - user: 1000:1000 - volumes: - - /etc/passwd:/etc/passwd:ro - - /etc/group:/etc/group:ro - shlink_roadrunner: user: 1000:1000 volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 71194fbe..5416136d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,44 +39,6 @@ services: extra_hosts: - 'host.docker.internal:host-gateway' - shlink_swoole_proxy: - container_name: shlink_swoole_proxy - image: nginx:1.25-alpine - ports: - - "8002:80" - volumes: - - ./:/home/shlink/www - - ./data/infra/swoole_proxy_vhost.conf:/etc/nginx/conf.d/default.conf - links: - - shlink_swoole - - shlink_swoole: - container_name: shlink_swoole - build: - context: . - dockerfile: ./data/infra/swoole.Dockerfile - ports: - - "8080:8080" - - "9001:9001" - volumes: - - ./:/home/shlink - - ./data/infra/php.ini:/usr/local/etc/php/php.ini - links: - - shlink_db_mysql - - shlink_db_postgres - - shlink_db_maria - - shlink_db_ms - - shlink_redis - - shlink_redis_acl - - shlink_mercure - - shlink_mercure_proxy - - shlink_rabbitmq - - shlink_matomo - environment: - LC_ALL: C - extra_hosts: - - 'host.docker.internal:host-gateway' - shlink_roadrunner: container_name: shlink_roadrunner build: diff --git a/docker/README.md b/docker/README.md index 13de359d..55bd8876 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 [RoadRunner](https://roadrunner.dev) or [openswoole](https://openswoole.com/), which can be linked to external databases to persist data. +It exposes a shlink instance served with [RoadRunner](https://roadrunner.dev), which can be linked to external databases to persist data. ## Usage diff --git a/indocker b/indocker index 7cfbe2c3..f232b129 100755 --- a/indocker +++ b/indocker @@ -1,8 +1,8 @@ #!/usr/bin/env bash # Run docker containers if they are not up yet -if ! [[ $(docker ps | grep shlink_swoole) ]]; then +if ! [[ $(docker ps | grep shlink_roadrunner) ]]; then docker compose up -d fi -docker exec -it shlink_swoole /bin/sh -c "$*" +docker exec -it shlink_roadrunner /bin/sh -c "$*" diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index 012b8e12..8fc534d4 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -20,7 +20,6 @@ use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\GeoLite2\GeoLite2Options; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use function Shlinkio\Shlink\Config\runningInOpenswoole; use function Shlinkio\Shlink\Config\runningInRoadRunner; return (static function (): array { @@ -47,7 +46,7 @@ return (static function (): array { ]; // Send visits to matomo asynchronously if the runtime allows it - if (runningInRoadRunner() || runningInOpenswoole()) { + if (runningInRoadRunner()) { $asyncEvents[EventDispatcher\Event\VisitLocated::class][] = EventDispatcher\Matomo\SendVisitToMatomo::class; } else { $regularEvents[EventDispatcher\Event\VisitLocated::class] = [EventDispatcher\Matomo\SendVisitToMatomo::class]; diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 7bb74ba6..0305f936 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -57,7 +57,7 @@ class UrlShortener implements UrlShortenerInterface $this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId())); } catch (ContainerExceptionInterface $e) { // Ignore container errors when dispatching the event. - // When using openswoole, this event will try to enqueue a task, which cannot be done outside an HTTP + // When using RoadRunner, this event will try to enqueue a task, which cannot be done outside an HTTP // request. // If the short URL is created from CLI, the event dispatching will fail. return UrlShorteningResult::withErrorOnEventDispatching($newShortUrl, $e); From 8c934442865a205d64fbab43c7246cbf86af9998 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 23:08:03 +0100 Subject: [PATCH 22/97] Do not set default value for php-extensions input in ci-setup action --- .github/actions/ci-setup/action.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 19df378a..9407d6d5 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -12,7 +12,6 @@ inputs: php-extensions: description: 'The PHP extensions to install' required: false - default: '' extensions-cache-key: description: 'The key used to cache PHP extensions. If empty value is provided, extension caching is disabled' required: true From 8015c6cc8814410e5409d43d31dcec54b71c15c3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 23:10:18 +0100 Subject: [PATCH 23/97] Skip extensions cache if there are no extensions to install --- .github/actions/ci-setup/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 9407d6d5..227578f5 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -20,6 +20,7 @@ runs: using: composite steps: - name: Setup cache environment + if: ${{ inputs.php-extensions }} id: extcache uses: shivammathur/cache-extensions@v1 with: @@ -27,7 +28,8 @@ runs: extensions: ${{ inputs.php-extensions }} key: ${{ inputs.extensions-cache-key }} - name: Cache extensions - uses: actions/cache@v3 + if: ${{ inputs.php-extensions }} + uses: actions/cache@v4 with: path: ${{ steps.extcache.outputs.dir }} key: ${{ steps.extcache.outputs.key }} From dd7545afdfcbc52a89845e7cb4930efe4df68d9b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 08:52:31 +0100 Subject: [PATCH 24/97] Move E2E coverage collectors to shlink-test-utils --- .github/workflows/ci.yml | 4 +- .gitignore | 1 + composer.json | 2 +- config/test/bootstrap_api_tests.php | 14 ---- config/test/test_config.global.php | 102 ++++------------------------ 5 files changed, 17 insertions(+), 106 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3289dc1..1777d139 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,8 +133,8 @@ jobs: - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - - run: wget https://phar.phpunit.de/phpcov-9.0.0.phar - - run: php phpcov-9.0.0.phar merge build --clover build/clover.xml + - run: wget https://phar.phpunit.de/phpcov-10.0.0.phar + - run: php phpcov-10.0.0.phar merge build --clover build/clover.xml - name: Publish coverage uses: codecov/codecov-action@v1 with: diff --git a/.gitignore b/.gitignore index 04c8ed56..a7f9b895 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ docs/mercure.html docker-compose.override.yml .phpunit.result.cache docs/swagger/swagger-inlined.json +phpcov* diff --git a/composer.json b/composer.json index 20da872a..1ffb6ab2 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.8.1", + "shlinkio/shlink-test-utils": "^3.9", "symfony/var-dumper": "^6.4", "veewee/composer-run-parallel": "^1.3" }, diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index b82e5bc6..8f757c05 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,12 +7,6 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; -use function register_shutdown_function; -use function sprintf; - -use const ShlinkioTest\Shlink\API_TESTS_HOST; -use const ShlinkioTest\Shlink\API_TESTS_PORT; - /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); @@ -20,14 +14,6 @@ $config = $container->get('config'); $em = $container->get(EntityManager::class); $httpClient = $container->get('shlink_test_api_client'); -// Dump code coverage when process shuts down -register_shutdown_function(function () use ($httpClient): void { - $httpClient->request( - 'GET', - sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT), - ); -}); - $testHelper->createTestDb( createDbCommand: ['bin/cli', 'db:create'], migrateDbCommand: ['bin/cli', 'db:migrate'], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 0fed88ee..578e5c24 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -6,15 +6,9 @@ namespace Shlinkio\Shlink; use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; -use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; -use League\Event\EventDispatcher; use Monolog\Level; use PHPUnit\Runner\Version; -use Psr\Container\ContainerInterface; -use Psr\Http\Message\ResponseInterface; -use Psr\Http\Message\ServerRequestInterface; -use Psr\Http\Server\RequestHandlerInterface; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Driver\Selector; use SebastianBergmann\CodeCoverage\Filter; @@ -22,13 +16,12 @@ use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html; use SebastianBergmann\CodeCoverage\Report\PHP; use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml; use Shlinkio\Shlink\Common\Logger\LoggerType; +use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware; +use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator; use Symfony\Component\Console\Application; -use Symfony\Component\Console\Event\ConsoleCommandEvent; -use Symfony\Component\Console\Event\ConsoleTerminateEvent; -use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; use function file_exists; -use function Laminas\Stratigility\middleware; +use function register_shutdown_function; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function sprintf; @@ -36,7 +29,7 @@ use function sprintf; use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_PORT; -$isApiTest = env('TEST_ENV') === 'api'; +$isApiTest = env('TEST_ENV') === 'api' && env('RR_MODE') === 'http'; $isCliTest = env('TEST_ENV') === 'cli'; $isE2eTest = $isApiTest || $isCliTest; $coverageType = env('GENERATE_COVERAGE'); @@ -75,6 +68,13 @@ $exportCoverage = static function (string $type = 'api') use (&$coverage, $cover } }; +// Dump code coverage when process shuts down, only if running in HTTP mode +register_shutdown_function(function () use ($exportCoverage, $isApiTest): void { + if ($isApiTest) { + $exportCoverage(); + } +}); + $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('CI', false); @@ -135,34 +135,9 @@ return [ ], ], - 'routes' => !$isApiTest ? [] : [ - [ - 'name' => 'dump_coverage', - 'path' => '/api-tests/stop-coverage', - 'middleware' => middleware(static function () use ($exportCoverage) { - // TODO I have tried moving this block to a listener so that it's invoked automatically, - // but then the coverage is generated empty ¯\_(ツ)_/¯ - $exportCoverage(); - return new EmptyResponse(); - }), - 'allowed_methods' => ['GET'], - ], - ], - 'middleware_pipeline' => !$isApiTest ? [] : [ 'capture_code_coverage' => [ - 'middleware' => middleware(static function ( - ServerRequestInterface $req, - RequestHandlerInterface $handler, - ) use (&$coverage): ResponseInterface { - $coverage?->start($req->getHeaderLine('x-coverage-id')); - - try { - return $handler->handle($req); - } finally { - $coverage?->stop(); - } - }), + 'middleware' => new CoverageMiddleware($coverage), 'priority' => 9999, ], ], @@ -185,58 +160,7 @@ return [ ], 'delegators' => $isCliTest ? [ Application::class => [ - static function ( - ContainerInterface $c, - string $serviceName, - callable $callback, - ) use ( - &$coverage, - $exportCoverage, - ) { - /** @var Application $app */ - $app = $callback(); - $wrappedEventDispatcher = new EventDispatcher(); - - // When the command starts, start collecting coverage - $wrappedEventDispatcher->subscribeTo( - ConsoleCommandEvent::class, - static function () use (&$coverage): void { - $id = env('COVERAGE_ID'); - if ($id === null) { - return; - } - - $coverage?->start($id); - }, - ); - // When the command ends, stop collecting coverage - $wrappedEventDispatcher->subscribeTo( - ConsoleTerminateEvent::class, - static function () use (&$coverage, $exportCoverage): void { - $id = env('COVERAGE_ID'); - if ($id === null) { - return; - } - - $coverage?->stop(); - $exportCoverage('cli'); - }, - ); - - $app->setDispatcher(new class ($wrappedEventDispatcher) implements EventDispatcherInterface { - public function __construct(private EventDispatcher $wrappedDispatcher) - { - } - - public function dispatch(object $event, ?string $eventName = null): object - { - $this->wrappedDispatcher->dispatch($event); - return $event; - } - }); - - return $app; - }, + new CliCoverageDelegator($exportCoverage(...), $coverage), ], ] : [], ], From 0a6a794e2328b401819183412f3297aab9a9c4f1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 16 Feb 2024 23:32:20 +0100 Subject: [PATCH 25/97] Recover prev approach to generate API tests coverage --- config/test/bootstrap_api_tests.php | 14 ++++++++++++++ config/test/test_config.global.php | 26 +++++++++++++++++--------- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index 8f757c05..b82e5bc6 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,6 +7,12 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; +use function register_shutdown_function; +use function sprintf; + +use const ShlinkioTest\Shlink\API_TESTS_HOST; +use const ShlinkioTest\Shlink\API_TESTS_PORT; + /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); @@ -14,6 +20,14 @@ $config = $container->get('config'); $em = $container->get(EntityManager::class); $httpClient = $container->get('shlink_test_api_client'); +// Dump code coverage when process shuts down +register_shutdown_function(function () use ($httpClient): void { + $httpClient->request( + 'GET', + sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT), + ); +}); + $testHelper->createTestDb( createDbCommand: ['bin/cli', 'db:create'], migrateDbCommand: ['bin/cli', 'db:migrate'], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 578e5c24..ba0d8f2b 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink; use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; +use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; use Monolog\Level; use PHPUnit\Runner\Version; @@ -21,7 +22,7 @@ use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator; use Symfony\Component\Console\Application; use function file_exists; -use function register_shutdown_function; +use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function sprintf; @@ -29,7 +30,7 @@ use function sprintf; use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_PORT; -$isApiTest = env('TEST_ENV') === 'api' && env('RR_MODE') === 'http'; +$isApiTest = env('TEST_ENV') === 'api'; $isCliTest = env('TEST_ENV') === 'cli'; $isE2eTest = $isApiTest || $isCliTest; $coverageType = env('GENERATE_COVERAGE'); @@ -68,13 +69,6 @@ $exportCoverage = static function (string $type = 'api') use (&$coverage, $cover } }; -// Dump code coverage when process shuts down, only if running in HTTP mode -register_shutdown_function(function () use ($exportCoverage, $isApiTest): void { - if ($isApiTest) { - $exportCoverage(); - } -}); - $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); $isCi = env('CI', false); @@ -135,6 +129,20 @@ return [ ], ], + 'routes' => !$isApiTest ? [] : [ + [ + 'name' => 'dump_coverage', + 'path' => '/api-tests/stop-coverage', + 'middleware' => middleware(static function () use ($exportCoverage) { + // TODO I have tried moving this block to a register_shutdown_function here, which internally checks if + // RR_MODE === 'http', but this seems to be false in CI, causing the coverage to not be generated + $exportCoverage(); + return new EmptyResponse(); + }), + 'allowed_methods' => ['GET'], + ], + ], + 'middleware_pipeline' => !$isApiTest ? [] : [ 'capture_code_coverage' => [ 'middleware' => new CoverageMiddleware($coverage), From e9c7053ef5b10e1a2b30f7476f191a19c8ebcf60 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 08:41:17 +0100 Subject: [PATCH 26/97] Move code around generating code coverage to test-utils lib --- composer.json | 2 +- config/test/test_config.global.php | 62 +++++++++--------------------- 2 files changed, 19 insertions(+), 45 deletions(-) diff --git a/composer.json b/composer.json index 1ffb6ab2..f9e918c7 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.9", + "shlinkio/shlink-test-utils": "^3.10", "symfony/var-dumper": "^6.4", "veewee/composer-run-parallel": "^1.3" }, diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index ba0d8f2b..94d723b6 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -9,19 +9,12 @@ use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; use Monolog\Level; -use PHPUnit\Runner\Version; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\Report\Html\Facade as Html; -use SebastianBergmann\CodeCoverage\Report\PHP; -use SebastianBergmann\CodeCoverage\Report\Xml\Facade as Xml; use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware; use Shlinkio\Shlink\TestUtils\CliTest\CliCoverageDelegator; +use Shlinkio\Shlink\TestUtils\Helper\CoverageHelper; use Symfony\Component\Console\Application; -use function file_exists; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Core\ArrayUtils\contains; @@ -33,41 +26,13 @@ use const ShlinkioTest\Shlink\API_TESTS_PORT; $isApiTest = env('TEST_ENV') === 'api'; $isCliTest = env('TEST_ENV') === 'cli'; $isE2eTest = $isApiTest || $isCliTest; + $coverageType = env('GENERATE_COVERAGE'); $generateCoverage = contains($coverageType, ['yes', 'pretty']); - -$coverage = null; -if ($isE2eTest && $generateCoverage) { - $filter = new Filter(); - $filter->includeDirectory(__DIR__ . '/../../module/Core/src'); - $filter->includeDirectory(__DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src'); - $coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); -} - -/** - * @param 'api'|'cli' $type - */ -$exportCoverage = static function (string $type = 'api') use (&$coverage, $coverageType): void { - if ($coverage === null) { - return; - } - - $basePath = __DIR__ . '/../../build/coverage-' . $type; - $covPath = $basePath . '.cov'; - - // Every CLI test runs on its own process and dumps the coverage afterwards. - // Try to load it and merge it, so that we end up with the whole coverage at the end. - if ($type === 'cli' && file_exists($covPath)) { - $coverage->merge(require $covPath); - } - - if ($coverageType === 'pretty') { - (new Html())->process($coverage, $basePath . '/coverage-html'); - } else { - (new PHP())->process($coverage, $covPath); - (new Xml(Version::getVersionString()))->process($coverage, $basePath . '/coverage-xml'); - } -}; +$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories([ + __DIR__ . '/../../module/Core/src', + __DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src', +]) : null; $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); @@ -133,10 +98,14 @@ return [ [ 'name' => 'dump_coverage', 'path' => '/api-tests/stop-coverage', - 'middleware' => middleware(static function () use ($exportCoverage) { + 'middleware' => middleware(static function () use ($coverage, $coverageType) { // TODO I have tried moving this block to a register_shutdown_function here, which internally checks if // RR_MODE === 'http', but this seems to be false in CI, causing the coverage to not be generated - $exportCoverage(); + CoverageHelper::exportCoverage( + $coverage, + __DIR__ . '/../../build/coverage-api', + pretty: $coverageType === 'pretty', + ); return new EmptyResponse(); }), 'allowed_methods' => ['GET'], @@ -168,7 +137,12 @@ return [ ], 'delegators' => $isCliTest ? [ Application::class => [ - new CliCoverageDelegator($exportCoverage(...), $coverage), + new CliCoverageDelegator(fn () => CoverageHelper::exportCoverage( + $coverage, + __DIR__ . '/../../build/coverage-cli', + pretty: $coverageType === 'pretty', + mergeWithExisting: true, + ), $coverage), ], ] : [], ], From 13f9f106be298f9110ca9e663a8b3cc1c4190917 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 08:41:53 +0100 Subject: [PATCH 27/97] Update to event-dispatcher without swoole --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index f9e918c7..89d65058 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,7 @@ "ramsey/uuid": "^4.7", "shlinkio/shlink-common": "dev-main#2323ff3 as 6.0", "shlinkio/shlink-config": "dev-main#6b287b3 as 2.6", - "shlinkio/shlink-event-dispatcher": "^3.1", + "shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0", "shlinkio/shlink-importer": "^5.2.1", "shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0", "shlinkio/shlink-ip-geolocation": "^3.4", From e073b4331aa6724c41e7dcb53fa6b2abfca396bf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 10:21:36 +0100 Subject: [PATCH 28/97] Update to doctrine ORM 3.0 --- CHANGELOG.md | 1 + composer.json | 8 ++++---- .../test/Command/Db/CreateDatabaseCommandTest.php | 2 +- module/Core/migrations/Version20160819142757.php | 4 ++-- module/Core/migrations/Version20180913205455.php | 11 +++++------ module/Core/migrations/Version20200105165647.php | 2 +- module/Core/migrations/Version20200323190014.php | 5 +++-- module/Core/migrations/Version20201102113208.php | 9 ++++----- .../ShortUrl/Repository/ShortUrlListRepository.php | 12 ++++++------ .../src/ShortUrl/Repository/ShortUrlRepository.php | 2 +- .../Resolver/PersistenceShortUrlRelationResolver.php | 1 + module/Core/src/Tag/Repository/TagRepository.php | 4 ++-- module/Core/test/Domain/DomainServiceTest.php | 10 +++++----- .../test/Importer/ImportedLinksProcessorTest.php | 10 +++++----- .../PersistenceShortUrlRelationResolverTest.php | 12 ++++++------ module/Core/test/ShortUrl/ShortUrlResolverTest.php | 6 +++--- module/Core/test/Visit/VisitsStatsHelperTest.php | 6 +++--- module/Rest/test/Service/ApiKeyServiceTest.php | 6 +++--- 18 files changed, 56 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92036903..237fb29c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. * [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3. * [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. +* [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index 89d65058..c2d30bda 100644 --- a/composer.json +++ b/composer.json @@ -20,12 +20,11 @@ "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^3.0.2", "doctrine/migrations": "^3.6", - "doctrine/orm": "^2.16", + "doctrine/orm": "^3.0", "endroid/qr-code": "^4.8", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", - "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2.116", "laminas/laminas-config": "^3.8", "laminas/laminas-config-aggregator": "^1.13", @@ -42,7 +41,8 @@ "pagerfanta/core": "^3.8", "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", - "shlinkio/shlink-common": "dev-main#2323ff3 as 6.0", + "shlinkio/doctrine-specification": "^2.1.1", + "shlinkio/shlink-common": "dev-main#178b332 as 6.0", "shlinkio/shlink-config": "dev-main#6b287b3 as 2.6", "shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0", "shlinkio/shlink-importer": "^5.2.1", @@ -71,7 +71,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.10", + "shlinkio/shlink-test-utils": "^3.11", "symfony/var-dumper": "^6.4", "veewee/composer-run-parallel": "^1.3" }, diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index cece20db..e612dc1d 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -10,7 +10,7 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Schema\AbstractSchemaManager; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\Persistence\Mapping\ClassMetadataFactory; +use Doctrine\ORM\Mapping\ClassMetadataFactory; use Exception; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; diff --git a/module/Core/migrations/Version20160819142757.php b/module/Core/migrations/Version20160819142757.php index aeb1eb16..171c5b7e 100644 --- a/module/Core/migrations/Version20160819142757.php +++ b/module/Core/migrations/Version20160819142757.php @@ -6,7 +6,7 @@ namespace ShlinkMigrations; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\MySQLPlatform; -use Doctrine\DBAL\Platforms\SqlitePlatform; +use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Schema\SchemaException; use Doctrine\Migrations\AbstractMigration; @@ -32,7 +32,7 @@ class Version20160819142757 extends AbstractMigration is_subclass_of($platformClass, MySQLPlatform::class) => $column ->setPlatformOption('charset', 'utf8mb4') ->setPlatformOption('collation', 'utf8mb4_bin'), - is_subclass_of($platformClass, SqlitePlatform::class) => $column->setPlatformOption('collate', 'BINARY'), + is_subclass_of($platformClass, SQLitePlatform::class) => $column->setPlatformOption('collate', 'BINARY'), default => null, }; } diff --git a/module/Core/migrations/Version20180913205455.php b/module/Core/migrations/Version20180913205455.php index fe04a395..de709bc8 100644 --- a/module/Core/migrations/Version20180913205455.php +++ b/module/Core/migrations/Version20180913205455.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\Migrations\AbstractMigration; -use PDO; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; @@ -33,11 +32,11 @@ final class Version20180913205455 extends AbstractMigration $st = $this->connection->executeQuery($qb->getSQL()); $qb = $this->connection->createQueryBuilder(); - $qb->update('visits', 'v') - ->set('v.remote_addr', ':obfuscatedAddr') - ->where('v.id=:id'); + $qb->update('visits') + ->set('remote_addr', ':obfuscatedAddr') + ->where('id=:id'); - while ($row = $st->fetch(PDO::FETCH_ASSOC)) { + while ($row = $st->fetchAssociative()) { $addr = $row['remote_addr'] ?? null; if ($addr === null) { continue; @@ -46,7 +45,7 @@ final class Version20180913205455 extends AbstractMigration $qb->setParameters([ 'id' => $row['id'], 'obfuscatedAddr' => $this->determineAddress((string) $addr), - ])->execute(); + ])->executeQuery(); } } diff --git a/module/Core/migrations/Version20200105165647.php b/module/Core/migrations/Version20200105165647.php index 26f8cc0a..1af26797 100644 --- a/module/Core/migrations/Version20200105165647.php +++ b/module/Core/migrations/Version20200105165647.php @@ -32,7 +32,7 @@ final class Version20200105165647 extends AbstractMigration $qb = $this->connection->createQueryBuilder(); $qb->update('visit_locations') ->set($columnName, ':zeroValue') - ->where($qb->expr()->orX( + ->where($qb->expr()->or( $qb->expr()->eq($columnName, ':emptyString'), $qb->expr()->isNull($columnName), )) diff --git a/module/Core/migrations/Version20200323190014.php b/module/Core/migrations/Version20200323190014.php index f76df5e7..b47fc65c 100644 --- a/module/Core/migrations/Version20200323190014.php +++ b/module/Core/migrations/Version20200323190014.php @@ -29,10 +29,11 @@ final class Version20200323190014 extends AbstractMigration ->andWhere($qb->expr()->eq('region_name', ':emptyString')) ->andWhere($qb->expr()->eq('city_name', ':emptyString')) ->andWhere($qb->expr()->eq('timezone', ':emptyString')) - ->andWhere($qb->expr()->eq('lat', 0)) - ->andWhere($qb->expr()->eq('lon', 0)) + ->andWhere($qb->expr()->eq('lat', ':latLong')) + ->andWhere($qb->expr()->eq('lon', ':latLong')) ->setParameter('isEmpty', true) ->setParameter('emptyString', '') + ->setParameter('latLong', 0) ->executeStatement(); } diff --git a/module/Core/migrations/Version20201102113208.php b/module/Core/migrations/Version20201102113208.php index 92647c7f..4bae99e1 100644 --- a/module/Core/migrations/Version20201102113208.php +++ b/module/Core/migrations/Version20201102113208.php @@ -5,8 +5,8 @@ declare(strict_types=1); namespace ShlinkMigrations; use Cake\Chronos\Chronos; -use Doctrine\DBAL\Driver\Result; use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; @@ -33,7 +33,7 @@ final class Version20201102113208 extends AbstractMigration public function postUp(Schema $schema): void { - // If there's only one API key and it's active, link all existing URLs with it + // If there's only one API key, and it's active, link all existing URLs with it $qb = $this->connection->createQueryBuilder(); $qb->select('id') ->from('api_keys') @@ -47,8 +47,7 @@ final class Version20201102113208 extends AbstractMigration 'expiration' => Chronos::now()->toDateTimeString(), ]); - /** @var Result $result */ - $result = $qb->execute(); + $result = $qb->executeQuery(); $id = $this->resolveOneApiKeyId($result); if ($id === null) { return; @@ -58,7 +57,7 @@ final class Version20201102113208 extends AbstractMigration $qb->update('short_urls') ->set(self::API_KEY_COLUMN, ':apiKeyId') ->setParameter('apiKeyId', $id) - ->execute(); + ->executeQuery(); } private function resolveOneApiKeyId(Result $result): string|int|null diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php index e014ac64..d6f7e421 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlListRepository.php @@ -55,15 +55,15 @@ class ShortUrlListRepository extends EntitySpecificationRepository implements Sh if (OrderableField::isBasicField($fieldName)) { $qb->orderBy('s.' . $fieldName, $order); } elseif (OrderableField::isVisitsField($fieldName)) { + $leftJoinConditions = [$qb->expr()->eq('v.shortUrl', 's')]; + if ($fieldName === OrderableField::NON_BOT_VISITS->value) { + $leftJoinConditions[] = $qb->expr()->eq('v.potentialBot', 'false'); + } + // 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)') - ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX( - $qb->expr()->eq('v.shortUrl', 's'), - $fieldName === OrderableField::NON_BOT_VISITS->value - ? $qb->expr()->eq('v.potentialBot', 'false') - : null, - )) + ->leftJoin('s.visits', 'v', Join::WITH, $qb->expr()->andX(...$leftJoinConditions)) ->groupBy('s') ->orderBy('COUNT(DISTINCT v)', $order); } diff --git a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php index 05800abd..e151a6c7 100644 --- a/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php +++ b/module/Core/src/ShortUrl/Repository/ShortUrlRepository.php @@ -72,7 +72,7 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU /** * @param LockMode::PESSIMISTIC_WRITE|null $lockMode */ - private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?int $lockMode): bool + private function doShortCodeIsInUse(ShortUrlIdentifier $identifier, ?Specification $spec, ?LockMode $lockMode): bool { $qb = $this->createFindOneQueryBuilder($identifier, $spec)->select('s.id'); $query = $qb->getQuery(); diff --git a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php index 6c49ab5f..3aa6c887 100644 --- a/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php +++ b/module/Core/src/ShortUrl/Resolver/PersistenceShortUrlRelationResolver.php @@ -79,6 +79,7 @@ class PersistenceShortUrlRelationResolver implements ShortUrlRelationResolverInt return new Collections\ArrayCollection(array_map(function (string $tagName) use ($repo): Tag { $this->lock($this->tagLocks, 'tag_' . $tagName); + /** @var Tag|null $existingTag */ $existingTag = $repo->findOneBy(['name' => $tagName]); if ($existingTag) { $this->releaseLock($this->tagLocks, 'tag_' . $tagName); diff --git a/module/Core/src/Tag/Repository/TagRepository.php b/module/Core/src/Tag/Repository/TagRepository.php index 7f07e867..0f113776 100644 --- a/module/Core/src/Tag/Repository/TagRepository.php +++ b/module/Core/src/Tag/Repository/TagRepository.php @@ -82,12 +82,12 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito : $visitsSubQb->expr()->and( $commonJoinCondition, $visitsSubQb->expr()->eq('v.potential_bot', $conn->quote('0')), - ); + )->__toString(); return $visitsSubQb ->select('st.tag_id AS tag_id', 'COUNT(DISTINCT v.id) AS ' . $aggregateAlias) ->from('visits', 'v') - ->join('v', 'short_urls', 's', $visitsJoin) // @phpstan-ignore-line + ->join('v', 'short_urls', 's', $visitsJoin) ->join('s', 'short_urls_in_tags', 'st', $visitsSubQb->expr()->eq('st.short_url_id', 's.id')) ->groupBy('st.tag_id'); }; diff --git a/module/Core/test/Domain/DomainServiceTest.php b/module/Core/test/Domain/DomainServiceTest.php index eb14c982..c67597ec 100644 --- a/module/Core/test/Domain/DomainServiceTest.php +++ b/module/Core/test/Domain/DomainServiceTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Core\Config\NotFoundRedirects; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Domain\Model\DomainItem; -use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Exception\DomainNotFoundException; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; @@ -34,7 +34,7 @@ class DomainServiceTest extends TestCase #[Test, DataProvider('provideExcludedDomains')] public function listDomainsDelegatesIntoRepository(array $domains, array $expectedResult, ?ApiKey $apiKey): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findDomains')->with($apiKey)->willReturn($domains); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -126,7 +126,7 @@ class DomainServiceTest extends TestCase public function getOrCreateAlwaysPersistsDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn( $foundDomain, ); @@ -148,7 +148,7 @@ class DomainServiceTest extends TestCase $domain = Domain::withAuthority($authority); $domain->setId('1'); $apiKey = ApiKey::fromMeta(ApiKeyMeta::withRoles(RoleDefinition::forDomain($domain))); - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn(null); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->em->expects($this->never())->method('persist'); @@ -163,7 +163,7 @@ class DomainServiceTest extends TestCase public function configureNotFoundRedirectsConfiguresFetchedDomain(?Domain $foundDomain, ?ApiKey $apiKey): void { $authority = 'example.com'; - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->method('findOneByAuthority')->with($authority, $apiKey)->willReturn($foundDomain); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); $this->em->expects($this->once())->method('persist')->with($foundDomain ?? $this->isInstanceOf(Domain::class)); diff --git a/module/Core/test/Importer/ImportedLinksProcessorTest.php b/module/Core/test/Importer/ImportedLinksProcessorTest.php index e267744d..7c8a17d1 100644 --- a/module/Core/test/Importer/ImportedLinksProcessorTest.php +++ b/module/Core/test/Importer/ImportedLinksProcessorTest.php @@ -16,12 +16,12 @@ use RuntimeException; use Shlinkio\Shlink\Core\Importer\ImportedLinksProcessor; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Util\DoctrineBatchHelperInterface; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Repository\VisitRepositoryInterface; +use Shlinkio\Shlink\Core\Visit\Repository\VisitRepository; use Shlinkio\Shlink\Importer\Model\ImportedShlinkOrphanVisit; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; @@ -42,13 +42,13 @@ class ImportedLinksProcessorTest extends TestCase private ImportedLinksProcessor $processor; private MockObject & EntityManagerInterface $em; private MockObject & ShortCodeUniquenessHelperInterface $shortCodeHelper; - private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & ShortUrlRepository $repo; private MockObject & StyleInterface $io; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlRepository::class); $this->shortCodeHelper = $this->createMock(ShortCodeUniquenessHelperInterface::class); $batchHelper = $this->createMock(DoctrineBatchHelperInterface::class); @@ -281,7 +281,7 @@ class ImportedLinksProcessorTest extends TestCase sprintf('Imported %s orphan visits.', $expectedImportedVisits), ); - $visitRepo = $this->createMock(VisitRepositoryInterface::class); + $visitRepo = $this->createMock(VisitRepository::class); $visitRepo->expects($importOrphanVisits ? $this->once() : $this->never())->method( 'findMostRecentOrphanVisit', )->willReturn($lastOrphanVisit); diff --git a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php index d7af118d..43860909 100644 --- a/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php +++ b/module/Core/test/ShortUrl/Resolver/PersistenceShortUrlRelationResolverTest.php @@ -11,11 +11,11 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Domain\Entity\Domain; -use Shlinkio\Shlink\Core\Domain\Repository\DomainRepositoryInterface; +use Shlinkio\Shlink\Core\Domain\Repository\DomainRepository; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; use Shlinkio\Shlink\Core\Tag\Entity\Tag; -use Shlinkio\Shlink\Core\Tag\Repository\TagRepositoryInterface; +use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use function count; @@ -50,7 +50,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test, DataProvider('provideFoundDomains')] public function findsOrCreatesDomainWhenValueIsProvided(?Domain $foundDomain, string $authority): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->once())->method('findOneBy')->with(['authority' => $authority])->willReturn($foundDomain); $this->em->expects($this->once())->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -78,7 +78,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase // One of the tags will already exist. The rest will be new $expectedPersistedTags = $expectedLookedOutTags - 1; - $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly($expectedLookedOutTags))->method('findOneBy')->with( $this->isType('array'), )->willReturnCallback(function (array $criteria): ?Tag { @@ -116,7 +116,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test] public function newDomainsAreMemoizedUntilStateIsCleared(): void { - $repo = $this->createMock(DomainRepositoryInterface::class); + $repo = $this->createMock(DomainRepository::class); $repo->expects($this->exactly(3))->method('findOneBy')->with($this->isType('array'))->willReturn(null); $this->em->method('getRepository')->with(Domain::class)->willReturn($repo); @@ -135,7 +135,7 @@ class PersistenceShortUrlRelationResolverTest extends TestCase #[Test] public function newTagsAreMemoizedUntilStateIsCleared(): void { - $tagRepo = $this->createMock(TagRepositoryInterface::class); + $tagRepo = $this->createMock(TagRepository::class); $tagRepo->expects($this->exactly(6))->method('findOneBy')->with($this->isType('array'))->willReturn(null); $this->em->method('getRepository')->with(Tag::class)->willReturn($tagRepo); diff --git a/module/Core/test/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/ShortUrl/ShortUrlResolverTest.php index 729302c9..1b3aa564 100644 --- a/module/Core/test/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/ShortUrl/ShortUrlResolverTest.php @@ -18,7 +18,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolver; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\Visitor; @@ -32,12 +32,12 @@ class ShortUrlResolverTest extends TestCase { private ShortUrlResolver $urlResolver; private MockObject & EntityManagerInterface $em; - private MockObject & ShortUrlRepositoryInterface $repo; + private MockObject & ShortUrlRepository $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManagerInterface::class); - $this->repo = $this->createMock(ShortUrlRepositoryInterface::class); + $this->repo = $this->createMock(ShortUrlRepository::class); $this->urlResolver = new ShortUrlResolver($this->em, new UrlShortenerOptions()); } diff --git a/module/Core/test/Visit/VisitsStatsHelperTest.php b/module/Core/test/Visit/VisitsStatsHelperTest.php index b023bc1c..f6bb5464 100644 --- a/module/Core/test/Visit/VisitsStatsHelperTest.php +++ b/module/Core/test/Visit/VisitsStatsHelperTest.php @@ -19,7 +19,7 @@ use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\TagNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Repository\ShortUrlRepository; use Shlinkio\Shlink\Core\Tag\Entity\Tag; use Shlinkio\Shlink\Core\Tag\Repository\TagRepository; use Shlinkio\Shlink\Core\Visit\Entity\Visit; @@ -90,7 +90,7 @@ class VisitsStatsHelperTest extends TestCase $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $spec = $apiKey?->spec(); - $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, $spec)->willReturn(true); $list = array_map( @@ -123,7 +123,7 @@ class VisitsStatsHelperTest extends TestCase $shortCode = '123ABC'; $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); - $repo = $this->createMock(ShortUrlRepositoryInterface::class); + $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('shortCodeIsInUse')->with($identifier, null)->willReturn(false); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index 3740bbfe..f45e6ca5 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -14,7 +14,7 @@ use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; -use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepositoryInterface; +use Shlinkio\Shlink\Rest\ApiKey\Repository\ApiKeyRepository; use Shlinkio\Shlink\Rest\Entity\ApiKey; use Shlinkio\Shlink\Rest\Service\ApiKeyService; @@ -22,12 +22,12 @@ class ApiKeyServiceTest extends TestCase { private ApiKeyService $service; private MockObject & EntityManager $em; - private MockObject & ApiKeyRepositoryInterface $repo; + private MockObject & ApiKeyRepository $repo; protected function setUp(): void { $this->em = $this->createMock(EntityManager::class); - $this->repo = $this->createMock(ApiKeyRepositoryInterface::class); + $this->repo = $this->createMock(ApiKeyRepository::class); $this->service = new ApiKeyService($this->em); } From 95ea64980b1750d1ce3efde12c7ac154095574a8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 10:46:29 +0100 Subject: [PATCH 29/97] Update to Symfony 7 --- CHANGELOG.md | 1 + composer.json | 16 ++++++++-------- module/CLI/src/Command/Api/DisableKeyCommand.php | 2 +- .../CLI/src/Command/Api/GenerateKeyCommand.php | 2 +- .../CLI/src/Command/Api/InitialApiKeyCommand.php | 2 +- module/CLI/src/Command/Api/ListKeysCommand.php | 2 +- .../Command/Domain/DomainRedirectsCommand.php | 2 +- .../src/Command/Domain/ListDomainsCommand.php | 2 +- .../Command/ShortUrl/CreateShortUrlCommand.php | 2 +- .../Command/ShortUrl/DeleteShortUrlCommand.php | 2 +- .../ShortUrl/DeleteShortUrlVisitsCommand.php | 2 +- .../Command/ShortUrl/ListShortUrlsCommand.php | 2 +- .../src/Command/ShortUrl/ResolveUrlCommand.php | 2 +- module/CLI/src/Command/Tag/DeleteTagsCommand.php | 2 +- module/CLI/src/Command/Tag/ListTagsCommand.php | 2 +- module/CLI/src/Command/Tag/RenameTagCommand.php | 2 +- .../src/Command/Util/AbstractLockedCommand.php | 2 +- .../Visit/AbstractDeleteVisitsCommand.php | 4 ++-- .../Command/Visit/AbstractVisitsListCommand.php | 2 +- .../Command/Visit/DeleteOrphanVisitsCommand.php | 2 +- .../Command/Visit/DownloadGeoLiteDbCommand.php | 2 +- .../Command/Db/CreateDatabaseCommandTest.php | 4 ++-- .../Command/Db/MigrateDatabaseCommandTest.php | 4 ++-- .../Command/Visit/LocateVisitsCommandTest.php | 2 +- .../test/GeoLite/GeolocationDbUpdaterTest.php | 2 +- 25 files changed, 35 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 237fb29c..de9d6b1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1909](https://github.com/shlinkio/shlink/issues/1909) Update docker image to PHP 8.3. * [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. * [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0. +* [#2010](https://github.com/shlinkio/shlink/issues/2010) Update to Symfony 7.0 components. ### Deprecated * *Nothing* diff --git a/composer.json b/composer.json index c2d30bda..9d646be0 100644 --- a/composer.json +++ b/composer.json @@ -45,19 +45,19 @@ "shlinkio/shlink-common": "dev-main#178b332 as 6.0", "shlinkio/shlink-config": "dev-main#6b287b3 as 2.6", "shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0", - "shlinkio/shlink-importer": "^5.2.1", + "shlinkio/shlink-importer": "dev-main#f0a1f1d as 5.3", "shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0", - "shlinkio/shlink-ip-geolocation": "^3.4", + "shlinkio/shlink-ip-geolocation": "dev-main#c123a52 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", "spiral/roadrunner-cli": "^2.6", "spiral/roadrunner-http": "^3.3", "spiral/roadrunner-jobs": "^4.3", - "symfony/console": "^6.4", - "symfony/filesystem": "^6.4", - "symfony/lock": "^6.4", - "symfony/process": "^6.4", - "symfony/string": "^6.4" + "symfony/console": "^7.0", + "symfony/filesystem": "^7.0", + "symfony/lock": "^7.0", + "symfony/process": "^7.0", + "symfony/string": "^7.0" }, "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", @@ -72,7 +72,7 @@ "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.11", - "symfony/var-dumper": "^6.4", + "symfony/var-dumper": "^7.0", "veewee/composer-run-parallel": "^1.3" }, "conflict": { diff --git a/module/CLI/src/Command/Api/DisableKeyCommand.php b/module/CLI/src/Command/Api/DisableKeyCommand.php index 4844121e..3da85e9e 100644 --- a/module/CLI/src/Command/Api/DisableKeyCommand.php +++ b/module/CLI/src/Command/Api/DisableKeyCommand.php @@ -31,7 +31,7 @@ class DisableKeyCommand extends Command ->addArgument('apiKey', InputArgument::REQUIRED, 'The API key to disable'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $apiKey = $input->getArgument('apiKey'); $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 1fe2f996..0a35bef7 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -98,7 +98,7 @@ class GenerateKeyCommand extends Command ->setHelp($help); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $expirationDate = $input->getOption('expiration-date'); diff --git a/module/CLI/src/Command/Api/InitialApiKeyCommand.php b/module/CLI/src/Command/Api/InitialApiKeyCommand.php index 1f5a1794..0f4945a9 100644 --- a/module/CLI/src/Command/Api/InitialApiKeyCommand.php +++ b/module/CLI/src/Command/Api/InitialApiKeyCommand.php @@ -29,7 +29,7 @@ class InitialApiKeyCommand extends Command ->addArgument('apiKey', InputArgument::REQUIRED, 'The initial API to create'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $key = $input->getArgument('apiKey'); $result = $this->apiKeyService->createInitial($key); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index b55dcd7d..fab02087 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -45,7 +45,7 @@ class ListKeysCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $enabledOnly = $input->getOption('enabled-only'); diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index bf08e7f3..c2e5e60d 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -68,7 +68,7 @@ class DomainRedirectsCommand extends Command $input->setArgument('domain', str_contains($selectedOption, 'New domain') ? $askNewDomain() : $selectedOption); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $domainAuthority = $input->getArgument('domain'); diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 50107292..7e6b8cc3 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -38,7 +38,7 @@ class ListDomainsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $domains = $this->domainService->listDomains(); $showRedirects = $input->getOption('show-redirects'); diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 64418aa6..3377e649 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -134,7 +134,7 @@ class CreateShortUrlCommand extends Command } } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = $this->getIO($input, $output); $longUrl = $input->getArgument('longUrl'); diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 11cfa270..8196bbfe 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -47,7 +47,7 @@ class DeleteShortUrlCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $identifier = ShortUrlIdentifier::fromCli($input); diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php index 6cd04bfe..5d122ea7 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php @@ -43,7 +43,7 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand ); } - protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int + protected function doExecute(InputInterface $input, SymfonyStyle $io): int { $identifier = ShortUrlIdentifier::fromCli($input); try { diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 297c435e..a318e6e4 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -129,7 +129,7 @@ class ListShortUrlsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index aec0a843..d41d292e 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -49,7 +49,7 @@ class ResolveUrlCommand extends Command } } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/src/Command/Tag/DeleteTagsCommand.php b/module/CLI/src/Command/Tag/DeleteTagsCommand.php index 151c5892..cf05f1b5 100644 --- a/module/CLI/src/Command/Tag/DeleteTagsCommand.php +++ b/module/CLI/src/Command/Tag/DeleteTagsCommand.php @@ -34,7 +34,7 @@ class DeleteTagsCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $tagNames = $input->getOption('name'); diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index d56e4101..2efeac5c 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -31,7 +31,7 @@ class ListTagsCommand extends Command ->setDescription('Lists existing tags.'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { ShlinkTable::default($output)->render(['Name', 'URLs amount', 'Visits amount'], $this->getTagsRows()); return ExitCode::EXIT_SUCCESS; diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 1da3b983..fdc0f0ce 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -33,7 +33,7 @@ class RenameTagCommand extends Command ->addArgument('newName', InputArgument::REQUIRED, 'New name of the tag.'); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); $oldName = $input->getArgument('oldName'); diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index ae930496..8bd728cd 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -19,7 +19,7 @@ abstract class AbstractLockedCommand extends Command parent::__construct(); } - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $lockConfig = $this->getLockConfig(); $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); diff --git a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php index f171d59a..7cb32698 100644 --- a/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php +++ b/module/CLI/src/Command/Visit/AbstractDeleteVisitsCommand.php @@ -12,7 +12,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; abstract class AbstractDeleteVisitsCommand extends Command { - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); if (! $this->confirm($io)) { @@ -29,7 +29,7 @@ abstract class AbstractDeleteVisitsCommand extends Command return $io->confirm('Continue deleting visits?', false); } - abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int; + abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int; abstract protected function getWarningMessage(): string; } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index a15eb5e7..bd20a4ae 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -34,7 +34,7 @@ abstract class AbstractVisitsListCommand extends Command $this->endDateOption = new EndDateOption($this, 'visits'); } - final protected function execute(InputInterface $input, OutputInterface $output): ?int + final protected function execute(InputInterface $input, OutputInterface $output): int { $startDate = $this->startDateOption->get($input, $output); $endDate = $this->endDateOption->get($input, $output); diff --git a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php index af1b7c66..2b34ae52 100644 --- a/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/DeleteOrphanVisitsCommand.php @@ -27,7 +27,7 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand ->setDescription('Deletes all orphan visits'); } - protected function doExecute(InputInterface $input, SymfonyStyle $io): ?int + protected function doExecute(InputInterface $input, SymfonyStyle $io): int { $result = $this->deleter->deleteOrphanVisits(); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); diff --git a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php index 8da6c753..ac8ee102 100644 --- a/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php +++ b/module/CLI/src/Command/Visit/DownloadGeoLiteDbCommand.php @@ -37,7 +37,7 @@ class DownloadGeoLiteDbCommand extends Command ); } - protected function execute(InputInterface $input, OutputInterface $output): ?int + protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); diff --git a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php index e612dc1d..b4a5c840 100644 --- a/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/CreateDatabaseCommandTest.php @@ -22,7 +22,7 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Process\PhpExecutableFinder; class CreateDatabaseCommandTest extends TestCase @@ -37,7 +37,7 @@ class CreateDatabaseCommandTest extends TestCase protected function setUp(): void { $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(LockInterface::class); + $lock = $this->createMock(SharedLockInterface::class); $lock->method('acquire')->withAnyParameters()->willReturn(true); $locker->method('createLock')->withAnyParameters()->willReturn($lock); diff --git a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php index ac4283d7..29932202 100644 --- a/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php +++ b/module/CLI/test/Command/Db/MigrateDatabaseCommandTest.php @@ -13,7 +13,7 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; -use Symfony\Component\Lock\LockInterface; +use Symfony\Component\Lock\SharedLockInterface; use Symfony\Component\Process\PhpExecutableFinder; class MigrateDatabaseCommandTest extends TestCase @@ -24,7 +24,7 @@ class MigrateDatabaseCommandTest extends TestCase protected function setUp(): void { $locker = $this->createMock(LockFactory::class); - $lock = $this->createMock(LockInterface::class); + $lock = $this->createMock(SharedLockInterface::class); $lock->method('acquire')->withAnyParameters()->willReturn(true); $locker->method('createLock')->withAnyParameters()->willReturn($lock); diff --git a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php index 031e8e45..59c6b72f 100644 --- a/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php +++ b/module/CLI/test/Command/Visit/LocateVisitsCommandTest.php @@ -46,7 +46,7 @@ class LocateVisitsCommandTest extends TestCase $this->visitToLocation = $this->createMock(VisitToLocationHelperInterface::class); $locker = $this->createMock(Lock\LockFactory::class); - $this->lock = $this->createMock(Lock\LockInterface::class); + $this->lock = $this->createMock(Lock\SharedLockInterface::class); $locker->method('createLock')->with($this->isType('string'), 600.0, false)->willReturn($this->lock); $command = new LocateVisitsCommand($this->visitService, $this->visitToLocation, $locker); diff --git a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php index 0f911db8..15db873e 100644 --- a/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php +++ b/module/CLI/test/GeoLite/GeolocationDbUpdaterTest.php @@ -34,7 +34,7 @@ class GeolocationDbUpdaterTest extends TestCase { $this->dbUpdater = $this->createMock(DbUpdaterInterface::class); $this->geoLiteDbReader = $this->createMock(Reader::class); - $this->lock = $this->createMock(Lock\LockInterface::class); + $this->lock = $this->createMock(Lock\SharedLockInterface::class); $this->lock->method('acquire')->with($this->isTrue())->willReturn(true); } From 5c1ab02753154c00301e0fd08632af6725a10fc1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 10:56:34 +0100 Subject: [PATCH 30/97] Update shlink dependencies --- composer.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/composer.json b/composer.json index 9d646be0..8a883325 100644 --- a/composer.json +++ b/composer.json @@ -42,12 +42,12 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#178b332 as 6.0", - "shlinkio/shlink-config": "dev-main#6b287b3 as 2.6", - "shlinkio/shlink-event-dispatcher": "dev-main#46f5e21 as 4.0", - "shlinkio/shlink-importer": "dev-main#f0a1f1d as 5.3", - "shlinkio/shlink-installer": "dev-develop#2dee7db as 9.0", - "shlinkio/shlink-ip-geolocation": "dev-main#c123a52 as 3.5", + "shlinkio/shlink-common": "dev-main#762b3b8 as 6.0", + "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", + "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", + "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", + "shlinkio/shlink-installer": "dev-develop#3e8d7d7 as 9.0", + "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", "spiral/roadrunner-cli": "^2.6", From e3de403c6cd88c38397bd804e305fbbaf754865a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 17 Feb 2024 12:02:57 +0100 Subject: [PATCH 31/97] Remove support to validate long URLs during short URL creation/edition --- UPGRADE.md | 2 +- docs/swagger/definitions/ShortUrlEdition.json | 5 - docs/swagger/paths/v1_short-urls.json | 22 --- docs/swagger/paths/v1_short-urls_shorten.json | 43 ----- .../ShortUrl/CreateShortUrlCommand.php | 11 +- .../ShortUrl/CreateShortUrlCommandTest.php | 25 +-- module/Core/config/dependencies.config.php | 4 +- .../src/Exception/InvalidUrlException.php | 35 ---- module/Core/src/ShortUrl/Entity/ShortUrl.php | 1 - .../Helper/ShortUrlTitleResolutionHelper.php | 85 +++++++-- ...ShortUrlTitleResolutionHelperInterface.php | 6 +- .../Helper/TitleResolutionModelInterface.php | 3 - .../src/ShortUrl/Model/ShortUrlCreation.php | 10 - .../src/ShortUrl/Model/ShortUrlEdition.php | 10 - .../Model/Validation/ShortUrlInputFilter.php | 5 +- module/Core/src/ShortUrl/ShortUrlService.php | 4 +- .../src/ShortUrl/ShortUrlServiceInterface.php | 2 - module/Core/src/ShortUrl/UrlShortener.php | 4 +- .../src/ShortUrl/UrlShortenerInterface.php | 2 - module/Core/src/Util/UrlValidator.php | 116 ------------ .../Core/src/Util/UrlValidatorInterface.php | 23 --- .../Exception/InvalidUrlExceptionTest.php | 41 ---- .../ShortUrlTitleResolutionHelperTest.php | 131 ++++++++++--- .../test/ShortUrl/ShortUrlServiceTest.php | 6 +- .../Core/test/ShortUrl/UrlShortenerTest.php | 6 +- module/Core/test/Util/UrlValidatorTest.php | 176 ------------------ .../test-api/Action/CreateShortUrlTest.php | 21 --- .../Rest/test-api/Action/EditShortUrlTest.php | 18 +- 28 files changed, 198 insertions(+), 619 deletions(-) delete mode 100644 module/Core/src/Exception/InvalidUrlException.php delete mode 100644 module/Core/src/Util/UrlValidator.php delete mode 100644 module/Core/src/Util/UrlValidatorInterface.php delete mode 100644 module/Core/test/Exception/InvalidUrlExceptionTest.php delete mode 100644 module/Core/test/Util/UrlValidatorTest.php diff --git a/UPGRADE.md b/UPGRADE.md index f76be20b..ab6085ad 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -13,6 +13,7 @@ * The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead. * QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations. If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. +* Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition. ### Changes in REST API @@ -21,7 +22,6 @@ * `INVALID_SHORT_URL_DELETION` -> `https://shlink.io/api/error/invalid-short-url-deletion` * `DOMAIN_NOT_FOUND` -> `https://shlink.io/api/error/domain-not-found` * `FORBIDDEN_OPERATION` -> `https://shlink.io/api/error/forbidden-tag-operation` - * `INVALID_URL` -> `https://shlink.io/api/error/invalid-url` * `INVALID_SLUG` -> `https://shlink.io/api/error/non-unique-slug` * `INVALID_SHORTCODE` -> `https://shlink.io/api/error/short-url-not-found` * `TAG_CONFLICT` -> `https://shlink.io/api/error/tag-conflict` diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index dda213ca..baef4f52 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -20,11 +20,6 @@ "description": "The maximum number of allowed visits for this short code", "type": ["number", "null"] }, - "validateUrl": { - "deprecated": true, - "description": "**[DEPRECATED]** Tells if the long URL should or should not be validated as a reachable URL. Defaults to `false`", - "type": "boolean" - }, "tags": { "type": "array", "items": { diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index c226046f..08e08b67 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -388,10 +388,6 @@ ] } }, - "url": { - "type": "string", - "description": "A URL that could not be verified, if the error type is https://shlink.io/api/error/invalid-url" - }, "customSlug": { "type": "string", "description": "Provided custom slug when the error type is https://shlink.io/api/error/non-unique-slug" @@ -408,15 +404,6 @@ "Invalid arguments with API v3 and newer": { "$ref": "../examples/short-url-invalid-args-v3.json" }, - "Invalid long URL with API v3 and newer": { - "value": { - "title": "Invalid URL", - "type": "https://shlink.io/api/error/invalid-url", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, "Non-unique slug with API v3 and newer": { "value": { "title": "Invalid custom slug", @@ -429,15 +416,6 @@ "Invalid arguments previous to API v3": { "$ref": "../examples/short-url-invalid-args-v2.json" }, - "Invalid long URL previous to API v3": { - "value": { - "title": "Invalid URL", - "type": "INVALID_URL", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, "Non-unique slug previous to API v3": { "value": { "title": "Invalid custom slug", diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index cacb00bb..5c16482c 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -88,49 +88,6 @@ } } }, - "400": { - "description": "The long URL was not provided or is invalid.", - "content": { - "application/problem+json": { - "schema": { - "$ref": "../definitions/Error.json" - }, - "examples": { - "API v3 and newer": { - "value": { - "title": "Invalid URL", - "type": "https://shlink.io/api/error/invalid-url", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - }, - "Previous to API v3": { - "value": { - "title": "Invalid URL", - "type": "INVALID_URL", - "detail": "Provided URL foo is invalid. Try with a different one.", - "status": 400, - "url": "https://invalid-url.com" - } - } - } - }, - "text/plain": { - "schema": { - "type": "string" - }, - "examples": { - "API v3 and newer": { - "value": "https://shlink.io/api/error/invalid-url" - }, - "Previous to API v3": { - "value": "INVALID_URL" - } - } - } - } - }, "default": { "description": "Unexpected error.", "content": { diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 3377e649..118ad201 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCode; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; @@ -95,12 +94,6 @@ class CreateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'The length for generated short code (it will be ignored if --custom-slug was provided).', ) - ->addOption( - 'validate-url', - null, - InputOption::VALUE_NONE, - '[DEPRECATED] Makes the URL to be validated as publicly accessible.', - ) ->addOption( 'crawlable', 'r', @@ -148,7 +141,6 @@ class CreateShortUrlCommand extends Command $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; - $doValidateUrl = $input->getOption('validate-url'); try { $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ @@ -160,7 +152,6 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlInputFilter::VALIDATE_URL => $doValidateUrl, ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), @@ -176,7 +167,7 @@ class CreateShortUrlCommand extends Command sprintf('Generated short URL: %s', $this->stringifier->stringify($result->shortUrl)), ]); return ExitCode::EXIT_SUCCESS; - } catch (InvalidUrlException | NonUniqueSlugException $e) { + } catch (NonUniqueSlugException $e) { $io->error($e->getMessage()); return ExitCode::EXIT_FAILURE; } diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index de0fe26b..33031c6b 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\CLI\Command\ShortUrl\CreateShortUrlCommand; use Shlinkio\Shlink\CLI\Util\ExitCode; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -68,22 +67,6 @@ class CreateShortUrlCommandTest extends TestCase self::assertStringNotContainsString('but the real-time updates cannot', $output); } - #[Test] - public function exceptionWhileParsingLongUrlOutputsError(): void - { - $url = 'http://domain.com/invalid'; - $this->urlShortener->expects($this->once())->method('shorten')->withAnyParameters()->willThrowException( - InvalidUrlException::fromUrl($url), - ); - $this->stringifier->method('stringify')->with($this->isInstanceOf(ShortUrl::class))->willReturn(''); - - $this->commandTester->execute(['longUrl' => $url]); - $output = $this->commandTester->getDisplay(); - - self::assertEquals(ExitCode::EXIT_FAILURE, $this->commandTester->getStatusCode()); - self::assertStringContainsString('Provided URL http://domain.com/invalid is invalid.', $output); - } - #[Test] public function providingNonUniqueSlugOutputsError(): void { @@ -148,12 +131,12 @@ class CreateShortUrlCommandTest extends TestCase } #[Test, DataProvider('provideFlags')] - public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedValidateUrl): void + public function urlValidationHasExpectedValueBasedOnProvidedFlags(array $options, ?bool $expectedCrawlable): void { $shortUrl = ShortUrl::createFake(); $this->urlShortener->expects($this->once())->method('shorten')->with( - $this->callback(function (ShortUrlCreation $meta) use ($expectedValidateUrl) { - Assert::assertEquals($expectedValidateUrl, $meta->doValidateUrl()); + $this->callback(function (ShortUrlCreation $meta) use ($expectedCrawlable) { + Assert::assertEquals($expectedCrawlable, $meta->crawlable); return true; }), )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl)); @@ -166,7 +149,7 @@ class CreateShortUrlCommandTest extends TestCase public static function provideFlags(): iterable { yield 'no flags' => [[], null]; - yield 'validate-url' => [['--validate-url' => true], true]; + yield 'crawlable' => [['--crawlable' => true], true]; } /** diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 6b6be190..5f9ae565 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -75,7 +75,6 @@ return [ Visit\Entity\Visit::class, ], - Util\UrlValidator::class => ConfigAbstractFactory::class, Util\DoctrineBatchHelper::class => ConfigAbstractFactory::class, Util\RedirectResponseHelper::class => ConfigAbstractFactory::class, @@ -153,7 +152,6 @@ return [ ShortUrl\Helper\ShortCodeUniquenessHelper::class => ['em', Options\UrlShortenerOptions::class], Domain\DomainService::class => ['em', 'config.url_shortener.domain.hostname'], - Util\UrlValidator::class => ['httpClient', Options\UrlShortenerOptions::class], Util\DoctrineBatchHelper::class => ['em'], Util\RedirectResponseHelper::class => [Options\RedirectOptions::class], @@ -180,7 +178,7 @@ return [ Lock\LockFactory::class, ], ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], - ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => [Util\UrlValidator::class], + ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class], ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [ diff --git a/module/Core/src/Exception/InvalidUrlException.php b/module/Core/src/Exception/InvalidUrlException.php deleted file mode 100644 index 200914c2..00000000 --- a/module/Core/src/Exception/InvalidUrlException.php +++ /dev/null @@ -1,35 +0,0 @@ -detail = $e->getMessage(); - $e->title = self::TITLE; - $e->type = toProblemDetailsType(self::ERROR_CODE); - $e->status = $status; - $e->additional = ['url' => $url]; - - return $e; - } -} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index e53e9afa..ee2c5920 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -120,7 +120,6 @@ class ShortUrl extends AbstractEntity ?ShortUrlRelationResolverInterface $relationResolver = null, ): self { $meta = [ - ShortUrlInputFilter::VALIDATE_URL => false, ShortUrlInputFilter::LONG_URL => $url->longUrl, ShortUrlInputFilter::DOMAIN => $url->domain, ShortUrlInputFilter::TAGS => $url->tags, diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php index 71963437..6a539f58 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelper.php @@ -4,31 +4,90 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; +use Fig\Http\Message\RequestMethodInterface; +use GuzzleHttp\ClientInterface; +use GuzzleHttp\RequestOptions; +use Psr\Http\Message\ResponseInterface; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Throwable; -class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface +use function html_entity_decode; +use function preg_match; +use function str_contains; +use function str_starts_with; +use function strtolower; +use function trim; + +use const Shlinkio\Shlink\TITLE_TAG_VALUE; + +readonly class ShortUrlTitleResolutionHelper implements ShortUrlTitleResolutionHelperInterface { - public function __construct(private readonly UrlValidatorInterface $urlValidator) - { + private const MAX_REDIRECTS = 15; + private const CHROME_USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) ' + . 'Chrome/121.0.0.0 Safari/537.36'; + + public function __construct( + private ClientInterface $httpClient, + private UrlShortenerOptions $options, + ) { } /** - * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 - * Move relevant logic from URL validator here. * @template T of TitleResolutionModelInterface * @param T $data * @return T - * @throws InvalidUrlException */ - public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface + public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface { - if ($data->hasTitle()) { - $this->urlValidator->validateUrl($data->getLongUrl(), $data->doValidateUrl()); + if (! $this->options->autoResolveTitles || $data->hasTitle()) { return $data; } - $title = $this->urlValidator->validateUrlWithTitle($data->getLongUrl(), $data->doValidateUrl()); - return $title === null ? $data : $data->withResolvedTitle($title); + $response = $this->fetchUrl($data->getLongUrl()); + if ($response === null) { + return $data; + } + + $contentType = strtolower($response->getHeaderLine('Content-Type')); + if (! str_starts_with($contentType, 'text/html')) { + return $data; + } + + $title = $this->tryToResolveTitle($response); + return $title !== null ? $data->withResolvedTitle($title) : $data; + } + + private function fetchUrl(string $url): ?ResponseInterface + { + try { + return $this->httpClient->request(RequestMethodInterface::METHOD_GET, $url, [ + // TODO Add a sensible timeout that prevents hanging here forever + // Prevent potential infinite redirection loops + RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], + RequestOptions::IDN_CONVERSION => true, + // Making the request with a browser's user agent results in responses closer to a real user + RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], + RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed + ]); + } catch (Throwable) { + return null; + } + } + + private function tryToResolveTitle(ResponseInterface $response): ?string + { + $collectedBody = ''; + $body = $response->getBody(); + // With streaming enabled, we can walk the body until the tag is found, and then stop + while (! str_contains($collectedBody, '') && ! $body->eof()) { + $collectedBody .= $body->read(1024); + } + preg_match(TITLE_TAG_VALUE, $collectedBody, $matches); + return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null; + } + + private function normalizeTitle(string $title): string + { + return html_entity_decode(trim($title)); } } diff --git a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php index 1861b451..6641460a 100644 --- a/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php +++ b/module/Core/src/ShortUrl/Helper/ShortUrlTitleResolutionHelperInterface.php @@ -4,16 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl\Helper; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; - interface ShortUrlTitleResolutionHelperInterface { /** - * @deprecated TODO Rename to processTitle once URL validation is removed with Shlink 4.0.0 * @template T of TitleResolutionModelInterface * @param T $data * @return T - * @throws InvalidUrlException */ - public function processTitleAndValidateUrl(TitleResolutionModelInterface $data): TitleResolutionModelInterface; + public function processTitle(TitleResolutionModelInterface $data): TitleResolutionModelInterface; } diff --git a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php index 4c56bfc1..cecd83e1 100644 --- a/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php +++ b/module/Core/src/ShortUrl/Helper/TitleResolutionModelInterface.php @@ -10,8 +10,5 @@ interface TitleResolutionModelInterface public function getLongUrl(): string; - /** @deprecated */ - public function doValidateUrl(): bool; - public function withResolvedTitle(string $title): static; } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 43b39874..1a1673ac 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -35,8 +35,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface public readonly bool $findIfExists = false, public readonly ?string $domain = null, public readonly int $shortCodeLength = 5, - /** @deprecated */ - public readonly bool $validateUrl = false, public readonly ?ApiKey $apiKey = null, public readonly array $tags = [], public readonly ?string $title = null, @@ -75,7 +73,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface $inputFilter, ShortUrlInputFilter::SHORT_CODE_LENGTH, ) ?? DEFAULT_SHORT_CODES_LENGTH, - validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, apiKey: $inputFilter->getValue(ShortUrlInputFilter::API_KEY), tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), @@ -97,7 +94,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface findIfExists: $this->findIfExists, domain: $this->domain, shortCodeLength: $this->shortCodeLength, - validateUrl: $this->validateUrl, apiKey: $this->apiKey, tags: $this->tags, title: $title, @@ -137,12 +133,6 @@ final class ShortUrlCreation implements TitleResolutionModelInterface return $this->domain !== null; } - /** @deprecated */ - public function doValidateUrl(): bool - { - return $this->validateUrl; - } - public function hasTitle(): bool { return $this->title !== null; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index fe92fae8..7a0000de 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -38,8 +38,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface private readonly bool $titlePropWasProvided = false, public readonly ?string $title = null, public readonly bool $titleWasAutoResolved = false, - /** @deprecated */ - public readonly bool $validateUrl = false, private readonly bool $crawlablePropWasProvided = false, public readonly bool $crawlable = false, private readonly bool $forwardQueryPropWasProvided = false, @@ -76,7 +74,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface tags: $inputFilter->getValue(ShortUrlInputFilter::TAGS), titlePropWasProvided: array_key_exists(ShortUrlInputFilter::TITLE, $data), title: $inputFilter->getValue(ShortUrlInputFilter::TITLE), - validateUrl: getOptionalBoolFromInputFilter($inputFilter, ShortUrlInputFilter::VALIDATE_URL) ?? false, crawlablePropWasProvided: array_key_exists(ShortUrlInputFilter::CRAWLABLE, $data), crawlable: $inputFilter->getValue(ShortUrlInputFilter::CRAWLABLE), forwardQueryPropWasProvided: array_key_exists(ShortUrlInputFilter::FORWARD_QUERY, $data), @@ -102,7 +99,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface titlePropWasProvided: $this->titlePropWasProvided, title: $title, titleWasAutoResolved: true, - validateUrl: $this->validateUrl, crawlablePropWasProvided: $this->crawlablePropWasProvided, crawlable: $this->crawlable, forwardQueryPropWasProvided: $this->forwardQueryPropWasProvided, @@ -155,12 +151,6 @@ final class ShortUrlEdition implements TitleResolutionModelInterface return $this->titleWasAutoResolved; } - /** @deprecated */ - public function doValidateUrl(): bool - { - return $this->validateUrl; - } - public function crawlableWasProvided(): bool { return $this->crawlablePropWasProvided; diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 23ac8a2f..325b400f 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -36,8 +36,6 @@ class ShortUrlInputFilter extends InputFilter public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const LONG_URL = 'longUrl'; public const DEVICE_LONG_URLS = 'deviceLongUrls'; - /** @deprecated */ - public const VALIDATE_URL = 'validateUrl'; public const API_KEY = 'apiKey'; public const TAGS = 'tags'; public const TITLE = 'title'; @@ -97,9 +95,8 @@ class ShortUrlInputFilter extends InputFilter $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); - // These cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null. + // This cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null. // Defining them as boolean will make null fall back to false, which is not the desired behavior. - $this->add($this->createInput(self::VALIDATE_URL, false)); $this->add($this->createInput(self::FORWARD_QUERY, false)); $domain = $this->createInput(self::DOMAIN, false); diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 95561fc5..1c3e9295 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; use Doctrine\ORM; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; @@ -26,7 +25,6 @@ class ShortUrlService implements ShortUrlServiceInterface /** * @throws ShortUrlNotFoundException - * @throws InvalidUrlException */ public function updateShortUrl( ShortUrlIdentifier $identifier, @@ -34,7 +32,7 @@ class ShortUrlService implements ShortUrlServiceInterface ?ApiKey $apiKey = null, ): ShortUrl { if ($shortUrlEdit->longUrlWasProvided()) { - $shortUrlEdit = $this->titleResolutionHelper->processTitleAndValidateUrl($shortUrlEdit); + $shortUrlEdit = $this->titleResolutionHelper->processTitle($shortUrlEdit); } $shortUrl = $this->urlResolver->resolveShortUrl($identifier, $apiKey); diff --git a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php index 3365374e..c7892f55 100644 --- a/module/Core/src/ShortUrl/ShortUrlServiceInterface.php +++ b/module/Core/src/ShortUrl/ShortUrlServiceInterface.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -15,7 +14,6 @@ interface ShortUrlServiceInterface { /** * @throws ShortUrlNotFoundException - * @throws InvalidUrlException */ public function updateShortUrl( ShortUrlIdentifier $identifier, diff --git a/module/Core/src/ShortUrl/UrlShortener.php b/module/Core/src/ShortUrl/UrlShortener.php index 0305f936..4a908c78 100644 --- a/module/Core/src/ShortUrl/UrlShortener.php +++ b/module/Core/src/ShortUrl/UrlShortener.php @@ -8,7 +8,6 @@ use Doctrine\ORM\EntityManagerInterface; use Psr\Container\ContainerExceptionInterface; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortCodeUniquenessHelperInterface; @@ -31,7 +30,6 @@ class UrlShortener implements UrlShortenerInterface /** * @throws NonUniqueSlugException - * @throws InvalidUrlException */ public function shorten(ShortUrlCreation $creation): UrlShorteningResult { @@ -41,7 +39,7 @@ class UrlShortener implements UrlShortenerInterface return UrlShorteningResult::withoutErrorOnEventDispatching($existingShortUrl); } - $creation = $this->titleResolutionHelper->processTitleAndValidateUrl($creation); + $creation = $this->titleResolutionHelper->processTitle($creation); /** @var ShortUrl $newShortUrl */ $newShortUrl = $this->em->wrapInTransaction(function () use ($creation): ShortUrl { diff --git a/module/Core/src/ShortUrl/UrlShortenerInterface.php b/module/Core/src/ShortUrl/UrlShortenerInterface.php index 70896ec1..7da0aaef 100644 --- a/module/Core/src/ShortUrl/UrlShortenerInterface.php +++ b/module/Core/src/ShortUrl/UrlShortenerInterface.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\ShortUrl; -use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\UrlShorteningResult; @@ -13,7 +12,6 @@ interface UrlShortenerInterface { /** * @throws NonUniqueSlugException - * @throws InvalidUrlException */ public function shorten(ShortUrlCreation $creation): UrlShorteningResult; } diff --git a/module/Core/src/Util/UrlValidator.php b/module/Core/src/Util/UrlValidator.php deleted file mode 100644 index 1ab3e8f8..00000000 --- a/module/Core/src/Util/UrlValidator.php +++ /dev/null @@ -1,116 +0,0 @@ -validateUrlAndGetResponse($url); - } - - /** - * @deprecated - * @throws InvalidUrlException - */ - public function validateUrlWithTitle(string $url, bool $doValidate): ?string - { - if (! $doValidate && ! $this->options->autoResolveTitles) { - return null; - } - - if (! $this->options->autoResolveTitles) { - $this->validateUrlAndGetResponse($url, self::METHOD_HEAD); - return null; - } - - $response = $doValidate ? $this->validateUrlAndGetResponse($url) : $this->getResponse($url); - if ($response === null) { - return null; - } - - $contentType = strtolower($response->getHeaderLine('Content-Type')); - if (! str_starts_with($contentType, 'text/html')) { - return null; - } - - $collectedBody = ''; - $body = $response->getBody(); - // With streaming enabled, we can walk the body until the tag is found, and then stop - while (! str_contains($collectedBody, '') && ! $body->eof()) { - $collectedBody .= $body->read(1024); - } - preg_match(TITLE_TAG_VALUE, $collectedBody, $matches); - return isset($matches[1]) ? $this->normalizeTitle($matches[1]) : null; - } - - /** - * @param self::METHOD_GET|self::METHOD_HEAD $method - * @throws InvalidUrlException - */ - private function validateUrlAndGetResponse(string $url, string $method = self::METHOD_GET): ResponseInterface - { - try { - return $this->httpClient->request($method, $url, [ - RequestOptions::ALLOW_REDIRECTS => ['max' => self::MAX_REDIRECTS], - RequestOptions::IDN_CONVERSION => true, - // Making the request with a browser's user agent makes the validation closer to a real user - RequestOptions::HEADERS => ['User-Agent' => self::CHROME_USER_AGENT], - RequestOptions::STREAM => true, // This ensures large files are not fully downloaded if not needed - ]); - } catch (GuzzleException $e) { - throw InvalidUrlException::fromUrl($url, $e); - } - } - - private function getResponse(string $url): ?ResponseInterface - { - try { - return $this->validateUrlAndGetResponse($url); - } catch (Throwable) { - return null; - } - } - - private function normalizeTitle(string $title): string - { - return html_entity_decode(trim($title)); - } -} diff --git a/module/Core/src/Util/UrlValidatorInterface.php b/module/Core/src/Util/UrlValidatorInterface.php deleted file mode 100644 index cb38dc42..00000000 --- a/module/Core/src/Util/UrlValidatorInterface.php +++ /dev/null @@ -1,23 +0,0 @@ -getMessage()); - self::assertEquals($expectedMessage, $e->getDetail()); - self::assertEquals('Invalid URL', $e->getTitle()); - self::assertEquals('https://shlink.io/api/error/invalid-url', $e->getType()); - self::assertEquals(['url' => $url], $e->getAdditionalData()); - self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getCode()); - self::assertEquals(StatusCodeInterface::STATUS_BAD_REQUEST, $e->getStatus()); - self::assertEquals($prev, $e->getPrevious()); - } - - public static function providePrevious(): iterable - { - yield 'null previous' => [null]; - yield 'instance previous' => [new Exception('Previous error', 10)]; - } -} diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php index ae89fa6f..d5c9f833 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlTitleResolutionHelperTest.php @@ -4,46 +4,131 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; -use PHPUnit\Framework\Attributes\DataProvider; +use Exception; +use GuzzleHttp\ClientInterface; +use Laminas\Diactoros\Response; +use Laminas\Diactoros\Response\JsonResponse; +use Laminas\Diactoros\Stream; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelper; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\Util\UrlValidatorInterface; class ShortUrlTitleResolutionHelperTest extends TestCase { - private ShortUrlTitleResolutionHelper $helper; - private MockObject & UrlValidatorInterface $urlValidator; + private MockObject & ClientInterface $httpClient; protected function setUp(): void { - $this->urlValidator = $this->createMock(UrlValidatorInterface::class); - $this->helper = new ShortUrlTitleResolutionHelper($this->urlValidator); + $this->httpClient = $this->createMock(ClientInterface::class); } - #[Test, DataProvider('provideTitles')] - public function urlIsProperlyShortened(?string $title, int $validateWithTitleCallsNum, int $validateCallsNum): void + #[Test] + public function dataIsReturnedAsIsWhenResolvingTitlesIsDisabled(): void { - $longUrl = 'http://foobar.com/12345/hello?foo=bar'; - $this->urlValidator->expects($this->exactly($validateWithTitleCallsNum))->method('validateUrlWithTitle')->with( - $longUrl, - $this->isFalse(), - ); - $this->urlValidator->expects($this->exactly($validateCallsNum))->method('validateUrl')->with( - $longUrl, - $this->isFalse(), - ); + $data = ShortUrlCreation::fromRawData(['longUrl' => 'http://foobar.com/12345/hello?foo=bar']); + $this->httpClient->expects($this->never())->method('request'); - $this->helper->processTitleAndValidateUrl( - ShortUrlCreation::fromRawData(['longUrl' => $longUrl, 'title' => $title]), - ); + $result = $this->helper()->processTitle($data); + + self::assertSame($data, $result); } - public static function provideTitles(): iterable + #[Test] + public function dataIsReturnedAsIsWhenItAlreadyHasTitle(): void { - yield 'no title' => [null, 1, 0]; - yield 'title' => ['link title', 0, 1]; + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => 'http://foobar.com/12345/hello?foo=bar', + 'title' => 'foo', + ]); + $this->httpClient->expects($this->never())->method('request'); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenFetchingFails(): void + { + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => 'http://foobar.com/12345/hello?foo=bar', + ]); + $this->httpClient->expects($this->once())->method('request')->willThrowException(new Exception('Error')); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenResponseIsNotHtml(): void + { + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => 'http://foobar.com/12345/hello?foo=bar', + ]); + $this->httpClient->expects($this->once())->method('request')->willReturn(new JsonResponse(['foo' => 'bar'])); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function dataIsReturnedAsIsWhenTitleCannotBeResolvedFromResponse(): void + { + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => 'http://foobar.com/12345/hello?foo=bar', + ]); + $this->httpClient->expects($this->once())->method('request')->willReturn($this->respWithoutTitle()); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertSame($data, $result); + } + + #[Test] + public function titleIsUpdatedWhenItCanBeResolvedFromResponse(): void + { + $data = ShortUrlCreation::fromRawData([ + 'longUrl' => 'http://foobar.com/12345/hello?foo=bar', + ]); + $this->httpClient->expects($this->once())->method('request')->willReturn($this->respWithTitle()); + + $result = $this->helper(autoResolveTitles: true)->processTitle($data); + + self::assertNotSame($data, $result); + self::assertEquals('Resolved "title"', $result->title); + } + + private function respWithoutTitle(): Response + { + $body = $this->createStreamWithContent('No title'); + return new Response($body, 200, ['Content-Type' => 'text/html']); + } + + private function respWithTitle(): Response + { + $body = $this->createStreamWithContent(' Resolved "title" '); + return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); + } + + private function createStreamWithContent(string $content): Stream + { + $body = new Stream('php://temp', 'wr'); + $body->write($content); + $body->rewind(); + + return $body; + } + + private function helper(bool $autoResolveTitles = false): ShortUrlTitleResolutionHelper + { + return new ShortUrlTitleResolutionHelper( + $this->httpClient, + new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles), + ); } } diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index 67b10720..dfbf7d75 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -63,7 +63,7 @@ class ShortUrlServiceTest extends TestCase )->willReturn($shortUrl); $this->titleResolutionHelper->expects($expectedValidateCalls) - ->method('processTitleAndValidateUrl') + ->method('processTitle') ->with($shortUrlEdit) ->willReturn($shortUrlEdit); @@ -102,10 +102,6 @@ class ShortUrlServiceTest extends TestCase 'maxVisits' => 10, 'longUrl' => 'https://modifiedLongUrl', ]), ApiKey::create()]; - yield 'long URL with validation' => [new InvokedCount(1), ShortUrlEdition::fromRawData([ - 'longUrl' => 'https://modifiedLongUrl', - 'validateUrl' => true, - ]), null]; yield 'device redirects' => [new InvokedCount(0), ShortUrlEdition::fromRawData([ 'deviceLongUrls' => [ DeviceType::IOS->value => 'https://iosLongUrl', diff --git a/module/Core/test/ShortUrl/UrlShortenerTest.php b/module/Core/test/ShortUrl/UrlShortenerTest.php index a442abb3..b332afd2 100644 --- a/module/Core/test/ShortUrl/UrlShortenerTest.php +++ b/module/Core/test/ShortUrl/UrlShortenerTest.php @@ -57,7 +57,7 @@ class UrlShortenerTest extends TestCase { $longUrl = 'http://foobar.com/12345/hello?foo=bar'; $meta = ShortUrlCreation::fromRawData(['longUrl' => $longUrl]); - $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $this->titleResolutionHelper->expects($this->once())->method('processTitle')->with( $meta, )->willReturnArgument(0); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); @@ -90,7 +90,7 @@ class UrlShortenerTest extends TestCase ); $this->shortCodeHelper->expects($this->once())->method('ensureShortCodeUniqueness')->willReturn(false); - $this->titleResolutionHelper->expects($this->once())->method('processTitleAndValidateUrl')->with( + $this->titleResolutionHelper->expects($this->once())->method('processTitle')->with( $meta, )->willReturnArgument(0); @@ -105,7 +105,7 @@ class UrlShortenerTest extends TestCase $repo = $this->createMock(ShortUrlRepository::class); $repo->expects($this->once())->method('findOneMatching')->willReturn($expected); $this->em->expects($this->once())->method('getRepository')->with(ShortUrl::class)->willReturn($repo); - $this->titleResolutionHelper->expects($this->never())->method('processTitleAndValidateUrl'); + $this->titleResolutionHelper->expects($this->never())->method('processTitle'); $this->shortCodeHelper->method('ensureShortCodeUniqueness')->willReturn(true); $result = $this->urlShortener->shorten($meta); diff --git a/module/Core/test/Util/UrlValidatorTest.php b/module/Core/test/Util/UrlValidatorTest.php deleted file mode 100644 index 233d69bd..00000000 --- a/module/Core/test/Util/UrlValidatorTest.php +++ /dev/null @@ -1,176 +0,0 @@ -httpClient = $this->createMock(ClientInterface::class); - } - - #[Test] - public function exceptionIsThrownWhenUrlIsInvalid(): void - { - $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); - $this->expectException(InvalidUrlException::class); - - $this->urlValidator()->validateUrl('http://foobar.com/12345/hello?foo=bar', true); - } - - #[Test] - public function expectedUrlIsCalledWhenTryingToVerify(): void - { - $expectedUrl = 'http://foobar.com'; - - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $expectedUrl, - $this->callback(function (array $options) { - Assert::assertArrayHasKey(RequestOptions::ALLOW_REDIRECTS, $options); - Assert::assertEquals(['max' => 15], $options[RequestOptions::ALLOW_REDIRECTS]); - Assert::assertArrayHasKey(RequestOptions::IDN_CONVERSION, $options); - Assert::assertTrue($options[RequestOptions::IDN_CONVERSION]); - Assert::assertArrayHasKey(RequestOptions::HEADERS, $options); - Assert::assertArrayHasKey('User-Agent', $options[RequestOptions::HEADERS]); - - return true; - }), - )->willReturn(new Response()); - - $this->urlValidator()->validateUrl($expectedUrl, true); - } - - #[Test] - public function noCheckIsPerformedWhenUrlValidationIsDisabled(): void - { - $this->httpClient->expects($this->never())->method('request'); - $this->urlValidator()->validateUrl('', false); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenRequestFailsAndValidationIsDisabled(): void - { - $this->httpClient->expects($this->once())->method('request')->willThrowException($this->clientException()); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabled(): void - { - $this->httpClient->expects($this->never())->method('request'); - - $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', false); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsDisabledAndValidationIsEnabled(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_HEAD, - $this->anything(), - $this->anything(), - )->willReturn($this->respWithTitle()); - - $result = $this->urlValidator()->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleResolvesTitleWhenAutoResolutionIsEnabled(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn($this->respWithTitle()); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertEquals('Resolved "title"', $result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndReturnedContentTypeIsInvalid(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn(new Response('php://memory', 200, ['Content-Type' => 'application/octet-stream'])); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - #[Test] - public function validateUrlWithTitleReturnsNullWhenAutoResolutionIsEnabledAndBodyDoesNotContainTitle(): void - { - $this->httpClient->expects($this->once())->method('request')->with( - RequestMethodInterface::METHOD_GET, - $this->anything(), - $this->anything(), - )->willReturn( - new Response($this->createStreamWithContent('No title'), 200, ['Content-Type' => 'text/html']), - ); - - $result = $this->urlValidator(true)->validateUrlWithTitle('http://foobar.com/12345/hello?foo=bar', true); - - self::assertNull($result); - } - - private function respWithTitle(): Response - { - $body = $this->createStreamWithContent(' Resolved "title" '); - return new Response($body, 200, ['Content-Type' => 'TEXT/html; charset=utf-8']); - } - - private function createStreamWithContent(string $content): Stream - { - $body = new Stream('php://temp', 'wr'); - $body->write($content); - $body->rewind(); - - return $body; - } - - private function clientException(): ClientException - { - return new ClientException( - '', - new Request(RequestMethodInterface::METHOD_GET, ''), - new Response(), - ); - } - - public function urlValidator(bool $autoResolveTitles = false): UrlValidator - { - return new UrlValidator($this->httpClient, new UrlShortenerOptions(autoResolveTitles: $autoResolveTitles)); - } -} diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index efd70666..41a4559b 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -224,27 +224,6 @@ class CreateShortUrlTest extends ApiTestCase yield ['http://téstb.shlink.io']; // Redirects to http://tést.shlink.io } - #[Test, DataProvider('provideInvalidUrls')] - public function failsToCreateShortUrlWithInvalidLongUrl(string $url, string $version, string $expectedType): void - { - $expectedDetail = sprintf('Provided URL %s is invalid. Try with a different one.', $url); - - [$statusCode, $payload] = $this->createShortUrl(['longUrl' => $url, 'validateUrl' => true], version: $version); - - self::assertEquals(self::STATUS_BAD_REQUEST, $statusCode); - self::assertEquals(self::STATUS_BAD_REQUEST, $payload['status']); - self::assertEquals($expectedType, $payload['type']); - self::assertEquals($expectedDetail, $payload['detail']); - self::assertEquals('Invalid URL', $payload['title']); - self::assertEquals($url, $payload['url']); - } - - public static function provideInvalidUrls(): iterable - { - yield 'API version 2' => ['https://this-has-to-be-invalid.com', '2', 'https://shlink.io/api/error/invalid-url']; - yield 'API version 3' => ['https://this-has-to-be-invalid.com', '3', 'https://shlink.io/api/error/invalid-url']; - } - #[Test, DataProvider('provideInvalidArgumentApiVersions')] public function failsToCreateShortUrlWithoutLongUrl(array $payload, string $version, string $expectedType): void { diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 89055adb..07fe84d3 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -75,28 +75,16 @@ class EditShortUrlTest extends ApiTestCase return $matchingShortUrl['meta'] ?? []; } - #[Test, DataProvider('provideLongUrls')] - public function longUrlCanBeEditedIfItIsValid(string $longUrl, int $expectedStatus, ?string $expectedError): void + public function longUrlCanBeEdited(): void { $shortCode = 'abc123'; $url = sprintf('/short-urls/%s', $shortCode); $resp = $this->callApiWithKey(self::METHOD_PATCH, $url, [RequestOptions::JSON => [ - 'longUrl' => $longUrl, - 'validateUrl' => true, + 'longUrl' => 'https://shlink.io', ]]); - self::assertEquals($expectedStatus, $resp->getStatusCode()); - if ($expectedError !== null) { - $payload = $this->getJsonResponsePayload($resp); - self::assertEquals($expectedError, $payload['type']); - } - } - - public static function provideLongUrls(): iterable - { - yield 'valid URL' => ['https://shlink.io', self::STATUS_OK, null]; - yield 'invalid URL' => ['http://foo', self::STATUS_BAD_REQUEST, 'https://shlink.io/api/error/invalid-url']; + self::assertEquals(self::STATUS_OK, $resp->getStatusCode()); } #[Test, DataProviderExternal(ApiTestDataProviders::class, 'invalidUrlsProvider')] From 938fb6509e892a8f8b4f82f01fd26092dbedf5ab Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Feb 2024 12:45:59 +0100 Subject: [PATCH 32/97] Add API test to verify titles for timing-out long URLs --- CHANGELOG.md | 2 +- UPGRADE.md | 1 + composer.json | 2 +- config/autoload/url-shortener.global.php | 2 +- config/autoload/url-shortener.local.php.dist | 1 - config/test/test_config.global.php | 21 +++++++++++++++++-- .../test-api/Action/CreateShortUrlTest.php | 11 ++++++++++ 7 files changed, 34 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de9d6b1b..3bb731dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,7 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1908](https://github.com/shlinkio/shlink/issues/1908) Remove support for openswoole (and swoole). ### Fixed -* *Nothing* +* [#2000](https://github.com/shlinkio/shlink/issues/2000) Fix short URL creation/edition getting stuck when trying to resolve the title of a long URL which never returns a response. ## [3.7.3] - 2024-01-04 diff --git a/UPGRADE.md b/UPGRADE.md index ab6085ad..aa235473 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -13,6 +13,7 @@ * The short URLs `loosely` mode is no longer supported, as it was a typo. Use `loose` mode instead. * QR codes URLs now work by default, even for short URLs that cannot be visited due to max visits or date range limitations. If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. +* Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option. * Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition. ### Changes in REST API diff --git a/composer.json b/composer.json index 8a883325..3ce4f63a 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", - "shlinkio/shlink-installer": "dev-develop#3e8d7d7 as 9.0", + "shlinkio/shlink-installer": "dev-develop#b314455 as 9.0", "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 2816577d..43bd5a74 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -24,7 +24,7 @@ return (static function (): array { 'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''), ], 'default_short_codes_length' => $shortCodesLength, - 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false), + 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(true), 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), 'trailing_slash_enabled' => (bool) EnvVars::SHORT_URL_TRAILING_SLASH->loadFromEnv(false), diff --git a/config/autoload/url-shortener.local.php.dist b/config/autoload/url-shortener.local.php.dist index 0b2e9db1..715d2822 100644 --- a/config/autoload/url-shortener.local.php.dist +++ b/config/autoload/url-shortener.local.php.dist @@ -14,7 +14,6 @@ return [ default => '8000', }), ], - 'auto_resolve_titles' => true, // 'multi_segment_slugs_enabled' => true, // 'trailing_slash_enabled' => true, ], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 94d723b6..d86700b9 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -7,7 +7,9 @@ namespace Shlinkio\Shlink; use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; +use Laminas\Diactoros\Response\HtmlResponse; use Laminas\ServiceManager\Factory\InvokableFactory; +use Mezzio\Router\FastRouteRouter; use Monolog\Level; use Shlinkio\Shlink\Common\Logger\LoggerType; use Shlinkio\Shlink\TestUtils\ApiTest\CoverageMiddleware; @@ -18,6 +20,7 @@ use Symfony\Component\Console\Application; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function sleep; use function sprintf; use const ShlinkioTest\Shlink\API_TESTS_HOST; @@ -86,6 +89,7 @@ return [ 'debug' => true, ConfigAggregator::ENABLE_CACHE => false, + FastRouteRouter::CONFIG_CACHE_ENABLED => false, 'url_shortener' => [ 'domain' => [ @@ -94,10 +98,12 @@ return [ ], ], - 'routes' => !$isApiTest ? [] : [ + 'routes' => [ + // This route is invoked at the end of API tests, in order to dump coverage collected so far [ 'name' => 'dump_coverage', 'path' => '/api-tests/stop-coverage', + 'allowed_methods' => ['GET'], 'middleware' => middleware(static function () use ($coverage, $coverageType) { // TODO I have tried moving this block to a register_shutdown_function here, which internally checks if // RR_MODE === 'http', but this seems to be false in CI, causing the coverage to not be generated @@ -108,7 +114,17 @@ return [ ); return new EmptyResponse(); }), + ], + + // This route is used to test that title resolution is skipped if the long URL times out + [ + 'name' => 'long_url_with_timeout', + 'path' => '/api-tests/long-url-with-timeout', 'allowed_methods' => ['GET'], + 'middleware' => middleware(static function () { + sleep(5); // Title resolution times out at 3 seconds + return new HtmlResponse('The title'); + }), ], ], @@ -119,6 +135,7 @@ return [ ], ], + // Disable mercure integration during E2E tests 'mercure' => [ 'public_hub_url' => null, 'internal_hub_url' => null, @@ -153,7 +170,7 @@ return [ 'data_fixtures' => [ 'paths' => [ - // TODO These are used for CLI tests too, so maybe should be somewhere else + // TODO These are used for other module's tests, so maybe should be somewhere else __DIR__ . '/../../module/Rest/test-api/Fixtures', ], ], diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 41a4559b..f612f628 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -328,6 +328,17 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null); } + #[Test] + public function titleIsIgnoredIfLongUrlTimesOut(): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'http://127.0.0.1:9999/api-tests/long-url-with-timeout', + ]); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertNull($payload['title']); + } + /** * @return array{int, array} */ From 58a3791a5ce07cae4a474321719c01c03369c0de Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Feb 2024 14:06:03 +0100 Subject: [PATCH 33/97] Allow customizing color, background color and logo in QR codes --- config/autoload/qr-codes.global.php | 5 ++ config/constants.php | 3 +- module/Core/src/Action/Model/QrCodeParams.php | 71 +++++++++++++++++-- module/Core/src/Action/QrCodeAction.php | 11 ++- module/Core/src/Config/EnvVars.php | 3 + module/Core/src/Options/QrCodeOptions.php | 5 ++ 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index 808ff961..919beffa 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -4,6 +4,8 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -26,6 +28,9 @@ return [ 'enabled_for_disabled_short_urls' => (bool) EnvVars::QR_CODE_FOR_DISABLED_SHORT_URLS->loadFromEnv( DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, ), + 'color' => EnvVars::DEFAULT_QR_CODE_COLOR->loadFromEnv(DEFAULT_QR_CODE_COLOR), + 'bg_color' => EnvVars::DEFAULT_QR_CODE_BG_COLOR->loadFromEnv(DEFAULT_QR_CODE_BG_COLOR), + 'logo_url' => EnvVars::DEFAULT_QR_CODE_LOGO_URL->loadFromEnv(), ], ]; diff --git a/config/constants.php b/config/constants.php index 7b263262..8d2e55cb 100644 --- a/config/constants.php +++ b/config/constants.php @@ -20,4 +20,5 @@ const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; -const MIN_TASK_WORKERS = 4; +const DEFAULT_QR_CODE_COLOR = '#000'; // Black +const DEFAULT_QR_CODE_BG_COLOR = '#fff'; // White diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 05181f20..638d9929 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action\Model; +use Endroid\QrCode\Color\Color; +use Endroid\QrCode\Color\ColorInterface; use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; @@ -18,9 +20,19 @@ use Endroid\QrCode\Writer\WriterInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; +use Throwable; +use function hexdec; +use function ltrim; +use function max; +use function min; +use function self; use function Shlinkio\Shlink\Core\ArrayUtils\contains; +use function strlen; use function strtolower; +use function substr; use function trim; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; final class QrCodeParams { @@ -34,6 +46,8 @@ final class QrCodeParams public readonly WriterInterface $writer, public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly ColorInterface $color, + public readonly ColorInterface $bgColor, ) { } @@ -42,11 +56,13 @@ final class QrCodeParams $query = $request->getQueryParams(); return new self( - self::resolveSize($query, $defaults), - self::resolveMargin($query, $defaults), - self::resolveWriter($query, $defaults), - self::resolveErrorCorrection($query, $defaults), - self::resolveRoundBlockSize($query, $defaults), + size: self::resolveSize($query, $defaults), + margin: self::resolveMargin($query, $defaults), + writer: self::resolveWriter($query, $defaults), + errorCorrectionLevel: self::resolveErrorCorrection($query, $defaults), + roundBlockSizeMode: self::resolveRoundBlockSize($query, $defaults), + color: self::resolveColor($query, $defaults), + bgColor: self::resolveBackgroundColor($query, $defaults), ); } @@ -57,7 +73,7 @@ final class QrCodeParams return self::MIN_SIZE; } - return $size > self::MAX_SIZE ? self::MAX_SIZE : $size; + return min($size, self::MAX_SIZE); } private static function resolveMargin(array $query, QrCodeOptions $defaults): int @@ -68,7 +84,7 @@ final class QrCodeParams return 0; } - return $intMargin < 0 ? 0 : $intMargin; + return max($intMargin, 0); } private static function resolveWriter(array $query, QrCodeOptions $defaults): WriterInterface @@ -101,6 +117,47 @@ final class QrCodeParams return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); } + private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $color = self::normalizeParam($query['color'] ?? $defaults->color); + return self::parseHexColor($color, DEFAULT_QR_CODE_COLOR); + } + + private static function resolveBackgroundColor(array $query, QrCodeOptions $defaults): ColorInterface + { + $bgColor = self::normalizeParam($query['bgColor'] ?? $defaults->bgColor); + return self::parseHexColor($bgColor, DEFAULT_QR_CODE_BG_COLOR); + } + + private static function parseHexColor(string $hexColor, ?string $fallback): Color + { + $hexColor = ltrim($hexColor, '#'); + + try { + if (strlen($hexColor) === 3) { + return new Color( + hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), + hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), + hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), + ); + } + + return new Color( + hexdec(substr($hexColor, 0, 2)), + hexdec(substr($hexColor, 2, 2)), + hexdec(substr($hexColor, 4, 2)), + ); + } catch (Throwable $e) { + // If a non-hex value was provided and an error occurs, fall back to the default color. + // Do not provide the fallback again this time, to avoid an infinite loop + if ($fallback !== null) { + return self::parseHexColor($fallback, null); + } + + throw $e; + } + } + private static function normalizeParam(string $param): string { return strtolower(trim($param)); diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index a952243a..748ed01b 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\Builder\Builder; +use Endroid\QrCode\Color\Color; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; @@ -48,7 +49,15 @@ readonly class QrCodeAction implements MiddlewareInterface ->margin($params->margin) ->writer($params->writer) ->errorCorrectionLevel($params->errorCorrectionLevel) - ->roundBlockSizeMode($params->roundBlockSizeMode); + ->roundBlockSizeMode($params->roundBlockSizeMode) + ->foregroundColor($params->color) + ->backgroundColor($params->bgColor); + + $logoUrl = $this->options->logoUrl; + if ($logoUrl !== null) { + $qrCodeBuilder->logoPath($logoUrl) + ->logoResizeToHeight((int) ($params->size / 4)); + } return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 14a850c9..0ea74451 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -45,6 +45,9 @@ enum EnvVars: string case DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; case DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; case QR_CODE_FOR_DISABLED_SHORT_URLS = 'QR_CODE_FOR_DISABLED_SHORT_URLS'; + case DEFAULT_QR_CODE_COLOR = 'DEFAULT_QR_CODE_COLOR'; + case DEFAULT_QR_CODE_BG_COLOR = 'DEFAULT_QR_CODE_BG_COLOR'; + case DEFAULT_QR_CODE_LOGO_URL = 'DEFAULT_QR_CODE_LOGO_URL'; case DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; case DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; case DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; diff --git a/module/Core/src/Options/QrCodeOptions.php b/module/Core/src/Options/QrCodeOptions.php index fff27858..da130d17 100644 --- a/module/Core/src/Options/QrCodeOptions.php +++ b/module/Core/src/Options/QrCodeOptions.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Options; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS; use const Shlinkio\Shlink\DEFAULT_QR_CODE_ERROR_CORRECTION; use const Shlinkio\Shlink\DEFAULT_QR_CODE_FORMAT; @@ -20,6 +22,9 @@ readonly final class QrCodeOptions public string $errorCorrection = DEFAULT_QR_CODE_ERROR_CORRECTION, public bool $roundBlockSize = DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, public bool $enabledForDisabledShortUrls = DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS, + public string $color = DEFAULT_QR_CODE_COLOR, + public string $bgColor = DEFAULT_QR_CODE_BG_COLOR, + public ?string $logoUrl = null, ) { } } From d01dc334d7f38ac41338d6115be524704673d6f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Feb 2024 19:58:19 +0100 Subject: [PATCH 34/97] Update to endroid/qr-code 5 --- composer.json | 4 +- module/Core/src/Action/Model/QrCodeParams.php | 44 ++++++++----------- module/Core/src/Action/QrCodeAction.php | 1 - 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/composer.json b/composer.json index 3ce4f63a..173c0eb1 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "cakephp/chronos": "^3.0.2", "doctrine/migrations": "^3.6", "doctrine/orm": "^3.0", - "endroid/qr-code": "^4.8", + "endroid/qr-code": "^5.0", "friendsofphp/proxy-manager-lts": "^1.0", "geoip2/geoip2": "^3.0", "guzzlehttp/guzzle": "^7.5", @@ -42,7 +42,7 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#762b3b8 as 6.0", + "shlinkio/shlink-common": "dev-main#b9a6bd5 as 6.0", "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 638d9929..0fc1ebc6 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -6,31 +6,25 @@ namespace Shlinkio\Shlink\Core\Action\Model; use Endroid\QrCode\Color\Color; use Endroid\QrCode\Color\ColorInterface; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium; -use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeInterface; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeMargin; -use Endroid\QrCode\RoundBlockSizeMode\RoundBlockSizeModeNone; +use Endroid\QrCode\ErrorCorrectionLevel; +use Endroid\QrCode\RoundBlockSizeMode; use Endroid\QrCode\Writer\PngWriter; use Endroid\QrCode\Writer\SvgWriter; use Endroid\QrCode\Writer\WriterInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; - use Throwable; + use function hexdec; use function ltrim; use function max; use function min; -use function self; use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function strlen; use function strtolower; use function substr; use function trim; + use const Shlinkio\Shlink\DEFAULT_QR_CODE_BG_COLOR; use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; @@ -44,8 +38,8 @@ final class QrCodeParams public readonly int $size, public readonly int $margin, public readonly WriterInterface $writer, - public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, - public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly ErrorCorrectionLevel $errorCorrectionLevel, + public readonly RoundBlockSizeMode $roundBlockSizeMode, public readonly ColorInterface $color, public readonly ColorInterface $bgColor, ) { @@ -98,23 +92,23 @@ final class QrCodeParams }; } - private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevelInterface + private static function resolveErrorCorrection(array $query, QrCodeOptions $defaults): ErrorCorrectionLevel { $errorCorrectionLevel = self::normalizeParam($query['errorCorrection'] ?? $defaults->errorCorrection); return match ($errorCorrectionLevel) { - 'h' => new ErrorCorrectionLevelHigh(), - 'q' => new ErrorCorrectionLevelQuartile(), - 'm' => new ErrorCorrectionLevelMedium(), - default => new ErrorCorrectionLevelLow(), // 'l' + 'h' => ErrorCorrectionLevel::High, + 'q' => ErrorCorrectionLevel::Quartile, + 'm' => ErrorCorrectionLevel::Medium, + default => ErrorCorrectionLevel::Low, // 'l' }; } - private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeModeInterface + private static function resolveRoundBlockSize(array $query, QrCodeOptions $defaults): RoundBlockSizeMode { $doNotRoundBlockSize = isset($query['roundBlockSize']) ? $query['roundBlockSize'] === 'false' : ! $defaults->roundBlockSize; - return $doNotRoundBlockSize ? new RoundBlockSizeModeNone() : new RoundBlockSizeModeMargin(); + return $doNotRoundBlockSize ? RoundBlockSizeMode::None : RoundBlockSizeMode::Margin; } private static function resolveColor(array $query, QrCodeOptions $defaults): ColorInterface @@ -136,16 +130,16 @@ final class QrCodeParams try { if (strlen($hexColor) === 3) { return new Color( - hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), - hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), - hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), + (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), + (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), + (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), ); } return new Color( - hexdec(substr($hexColor, 0, 2)), - hexdec(substr($hexColor, 2, 2)), - hexdec(substr($hexColor, 4, 2)), + (int) hexdec(substr($hexColor, 0, 2)), + (int) hexdec(substr($hexColor, 2, 2)), + (int) hexdec(substr($hexColor, 4, 2)), ); } catch (Throwable $e) { // If a non-hex value was provided and an error occurs, fall back to the default color. diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 748ed01b..53fb1251 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -5,7 +5,6 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Action; use Endroid\QrCode\Builder\Builder; -use Endroid\QrCode\Color\Color; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; use Psr\Http\Server\MiddlewareInterface; From 689343d1c9e54c0a46fc47bb5dade59a436d17a9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 18 Feb 2024 21:02:35 +0100 Subject: [PATCH 35/97] Test QR codes logic when providing a color --- config/constants.php | 4 +- module/Core/src/Action/Model/QrCodeParams.php | 41 +++++------ module/Core/test/Action/QrCodeActionTest.php | 68 +++++++++++++++---- phpunit.xml.dist | 1 + 4 files changed, 74 insertions(+), 40 deletions(-) diff --git a/config/constants.php b/config/constants.php index 8d2e55cb..51ee0476 100644 --- a/config/constants.php +++ b/config/constants.php @@ -20,5 +20,5 @@ const DEFAULT_QR_CODE_FORMAT = 'png'; const DEFAULT_QR_CODE_ERROR_CORRECTION = 'l'; const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = true; const DEFAULT_QR_CODE_ENABLED_FOR_DISABLED_SHORT_URLS = true; -const DEFAULT_QR_CODE_COLOR = '#000'; // Black -const DEFAULT_QR_CODE_BG_COLOR = '#fff'; // White +const DEFAULT_QR_CODE_COLOR = '#000000'; // Black +const DEFAULT_QR_CODE_BG_COLOR = '#ffffff'; // White diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 0fc1ebc6..2a3907cc 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -13,8 +13,8 @@ use Endroid\QrCode\Writer\SvgWriter; use Endroid\QrCode\Writer\WriterInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Options\QrCodeOptions; -use Throwable; +use function ctype_xdigit; use function hexdec; use function ltrim; use function max; @@ -126,30 +126,23 @@ final class QrCodeParams private static function parseHexColor(string $hexColor, ?string $fallback): Color { $hexColor = ltrim($hexColor, '#'); - - try { - if (strlen($hexColor) === 3) { - return new Color( - (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), - (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), - (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), - ); - } - - return new Color( - (int) hexdec(substr($hexColor, 0, 2)), - (int) hexdec(substr($hexColor, 2, 2)), - (int) hexdec(substr($hexColor, 4, 2)), - ); - } catch (Throwable $e) { - // If a non-hex value was provided and an error occurs, fall back to the default color. - // Do not provide the fallback again this time, to avoid an infinite loop - if ($fallback !== null) { - return self::parseHexColor($fallback, null); - } - - throw $e; + if (! ctype_xdigit($hexColor) && $fallback !== null) { + return self::parseHexColor($fallback, null); } + + if (strlen($hexColor) === 3) { + return new Color( + (int) hexdec(substr($hexColor, 0, 1) . substr($hexColor, 0, 1)), + (int) hexdec(substr($hexColor, 1, 1) . substr($hexColor, 1, 1)), + (int) hexdec(substr($hexColor, 2, 1) . substr($hexColor, 2, 1)), + ); + } + + return new Color( + (int) hexdec(substr($hexColor, 0, 2)), + (int) hexdec(substr($hexColor, 2, 2)), + (int) hexdec(substr($hexColor, 4, 2)), + ); } private static function normalizeParam(string $param): string diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 98e1e375..63f26b3f 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -24,9 +24,12 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use function getimagesizefromstring; +use function hexdec; use function imagecolorat; use function imagecreatefromstring; +use const Shlinkio\Shlink\DEFAULT_QR_CODE_COLOR; + class QrCodeActionTest extends TestCase { private const WHITE = 0xFFFFFF; @@ -46,10 +49,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willThrowException(ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain(''))); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->once())->method('handle')->withAnyParameters()->willReturn(new Response()); - $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); } #[Test] @@ -59,10 +62,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->expects($this->once())->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); - $delegate->expects($this->never())->method('handle'); + $handler = $this->createMock(RequestHandlerInterface::class); + $handler->expects($this->never())->method('handle'); - $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $delegate); + $resp = $this->action()->process((new ServerRequest())->withAttribute('shortCode', $shortCode), $handler); self::assertInstanceOf(QrCodeResponse::class, $resp); self::assertEquals(200, $resp->getStatusCode()); @@ -78,10 +81,10 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); $req = (new ServerRequest())->withAttribute('shortCode', $code)->withQueryParams($query); - $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $delegate); + $resp = $this->action(new QrCodeOptions(format: $defaultFormat))->process($req, $handler); self::assertEquals($expectedContentType, $resp->getHeaderLine('Content-Type')); } @@ -108,9 +111,9 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::createFake()); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $delegate); + $resp = $this->action($defaultOptions)->process($req->withAttribute('shortCode', $code), $handler); $result = getimagesizefromstring($resp->getBody()->__toString()); self::assertNotFalse($result); @@ -198,14 +201,14 @@ class QrCodeActionTest extends TestCase $this->urlResolver->method('resolveEnabledShortUrl')->with( ShortUrlIdentifier::fromShortCodeAndDomain($code, ''), )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); - $delegate = $this->createMock(RequestHandlerInterface::class); + $handler = $this->createMock(RequestHandlerInterface::class); - $resp = $this->action($defaultOptions)->process($req, $delegate); + $resp = $this->action($defaultOptions)->process($req, $handler); $image = imagecreatefromstring($resp->getBody()->__toString()); self::assertNotFalse($image); $color = imagecolorat($image, 1, 1); - self::assertEquals($color, $expectedColor); + self::assertEquals($expectedColor, $color); } public static function provideRoundBlockSize(): iterable @@ -230,10 +233,47 @@ class QrCodeActionTest extends TestCase ]; } + #[Test, DataProvider('provideColors')] + public function properColorsAreUsed(?string $queryColor, ?string $optionsColor, int $expectedColor): void + { + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals() + ->withQueryParams(['color' => $queryColor]) + ->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action( + new QrCodeOptions(size: 250, roundBlockSize: false, color: $optionsColor ?? DEFAULT_QR_CODE_COLOR), + )->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + $resultingColor = imagecolorat($image, 1, 1); + self::assertEquals($expectedColor, $resultingColor); + } + + public static function provideColors(): iterable + { + yield 'no query, no default' => [null, null, self::BLACK]; + yield '6-char-query black' => ['000000', null, self::BLACK]; + yield '6-char-query white' => ['ffffff', null, self::WHITE]; + yield '6-char-query red' => ['ff0000', null, (int) hexdec('ff0000')]; + yield '3-char-query black' => ['000', null, self::BLACK]; + yield '3-char-query white' => ['fff', null, self::WHITE]; + yield '3-char-query red' => ['f00', null, (int) hexdec('ff0000')]; + yield '3-char-default red' => [null, 'f00', (int) hexdec('ff0000')]; + yield 'invalid color in query' => ['zzzzzzzz', null, self::BLACK]; + yield 'invalid color in query with default' => ['zzzzzzzz', 'aa88cc', self::BLACK]; + yield 'invalid color in default' => [null, 'zzzzzzzz', self::BLACK]; + } + #[Test, DataProvider('provideEnabled')] public function qrCodeIsResolvedBasedOnOptions(bool $enabledForDisabledShortUrls): void { - if ($enabledForDisabledShortUrls) { $this->urlResolver->expects($this->once())->method('resolvePublicShortUrl')->willThrowException( ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('')), diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 5abec3eb..9c85d2c4 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -5,6 +5,7 @@ bootstrap="./vendor/autoload.php" colors="true" cacheDirectory="build/.phpunit/unit-tests.cache" + displayDetailsOnTestsThatTriggerWarnings="true" > From 23e9ed93bb1ccfa7c02cb0a3b83bc81c6a4bdf7b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Feb 2024 23:10:51 +0100 Subject: [PATCH 36/97] Add test for QrCodeAction with logo URL --- composer.json | 2 +- config/autoload/installer.global.php | 3 +++ module/Core/test/Action/QrCodeActionTest.php | 21 ++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 173c0eb1..8f9b86a6 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", - "shlinkio/shlink-installer": "dev-develop#b314455 as 9.0", + "shlinkio/shlink-installer": "dev-develop#41e433c as 9.0", "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index a3b477af..753bdb9a 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -59,6 +59,9 @@ return [ Option\QrCode\DefaultFormatConfigOption::class, Option\QrCode\DefaultErrorCorrectionConfigOption::class, Option\QrCode\DefaultRoundBlockSizeConfigOption::class, + Option\QrCode\DefaultColorConfigOption::class, + Option\QrCode\DefaultBgColorConfigOption::class, + Option\QrCode\DefaultLogoUrlConfigOption::class, Option\QrCode\EnabledForDisabledShortUrlsConfigOption::class, Option\RabbitMq\RabbitMqEnabledConfigOption::class, Option\RabbitMq\RabbitMqHostConfigOption::class, diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 63f26b3f..08564bf9 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -293,6 +293,27 @@ class QrCodeActionTest extends TestCase ); } + #[Test] + public function logoIsAddedToQrCodeIfOptionIsDefined(): void + { + $logoUrl = 'https://avatars.githubusercontent.com/u/20341790?v=4'; // Shlink logo + $code = 'abc123'; + $req = ServerRequestFactory::fromGlobals()->withAttribute('shortCode', $code); + + $this->urlResolver->method('resolveEnabledShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain($code), + )->willReturn(ShortUrl::withLongUrl('https://shlink.io')); + $handler = $this->createMock(RequestHandlerInterface::class); + + $resp = $this->action(new QrCodeOptions(size: 250, logoUrl: $logoUrl))->process($req, $handler); + $image = imagecreatefromstring($resp->getBody()->__toString()); + self::assertNotFalse($image); + + // At around 100x100 px we can already find the logo, which has Shlink's brand color + $resultingColor = imagecolorat($image, 100, 100); + self::assertEquals(hexdec('4696E5'), $resultingColor); + } + public static function provideEnabled(): iterable { yield 'always enabled' => [true]; From 2a0364ca8f772504011094e8692db954cf3bfb4f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 19 Feb 2024 23:13:09 +0100 Subject: [PATCH 37/97] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb731dc..cced1725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. +* [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo. + ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. * [#1988](https://github.com/shlinkio/shlink/issues/1988) Remove dependency on `league\uri` package. From 8f6fc97fc8797a885621ba581acf47ffcfbf2d07 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 18:23:37 +0100 Subject: [PATCH 38/97] Simplify and improve how code coverage is generated in API and CLI tests --- .github/workflows/ci.yml | 5 ++-- CHANGELOG.md | 1 + bin/test/run-api-tests.sh | 2 +- composer.json | 11 ++++---- config/test/bootstrap_api_tests.php | 14 --------- config/test/test_config.global.php | 44 ++++++++--------------------- 6 files changed, 22 insertions(+), 55 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1777d139..3f33b0d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,10 +133,9 @@ jobs: - run: mv build/coverage-db/coverage-db.cov build/coverage-db.cov - run: mv build/coverage-api/coverage-api.cov build/coverage-api.cov - run: mv build/coverage-cli/coverage-cli.cov build/coverage-cli.cov - - run: wget https://phar.phpunit.de/phpcov-10.0.0.phar - - run: php phpcov-10.0.0.phar merge build --clover build/clover.xml + - run: vendor/bin/phpcov merge build --clover build/clover.xml - name: Publish coverage - uses: codecov/codecov-action@v1 + uses: codecov/codecov-action@v4 with: file: ./build/clover.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index cced1725..39797631 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. * [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0. * [#2010](https://github.com/shlinkio/shlink/issues/2010) Update to Symfony 7.0 components. +* [#2016](https://github.com/shlinkio/shlink/issues/2016) Simplify and improve how code coverage is generated in API and CLI tests". ### Deprecated * *Nothing* diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 18ad2dd5..27fb1ae2 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -25,7 +25,7 @@ sleep 2 # Let's give the server a couple of seconds to start vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* TESTS_EXIT_CODE=$? -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c config/roadrunner/.rr.dev.yml -o=http.address=0.0.0.0:9999 +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c=config/roadrunner/.rr.dev.yml -w . -o=http.address=0.0.0.0:9999 # Exit this script with the same code as the tests. If tests failed, this script has to fail exit $TESTS_EXIT_CODE diff --git a/composer.json b/composer.json index 8f9b86a6..d17c0631 100644 --- a/composer.json +++ b/composer.json @@ -68,10 +68,11 @@ "phpstan/phpstan-phpunit": "^1.3", "phpstan/phpstan-symfony": "^1.3", "phpunit/php-code-coverage": "^10.1", + "phpunit/phpcov": "^9.0", "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^3.11", + "shlinkio/shlink-test-utils": "^4.0", "symfony/var-dumper": "^7.0", "veewee/composer-run-parallel": "^1.3" }, @@ -128,11 +129,11 @@ "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:api:ci": "GENERATE_COVERAGE=yes composer test:api", - "test:api:pretty": "GENERATE_COVERAGE=pretty composer test:api", + "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --xml build/coverage-api/coverage-xml --php build/coverage-api.cov && rm build/coverage-api/*.cov", + "test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", - "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli", - "test:cli:pretty": "GENERATE_COVERAGE=pretty composer test:cli", + "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --xml build/coverage-cli/coverage-xml --php build/coverage-cli.cov && rm build/coverage-cli/*.cov", + "test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov", "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests", "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", diff --git a/config/test/bootstrap_api_tests.php b/config/test/bootstrap_api_tests.php index b82e5bc6..8f757c05 100644 --- a/config/test/bootstrap_api_tests.php +++ b/config/test/bootstrap_api_tests.php @@ -7,12 +7,6 @@ namespace Shlinkio\Shlink\TestUtils; use Doctrine\ORM\EntityManager; use Psr\Container\ContainerInterface; -use function register_shutdown_function; -use function sprintf; - -use const ShlinkioTest\Shlink\API_TESTS_HOST; -use const ShlinkioTest\Shlink\API_TESTS_PORT; - /** @var ContainerInterface $container */ $container = require __DIR__ . '/../container.php'; $testHelper = $container->get(Helper\TestHelper::class); @@ -20,14 +14,6 @@ $config = $container->get('config'); $em = $container->get(EntityManager::class); $httpClient = $container->get('shlink_test_api_client'); -// Dump code coverage when process shuts down -register_shutdown_function(function () use ($httpClient): void { - $httpClient->request( - 'GET', - sprintf('http://%s:%s/api-tests/stop-coverage', API_TESTS_HOST, API_TESTS_PORT), - ); -}); - $testHelper->createTestDb( createDbCommand: ['bin/cli', 'db:create'], migrateDbCommand: ['bin/cli', 'db:migrate'], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index d86700b9..55f06bbf 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink; use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; -use Laminas\Diactoros\Response\EmptyResponse; use Laminas\Diactoros\Response\HtmlResponse; use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Router\FastRouteRouter; @@ -19,23 +18,26 @@ use Symfony\Component\Console\Application; use function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; -use function Shlinkio\Shlink\Core\ArrayUtils\contains; use function sleep; use function sprintf; use const ShlinkioTest\Shlink\API_TESTS_HOST; use const ShlinkioTest\Shlink\API_TESTS_PORT; -$isApiTest = env('TEST_ENV') === 'api'; -$isCliTest = env('TEST_ENV') === 'cli'; +$testEnv = env('TEST_ENV'); +$isApiTest = $testEnv === 'api'; +$isCliTest = $testEnv === 'cli'; $isE2eTest = $isApiTest || $isCliTest; $coverageType = env('GENERATE_COVERAGE'); -$generateCoverage = contains($coverageType, ['yes', 'pretty']); -$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories([ - __DIR__ . '/../../module/Core/src', - __DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src', -]) : null; +$generateCoverage = $coverageType === 'yes'; +$coverage = $isE2eTest && $generateCoverage ? CoverageHelper::createCoverageForDirectories( + [ + __DIR__ . '/../../module/Core/src', + __DIR__ . '/../../module/' . ($isApiTest ? 'Rest' : 'CLI') . '/src', + ], + __DIR__ . '/../../build/coverage-' . $testEnv, +) : null; $buildDbConnection = static function (): array { $driver = env('DB_DRIVER', 'sqlite'); @@ -99,23 +101,6 @@ return [ ], 'routes' => [ - // This route is invoked at the end of API tests, in order to dump coverage collected so far - [ - 'name' => 'dump_coverage', - 'path' => '/api-tests/stop-coverage', - 'allowed_methods' => ['GET'], - 'middleware' => middleware(static function () use ($coverage, $coverageType) { - // TODO I have tried moving this block to a register_shutdown_function here, which internally checks if - // RR_MODE === 'http', but this seems to be false in CI, causing the coverage to not be generated - CoverageHelper::exportCoverage( - $coverage, - __DIR__ . '/../../build/coverage-api', - pretty: $coverageType === 'pretty', - ); - return new EmptyResponse(); - }), - ], - // This route is used to test that title resolution is skipped if the long URL times out [ 'name' => 'long_url_with_timeout', @@ -154,12 +139,7 @@ return [ ], 'delegators' => $isCliTest ? [ Application::class => [ - new CliCoverageDelegator(fn () => CoverageHelper::exportCoverage( - $coverage, - __DIR__ . '/../../build/coverage-cli', - pretty: $coverageType === 'pretty', - mergeWithExisting: true, - ), $coverage), + new CliCoverageDelegator($coverage), ], ] : [], ], From 3f1b89d665d7535ba3ff005d2d6bb61e7464ed8e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 18:30:34 +0100 Subject: [PATCH 39/97] Install dependencies in upload-coverage CI job --- .github/workflows/ci.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3f33b0d1..2eef1afc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -121,11 +121,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Use PHP - uses: shivammathur/setup-php@v2 + uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - coverage: pcov - ini-values: pcov.directory=module + extensions-cache-key: tests-extensions-${{ matrix.php-version }} - uses: actions/download-artifact@v4 with: path: build From 4f5ce9fb43b329f8dff000c07dc0f3f9daf60f08 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 22:16:09 +0100 Subject: [PATCH 40/97] Remove dependency on infection and mutation tests --- .dockerignore | 1 - .github/workflows/ci-mutation-tests.yml | 44 ----------------------- .github/workflows/ci.yml | 34 ------------------ CONTRIBUTING.md | 1 - README.md | 3 +- bin/test/run-api-tests.sh | 2 +- composer.json | 48 +++++-------------------- infection-api.json5 | 24 ------------- infection-cli.json5 | 24 ------------- infection-db.json5 | 24 ------------- infection.json5 | 26 -------------- 11 files changed, 11 insertions(+), 220 deletions(-) delete mode 100644 .github/workflows/ci-mutation-tests.yml delete mode 100644 infection-api.json5 delete mode 100644 infection-cli.json5 delete mode 100644 infection-db.json5 delete mode 100644 infection.json5 diff --git a/.dockerignore b/.dockerignore index beca6373..e3aff686 100644 --- a/.dockerignore +++ b/.dockerignore @@ -19,7 +19,6 @@ indocker docker-* phpstan.neon php*xml* -infection* **/test* build* **/.* diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml deleted file mode 100644 index c34902d3..00000000 --- a/.github/workflows/ci-mutation-tests.yml +++ /dev/null @@ -1,44 +0,0 @@ -name: Mutation tests - -on: - workflow_call: - inputs: - test-group: - type: string - required: true - description: One of unit, db, api or cli - -jobs: - mutation-tests: - runs-on: ubuntu-22.04 - strategy: - matrix: - php-version: ['8.2', '8.3'] - steps: - - uses: actions/checkout@v4 - - uses: './.github/actions/ci-setup' - with: - php-version: ${{ matrix.php-version }} - extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - - uses: actions/download-artifact@v4 - with: - name: coverage-${{ inputs.test-group }} - path: build - - name: Resolve infection args - id: infection_args - run: echo "args=--logger-github=false" >> $GITHUB_OUTPUT -# TODO Try to filter mutation tests to improve execution times. Investigate why --git-diff-lines --git-diff-base=develop does not work -# run: | -# BRANCH="${GITHUB_REF#refs/heads/}" | -# if [[ $BRANCH == 'main' || $BRANCH == 'develop' ]]; then -# echo "args=--logger-github=false" >> $GITHUB_OUTPUT -# else -# echo "args=--logger-github=false --git-diff-lines --git-diff-base=develop" >> $GITHUB_OUTPUT -# fi; - shell: bash - - if: ${{ inputs.test-group == 'unit' }} - run: composer infect:ci:unit -- ${{ steps.infection_args.outputs.args }} - env: - INFECTION_BADGE_API_KEY: ${{ secrets.INFECTION_BADGE_API_KEY }} - - if: ${{ inputs.test-group != 'unit' }} - run: composer infect:ci:${{ inputs.test-group }} -- ${{ steps.infection_args.outputs.args }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2eef1afc..64612bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,7 +8,6 @@ on: - '*.md' - '*.xml' - '*.yml*' - - '*.json5' - '*.neon' push: branches: @@ -21,7 +20,6 @@ on: - '*.md' - '*.xml' - '*.yml*' - - '*.json5' - '*.neon' jobs: @@ -79,34 +77,6 @@ jobs: with: platform: 'ms' - unit-mutation-tests: - needs: - - unit-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: unit - - db-mutation-tests: - needs: - - sqlite-db-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: db - - api-mutation-tests: - needs: - - api-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: api - - cli-mutation-tests: - needs: - - cli-tests - uses: './.github/workflows/ci-mutation-tests.yml' - with: - test-group: cli - upload-coverage: needs: - unit-tests @@ -140,10 +110,6 @@ jobs: delete-artifacts: needs: - - unit-mutation-tests - - db-mutation-tests - - api-mutation-tests - - cli-mutation-tests - upload-coverage runs-on: ubuntu-22.04 steps: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e9a77964..4ee94c70 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -124,7 +124,6 @@ Depending on the kind of contribution, maybe not all kinds of tests are needed, * Run `./indocker composer test:api` to run API E2E tests. For these, the Postgres database engine is used. * Run `./indocker composer test:cli` to run CLI E2E tests. For these, the Maria DB database engine is used. -* Run `./indocker composer infect:test` to run both unit and database tests (over sqlite) and then apply mutations to them with [infection](https://infection.github.io/). * Run `./indocker composer ci` to run all previous commands together, parallelizing non-conflicting tasks as much as possible. ## Testing endpoints diff --git a/README.md b/README.md index 2a2d3f6c..21ea1aa2 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@ [![Build Status](https://img.shields.io/github/actions/workflow/status/shlinkio/shlink/ci.yml?branch=develop&logo=github&style=flat-square)](https://github.com/shlinkio/shlink/actions/workflows/ci.yml?query=workflow%3A%22Continuous+integration%22) [![Code Coverage](https://img.shields.io/codecov/c/gh/shlinkio/shlink/develop?style=flat-square)](https://app.codecov.io/gh/shlinkio/shlink) -[![Infection MSI](https://img.shields.io/endpoint?style=flat-square&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fshlinkio%2Fshlink%2Fdevelop)](https://dashboard.stryker-mutator.io/reports/github.com/shlinkio/shlink/develop) [![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/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) +[![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) [![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 domain. diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 27fb1ae2..003d3a8f 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -22,7 +22,7 @@ echo 'Starting server...' -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & sleep 2 # Let's give the server a couple of seconds to start -vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always --log-junit=build/coverage-api/junit.xml $* +vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* TESTS_EXIT_CODE=$? [ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c=config/roadrunner/.rr.dev.yml -w . -o=http.address=0.0.0.0:9999 diff --git a/composer.json b/composer.json index d17c0631..15e7ab89 100644 --- a/composer.json +++ b/composer.json @@ -62,7 +62,6 @@ "require-dev": { "devizzent/cebe-php-openapi": "^1.0.1", "devster/ubench": "^2.1", - "infection/infection": "^0.27", "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.3", "phpstan/phpstan-phpunit": "^1.3", @@ -108,8 +107,8 @@ }, "scripts": { "ci": [ - "@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", - "@parallel infect:test:api infect:test:cli infect:ci:unit infect:ci:db" + "@parallel cs stan swagger:validate test:unit:ci test:db:sqlite:ci test:db:postgres test:db:mysql test:db:maria test:db:ms", + "@parallel test:api:ci test:cli:ci" ], "cs": "phpcs -s", "cs:fix": "phpcbf", @@ -119,53 +118,27 @@ "@parallel test:api test:cli" ], "test:unit": "@php vendor/bin/phpunit --order-by=random --colors=always --testdox", - "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov --coverage-xml=build/coverage-unit/coverage-xml --log-junit=build/coverage-unit/junit.xml", + "test:unit:ci": "@test:unit --coverage-php=build/coverage-unit.cov", "test:unit:pretty": "@test:unit --coverage-html build/coverage-unit/coverage-html", "test:db": "@parallel test:db:sqlite:ci test:db:mysql test:db:maria test:db:postgres test:db:ms", "test:db:sqlite": "APP_ENV=test php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-db.xml", - "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov --coverage-xml=build/coverage-db/coverage-xml --log-junit=build/coverage-db/junit.xml", + "test:db:sqlite:ci": "@test:db:sqlite --coverage-php build/coverage-db.cov", "test:db:mysql": "DB_DRIVER=mysql composer test:db:sqlite", "test:db:maria": "DB_DRIVER=maria composer test:db:sqlite", "test:db:postgres": "DB_DRIVER=postgres composer test:db:sqlite", "test:db:ms": "DB_DRIVER=mssql composer test:db:sqlite", "test:api": "bin/test/run-api-tests.sh", - "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --xml build/coverage-api/coverage-xml --php build/coverage-api.cov && rm build/coverage-api/*.cov", + "test:api:ci": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --php build/coverage-api.cov && rm build/coverage-api/*.cov", "test:api:pretty": "GENERATE_COVERAGE=yes composer test:api && vendor/bin/phpcov merge build/coverage-api --html build/coverage-api/coverage-html && rm build/coverage-api/*.cov", - "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml --log-junit=build/coverage-cli/junit.xml", - "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --xml build/coverage-cli/coverage-xml --php build/coverage-cli.cov && rm build/coverage-cli/*.cov", + "test:cli": "APP_ENV=test DB_DRIVER=maria TEST_ENV=cli php vendor/bin/phpunit --order-by=random --colors=always --testdox -c phpunit-cli.xml", + "test:cli:ci": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --php build/coverage-cli.cov && rm build/coverage-cli/*.cov", "test:cli:pretty": "GENERATE_COVERAGE=yes composer test:cli && vendor/bin/phpcov merge build/coverage-cli --html build/coverage-cli/coverage-html && rm build/coverage-cli/*.cov", - "infect:ci:base": "infection --threads=max --only-covered --skip-initial-tests", - "infect:ci:unit": "@infect:ci:base --coverage=build/coverage-unit --min-msi=80", - "infect:ci:db": "@infect:ci:base --coverage=build/coverage-db --min-msi=95 --configuration=infection-db.json5", - "infect:ci:api": "@infect:ci:base --coverage=build/coverage-api --min-msi=95 --configuration=infection-api.json5", - "infect:ci:cli": "@infect:ci:base --coverage=build/coverage-cli --min-msi=90 --configuration=infection-cli.json5", - "infect:ci": "@parallel infect:ci:unit infect:ci:db infect:ci:api infect:ci:cli", - "infect:test": [ - "@parallel test:unit:ci test:db:sqlite:ci test:api:ci", - "@infect:ci" - ], - "infect:test:unit": [ - "@test:unit:ci", - "@infect:ci:unit" - ], - "infect:test:db": [ - "@test:db:sqlite:ci", - "@infect:ci:db" - ], - "infect:test:api": [ - "@test:api:ci", - "@infect:ci:api" - ], - "infect:test:cli": [ - "@test:cli:ci", - "@infect:ci:cli" - ], "swagger:validate": "php-openapi validate docs/swagger/swagger.json", "swagger:inline": "php-openapi inline docs/swagger/swagger.json docs/swagger/swagger-inlined.json", "clean:dev": "rm -f data/database.sqlite && rm -f config/params/generated_config.php" }, "scripts-descriptions": { - "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\", \"test:ci\" and \"infect:ci\"", + "ci": "Alias for \"cs\", \"stan\", \"swagger:validate\" and \"test:ci\"", "cs": "Checks coding styles", "cs:fix": "Fixes coding styles, when possible", "stan": "Inspects code with phpstan", @@ -186,10 +159,6 @@ "test:cli": "Runs CLI test suites", "test:cli:ci": "Runs CLI test suites, and generates code coverage for CI", "test:cli:pretty": "Runs CLI test suites, and generates code coverage in HTML format", - "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs", - "infect:ci:unit": "Checks unit tests quality applying mutation testing with existing reports and logs", - "infect:ci:db": "Checks db tests quality applying mutation testing with existing reports and logs", - "infect:test": "Runs unit and db tests, then checks tests quality applying mutation testing", "swagger:validate": "Validates the swagger docs, making sure they fulfil the spec", "swagger:inline": "Inlines swagger docs in a single file", "clean:dev": "Deletes artifacts which are gitignored and could affect dev env" @@ -200,7 +169,6 @@ "allow-plugins": { "composer/package-versions-deprecated": true, "dealerdirect/phpcodesniffer-composer-installer": true, - "infection/extension-installer": true, "veewee/composer-run-parallel": true } } diff --git a/infection-api.json5 b/infection-api.json5 deleted file mode 100644 index e2cd08dc..00000000 --- a/infection-api.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - 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' - }, - tmpDir: 'build/infection-api/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-api.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection-cli.json5 b/infection-cli.json5 deleted file mode 100644 index cc809fba..00000000 --- a/infection-cli.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - timeout: 5, - logs: { - text: 'build/infection-cli/infection-log.txt', - html: 'build/infection-cli/infection-log.html', - summary: 'build/infection-cli/summary-log.txt', - debug: 'build/infection-cli/debug-log.txt' - }, - tmpDir: 'build/infection-cli/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-cli.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection-db.json5 b/infection-db.json5 deleted file mode 100644 index 1f484343..00000000 --- a/infection-db.json5 +++ /dev/null @@ -1,24 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - 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' - }, - tmpDir: 'build/infection-db/temp', - phpUnit: { - configDir: '.' - }, - testFrameworkOptions: '--configuration=phpunit-db.xml', - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} diff --git a/infection.json5 b/infection.json5 deleted file mode 100644 index 050a08e3..00000000 --- a/infection.json5 +++ /dev/null @@ -1,26 +0,0 @@ -{ - source: { - directories: [ - 'module/*/src' - ] - }, - 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', - stryker: { - report: 'develop' - } - }, - tmpDir: 'build/infection-unit/temp', - phpUnit: { - configDir: '.' - }, - mutators: { - '@default': true, - IdenticalEqual: false, - NotIdenticalNotEqual: false - } -} From 7b9331bd14a4597ef77bd32529db0bb4eb60559e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 22:26:04 +0100 Subject: [PATCH 41/97] Merge DB test jobs into one with a matrix --- .github/workflows/ci.yml | 29 ++++++----------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 64612bb0..933d71b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,37 +52,20 @@ jobs: with: test-group: api - sqlite-db-tests: + db-tests: + strategy: + matrix: + platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] uses: './.github/workflows/ci-db-tests.yml' with: - platform: 'sqlite:ci' - - mysql-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'mysql' - - maria-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'maria' - - postgres-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'postgres' - - ms-db-tests: - uses: './.github/workflows/ci-db-tests.yml' - with: - platform: 'ms' + platform: ${{ matrix.platform }} upload-coverage: needs: - unit-tests - api-tests - cli-tests - - sqlite-db-tests + - db-tests runs-on: ubuntu-22.04 strategy: matrix: From 50cc7ae632c98bc2029041f44c4d3c836dcf47c6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 22:33:10 +0100 Subject: [PATCH 42/97] Fix donate URL --- .github/FUNDING.yml | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 53e16c98..41f36795 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,2 +1,2 @@ github: ['acelaya'] -custom: ['https://acel.me/donate'] +custom: ['https://slnk.to/donate'] diff --git a/README.md b/README.md index 21ea1aa2..7f8cc164 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,9 @@ [![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/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](https://twitter.com/shlinkio) [![Mastodon](https://img.shields.io/mastodon/follow/109329425426175098?color=%236364ff&domain=https%3A%2F%2Ffosstodon.org&label=follow&logo=mastodon&logoColor=white&style=flat-square)](https://fosstodon.org/@shlinkio) [![Bluesky](https://img.shields.io/badge/follow-shlinkio-0285FF.svg?style=flat-square&logo=bluesky&logoColor=white)](https://bsky.app/profile/shlinkio.bsky.social) +[![Twitter](https://img.shields.io/badge/follow-shlinkio-blue.svg?style=flat-square&logo=x&color=black)](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 domain. From 0e78deb8f2c4bda82c914f405f867a241c6f3cf7 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 10:12:40 +0100 Subject: [PATCH 43/97] Refactor ShortUrlInputFilter for creation and edition --- .../src/ShortUrl/Model/ShortUrlCreation.php | 40 +++--- .../src/ShortUrl/Model/ShortUrlEdition.php | 42 +++--- .../Model/Validation/CustomSlugFilter.php | 11 +- .../Model/Validation/ShortUrlInputFilter.php | 124 ++++++++++-------- 4 files changed, 115 insertions(+), 102 deletions(-) diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 1a1673ac..976973b2 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -18,39 +18,39 @@ use function Shlinkio\Shlink\Core\normalizeOptionalDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; -final class ShortUrlCreation implements TitleResolutionModelInterface +final readonly class ShortUrlCreation implements TitleResolutionModelInterface { /** * @param string[] $tags * @param DeviceLongUrlPair[] $deviceLongUrls */ private function __construct( - public readonly string $longUrl, - public readonly ShortUrlMode $shortUrlMode, - public readonly array $deviceLongUrls = [], - public readonly ?Chronos $validSince = null, - public readonly ?Chronos $validUntil = null, - public readonly ?string $customSlug = null, - public readonly ?int $maxVisits = null, - public readonly bool $findIfExists = false, - public readonly ?string $domain = null, - public readonly int $shortCodeLength = 5, - public readonly ?ApiKey $apiKey = null, - public readonly array $tags = [], - public readonly ?string $title = null, - public readonly bool $titleWasAutoResolved = false, - public readonly bool $crawlable = false, - public readonly bool $forwardQuery = true, + public string $longUrl, + public ShortUrlMode $shortUrlMode, + public array $deviceLongUrls = [], + public ?Chronos $validSince = null, + public ?Chronos $validUntil = null, + public ?string $customSlug = null, + public ?string $pathPrefix = null, + public ?int $maxVisits = null, + public bool $findIfExists = false, + public ?string $domain = null, + public int $shortCodeLength = 5, + public ?ApiKey $apiKey = null, + public array $tags = [], + public ?string $title = null, + public bool $titleWasAutoResolved = false, + public bool $crawlable = false, + public bool $forwardQuery = true, ) { } /** * @throws ValidationException */ - public static function fromRawData(array $data, ?UrlShortenerOptions $options = null): self + public static function fromRawData(array $data, UrlShortenerOptions $options = new UrlShortenerOptions()): self { - $options = $options ?? new UrlShortenerOptions(); - $inputFilter = ShortUrlInputFilter::withRequiredLongUrl($data, $options); + $inputFilter = ShortUrlInputFilter::forCreation($data, $options); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 7a0000de..2502331a 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\normalizeOptionalDate; -final class ShortUrlEdition implements TitleResolutionModelInterface +final readonly class ShortUrlEdition implements TitleResolutionModelInterface { /** * @param string[] $tags @@ -23,25 +23,25 @@ final class ShortUrlEdition implements TitleResolutionModelInterface * @param DeviceType[] $devicesToRemove */ private function __construct( - private readonly bool $longUrlPropWasProvided = false, - public readonly ?string $longUrl = null, - public readonly array $deviceLongUrls = [], - public readonly array $devicesToRemove = [], - private readonly bool $validSincePropWasProvided = false, - public readonly ?Chronos $validSince = null, - private readonly bool $validUntilPropWasProvided = false, - public readonly ?Chronos $validUntil = null, - private readonly bool $maxVisitsPropWasProvided = false, - public readonly ?int $maxVisits = null, - private readonly bool $tagsPropWasProvided = false, - public readonly array $tags = [], - private readonly bool $titlePropWasProvided = false, - public readonly ?string $title = null, - public readonly bool $titleWasAutoResolved = false, - private readonly bool $crawlablePropWasProvided = false, - public readonly bool $crawlable = false, - private readonly bool $forwardQueryPropWasProvided = false, - public readonly bool $forwardQuery = true, + private bool $longUrlPropWasProvided = false, + public ?string $longUrl = null, + public array $deviceLongUrls = [], + public array $devicesToRemove = [], + private bool $validSincePropWasProvided = false, + public ?Chronos $validSince = null, + private bool $validUntilPropWasProvided = false, + public ?Chronos $validUntil = null, + private bool $maxVisitsPropWasProvided = false, + public ?int $maxVisits = null, + private bool $tagsPropWasProvided = false, + public array $tags = [], + private bool $titlePropWasProvided = false, + public ?string $title = null, + public bool $titleWasAutoResolved = false, + private bool $crawlablePropWasProvided = false, + public bool $crawlable = false, + private bool $forwardQueryPropWasProvided = false, + public bool $forwardQuery = true, ) { } @@ -50,7 +50,7 @@ final class ShortUrlEdition implements TitleResolutionModelInterface */ public static function fromRawData(array $data): self { - $inputFilter = ShortUrlInputFilter::withNonRequiredLongUrl($data); + $inputFilter = ShortUrlInputFilter::forEdition($data); if (! $inputFilter->isValid()) { throw ValidationException::fromInputFilter($inputFilter); } diff --git a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php index d7012bf1..2512fc44 100644 --- a/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/CustomSlugFilter.php @@ -12,9 +12,9 @@ use function str_replace; use function strtolower; use function trim; -class CustomSlugFilter implements FilterInterface +readonly class CustomSlugFilter implements FilterInterface { - public function __construct(private readonly UrlShortenerOptions $options) + public function __construct(private UrlShortenerOptions $options) { } @@ -25,9 +25,8 @@ class CustomSlugFilter implements FilterInterface } $value = $this->options->isLooseMode() ? strtolower($value) : $value; - return (match ($this->options->multiSegmentSlugsEnabled) { - true => trim(str_replace(' ', '-', $value), '/'), - false => str_replace([' ', '/'], '-', $value), - }); + return $this->options->multiSegmentSlugsEnabled + ? trim(str_replace(' ', '-', $value), '/') + : str_replace([' ', '/'], '-', $value); } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 325b400f..ad3a6df7 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -19,68 +19,52 @@ use function substr; use const Shlinkio\Shlink\LOOSE_URI_MATCHER; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; -/** - * @todo Pass forCreation/forEdition, instead of withRequiredLongUrl/withNonRequiredLongUrl. - * Make it also dynamically add the relevant fields - */ class ShortUrlInputFilter extends InputFilter { use Validation\InputFactoryTrait; - public const VALID_SINCE = 'validSince'; - public const VALID_UNTIL = 'validUntil'; + // Fields for creation only + public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const CUSTOM_SLUG = 'customSlug'; - public const MAX_VISITS = 'maxVisits'; + public const PATH_PREFIX = 'pathPrefix'; public const FIND_IF_EXISTS = 'findIfExists'; public const DOMAIN = 'domain'; - public const SHORT_CODE_LENGTH = 'shortCodeLength'; + + // Fields for creation and edition public const LONG_URL = 'longUrl'; public const DEVICE_LONG_URLS = 'deviceLongUrls'; - public const API_KEY = 'apiKey'; - public const TAGS = 'tags'; + public const VALID_SINCE = 'validSince'; + public const VALID_UNTIL = 'validUntil'; + public const MAX_VISITS = 'maxVisits'; public const TITLE = 'title'; + public const TAGS = 'tags'; public const CRAWLABLE = 'crawlable'; public const FORWARD_QUERY = 'forwardQuery'; + public const API_KEY = 'apiKey'; - private function __construct(array $data, bool $requireLongUrl, UrlShortenerOptions $options) + public static function forCreation(array $data, UrlShortenerOptions $options): self { - $this->initialize($requireLongUrl, $options); - $this->setData($data); + $instance = new self(); + $instance->initializeForCreation($options); + $instance->setData($data); + + return $instance; } - public static function withRequiredLongUrl(array $data, UrlShortenerOptions $options): self + public static function forEdition(array $data): self { - return new self($data, true, $options); + $instance = new self(); + $instance->initializeForEdition(); + $instance->setData($data); + + return $instance; } - public static function withNonRequiredLongUrl(array $data): self + private function initializeForCreation(UrlShortenerOptions $options): void { - return new self($data, false, new UrlShortenerOptions()); - } - - private function initialize(bool $requireLongUrl, UrlShortenerOptions $options): void - { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); - $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); - $this->add($longUrlInput); - - $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, false); - $deviceLongUrlsInput->getValidatorChain()->attach( - new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), - ); - $this->add($deviceLongUrlsInput); - - $validSince = $this->createInput(self::VALID_SINCE, false); - $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); - $this->add($validSince); - - $validUntil = $this->createInput(self::VALID_UNTIL, false); - $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); - $this->add($validUntil); - // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value - // is with setContinueIfEmpty - $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); + // is with setContinueIfEmpty(true) + $customSlug = $this->createInput(self::CUSTOM_SLUG, required: false)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain() ->attach(new Validator\NotEmpty([ @@ -90,32 +74,62 @@ class ShortUrlInputFilter extends InputFilter ->attach(CustomSlugValidator::forUrlShortenerOptions($options)); $this->add($customSlug); - $this->add($this->createNumericInput(self::MAX_VISITS, false)); - $this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, false, MIN_SHORT_CODES_LENGTH)); + // The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into + // consideration if multi-segment slugs are enabled or not. + // The only difference is that empty values are allowed here. + $pathPrefix = $this->createInput(self::PATH_PREFIX, required: false); + $pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options)); + $pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options)); + $this->add($pathPrefix); - $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, false)); + $this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, required: false, min: MIN_SHORT_CODES_LENGTH)); + $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, required: false)); - // This cannot be defined as a boolean inputs, because they can actually have 3 values: true, false and null. - // Defining them as boolean will make null fall back to false, which is not the desired behavior. - $this->add($this->createInput(self::FORWARD_QUERY, false)); - - $domain = $this->createInput(self::DOMAIN, false); + $domain = $this->createInput(self::DOMAIN, required: false); $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); $this->add($domain); - $apiKeyInput = $this->createInput(self::API_KEY, false); - $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); - $this->add($apiKeyInput); + $this->initializeForEdition(requireLongUrl: true); + } - $this->add($this->createTagsInput(self::TAGS, false)); + private function initializeForEdition(bool $requireLongUrl = false): void + { + $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); + $this->add($longUrlInput); - $title = $this->createInput(self::TITLE, false); + $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, required: false); + $deviceLongUrlsInput->getValidatorChain()->attach( + new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), + ); + $this->add($deviceLongUrlsInput); + + $validSince = $this->createInput(self::VALID_SINCE, required: false); + $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validSince); + + $validUntil = $this->createInput(self::VALID_UNTIL, required: false); + $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); + $this->add($validUntil); + + $this->add($this->createNumericInput(self::MAX_VISITS, required: false)); + + $title = $this->createInput(self::TITLE, required: false); $title->getFilterChain()->attach(new Filter\Callback( static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), )); $this->add($title); - $this->add($this->createBooleanInput(self::CRAWLABLE, false)); + $this->add($this->createTagsInput(self::TAGS, required: false)); + $this->add($this->createBooleanInput(self::CRAWLABLE, required: false)); + + // This cannot be defined as a boolean inputs, because it can actually have 3 values: true, false and null. + // Defining them as boolean will make null fall back to false, which is not the desired behavior. + $this->add($this->createInput(self::FORWARD_QUERY, required: false)); + + $apiKeyInput = $this->createInput(self::API_KEY, required: false); + $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); + $this->add($apiKeyInput); } private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain From 467dbdd183df9920387b581041d77ad3b058b6fe Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 17:57:45 +0100 Subject: [PATCH 44/97] Update to latest shlink-common --- composer.json | 2 +- config/autoload/dependencies.global.php | 3 ++ .../Validation/DomainRedirectsInputFilter.php | 15 ++++---- .../Model/Validation/ShortUrlInputFilter.php | 37 +++++++++---------- .../Validation/ShortUrlsParamsInputFilter.php | 24 ++++++------ 5 files changed, 40 insertions(+), 41 deletions(-) diff --git a/composer.json b/composer.json index 15e7ab89..76a98041 100644 --- a/composer.json +++ b/composer.json @@ -42,7 +42,7 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#b9a6bd5 as 6.0", + "shlinkio/shlink-common": "dev-main#3e5bf59 as 6.0", "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", diff --git a/config/autoload/dependencies.global.php b/config/autoload/dependencies.global.php index a0014ef6..469171ca 100644 --- a/config/autoload/dependencies.global.php +++ b/config/autoload/dependencies.global.php @@ -4,6 +4,7 @@ declare(strict_types=1); use GuzzleHttp\Client; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; +use Laminas\ServiceManager\Factory\InvokableFactory; use Mezzio\Application; use Mezzio\Container; use Psr\Http\Client\ClientInterface; @@ -12,12 +13,14 @@ use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\UploadedFileFactoryInterface; use Spiral\RoadRunner\Http\PSR7Worker; use Spiral\RoadRunner\WorkerInterface; +use Symfony\Component\Filesystem\Filesystem; return [ 'dependencies' => [ 'factories' => [ PSR7Worker::class => ConfigAbstractFactory::class, + Filesystem::class => InvokableFactory::class, ], 'delegators' => [ diff --git a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php index de627c1c..48035c6c 100644 --- a/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php +++ b/module/Core/src/Domain/Validation/DomainRedirectsInputFilter.php @@ -5,12 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Domain\Validation; use Laminas\InputFilter\InputFilter; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\HostAndPortValidator; +use Shlinkio\Shlink\Common\Validation\InputFactory; class DomainRedirectsInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - public const DOMAIN = 'domain'; public const BASE_URL_REDIRECT = 'baseUrlRedirect'; public const REGULAR_404_REDIRECT = 'regular404Redirect'; @@ -32,12 +31,12 @@ class DomainRedirectsInputFilter extends InputFilter private function initializeInputs(): void { - $domain = $this->createInput(self::DOMAIN); - $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); + $domain = InputFactory::basic(self::DOMAIN, required: true); + $domain->getValidatorChain()->attach(new HostAndPortValidator()); $this->add($domain); - $this->add($this->createInput(self::BASE_URL_REDIRECT, false)); - $this->add($this->createInput(self::REGULAR_404_REDIRECT, false)); - $this->add($this->createInput(self::INVALID_SHORT_URL_REDIRECT, false)); + $this->add(InputFactory::basic(self::BASE_URL_REDIRECT)); + $this->add(InputFactory::basic(self::REGULAR_404_REDIRECT)); + $this->add(InputFactory::basic(self::INVALID_SHORT_URL_REDIRECT)); } } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index ad3a6df7..287ea746 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -8,7 +8,8 @@ use DateTimeInterface; use Laminas\Filter; use Laminas\InputFilter\InputFilter; use Laminas\Validator; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\HostAndPortValidator; +use Shlinkio\Shlink\Common\Validation\InputFactory; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -21,8 +22,6 @@ use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; class ShortUrlInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - // Fields for creation only public const SHORT_CODE_LENGTH = 'shortCodeLength'; public const CUSTOM_SLUG = 'customSlug'; @@ -64,7 +63,7 @@ class ShortUrlInputFilter extends InputFilter { // The only way to enforce the NotEmpty validator to be evaluated when the key is present with an empty value // is with setContinueIfEmpty(true) - $customSlug = $this->createInput(self::CUSTOM_SLUG, required: false)->setContinueIfEmpty(true); + $customSlug = InputFactory::basic(self::CUSTOM_SLUG)->setContinueIfEmpty(true); $customSlug->getFilterChain()->attach(new CustomSlugFilter($options)); $customSlug->getValidatorChain() ->attach(new Validator\NotEmpty([ @@ -77,16 +76,16 @@ class ShortUrlInputFilter extends InputFilter // The path prefix is subject to the same filtering and validation logic as the custom slug, which takes into // consideration if multi-segment slugs are enabled or not. // The only difference is that empty values are allowed here. - $pathPrefix = $this->createInput(self::PATH_PREFIX, required: false); + $pathPrefix = InputFactory::basic(self::PATH_PREFIX); $pathPrefix->getFilterChain()->attach(new CustomSlugFilter($options)); $pathPrefix->getValidatorChain()->attach(CustomSlugValidator::forUrlShortenerOptions($options)); $this->add($pathPrefix); - $this->add($this->createNumericInput(self::SHORT_CODE_LENGTH, required: false, min: MIN_SHORT_CODES_LENGTH)); - $this->add($this->createBooleanInput(self::FIND_IF_EXISTS, required: false)); + $this->add(InputFactory::numeric(self::SHORT_CODE_LENGTH, min: MIN_SHORT_CODES_LENGTH)); + $this->add(InputFactory::boolean(self::FIND_IF_EXISTS)); - $domain = $this->createInput(self::DOMAIN, required: false); - $domain->getValidatorChain()->attach(new Validation\HostAndPortValidator()); + $domain = InputFactory::basic(self::DOMAIN); + $domain->getValidatorChain()->attach(new HostAndPortValidator()); $this->add($domain); $this->initializeForEdition(requireLongUrl: true); @@ -94,40 +93,40 @@ class ShortUrlInputFilter extends InputFilter private function initializeForEdition(bool $requireLongUrl = false): void { - $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); + $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); $this->add($longUrlInput); - $deviceLongUrlsInput = $this->createInput(self::DEVICE_LONG_URLS, required: false); + $deviceLongUrlsInput = InputFactory::basic(self::DEVICE_LONG_URLS); $deviceLongUrlsInput->getValidatorChain()->attach( new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), ); $this->add($deviceLongUrlsInput); - $validSince = $this->createInput(self::VALID_SINCE, required: false); + $validSince = InputFactory::basic(self::VALID_SINCE); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validSince); - $validUntil = $this->createInput(self::VALID_UNTIL, required: false); + $validUntil = InputFactory::basic(self::VALID_UNTIL); $validUntil->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validUntil); - $this->add($this->createNumericInput(self::MAX_VISITS, required: false)); + $this->add(InputFactory::numeric(self::MAX_VISITS)); - $title = $this->createInput(self::TITLE, required: false); + $title = InputFactory::basic(self::TITLE); $title->getFilterChain()->attach(new Filter\Callback( static fn (?string $value) => $value === null ? $value : substr($value, 0, 512), )); $this->add($title); - $this->add($this->createTagsInput(self::TAGS, required: false)); - $this->add($this->createBooleanInput(self::CRAWLABLE, required: false)); + $this->add(InputFactory::tags(self::TAGS)); + $this->add(InputFactory::boolean(self::CRAWLABLE)); // This cannot be defined as a boolean inputs, because it can actually have 3 values: true, false and null. // Defining them as boolean will make null fall back to false, which is not the desired behavior. - $this->add($this->createInput(self::FORWARD_QUERY, required: false)); + $this->add(InputFactory::basic(self::FORWARD_QUERY)); - $apiKeyInput = $this->createInput(self::API_KEY, required: false); + $apiKeyInput = InputFactory::basic(self::API_KEY); $apiKeyInput->getValidatorChain()->attach(new Validator\IsInstanceOf(['className' => ApiKey::class])); $this->add($apiKeyInput); } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php index d7cda41e..f4f7c338 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlsParamsInputFilter.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model\Validation; use Laminas\InputFilter\InputFilter; use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Paginator\Paginator; -use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Common\Validation\InputFactory; use Shlinkio\Shlink\Core\ShortUrl\Model\OrderableField; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; @@ -15,8 +15,6 @@ use function Shlinkio\Shlink\Core\enumValues; class ShortUrlsParamsInputFilter extends InputFilter { - use Validation\InputFactoryTrait; - public const PAGE = 'page'; public const SEARCH_TERM = 'searchTerm'; public const TAGS = 'tags'; @@ -36,26 +34,26 @@ class ShortUrlsParamsInputFilter extends InputFilter private function initialize(): void { - $this->add($this->createDateInput(self::START_DATE, false)); - $this->add($this->createDateInput(self::END_DATE, false)); + $this->add(InputFactory::date(self::START_DATE)); + $this->add(InputFactory::date(self::END_DATE)); - $this->add($this->createInput(self::SEARCH_TERM, false)); + $this->add(InputFactory::basic(self::SEARCH_TERM)); - $this->add($this->createNumericInput(self::PAGE, false)); - $this->add($this->createNumericInput(self::ITEMS_PER_PAGE, false, Paginator::ALL_ITEMS)); + $this->add(InputFactory::numeric(self::PAGE)); + $this->add(InputFactory::numeric(self::ITEMS_PER_PAGE, Paginator::ALL_ITEMS)); - $this->add($this->createTagsInput(self::TAGS, false)); + $this->add(InputFactory::tags(self::TAGS)); - $tagsMode = $this->createInput(self::TAGS_MODE, false); + $tagsMode = InputFactory::basic(self::TAGS_MODE); $tagsMode->getValidatorChain()->attach(new InArray([ 'haystack' => enumValues(TagsMode::class), 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); - $this->add($this->createOrderByInput(self::ORDER_BY, enumValues(OrderableField::class))); + $this->add(InputFactory::orderBy(self::ORDER_BY, enumValues(OrderableField::class))); - $this->add($this->createBooleanInput(self::EXCLUDE_MAX_VISITS_REACHED, false)); - $this->add($this->createBooleanInput(self::EXCLUDE_PAST_VALID_UNTIL, false)); + $this->add(InputFactory::boolean(self::EXCLUDE_MAX_VISITS_REACHED)); + $this->add(InputFactory::boolean(self::EXCLUDE_PAST_VALID_UNTIL)); } } From f30c74b9877dfd2a1aad44a347773496f85631b5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 18:06:06 +0100 Subject: [PATCH 45/97] Prepend path prefix to generated short code or custom slug --- docs/swagger/paths/v1_short-urls.json | 5 +++++ module/Core/src/ShortUrl/Entity/ShortUrl.php | 8 +++++--- module/Core/src/ShortUrl/Model/ShortUrlCreation.php | 2 ++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 08e08b67..c9bbe68a 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -303,6 +303,10 @@ "description": "A unique custom slug to be used instead of the generated short code", "type": "string" }, + "pathPrefix": { + "description": "A prefix that will be prepended to provided custom slug or auto-generated short code", + "type": "string" + }, "findIfExists": { "description": "Will force existing matching URL to be returned if found, instead of creating a new one", "type": "boolean" @@ -382,6 +386,7 @@ "validSince", "validUntil", "customSlug", + "pathPrefix", "maxVisits", "findIfExists", "domain" diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index ee2c5920..411e7bb1 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -33,6 +33,7 @@ use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; +use function sprintf; class ShortUrl extends AbstractEntity { @@ -100,9 +101,10 @@ class ShortUrl extends AbstractEntity $instance->maxVisits = $creation->maxVisits; $instance->customSlugWasProvided = $creation->hasCustomSlug(); $instance->shortCodeLength = $creation->shortCodeLength; - $instance->shortCode = $creation->customSlug ?? generateRandomShortCode( - $instance->shortCodeLength, - $creation->shortUrlMode, + $instance->shortCode = sprintf( + '%s%s', + $creation->pathPrefix ?? '', + $creation->customSlug ?? generateRandomShortCode($instance->shortCodeLength, $creation->shortUrlMode), ); $instance->domain = $relationResolver->resolveDomain($creation->domain); $instance->authorApiKey = $creation->apiKey; diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 976973b2..4d22a8be 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -66,6 +66,7 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), + pathPrefix: $inputFilter->getValue(ShortUrlInputFilter::PATH_PREFIX), maxVisits: getOptionalIntFromInputFilter($inputFilter, ShortUrlInputFilter::MAX_VISITS), findIfExists: $inputFilter->getValue(ShortUrlInputFilter::FIND_IF_EXISTS) ?? false, domain: getNonEmptyOptionalValueFromInputFilter($inputFilter, ShortUrlInputFilter::DOMAIN), @@ -90,6 +91,7 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface validSince: $this->validSince, validUntil: $this->validUntil, customSlug: $this->customSlug, + pathPrefix: $this->pathPrefix, maxVisits: $this->maxVisits, findIfExists: $this->findIfExists, domain: $this->domain, From ff963a9df48c0acecc13a9a0a2d7c22ba9ac7363 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 19:14:30 +0100 Subject: [PATCH 46/97] Add API test for short URL path prefix --- .../Rest/test-api/Action/CreateShortUrlTest.php | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index f612f628..96dc5e7b 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function array_map; @@ -339,6 +340,21 @@ class CreateShortUrlTest extends ApiTestCase self::assertNull($payload['title']); } + #[Test] + #[TestWith([null])] + #[TestWith(['my-custom-slug'])] + public function prefixCanBeSet(?string $customSlug): void + { + [$statusCode, $payload] = $this->createShortUrl([ + 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', + 'pathPrefix' => 'foo/b ar-baz', + 'customSlug' => $customSlug, + ]); + + self::assertEquals(self::STATUS_OK, $statusCode); + self::assertStringStartsWith('foo-b--ar-baz', $payload['shortCode']); + } + /** * @return array{int, array} */ From f08951a9b99a735f53af35806a2c678157eb1e5f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 19:24:30 +0100 Subject: [PATCH 47/97] Add unit test for short URL path prefix --- .../test/ShortUrl/Entity/ShortUrlTest.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index 0a898399..eb89df5c 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -7,6 +7,7 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Entity; use Cake\Chronos\Chronos; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; use Shlinkio\Shlink\Core\Model\DeviceType; @@ -91,6 +92,27 @@ class ShortUrlTest extends TestCase yield from array_map(fn (int $value) => [$value, $value], range(4, 10)); } + #[Test] + #[TestWith([null, '', 5])] + #[TestWith(['foo bar/', 'foo-bar-', 13])] + public function shortCodesHaveExpectedPrefix( + ?string $pathPrefix, + string $expectedPrefix, + int $expectedShortCodeLength, + ): void { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://longUrl', + ShortUrlInputFilter::SHORT_CODE_LENGTH => 5, + ShortUrlInputFilter::PATH_PREFIX => $pathPrefix, + ])); + $shortCode = $shortUrl->getShortCode(); + + if (strlen($expectedPrefix) > 0) { + self::assertStringStartsWith($expectedPrefix, $shortCode); + } + self::assertEquals($expectedShortCodeLength, strlen($shortCode)); + } + #[Test] public function deviceLongUrlsAreUpdated(): void { From 7673232793b6899acda7602095416f72dd105551 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 21 Feb 2024 19:38:11 +0100 Subject: [PATCH 48/97] Add --path-prefix to short URL creation --- CHANGELOG.md | 5 +++++ .../CLI/src/Command/ShortUrl/CreateShortUrlCommand.php | 10 ++++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39797631..9f51f641 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is supported both by the `GET /visits/orphan` API endpoint via `type=...` query param, and by the `visit:orphan` CLI command, via `--type` flag. * [#1904](https://github.com/shlinkio/shlink/issues/1904) Allow to customize QR codes foreground color, background color and logo. +* [#1884](https://github.com/shlinkio/shlink/issues/1884) Allow a path prefix to be provided during short URL creation. + + This can be useful to let Shlink generate partially random URLs, but with a known prefix. + + Path prefixes are validated and filtered taking multi-segment slugs into consideration, which means slashes are replaced with dashes as long as multi-segment slugs are disabled. ### Changed * [#1935](https://github.com/shlinkio/shlink/issues/1935) Replace dependency on abandoned `php-middleware/request-id` with userland simple middleware. diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 118ad201..4b6a088d 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -70,6 +70,12 @@ class CreateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'If provided, this slug will be used instead of generating a short code', ) + ->addOption( + 'path-prefix', + 'p', + InputOption::VALUE_REQUIRED, + 'Prefix to prepend before the generated short code or provided custom slug', + ) ->addOption( 'max-visits', 'm', @@ -138,7 +144,6 @@ class CreateShortUrlCommand extends Command $explodeWithComma = static fn (string $tag) => explode(',', $tag); $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); - $customSlug = $input->getOption('custom-slug'); $maxVisits = $input->getOption('max-visits'); $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; @@ -147,8 +152,9 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::LONG_URL => $longUrl, 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::CUSTOM_SLUG => $input->getOption('custom-slug'), + ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'), ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, From 8f954151cad041154a883ee0178c324ebfaa3c25 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 20 Feb 2024 22:57:33 +0100 Subject: [PATCH 49/97] Change long URL columns to TEXT type --- ...inkio.Shlink.Core.Domain.Entity.Domain.php | 6 ++--- ...ink.Core.ShortUrl.Entity.DeviceLongUrl.php | 3 +-- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 3 +-- .../Core/migrations/Version20240220214031.php | 27 +++++++++++++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) create mode 100644 module/Core/migrations/Version20240220214031.php diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php index 68427b42..e02d1f7e 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php @@ -25,17 +25,17 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->unique() ->build(); - fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::TEXT), $emConfig) ->columnName('base_url_redirect') ->nullable() ->build(); - fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::TEXT), $emConfig) ->columnName('regular_not_found_redirect') ->nullable() ->build(); - fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::TEXT), $emConfig) ->columnName('invalid_short_url_redirect') ->nullable() ->build(); diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php index 8de69c18..3620ea54 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php @@ -30,9 +30,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->length(255) ->build(); - fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) ->columnName('long_url') - ->length(2048) ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 746ac3fd..f33530ec 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -23,9 +23,8 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - fieldWithUtf8Charset($builder->createField('longUrl', Types::STRING), $emConfig) + fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) ->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯ - ->length(2048) ->build(); fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin') diff --git a/module/Core/migrations/Version20240220214031.php b/module/Core/migrations/Version20240220214031.php new file mode 100644 index 00000000..0cf56094 --- /dev/null +++ b/module/Core/migrations/Version20240220214031.php @@ -0,0 +1,27 @@ +connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} From d1a6e60b01649c975b3505f19ab628aa8d846263 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 22 Feb 2024 09:35:14 +0100 Subject: [PATCH 50/97] Add migration to update long URLs columns to text type --- CHANGELOG.md | 3 +- .../Core/migrations/Version20240220214031.php | 31 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f51f641..f1cf0b8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +25,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#1786](https://github.com/shlinkio/shlink/issues/1786) Run API tests with RoadRunner by default. * [#2008](https://github.com/shlinkio/shlink/issues/2008) Update to Doctrine ORM 3.0. * [#2010](https://github.com/shlinkio/shlink/issues/2010) Update to Symfony 7.0 components. -* [#2016](https://github.com/shlinkio/shlink/issues/2016) Simplify and improve how code coverage is generated in API and CLI tests". +* [#2016](https://github.com/shlinkio/shlink/issues/2016) Simplify and improve how code coverage is generated in API and CLI tests. +* [#1674](https://github.com/shlinkio/shlink/issues/1674) Database columns persisting long URLs have now `TEXT` type, which allows for much longer values. ### Deprecated * *Nothing* diff --git a/module/Core/migrations/Version20240220214031.php b/module/Core/migrations/Version20240220214031.php index 0cf56094..b8dc12fd 100644 --- a/module/Core/migrations/Version20240220214031.php +++ b/module/Core/migrations/Version20240220214031.php @@ -6,18 +6,47 @@ namespace ShlinkMigrations; use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; +use function in_array; + final class Version20240220214031 extends AbstractMigration { + private const DOMAINS_COLUMNS = ['base_url_redirect', 'regular_not_found_redirect', 'invalid_short_url_redirect']; + private const TEXT_COLUMNS = [ + 'domains' => self::DOMAINS_COLUMNS, + 'device_long_urls' => ['long_url'], + 'short_urls' => ['original_url'], + ]; + public function up(Schema $schema): void { + $textType = Type::getType(Types::TEXT); + foreach (self::TEXT_COLUMNS as $table => $columns) { + $t = $schema->getTable($table); + + foreach ($columns as $column) { + $c = $t->getColumn($column); + + if ($c->getType() === $textType) { + continue; + } + + if (in_array($column, self::DOMAINS_COLUMNS, true)) { + // Domain columns had an incorrect length + $t->modifyColumn($column, ['length' => 2048]); + } + $c->setType($textType); + } + } } public function down(Schema $schema): void { - + // Can't revert from TEXT to STRING, as it's bigger } public function isTransactional(): bool From acc4c4756e07a026a2abd69e4647f2568bae3892 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 23 Feb 2024 19:30:58 +0100 Subject: [PATCH 51/97] Simplify and normalize API tests script --- bin/test/run-api-tests.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/test/run-api-tests.sh b/bin/test/run-api-tests.sh index 003d3a8f..e39d564d 100755 --- a/bin/test/run-api-tests.sh +++ b/bin/test/run-api-tests.sh @@ -13,10 +13,10 @@ mkdir data/log/api-tests touch $OUTPUT_LOGS # Try to stop server just in case it hanged in last execution -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -f -w . echo 'Starting server...' -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -c=config/roadrunner/.rr.test.yml -w . \ +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr serve -p -w . -c=config/roadrunner/.rr.test.yml \ -o=logs.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.http.output="${PWD}/${OUTPUT_LOGS}" \ -o=logs.channels.server.output="${PWD}/${OUTPUT_LOGS}" & @@ -25,7 +25,7 @@ sleep 2 # Let's give the server a couple of seconds to start vendor/bin/phpunit --order-by=random -c phpunit-api.xml --testdox --colors=always $* TESTS_EXIT_CODE=$? -[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -c=config/roadrunner/.rr.dev.yml -w . -o=http.address=0.0.0.0:9999 +[ "$TEST_RUNTIME" = 'rr' ] && bin/rr stop -w . # Exit this script with the same code as the tests. If tests failed, this script has to fail exit $TESTS_EXIT_CODE From 857c3a4f8d49e303f19d4c571402ce855a1595b8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 17:26:12 +0100 Subject: [PATCH 52/97] Remove unused entries from EnvVars enum --- UPGRADE.md | 1 + composer.json | 2 +- config/autoload/installer.global.php | 2 -- module/Core/src/Config/EnvVars.php | 3 --- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index aa235473..e8390213 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -7,6 +7,7 @@ * Swoole and Openswoole are no longer officially supported runtimes. The recommended alternative is RoadRunner. * Dist files for swoole/openswoole are no longer published. * Webhooks are no longer supported. Migrate to one of the other [real-time updates](https://shlink.io/documentation/advanced/real-time-updates/) mechanisms. +* When using RoadRunner, the amount of web workers, task workers and the port number can no longer be provided via config options. Use `WEB_WORKER_NUM`, `TASK_WORKER_NUM` and `PORT` env vars instead. ### Changes in URL shortener diff --git a/composer.json b/composer.json index 76a98041..fdb6459f 100644 --- a/composer.json +++ b/composer.json @@ -46,7 +46,7 @@ "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", - "shlinkio/shlink-installer": "dev-develop#41e433c as 9.0", + "shlinkio/shlink-installer": "dev-develop#5943255 as 9.0", "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 753bdb9a..b6a79679 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -28,8 +28,6 @@ return [ Option\BasePathConfigOption::class, Option\TimezoneConfigOption::class, Option\Cache\CacheNamespaceConfigOption::class, - Option\Worker\TaskWorkerNumConfigOption::class, - Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, Option\Redis\RedisPubSubConfigOption::class, diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 0ea74451..e5df9532 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -56,9 +56,6 @@ enum EnvVars: string case BASE_PATH = 'BASE_PATH'; case SHORT_URL_TRAILING_SLASH = 'SHORT_URL_TRAILING_SLASH'; case SHORT_URL_MODE = 'SHORT_URL_MODE'; - case PORT = 'PORT'; - case TASK_WORKER_NUM = 'TASK_WORKER_NUM'; - case WEB_WORKER_NUM = 'WEB_WORKER_NUM'; case ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; case TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; case DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; From 752100f1ceff943e02f78279006e678b21ca9466 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 13:35:59 +0100 Subject: [PATCH 53/97] Create migration for new rules and conditions tables --- .../Core/migrations/Version20240224115725.php | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 module/Core/migrations/Version20240224115725.php diff --git a/module/Core/migrations/Version20240224115725.php b/module/Core/migrations/Version20240224115725.php new file mode 100644 index 00000000..2b68174c --- /dev/null +++ b/module/Core/migrations/Version20240224115725.php @@ -0,0 +1,94 @@ +skipIf($schema->hasTable('short_url_redirect_rules'), 'New columns already exist'); + + $redirectRules = $this->createTableWithId($schema, 'short_url_redirect_rules'); + $redirectRules->addColumn('priority', Types::INTEGER, ['unsigned' => true, 'default' => 1]); + // The length here is just so that Doctrine knows it should not use too small text types + $redirectRules->addColumn('long_url', Types::TEXT, ['length' => 2048]); + + $redirectRules->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $redirectRules->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $redirectConditions = $this->createTableWithId($schema, 'redirect_conditions'); + $redirectConditions->addColumn('name', Types::STRING, ['length' => 512]); + $redirectConditions->addUniqueIndex(['name'], 'UQ_name'); + + $redirectConditions->addColumn('type', Types::STRING, ['length' => 255]); + $redirectConditions->addColumn('match_key', Types::STRING, [ + 'length' => 512, + 'notnull' => false, + 'default' => null, + ]); + $redirectConditions->addColumn('match_value', Types::STRING, ['length' => 512]); + + $joinTable = $schema->createTable('redirect_conditions_in_short_url_redirect_rules'); + + $joinTable->addColumn('redirect_condition_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $joinTable->addForeignKeyConstraint('redirect_conditions', ['redirect_condition_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $joinTable->addColumn('short_url_redirect_rule_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + $joinTable->addForeignKeyConstraint('short_url_redirect_rules', ['short_url_redirect_rule_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $joinTable->setPrimaryKey(['redirect_condition_id', 'short_url_redirect_rule_id']); + } + + private function createTableWithId(Schema $schema, string $tableName): Table + { + $table = $schema->createTable($tableName); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + return $table; + } + + public function down(Schema $schema): void + { + $this->skipIf(! $schema->hasTable('short_url_redirect_rules'), 'Columns do not exist'); + + $schema->dropTable('redirect_conditions_in_short_url_redirect_rules'); + $schema->dropTable('short_url_redirect_rules'); + $schema->dropTable('redirect_conditions'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} From c91a534d1a09db66d4df5ad11099f31db1ca6463 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 18:17:09 +0100 Subject: [PATCH 54/97] Create new entities for redirect rules --- ...inkio.Shlink.Core.Domain.Entity.Domain.php | 3 ++ ....RedirectRule.Entity.RedirectCondition.php | 50 +++++++++++++++++++ ...directRule.Entity.ShortUrlRedirectRule.php | 41 +++++++++++++++ ...ink.Core.ShortUrl.Entity.DeviceLongUrl.php | 1 + ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 1 + .../RedirectRule/Entity/RedirectCondition.php | 17 +++++++ .../Entity/ShortUrlRedirectRule.php | 22 ++++++++ .../Model/RedirectConditionType.php | 10 ++++ 8 files changed, 145 insertions(+) create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php create mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php create mode 100644 module/Core/src/RedirectRule/Entity/RedirectCondition.php create mode 100644 module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php create mode 100644 module/Core/src/RedirectRule/Model/RedirectConditionType.php diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php index e02d1f7e..ad77476a 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Domain.Entity.Domain.php @@ -28,15 +28,18 @@ return static function (ClassMetadata $metadata, array $emConfig): void { fieldWithUtf8Charset($builder->createField('baseUrlRedirect', Types::TEXT), $emConfig) ->columnName('base_url_redirect') ->nullable() + ->length(2048) ->build(); fieldWithUtf8Charset($builder->createField('regular404Redirect', Types::TEXT), $emConfig) ->columnName('regular_not_found_redirect') ->nullable() + ->length(2048) ->build(); fieldWithUtf8Charset($builder->createField('invalidShortUrlRedirect', Types::TEXT), $emConfig) ->columnName('invalid_short_url_redirect') ->nullable() + ->length(2048) ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php new file mode 100644 index 00000000..2c1e1bdc --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php @@ -0,0 +1,50 @@ +setTable(determineTableName('redirect_conditions', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + fieldWithUtf8Charset($builder->createField('name', Types::STRING), $emConfig) + ->columnName('name') + ->length(512) + ->build(); + + $builder->addUniqueConstraint(['name'], 'UQ_name'); + + (new FieldBuilder($builder, [ + 'fieldName' => 'type', + 'type' => Types::STRING, + 'enumType' => RedirectConditionType::class, + ]))->columnName('type') + ->length(255) + ->build(); + + fieldWithUtf8Charset($builder->createField('matchKey', Types::STRING), $emConfig) + ->columnName('match_key') + ->length(512) + ->nullable() + ->build(); + + fieldWithUtf8Charset($builder->createField('matchValue', Types::STRING), $emConfig) + ->columnName('match_value') + ->length(512) + ->build(); +}; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php new file mode 100644 index 00000000..4ffd60d7 --- /dev/null +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -0,0 +1,41 @@ +setTable(determineTableName('short_url_redirect_rules', $emConfig)); + + $builder->createField('id', Types::BIGINT) + ->columnName('id') + ->makePrimaryKey() + ->generatedValue('IDENTITY') + ->option('unsigned', true) + ->build(); + + $builder->createField('priority', Types::INTEGER) + ->columnName('priority') + ->build(); + + fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) + ->columnName('long_url') + ->length(2048) + ->build(); + + $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) + ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->build(); + + $builder->createManyToMany('conditions', RedirectRule\Entity\RedirectCondition::class) + ->setJoinTable(determineTableName('redirect_conditions_in_short_url_redirect_rules', $emConfig)) + ->addInverseJoinColumn('redirect_condition_id', 'id', onDelete: 'CASCADE') + ->addJoinColumn('short_url_redirect_rule_id', 'id', onDelete: 'CASCADE') + ->build(); +}; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php index 3620ea54..bcd3702e 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php @@ -32,6 +32,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) ->columnName('long_url') + ->length(2048) ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index f33530ec..4967482e 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -25,6 +25,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) ->columnName('original_url') // Rename to long_url some day? ¯\_(ツ)_/¯ + ->length(2048) ->build(); fieldWithUtf8Charset($builder->createField('shortCode', Types::STRING), $emConfig, 'bin') diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php new file mode 100644 index 00000000..367d96d9 --- /dev/null +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -0,0 +1,17 @@ + $conditions + */ + public function __construct( + private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine + public readonly int $priority, + public readonly string $longUrl, + public readonly Collection $conditions = new ArrayCollection(), + ) { + } +} diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php new file mode 100644 index 00000000..6e6b8113 --- /dev/null +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -0,0 +1,10 @@ + Date: Sat, 24 Feb 2024 18:21:53 +0100 Subject: [PATCH 55/97] Use named args to avoid passing default values for args --- ...link.Core.RedirectRule.Entity.ShortUrlRedirectRule.php | 2 +- ...Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php | 2 +- .../Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 8 ++++---- .../Shlinkio.Shlink.Core.Visit.Entity.Visit.php | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php index 4ffd60d7..9ff70973 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -30,7 +30,7 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->addJoinColumn('short_url_id', 'id', nullable: false, onDelete: 'CASCADE') ->build(); $builder->createManyToMany('conditions', RedirectRule\Entity\RedirectCondition::class) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php index bcd3702e..1e84a292 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php @@ -36,6 +36,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', false, false, 'CASCADE') + ->addJoinColumn('short_url_id', 'id', nullable: false, onDelete: 'CASCADE') ->build(); }; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index 4967482e..ff933b8c 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -76,18 +76,18 @@ return static function (ClassMetadata $metadata, array $emConfig): void { $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) - ->addInverseJoinColumn('tag_id', 'id', true, false, 'CASCADE') - ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->addInverseJoinColumn('tag_id', 'id', onDelete: 'CASCADE') + ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->setOrderBy(['name' => 'ASC']) ->build(); $builder->createManyToOne('domain', Domain\Entity\Domain::class) - ->addJoinColumn('domain_id', 'id', true, false, 'RESTRICT') + ->addJoinColumn('domain_id', 'id', onDelete: 'RESTRICT') ->cascadePersist() ->build(); $builder->createManyToOne('authorApiKey', ApiKey::class) - ->addJoinColumn('author_api_key_id', 'id', true, false, 'SET NULL') + ->addJoinColumn('author_api_key_id', 'id', onDelete: 'SET NULL') ->build(); $builder->addUniqueConstraint(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php index 28adea80..7d402384 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Visit.Entity.Visit.php @@ -49,11 +49,11 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->build(); $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', true, false, 'CASCADE') + ->addJoinColumn('short_url_id', 'id', onDelete: 'CASCADE') ->build(); $builder->createManyToOne('visitLocation', Visit\Entity\VisitLocation::class) - ->addJoinColumn('visit_location_id', 'id', true, false, 'Set NULL') + ->addJoinColumn('visit_location_id', 'id', onDelete: 'Set NULL') ->cascadePersist() ->build(); From 66462323111d4d0fc024d71c06f96ba8c506b64c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 20:24:41 +0100 Subject: [PATCH 56/97] Set eager loading for redirect rule conditions --- ...nkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php | 1 + 1 file changed, 1 insertion(+) diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php index 9ff70973..cab72e89 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -37,5 +37,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->setJoinTable(determineTableName('redirect_conditions_in_short_url_redirect_rules', $emConfig)) ->addInverseJoinColumn('redirect_condition_id', 'id', onDelete: 'CASCADE') ->addJoinColumn('short_url_redirect_rule_id', 'id', onDelete: 'CASCADE') + ->fetchEager() // Always fetch the corresponding conditions when loading a rule ->build(); }; From c5ddd8302a80569d5b8d08fdb2f28f303c261a83 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 12:28:20 +0100 Subject: [PATCH 57/97] Delete old migrations --- .../Core/migrations/Version20160819142757.php | 49 --------- .../Core/migrations/Version20160820191203.php | 82 -------------- .../Core/migrations/Version20171021093246.php | 54 --------- .../Core/migrations/Version20171022064541.php | 51 --------- .../Core/migrations/Version20180801183328.php | 48 -------- .../Core/migrations/Version20180913205455.php | 74 ------------- .../Core/migrations/Version20180915110857.php | 56 ---------- .../Core/migrations/Version20181020060559.php | 74 ------------- .../Core/migrations/Version20181020065148.php | 47 -------- .../Core/migrations/Version20181110175521.php | 43 -------- .../Core/migrations/Version20190824075137.php | 43 -------- .../Core/migrations/Version20190930165521.php | 61 ----------- .../Core/migrations/Version20191001201532.php | 55 ---------- .../Core/migrations/Version20191020074522.php | 43 -------- .../Core/migrations/Version20200105165647.php | 103 ------------------ .../Core/migrations/Version20200106215144.php | 60 ---------- .../Core/migrations/Version20200110182849.php | 60 ---------- .../Core/migrations/Version20200323190014.php | 52 --------- .../Core/migrations/Version20200503170404.php | 33 ------ .../Core/migrations/Version20201023090929.php | 50 --------- .../Core/migrations/Version20201102113208.php | 91 ---------------- .../Core/migrations/Version20210102174433.php | 58 ---------- .../Core/migrations/Version20210118153932.php | 32 ------ .../Core/migrations/Version20210202181026.php | 42 ------- .../Core/migrations/Version20210207100807.php | 49 --------- .../Core/migrations/Version20210306165711.php | 43 -------- .../Core/migrations/Version20210522051601.php | 32 ------ .../Core/migrations/Version20210522124633.php | 34 ------ .../Core/migrations/Version20210720143824.php | 47 -------- .../Core/migrations/Version20211002072605.php | 32 ------ 30 files changed, 1598 deletions(-) delete mode 100644 module/Core/migrations/Version20160819142757.php delete mode 100644 module/Core/migrations/Version20160820191203.php delete mode 100644 module/Core/migrations/Version20171021093246.php delete mode 100644 module/Core/migrations/Version20171022064541.php delete mode 100644 module/Core/migrations/Version20180801183328.php delete mode 100644 module/Core/migrations/Version20180913205455.php delete mode 100644 module/Core/migrations/Version20180915110857.php delete mode 100644 module/Core/migrations/Version20181020060559.php delete mode 100644 module/Core/migrations/Version20181020065148.php delete mode 100644 module/Core/migrations/Version20181110175521.php delete mode 100644 module/Core/migrations/Version20190824075137.php delete mode 100644 module/Core/migrations/Version20190930165521.php delete mode 100644 module/Core/migrations/Version20191001201532.php delete mode 100644 module/Core/migrations/Version20191020074522.php delete mode 100644 module/Core/migrations/Version20200105165647.php delete mode 100644 module/Core/migrations/Version20200106215144.php delete mode 100644 module/Core/migrations/Version20200110182849.php delete mode 100644 module/Core/migrations/Version20200323190014.php delete mode 100644 module/Core/migrations/Version20200503170404.php delete mode 100644 module/Core/migrations/Version20201023090929.php delete mode 100644 module/Core/migrations/Version20201102113208.php delete mode 100644 module/Core/migrations/Version20210102174433.php delete mode 100644 module/Core/migrations/Version20210118153932.php delete mode 100644 module/Core/migrations/Version20210202181026.php delete mode 100644 module/Core/migrations/Version20210207100807.php delete mode 100644 module/Core/migrations/Version20210306165711.php delete mode 100644 module/Core/migrations/Version20210522051601.php delete mode 100644 module/Core/migrations/Version20210522124633.php delete mode 100644 module/Core/migrations/Version20210720143824.php delete mode 100644 module/Core/migrations/Version20211002072605.php diff --git a/module/Core/migrations/Version20160819142757.php b/module/Core/migrations/Version20160819142757.php deleted file mode 100644 index 171c5b7e..00000000 --- a/module/Core/migrations/Version20160819142757.php +++ /dev/null @@ -1,49 +0,0 @@ -connection->getDatabasePlatform(); - $table = $schema->getTable('short_urls'); - $column = $table->getColumn('short_code'); - - 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, - }; - } - - public function down(Schema $schema): void - { - // Nothing to roll back - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20160820191203.php b/module/Core/migrations/Version20160820191203.php deleted file mode 100644 index dea327b1..00000000 --- a/module/Core/migrations/Version20160820191203.php +++ /dev/null @@ -1,82 +0,0 @@ -getTables(); - foreach ($tables as $table) { - if ($table->getName() === 'tags') { - return; - } - } - - $this->createTagsTable($schema); - $this->createShortUrlsInTagsTable($schema); - } - - private function createTagsTable(Schema $schema): void - { - $table = $schema->createTable('tags'); - $table->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $table->addColumn('name', Types::STRING, [ - 'length' => 255, - 'notnull' => true, - ]); - $table->addUniqueIndex(['name']); - - $table->setPrimaryKey(['id']); - } - - private function createShortUrlsInTagsTable(Schema $schema): void - { - $table = $schema->createTable('short_urls_in_tags'); - $table->addColumn('short_url_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - $table->addColumn('tag_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - - $table->addForeignKeyConstraint('tags', ['tag_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - - $table->setPrimaryKey(['short_url_id', 'tag_id']); - } - - public function down(Schema $schema): void - { - $schema->dropTable('short_urls_in_tags'); - $schema->dropTable('tags'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20171021093246.php b/module/Core/migrations/Version20171021093246.php deleted file mode 100644 index a810f49c..00000000 --- a/module/Core/migrations/Version20171021093246.php +++ /dev/null @@ -1,54 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('valid_since')) { - return; - } - - $shortUrls->addColumn('valid_since', Types::DATETIME_MUTABLE, [ - 'notnull' => false, - ]); - $shortUrls->addColumn('valid_until', Types::DATETIME_MUTABLE, [ - 'notnull' => false, - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - if (! $shortUrls->hasColumn('valid_since')) { - return; - } - - $shortUrls->dropColumn('valid_since'); - $shortUrls->dropColumn('valid_until'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20171022064541.php b/module/Core/migrations/Version20171022064541.php deleted file mode 100644 index fb5f8d7a..00000000 --- a/module/Core/migrations/Version20171022064541.php +++ /dev/null @@ -1,51 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('max_visits')) { - return; - } - - $shortUrls->addColumn('max_visits', Types::INTEGER, [ - 'unsigned' => true, - 'notnull' => false, - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - if (! $shortUrls->hasColumn('max_visits')) { - return; - } - - $shortUrls->dropColumn('max_visits'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180801183328.php b/module/Core/migrations/Version20180801183328.php deleted file mode 100644 index 5fd40030..00000000 --- a/module/Core/migrations/Version20180801183328.php +++ /dev/null @@ -1,48 +0,0 @@ -setSize($schema, self::NEW_SIZE); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->setSize($schema, self::OLD_SIZE); - } - - /** - * @throws SchemaException - */ - private function setSize(Schema $schema, int $size): void - { - $schema->getTable('short_urls')->getColumn('short_code')->setLength($size); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180913205455.php b/module/Core/migrations/Version20180913205455.php deleted file mode 100644 index de709bc8..00000000 --- a/module/Core/migrations/Version20180913205455.php +++ /dev/null @@ -1,74 +0,0 @@ -connection->createQueryBuilder(); - $qb->select('id', 'remote_addr') - ->from('visits'); - $st = $this->connection->executeQuery($qb->getSQL()); - - $qb = $this->connection->createQueryBuilder(); - $qb->update('visits') - ->set('remote_addr', ':obfuscatedAddr') - ->where('id=:id'); - - while ($row = $st->fetchAssociative()) { - $addr = $row['remote_addr'] ?? null; - if ($addr === null) { - continue; - } - - $qb->setParameters([ - 'id' => $row['id'], - 'obfuscatedAddr' => $this->determineAddress((string) $addr), - ])->executeQuery(); - } - } - - private function determineAddress(string $addr): ?string - { - if ($addr === IpAddress::LOCALHOST) { - return $addr; - } - - try { - return (string) IpAddress::fromString($addr)->getAnonymizedCopy(); - } catch (InvalidArgumentException) { - return null; - } - } - - public function down(Schema $schema): void - { - // Nothing to rollback - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20180915110857.php b/module/Core/migrations/Version20180915110857.php deleted file mode 100644 index b31ac105..00000000 --- a/module/Core/migrations/Version20180915110857.php +++ /dev/null @@ -1,56 +0,0 @@ - 'SET NULL', - 'short_urls' => 'CASCADE', - ]; - - /** - * @throws SchemaException - */ - public function up(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $foreignKeys = $visits->getForeignKeys(); - - // Remove all existing foreign keys and add them again with CASCADE delete - foreach ($foreignKeys as $foreignKey) { - $visits->removeForeignKey($foreignKey->getName()); - $foreignTable = $foreignKey->getForeignTableName(); - - $visits->addForeignKeyConstraint( - $foreignTable, - $foreignKey->getLocalColumns(), - $foreignKey->getForeignColumns(), - [ - 'onDelete' => self::ON_DELETE_MAP[$foreignTable], - 'onUpdate' => 'RESTRICT', - ], - ); - } - } - - public function down(Schema $schema): void - { - // Nothing to run - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181020060559.php b/module/Core/migrations/Version20181020060559.php deleted file mode 100644 index 908bf304..00000000 --- a/module/Core/migrations/Version20181020060559.php +++ /dev/null @@ -1,74 +0,0 @@ - 'country_code', - 'countryName' => 'country_name', - 'regionName' => 'region_name', - 'cityName' => 'city_name', - ]; - - /** - * @throws SchemaException - */ - public function up(Schema $schema): void - { - $this->createColumns($schema->getTable('visit_locations'), self::COLUMNS); - } - - private function createColumns(Table $visitLocations, array $columnNames): void - { - foreach ($columnNames as $name) { - if (! $visitLocations->hasColumn($name)) { - $visitLocations->addColumn($name, Types::STRING, ['notnull' => false]); - } - } - } - - /** - * @throws SchemaException - * @throws Exception - */ - public function postUp(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - // If the camel case columns do not exist, do nothing - if (! $visitLocations->hasColumn('countryCode')) { - return; - } - - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations'); - foreach (self::COLUMNS as $camelCaseName => $snakeCaseName) { - $qb->set($snakeCaseName, $camelCaseName); - } - $qb->executeStatement(); - } - - public function down(Schema $schema): void - { - // No down - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181020065148.php b/module/Core/migrations/Version20181020065148.php deleted file mode 100644 index 873e7f11..00000000 --- a/module/Core/migrations/Version20181020065148.php +++ /dev/null @@ -1,47 +0,0 @@ -getTable('visit_locations'); - - foreach (self::CAMEL_CASE_COLUMNS as $name) { - if ($visitLocations->hasColumn($name)) { - $visitLocations->dropColumn($name); - } - } - } - - public function down(Schema $schema): void - { - // No down - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20181110175521.php b/module/Core/migrations/Version20181110175521.php deleted file mode 100644 index 9fb989fa..00000000 --- a/module/Core/migrations/Version20181110175521.php +++ /dev/null @@ -1,43 +0,0 @@ -getUserAgentColumn($schema)->setLength(512); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getUserAgentColumn($schema)->setLength(256); - } - - /** - * @throws SchemaException - */ - private function getUserAgentColumn(Schema $schema): Column - { - return $schema->getTable('visits')->getColumn('user_agent'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20190824075137.php b/module/Core/migrations/Version20190824075137.php deleted file mode 100644 index 663111ff..00000000 --- a/module/Core/migrations/Version20190824075137.php +++ /dev/null @@ -1,43 +0,0 @@ -getRefererColumn($schema)->setLength(1024); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getRefererColumn($schema)->setLength(256); - } - - /** - * @throws SchemaException - */ - private function getRefererColumn(Schema $schema): Column - { - return $schema->getTable('visits')->getColumn('referer'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20190930165521.php b/module/Core/migrations/Version20190930165521.php deleted file mode 100644 index 97863843..00000000 --- a/module/Core/migrations/Version20190930165521.php +++ /dev/null @@ -1,61 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasColumn('domain_id')) { - return; - } - - $domains = $schema->createTable('domains'); - $domains->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $domains->addColumn('authority', Types::STRING, [ - 'length' => 512, - 'notnull' => true, - ]); - $domains->addUniqueIndex(['authority']); - $domains->setPrimaryKey(['id']); - - $shortUrls->addColumn('domain_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => false, - ]); - $shortUrls->addForeignKeyConstraint('domains', ['domain_id'], ['id'], [ - 'onDelete' => 'RESTRICT', - 'onUpdate' => 'RESTRICT', - ]); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $schema->getTable('short_urls')->dropColumn('domain_id'); - $schema->dropTable('domains'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20191001201532.php b/module/Core/migrations/Version20191001201532.php deleted file mode 100644 index fa13b85d..00000000 --- a/module/Core/migrations/Version20191001201532.php +++ /dev/null @@ -1,55 +0,0 @@ -getTable('short_urls'); - if ($shortUrls->hasIndex('unique_short_code_plus_domain')) { - return; - } - - /** @var Index|null $shortCodesIndex */ - $shortCodesIndex = array_reduce($shortUrls->getIndexes(), function (?Index $found, Index $current) { - [$column] = $current->getColumns(); - return $column === 'short_code' ? $current : $found; - }); - if ($shortCodesIndex === null) { - return; - } - - $shortUrls->dropIndex($shortCodesIndex->getName()); - $shortUrls->addUniqueIndex(['short_code', 'domain_id'], 'unique_short_code_plus_domain'); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - - $shortUrls->dropIndex('unique_short_code_plus_domain'); - $shortUrls->addUniqueIndex(['short_code']); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20191020074522.php b/module/Core/migrations/Version20191020074522.php deleted file mode 100644 index c1b9aea9..00000000 --- a/module/Core/migrations/Version20191020074522.php +++ /dev/null @@ -1,43 +0,0 @@ -getOriginalUrlColumn($schema)->setLength(2048); - } - - /** - * @throws SchemaException - */ - public function down(Schema $schema): void - { - $this->getOriginalUrlColumn($schema)->setLength(1024); - } - - /** - * @throws SchemaException - */ - private function getOriginalUrlColumn(Schema $schema): Column - { - return $schema->getTable('short_urls')->getColumn('original_url'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200105165647.php b/module/Core/migrations/Version20200105165647.php deleted file mode 100644 index 1af26797..00000000 --- a/module/Core/migrations/Version20200105165647.php +++ /dev/null @@ -1,103 +0,0 @@ - 'latitude', 'lon' => 'longitude']; - - /** - * @throws Exception - */ - public function preUp(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - $this->skipIf(some( - self::COLUMNS, - fn (string $v, string|int $newColName) => $visitLocations->hasColumn((string) $newColName), - ), 'New columns already exist'); - - foreach (self::COLUMNS as $columnName) { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set($columnName, ':zeroValue') - ->where($qb->expr()->or( - $qb->expr()->eq($columnName, ':emptyString'), - $qb->expr()->isNull($columnName), - )) - ->setParameters([ - 'zeroValue' => '0', - 'emptyString' => '', - ]) - ->executeStatement(); - } - } - - /** - * @throws Exception - */ - public function up(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $newName => $oldName) { - $visitLocations->addColumn($newName, Types::FLOAT, [ - 'default' => '0.0', - ]); - } - } - - /** - * @throws Exception - */ - public function postUp(Schema $schema): void - { - $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 . ')') - ->executeStatement(); - } - } - - public function preDown(Schema $schema): void - { - foreach (self::COLUMNS as $newName => $oldName) { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set($oldName, $newName) - ->executeStatement(); - } - } - - /** - * @throws Exception - */ - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $colName => $oldName) { - $visitLocations->dropColumn($colName); - } - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200106215144.php b/module/Core/migrations/Version20200106215144.php deleted file mode 100644 index f5faba4e..00000000 --- a/module/Core/migrations/Version20200106215144.php +++ /dev/null @@ -1,60 +0,0 @@ -getTable('visit_locations'); - $this->skipIf($this->oldColumnsDoNotExist($visitLocations), 'Old columns do not exist'); - - foreach (self::COLUMNS as $colName) { - $visitLocations->dropColumn($colName); - } - } - - public function oldColumnsDoNotExist(Table $visitLocations): bool - { - foreach (self::COLUMNS as $oldColName) { - if ($visitLocations->hasColumn($oldColName)) { - return false; - } - } - - return true; - } - - /** - * @throws Exception - */ - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - - foreach (self::COLUMNS as $colName) { - $visitLocations->addColumn($colName, Types::STRING, [ - 'notnull' => false, - ]); - } - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200110182849.php b/module/Core/migrations/Version20200110182849.php deleted file mode 100644 index 4b608bb2..00000000 --- a/module/Core/migrations/Version20200110182849.php +++ /dev/null @@ -1,60 +0,0 @@ - [ - 'referer', - 'user_agent', - ], - 'visit_locations' => [ - 'timezone', - 'country_code', - 'country_name', - 'region_name', - 'city_name', - ], - ]; - - public function up(Schema $schema): void - { - foreach (self::COLUMN_DEFAULTS_MAP as $tableName => $columns) { - foreach ($columns as $columnName) { - $this->setDefaultValueForColumnInTable($tableName, $columnName); - } - } - } - - /** - * @throws Exception - */ - public function setDefaultValueForColumnInTable(string $tableName, string $columnName): void - { - $qb = $this->connection->createQueryBuilder(); - $qb->update($tableName) - ->set($columnName, ':emptyValue') - ->setParameter('emptyValue', self::DEFAULT_EMPTY_VALUE) - ->where($qb->expr()->isNull($columnName)) - ->executeStatement(); - } - - public function down(Schema $schema): void - { - // No need (and no way) to undo this migration - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200323190014.php b/module/Core/migrations/Version20200323190014.php deleted file mode 100644 index b47fc65c..00000000 --- a/module/Core/migrations/Version20200323190014.php +++ /dev/null @@ -1,52 +0,0 @@ -getTable('visit_locations'); - $this->skipIf($visitLocations->hasColumn('is_empty')); - - $visitLocations->addColumn('is_empty', Types::BOOLEAN, ['default' => false]); - } - - public function postUp(Schema $schema): void - { - $qb = $this->connection->createQueryBuilder(); - $qb->update('visit_locations') - ->set('is_empty', ':isEmpty') - ->where($qb->expr()->eq('country_code', ':emptyString')) - ->andWhere($qb->expr()->eq('country_name', ':emptyString')) - ->andWhere($qb->expr()->eq('region_name', ':emptyString')) - ->andWhere($qb->expr()->eq('city_name', ':emptyString')) - ->andWhere($qb->expr()->eq('timezone', ':emptyString')) - ->andWhere($qb->expr()->eq('lat', ':latLong')) - ->andWhere($qb->expr()->eq('lon', ':latLong')) - ->setParameter('isEmpty', true) - ->setParameter('emptyString', '') - ->setParameter('latLong', 0) - ->executeStatement(); - } - - public function down(Schema $schema): void - { - $visitLocations = $schema->getTable('visit_locations'); - $this->skipIf(!$visitLocations->hasColumn('is_empty')); - - $visitLocations->dropColumn('is_empty'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20200503170404.php b/module/Core/migrations/Version20200503170404.php deleted file mode 100644 index ad2c63df..00000000 --- a/module/Core/migrations/Version20200503170404.php +++ /dev/null @@ -1,33 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasIndex(self::INDEX_NAME)); - $visits->addIndex(['date'], self::INDEX_NAME); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasIndex(self::INDEX_NAME)); - $visits->dropIndex(self::INDEX_NAME); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20201023090929.php b/module/Core/migrations/Version20201023090929.php deleted file mode 100644 index 4655cbd5..00000000 --- a/module/Core/migrations/Version20201023090929.php +++ /dev/null @@ -1,50 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); - - $shortUrls->addColumn(self::IMPORT_SOURCE_COLUMN, Types::STRING, [ - 'length' => 255, - 'notnull' => false, - ]); - $shortUrls->addColumn('import_original_short_code', Types::STRING, [ - 'length' => 255, - 'notnull' => false, - ]); - - $shortUrls->addUniqueIndex( - [self::IMPORT_SOURCE_COLUMN, 'import_original_short_code', 'domain_id'], - 'unique_imports', - ); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::IMPORT_SOURCE_COLUMN)); - - $shortUrls->dropColumn(self::IMPORT_SOURCE_COLUMN); - $shortUrls->dropColumn('import_original_short_code'); - $shortUrls->dropIndex('unique_imports'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20201102113208.php b/module/Core/migrations/Version20201102113208.php deleted file mode 100644 index 4bae99e1..00000000 --- a/module/Core/migrations/Version20201102113208.php +++ /dev/null @@ -1,91 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::API_KEY_COLUMN)); - - $shortUrls->addColumn(self::API_KEY_COLUMN, Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => false, - ]); - - $shortUrls->addForeignKeyConstraint('api_keys', [self::API_KEY_COLUMN], ['id'], [ - 'onDelete' => 'SET NULL', - 'onUpdate' => 'RESTRICT', - ], 'FK_' . self::API_KEY_COLUMN); - } - - public function postUp(Schema $schema): void - { - // If there's only one API key, and it's active, link all existing URLs with it - $qb = $this->connection->createQueryBuilder(); - $qb->select('id') - ->from('api_keys') - ->where($qb->expr()->eq('enabled', ':enabled')) - ->andWhere($qb->expr()->or( - $qb->expr()->isNull('expiration_date'), - $qb->expr()->gt('expiration_date', ':expiration'), - )) - ->setParameters([ - 'enabled' => true, - 'expiration' => Chronos::now()->toDateTimeString(), - ]); - - $result = $qb->executeQuery(); - $id = $this->resolveOneApiKeyId($result); - if ($id === null) { - return; - } - - $qb = $this->connection->createQueryBuilder(); - $qb->update('short_urls') - ->set(self::API_KEY_COLUMN, ':apiKeyId') - ->setParameter('apiKeyId', $id) - ->executeQuery(); - } - - private function resolveOneApiKeyId(Result $result): string|int|null - { - $results = []; - while ($row = $result->fetchAssociative()) { - // As soon as we have to iterate more than once, then we cannot resolve a single API key - if (! empty($results)) { - return null; - } - - $results[] = $row['id'] ?? null; - } - - return $results[0] ?? null; - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::API_KEY_COLUMN)); - - $shortUrls->removeForeignKey('FK_' . self::API_KEY_COLUMN); - $shortUrls->dropColumn(self::API_KEY_COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210102174433.php b/module/Core/migrations/Version20210102174433.php deleted file mode 100644 index 58ea36cd..00000000 --- a/module/Core/migrations/Version20210102174433.php +++ /dev/null @@ -1,58 +0,0 @@ -skipIf($schema->hasTable(self::TABLE_NAME)); - - $table = $schema->createTable(self::TABLE_NAME); - $table->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $table->setPrimaryKey(['id']); - - $table->addColumn('role_name', Types::STRING, [ - 'length' => 255, - 'notnull' => true, - ]); - $table->addColumn('meta', Types::JSON, [ - 'notnull' => true, - ]); - - $table->addColumn('api_key_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - $table->addForeignKeyConstraint('api_keys', ['api_key_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - $table->addUniqueIndex(['role_name', 'api_key_id'], 'UQ_role_plus_api_key'); - } - - public function down(Schema $schema): void - { - $this->skipIf(! $schema->hasTable(self::TABLE_NAME)); - $schema->getTable(self::TABLE_NAME)->dropIndex('UQ_role_plus_api_key'); - $schema->dropTable(self::TABLE_NAME); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210118153932.php b/module/Core/migrations/Version20210118153932.php deleted file mode 100644 index 476f8d84..00000000 --- a/module/Core/migrations/Version20210118153932.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('api_key_roles'); - $nameColumn = $rolesTable->getColumn('role_name'); - $nameColumn->setLength(255); - } - - public function down(Schema $schema): void - { - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210202181026.php b/module/Core/migrations/Version20210202181026.php deleted file mode 100644 index 7a63b814..00000000 --- a/module/Core/migrations/Version20210202181026.php +++ /dev/null @@ -1,42 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn(self::TITLE)); - - $shortUrls->addColumn(self::TITLE, Types::STRING, [ - 'notnull' => false, - 'length' => 512, - ]); - $shortUrls->addColumn('title_was_auto_resolved', Types::BOOLEAN, [ - 'default' => false, - ]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn(self::TITLE)); - $shortUrls->dropColumn(self::TITLE); - $shortUrls->dropColumn('title_was_auto_resolved'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210207100807.php b/module/Core/migrations/Version20210207100807.php deleted file mode 100644 index 4a77eba2..00000000 --- a/module/Core/migrations/Version20210207100807.php +++ /dev/null @@ -1,49 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasColumn('visited_url')); - - $shortUrlId = $visits->getColumn('short_url_id'); - $shortUrlId->setNotnull(false); - - $visits->addColumn('visited_url', Types::STRING, [ - 'length' => Visitor::VISITED_URL_MAX_LENGTH, - 'notnull' => false, - ]); - $visits->addColumn('type', Types::STRING, [ - 'length' => 255, - 'default' => VisitType::VALID_SHORT_URL->value, - ]); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasColumn('visited_url')); - - $shortUrlId = $visits->getColumn('short_url_id'); - $shortUrlId->setNotnull(true); - $visits->dropColumn('visited_url'); - $visits->dropColumn('type'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210306165711.php b/module/Core/migrations/Version20210306165711.php deleted file mode 100644 index ba1a4476..00000000 --- a/module/Core/migrations/Version20210306165711.php +++ /dev/null @@ -1,43 +0,0 @@ -getTable(self::TABLE); - $this->skipIf($apiKeys->hasColumn(self::COLUMN)); - - $apiKeys->addColumn( - self::COLUMN, - Types::STRING, - [ - 'notnull' => false, - ], - ); - } - - public function down(Schema $schema): void - { - $apiKeys = $schema->getTable(self::TABLE); - $this->skipIf(! $apiKeys->hasColumn(self::COLUMN)); - - $apiKeys->dropColumn(self::COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210522051601.php b/module/Core/migrations/Version20210522051601.php deleted file mode 100644 index 279c7a7e..00000000 --- a/module/Core/migrations/Version20210522051601.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn('crawlable')); - $shortUrls->addColumn('crawlable', Types::BOOLEAN, ['default' => false]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn('crawlable')); - $shortUrls->dropColumn('crawlable'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210522124633.php b/module/Core/migrations/Version20210522124633.php deleted file mode 100644 index 921e0831..00000000 --- a/module/Core/migrations/Version20210522124633.php +++ /dev/null @@ -1,34 +0,0 @@ -getTable('visits'); - $this->skipIf($visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); - $visits->addColumn(self::POTENTIAL_BOT_COLUMN, Types::BOOLEAN, ['default' => false]); - } - - public function down(Schema $schema): void - { - $visits = $schema->getTable('visits'); - $this->skipIf(! $visits->hasColumn(self::POTENTIAL_BOT_COLUMN)); - $visits->dropColumn(self::POTENTIAL_BOT_COLUMN); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20210720143824.php b/module/Core/migrations/Version20210720143824.php deleted file mode 100644 index 407c5c79..00000000 --- a/module/Core/migrations/Version20210720143824.php +++ /dev/null @@ -1,47 +0,0 @@ -getTable('domains'); - $this->skipIf($domainsTable->hasColumn('base_url_redirect')); - - $this->createRedirectColumn($domainsTable, 'base_url_redirect'); - $this->createRedirectColumn($domainsTable, 'regular_not_found_redirect'); - $this->createRedirectColumn($domainsTable, 'invalid_short_url_redirect'); - } - - private function createRedirectColumn(Table $table, string $columnName): void - { - $table->addColumn($columnName, Types::STRING, [ - 'notnull' => false, - 'default' => null, - ]); - } - - public function down(Schema $schema): void - { - $domainsTable = $schema->getTable('domains'); - $this->skipIf(! $domainsTable->hasColumn('base_url_redirect')); - - $domainsTable->dropColumn('base_url_redirect'); - $domainsTable->dropColumn('regular_not_found_redirect'); - $domainsTable->dropColumn('invalid_short_url_redirect'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} diff --git a/module/Core/migrations/Version20211002072605.php b/module/Core/migrations/Version20211002072605.php deleted file mode 100644 index 970d51d6..00000000 --- a/module/Core/migrations/Version20211002072605.php +++ /dev/null @@ -1,32 +0,0 @@ -getTable('short_urls'); - $this->skipIf($shortUrls->hasColumn('forward_query')); - $shortUrls->addColumn('forward_query', Types::BOOLEAN, ['default' => true]); - } - - public function down(Schema $schema): void - { - $shortUrls = $schema->getTable('short_urls'); - $this->skipIf(! $shortUrls->hasColumn('forward_query')); - $shortUrls->dropColumn('forward_query'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } -} From 09e81b00c559a5615eff2d2108dd9d5f8321aa5d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 23:10:08 +0100 Subject: [PATCH 58/97] Create component to resolve the long URL to redirect to for a short URL --- module/Core/config/dependencies.config.php | 8 ++- .../ShortUrlRedirectionResolver.php | 23 +++++++ .../ShortUrlRedirectionResolverInterface.php | 11 ++++ .../Helper/ShortUrlRedirectionBuilder.php | 13 ++-- .../ShortUrlRedirectionResolverTest.php | 60 +++++++++++++++++++ .../Helper/ShortUrlRedirectionBuilderTest.php | 37 ++++-------- 6 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php create mode 100644 module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php create mode 100644 module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 5f9ae565..6246b307 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -32,6 +32,8 @@ return [ Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], + RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class, + ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class, ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class, @@ -156,6 +158,7 @@ return [ Util\RedirectResponseHelper::class => [Options\RedirectOptions::class], Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'], + RedirectRule\ShortUrlRedirectionResolver::class => ['em'], Action\RedirectAction::class => [ ShortUrl\ShortUrlResolver::class, @@ -179,7 +182,10 @@ return [ ], ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'], ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class], - ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class], + ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [ + Options\TrackingOptions::class, + RedirectRule\ShortUrlRedirectionResolver::class, + ], ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class], ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [ ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php new file mode 100644 index 00000000..dc2ae131 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php @@ -0,0 +1,23 @@ +getHeaderLine('User-Agent')); + return $shortUrl->longUrlForDevice($device); + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php new file mode 100644 index 00000000..a1dd92a2 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectionResolverInterface.php @@ -0,0 +1,11 @@ +redirectionResolver->resolveLongUrl($shortUrl, $request)); $currentQuery = $request->getQueryParams(); - $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - $uri = new Uri($shortUrl->longUrlForDevice($device)); $shouldForwardQuery = $shortUrl->forwardQuery(); return $uri diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php new file mode 100644 index 00000000..03a5ce6c --- /dev/null +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -0,0 +1,60 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->resolver = new ShortUrlRedirectionResolver($this->em); + } + + #[Test, DataProvider('provideData')] + public function resolveLongUrlReturnsExpectedValue(ServerRequestInterface $request, string $expectedUrl): void + { + $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ + 'longUrl' => 'https://example.com/foo/bar', + 'deviceLongUrls' => [ + DeviceType::ANDROID->value => 'https://example.com/android', + DeviceType::IOS->value => 'https://example.com/ios', + ], + ])); + + $result = $this->resolver->resolveLongUrl($shortUrl, $request); + + self::assertEquals($expectedUrl, $result); + } + + public static function provideData(): iterable + { + $request = static fn (string $userAgent = '') => ServerRequestFactory::fromGlobals()->withHeader( + 'User-Agent', + $userAgent, + ); + + yield 'unknown user agent' => [$request('Unknown'), 'https://example.com/foo/bar']; + yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), 'https://example.com/foo/bar']; + yield 'android user agent' => [$request(ANDROID_USER_AGENT), 'https://example.com/android']; + yield 'ios user agent' => [$request(IOS_USER_AGENT), 'https://example.com/ios']; + } +} diff --git a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php index cf88db35..53a86322 100644 --- a/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php +++ b/module/Core/test/ShortUrl/Helper/ShortUrlRedirectionBuilderTest.php @@ -7,26 +7,26 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\TrackingOptions; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; -use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; -use const ShlinkioTest\Shlink\IOS_USER_AGENT; - class ShortUrlRedirectionBuilderTest extends TestCase { private ShortUrlRedirectionBuilder $redirectionBuilder; + private ShortUrlRedirectionResolverInterface & MockObject $redirectionResolver; protected function setUp(): void { $trackingOptions = new TrackingOptions(disableTrackParam: 'foobar'); - $this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions); + $this->redirectionResolver = $this->createMock(ShortUrlRedirectionResolverInterface::class); + + $this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions, $this->redirectionResolver); } #[Test, DataProvider('provideData')] @@ -39,11 +39,12 @@ class ShortUrlRedirectionBuilderTest extends TestCase $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://domain.com/foo/bar?some=thing', 'forwardQuery' => $forwardQuery, - 'deviceLongUrls' => [ - DeviceType::ANDROID->value => 'https://domain.com/android', - DeviceType::IOS->value => 'https://domain.com/ios', - ], ])); + $this->redirectionResolver->expects($this->once())->method('resolveLongUrl')->with( + $shortUrl, + $request, + )->willReturn($shortUrl->getLongUrl()); + $result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath); self::assertEquals($expectedUrl, $result); @@ -72,7 +73,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase ]; yield [ 'https://domain.com/foo/bar?some=overwritten', - $request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'), + $request(['foobar' => 'notrack', 'some' => 'overwritten']), null, true, ]; @@ -91,7 +92,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true]; yield [ 'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world', - $request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT), + $request(['hello' => 'world']), '/something/else-baz', true, ]; @@ -107,17 +108,5 @@ class ShortUrlRedirectionBuilderTest extends TestCase '/something/else-baz', false, ]; - yield [ - 'https://domain.com/android/something', - $request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT), - '/something', - false, - ]; - yield [ - 'https://domain.com/ios?foo=bar', - $request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT), - null, - null, - ]; } } From 7f83d37b3c35d2f397b1e25587f7cdc0adaebcbc Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 24 Feb 2024 23:33:16 +0100 Subject: [PATCH 59/97] Add logic to match redirect conditions based on query params or language --- module/Core/functions/functions.php | 7 +++++ .../RedirectRule/Entity/RedirectCondition.php | 29 +++++++++++++++++++ .../Model/RedirectConditionType.php | 6 ++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index f26cb84f..586195da 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -26,7 +26,9 @@ use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function str_replace; use function strtolower; +use function trim; use function ucfirst; function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string @@ -74,6 +76,11 @@ function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos return normalizeOptionalDate($date); } +function normalizeLocale(string $locale): string +{ + return trim(strtolower(str_replace('_', '-', $locale))); +} + function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int { $value = $inputFilter->getValue($fieldName); diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 367d96d9..72960d82 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -2,9 +2,14 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use function explode; +use function Shlinkio\Shlink\Core\ArrayUtils\some; +use function Shlinkio\Shlink\Core\normalizeLocale; + class RedirectCondition extends AbstractEntity { public function __construct( @@ -14,4 +19,28 @@ class RedirectCondition extends AbstractEntity public readonly ?string $matchKey = null, ) { } + + /** + * Tells if this condition matches provided request + */ + public function matchesRequest(ServerRequestInterface $request): bool + { + if ($this->type === RedirectConditionType::QUERY_PARAM && $this->matchKey !== null) { + $query = $request->getQueryParams(); + $queryValue = $query[$this->matchKey] ?? null; + return $queryValue === $this->matchValue; + } + + if ($this->type === RedirectConditionType::LANGUAGE && $request->hasHeader('Accept-Language')) { + $acceptedLanguages = explode(',', $request->getHeaderLine('Accept-Language')); + $normalizedLanguage = normalizeLocale($this->matchValue); + + return some( + $acceptedLanguages, + static fn (string $lang) => normalizeLocale($lang) === $normalizedLanguage, + ); + } + + return false; + } } diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 6e6b8113..764c5a2b 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model; enum RedirectConditionType: string { - case DEVICE = 'device'; -// case LANGUAGE = 'language'; -// case QUERY_PARAM = 'query'; +// case DEVICE = 'device'; + case LANGUAGE = 'language'; + case QUERY_PARAM = 'query'; } From 4e87affb0bfd7a8dbe576a80ab0f01db7ff9b6f1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 12:25:55 +0100 Subject: [PATCH 60/97] Take redirect rules into consideration when resolving the long URL for a short URL --- .../RedirectRule/Entity/ShortUrlRedirectRule.php | 16 +++++++++++++++- .../RedirectRule/ShortUrlRedirectionResolver.php | 12 +++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 65b39901..7fa1a671 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -4,9 +4,12 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function Shlinkio\Shlink\Core\ArrayUtils\every; + class ShortUrlRedirectRule extends AbstractEntity { /** @@ -14,9 +17,20 @@ class ShortUrlRedirectRule extends AbstractEntity */ public function __construct( private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine - public readonly int $priority, + private readonly int $priority, public readonly string $longUrl, public readonly Collection $conditions = new ArrayCollection(), ) { } + + /** + * Tells if this condition matches provided request + */ + public function matchesRequest(ServerRequestInterface $request): bool + { + return every( + $this->conditions, + static fn (RedirectCondition $condition) => $condition->matchesRequest($request), + ); + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php index dc2ae131..b916d78b 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php @@ -5,6 +5,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Doctrine\ORM\EntityManagerInterface; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; readonly class ShortUrlRedirectionResolver implements ShortUrlRedirectionResolverInterface @@ -15,7 +16,16 @@ readonly class ShortUrlRedirectionResolver implements ShortUrlRedirectionResolve public function resolveLongUrl(ShortUrl $shortUrl, ServerRequestInterface $request): string { - // TODO Resolve rules and check if any of them matches + $rules = $this->em->getRepository(ShortUrlRedirectRule::class)->findBy( + criteria: ['shortUrl' => $shortUrl], + orderBy: ['priority' => 'ASC'], + ); + foreach ($rules as $rule) { + // Return the long URL for the first rule found that matches + if ($rule->matchesRequest($request)) { + return $rule->longUrl; + } + } $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); return $shortUrl->longUrlForDevice($device); From 202d0b86b3fe0ca480737a8ac6a0e585ee307ac5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 17:13:54 +0100 Subject: [PATCH 61/97] Extract logic to match every type of redirect condition to its own private method --- .../RedirectRule/Entity/RedirectCondition.php | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 72960d82..608acd06 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -25,22 +25,38 @@ class RedirectCondition extends AbstractEntity */ public function matchesRequest(ServerRequestInterface $request): bool { - if ($this->type === RedirectConditionType::QUERY_PARAM && $this->matchKey !== null) { - $query = $request->getQueryParams(); - $queryValue = $query[$this->matchKey] ?? null; - return $queryValue === $this->matchValue; + return match ($this->type) { + RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request), + RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), + default => false, + }; + } + + public function matchesQueryParam(ServerRequestInterface $request): bool + { + if ($this->matchKey !== null) { + return false; } - if ($this->type === RedirectConditionType::LANGUAGE && $request->hasHeader('Accept-Language')) { - $acceptedLanguages = explode(',', $request->getHeaderLine('Accept-Language')); - $normalizedLanguage = normalizeLocale($this->matchValue); + $query = $request->getQueryParams(); + $queryValue = $query[$this->matchKey] ?? null; - return some( - $acceptedLanguages, - static fn (string $lang) => normalizeLocale($lang) === $normalizedLanguage, - ); + return $queryValue === $this->matchValue; + } + + public function matchesLanguage(ServerRequestInterface $request): bool + { + $acceptLanguage = $request->getHeaderLine('Accept-Language'); + if ($acceptLanguage === '' || $acceptLanguage === '*') { + return false; } - return false; + $acceptedLanguages = explode(',', $acceptLanguage); + $normalizedLanguage = normalizeLocale($this->matchValue); + + return some( + $acceptedLanguages, + static fn (string $lang) => normalizeLocale($lang) === $normalizedLanguage, + ); } } From 3f1b253c3183c92d37b81d51ad74ba285ed072f8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 19:21:39 +0100 Subject: [PATCH 62/97] Add test for RedirectCondition request matching --- .../RedirectRule/Entity/RedirectCondition.php | 30 +++++++--- .../Entity/RedirectConditionTest.php | 59 +++++++++++++++++++ 2 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 module/Core/test/RedirectRule/Entity/RedirectConditionTest.php diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 608acd06..5f3de073 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -9,17 +9,34 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use function explode; use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\normalizeLocale; +use function sprintf; class RedirectCondition extends AbstractEntity { - public function __construct( + private function __construct( public readonly string $name, - public readonly RedirectConditionType $type, + private readonly RedirectConditionType $type, public readonly string $matchValue, public readonly ?string $matchKey = null, ) { } + public static function forQueryParam(string $param, string $value): self + { + $type = RedirectConditionType::QUERY_PARAM; + $name = sprintf('%s-%s-%s', $type->value, $param, $value); + + return new self($name, $type, $value, $param); + } + + public static function forLanguage(string $language): self + { + $type = RedirectConditionType::LANGUAGE; + $name = sprintf('%s-%s', $type->value, $language); + + return new self($name, $type, $language); + } + /** * Tells if this condition matches provided request */ @@ -28,23 +45,18 @@ class RedirectCondition extends AbstractEntity return match ($this->type) { RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request), RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), - default => false, }; } - public function matchesQueryParam(ServerRequestInterface $request): bool + private function matchesQueryParam(ServerRequestInterface $request): bool { - if ($this->matchKey !== null) { - return false; - } - $query = $request->getQueryParams(); $queryValue = $query[$this->matchKey] ?? null; return $queryValue === $this->matchValue; } - public function matchesLanguage(ServerRequestInterface $request): bool + private function matchesLanguage(ServerRequestInterface $request): bool { $acceptLanguage = $request->getHeaderLine('Accept-Language'); if ($acceptLanguage === '' || $acceptLanguage === '*') { diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php new file mode 100644 index 00000000..c52988dc --- /dev/null +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -0,0 +1,59 @@ +withQueryParams(['foo' => 'bar']); + $result = RedirectCondition::forQueryParam($param, $value)->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + #[Test] + #[TestWith([null, '', false])] // no accept language + #[TestWith(['', '', false])] // empty accept language + #[TestWith(['*', '', false])] // wildcard accept language + #[TestWith(['en', 'en', true])] // single language match + #[TestWith(['es, en,fr', 'en', true])] // multiple languages match + #[TestWith(['es_ES', 'es-ES', true])] // single locale match + #[TestWith(['en-UK', 'en-uk', true])] // different casing match + public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals(); + if ($acceptLanguage !== null) { + $request = $request->withHeader('Accept-Language', $acceptLanguage); + } + + $result = RedirectCondition::forLanguage($value)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + + #[Test, DataProvider('provideNames')] + public function generatesExpectedName(RedirectCondition $condition, string $expectedName): void + { + self::assertEquals($expectedName, $condition->name); + } + + public static function provideNames(): iterable + { + yield [RedirectCondition::forLanguage('es-ES'), 'language-es-ES']; + yield [RedirectCondition::forLanguage('en_UK'), 'language-en_UK']; + yield [RedirectCondition::forQueryParam('foo', 'bar'), 'query-foo-bar']; + yield [RedirectCondition::forQueryParam('baz', 'foo'), 'query-baz-foo']; + } +} From 175712d4a951342f105b40ae64bc40d3c1405c94 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 19:38:54 +0100 Subject: [PATCH 63/97] Add test for ShortUrlRedirectRule request matching --- .../Entity/ShortUrlRedirectRule.php | 2 +- .../Entity/ShortUrlRedirectRuleTest.php | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 7fa1a671..74a87930 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -28,7 +28,7 @@ class ShortUrlRedirectRule extends AbstractEntity */ public function matchesRequest(ServerRequestInterface $request): bool { - return every( + return $this->conditions->count() > 0 && every( $this->conditions, static fn (RedirectCondition $condition) => $condition->matchesRequest($request), ); diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php new file mode 100644 index 00000000..244e4f2f --- /dev/null +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -0,0 +1,49 @@ +withHeader('Accept-Language', 'en-UK') + ->withQueryParams(['foo' => 'bar']); + + $result = $this->createRule($conditions)->matchesRequest($request); + + self::assertEquals($expectedResult, $result); + } + + public static function provideConditions(): iterable + { + yield 'no conditions' => [[], false]; + yield 'not all conditions match' => [ + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'foo')], + false, + ]; + yield 'all conditions match' => [ + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + true, + ]; + } + + /** + * @param RedirectCondition[] $conditions + */ + private function createRule(array $conditions): ShortUrlRedirectRule + { + $shortUrl = ShortUrl::withLongUrl('https://s.test'); + return new ShortUrlRedirectRule($shortUrl, 1, '', new ArrayCollection($conditions)); + } +} From 07ae92943d66b02aa3f04baf7a004bb106c7e254 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 25 Feb 2024 23:09:16 +0100 Subject: [PATCH 64/97] Add test for ShortUrlRedirectResolver rule matching --- .../ShortUrlRedirectionResolverTest.php | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index 03a5ce6c..2a71dfe2 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -2,7 +2,9 @@ namespace RedirectRule; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\EntityRepository; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -10,6 +12,8 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; +use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolver; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; @@ -30,8 +34,11 @@ class ShortUrlRedirectionResolverTest extends TestCase } #[Test, DataProvider('provideData')] - public function resolveLongUrlReturnsExpectedValue(ServerRequestInterface $request, string $expectedUrl): void - { + public function resolveLongUrlReturnsExpectedValue( + ServerRequestInterface $request, + ?RedirectCondition $condition, + string $expectedUrl, + ): void { $shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([ 'longUrl' => 'https://example.com/foo/bar', 'deviceLongUrls' => [ @@ -40,6 +47,16 @@ class ShortUrlRedirectionResolverTest extends TestCase ], ])); + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->willReturn($condition !== null ? [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + $condition, + ])), + ] : []); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + $result = $this->resolver->resolveLongUrl($shortUrl, $request); self::assertEquals($expectedUrl, $result); @@ -52,9 +69,27 @@ class ShortUrlRedirectionResolverTest extends TestCase $userAgent, ); - yield 'unknown user agent' => [$request('Unknown'), 'https://example.com/foo/bar']; - yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), 'https://example.com/foo/bar']; - yield 'android user agent' => [$request(ANDROID_USER_AGENT), 'https://example.com/android']; - yield 'ios user agent' => [$request(IOS_USER_AGENT), 'https://example.com/ios']; + yield 'unknown user agent' => [ + $request('Unknown'), // This user agent won't match any device + RedirectCondition::forLanguage('es-ES'), // This condition won't match + 'https://example.com/foo/bar', + ]; + yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), null, 'https://example.com/foo/bar']; + yield 'android user agent' => [ + $request(ANDROID_USER_AGENT), + RedirectCondition::forQueryParam('foo', 'bar'), // This condition won't match + 'https://example.com/android', + ]; + yield 'ios user agent' => [$request(IOS_USER_AGENT), null, 'https://example.com/ios']; + yield 'matching language' => [ + $request()->withHeader('Accept-Language', 'es-ES'), + RedirectCondition::forLanguage('es-ES'), + 'https://example.com/from-rule', + ]; + yield 'matching query params' => [ + $request()->withQueryParams(['foo' => 'bar']), + RedirectCondition::forQueryParam('foo', 'bar'), + 'https://example.com/from-rule', + ]; } } From df5ad554c13256cb31be4e74853368669118e670 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 19:05:39 +0100 Subject: [PATCH 65/97] Add E2E tests for dynamic rule-based redirects --- composer.json | 2 +- .../RedirectRule/Entity/RedirectCondition.php | 4 +- .../Entity/ShortUrlRedirectRule.php | 2 +- module/Core/test-api/Action/RedirectTest.php | 46 ++++++++++++-- .../Fixtures/ShortUrlRedirectRulesFixture.php | 62 +++++++++++++++++++ 5 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php diff --git a/composer.json b/composer.json index fdb6459f..a09eafa0 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "phpunit/phpunit": "^10.4", "roave/security-advisories": "dev-master", "shlinkio/php-coding-standard": "~2.3.0", - "shlinkio/shlink-test-utils": "^4.0", + "shlinkio/shlink-test-utils": "^4.1", "symfony/var-dumper": "^7.0", "veewee/composer-run-parallel": "^1.3" }, diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 5f3de073..da235f9e 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -16,8 +16,8 @@ class RedirectCondition extends AbstractEntity private function __construct( public readonly string $name, private readonly RedirectConditionType $type, - public readonly string $matchValue, - public readonly ?string $matchKey = null, + private readonly string $matchValue, + private readonly ?string $matchKey = null, ) { } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 74a87930..9e84e4fb 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -19,7 +19,7 @@ class ShortUrlRedirectRule extends AbstractEntity private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine private readonly int $priority, public readonly string $longUrl, - public readonly Collection $conditions = new ArrayCollection(), + private Collection $conditions = new ArrayCollection(), ) { } diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index bbcc6fec..64c5fd95 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace ShlinkioApiTest\Shlink\Core\Action; +use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; @@ -15,9 +16,9 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT; class RedirectTest extends ApiTestCase { #[Test, DataProvider('provideUserAgents')] - public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void + public function properRedirectHappensBasedOnUserAgent(array $options, string $expectedRedirect): void { - $response = $this->callShortUrl('def456', $userAgent); + $response = $this->callShortUrl('def456', $options); self::assertEquals(302, $response->getStatusCode()); self::assertEquals($expectedRedirect, $response->getHeaderLine('Location')); @@ -25,15 +26,48 @@ class RedirectTest extends ApiTestCase public static function provideUserAgents(): iterable { - yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android']; - yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios']; + yield 'android' => [ + [ + RequestOptions::HEADERS => ['User-Agent' => ANDROID_USER_AGENT], + ], + 'https://blog.alejandrocelaya.com/android', + ]; + yield 'ios' => [ + [ + RequestOptions::HEADERS => ['User-Agent' => IOS_USER_AGENT], + ], + 'https://blog.alejandrocelaya.com/ios', + ]; yield 'desktop' => [ - DESKTOP_USER_AGENT, + [ + RequestOptions::HEADERS => ['User-Agent' => DESKTOP_USER_AGENT], + ], 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; yield 'unknown' => [ - null, + [], 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', ]; + yield 'rule: english and foo' => [ + [ + RequestOptions::HEADERS => [ + 'Accept-Language' => 'en-UK', + ], + RequestOptions::QUERY => ['foo' => 'bar'], + ], + 'https://example.com/english-and-foo-query?foo=bar', + ]; + yield 'rule: multiple query params' => [ + [ + RequestOptions::QUERY => ['foo' => 'bar', 'hello' => 'world'], + ], + 'https://example.com/multiple-query-params?foo=bar&hello=world', + ]; + yield 'rule: english' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'], + ], + 'https://example.com/only-english', + ]; } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php new file mode 100644 index 00000000..8f40af7d --- /dev/null +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -0,0 +1,62 @@ +getReference('def456_short_url'); + + $englishCondition = RedirectCondition::forLanguage('en-UK'); + $manager->persist($englishCondition); + + $fooQueryCondition = RedirectCondition::forQueryParam('foo', 'bar'); + $manager->persist($fooQueryCondition); + + $helloQueryCondition = RedirectCondition::forQueryParam('hello', 'world'); + $manager->persist($helloQueryCondition); + + $englishAndFooQueryRule = new ShortUrlRedirectRule( + $defShortUrl, + 1, + 'https://example.com/english-and-foo-query', + new ArrayCollection([$englishCondition, $fooQueryCondition]), + ); + $manager->persist($englishAndFooQueryRule); + + $multipleQueryParamsRule = new ShortUrlRedirectRule( + $defShortUrl, + 2, + 'https://example.com/multiple-query-params', + new ArrayCollection([$helloQueryCondition, $fooQueryCondition]), + ); + $manager->persist($multipleQueryParamsRule); + + $onlyEnglishRule = new ShortUrlRedirectRule( + $defShortUrl, + 3, + 'https://example.com/only-english', + new ArrayCollection([$englishCondition]), + ); + $manager->persist($onlyEnglishRule); + + $manager->flush(); + } +} From 3284cea6f243f4637246857573b992f01ae2a298 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 19:08:21 +0100 Subject: [PATCH 66/97] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f1cf0b8d..522f28c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ## [Unreleased] ### Added +* [#1902](https://github.com/shlinkio/shlink/issues/1902) Add dynamic redirects based on query parameters. + + This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). + * [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image. * [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type. From db02d9f1ba6775df12c9d7c597bd2ed1969b4b76 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 19:58:46 +0100 Subject: [PATCH 67/97] Finalize logic to dynamically match accept language rules --- module/Core/functions/functions.php | 30 +++++++++++++++++++ .../RedirectRule/Entity/RedirectCondition.php | 21 +++++++++---- module/Core/test-api/Action/RedirectTest.php | 18 ++++++++--- .../Entity/RedirectConditionTest.php | 4 +++ .../Fixtures/ShortUrlRedirectRulesFixture.php | 2 +- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index 586195da..e910833a 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -16,10 +16,13 @@ use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; +use function array_filter; use function array_keys; use function array_map; +use function array_pad; use function array_reduce; use function date_default_timezone_get; +use function explode; use function implode; use function is_array; use function print_r; @@ -81,6 +84,33 @@ function normalizeLocale(string $locale): string return trim(strtolower(str_replace('_', '-', $locale))); } +/** + * @param non-empty-string $acceptLanguage + * @return string[]; + */ +function acceptLanguageToLocales(string $acceptLanguage): array +{ + $acceptLanguagesList = array_map(function (string $lang): string { + [$lang] = explode(';', $lang); // Discard everything after the semicolon (en-US;q=0.7) + return normalizeLocale($lang); + }, explode(',', $acceptLanguage)); + return array_filter($acceptLanguagesList, static fn (string $lang) => $lang !== '*'); +} + +/** + * Splits a locale into its corresponding language and country codes. + * The country code will be null if not present + * 'es-AR' -> ['es', 'AR'] + * 'fr-FR' -> ['fr', 'FR'] + * 'en' -> ['en', null] + * + * @return array{string, string|null} + */ +function splitLocale(string $locale): array +{ + return array_pad(explode('-', $locale), 2, null); +} + function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int { $value = $inputFilter->getValue($fieldName); diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index da235f9e..9b7d3b3d 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -6,10 +6,12 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; -use function explode; +use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\normalizeLocale; +use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; +use function trim; class RedirectCondition extends AbstractEntity { @@ -58,17 +60,26 @@ class RedirectCondition extends AbstractEntity private function matchesLanguage(ServerRequestInterface $request): bool { - $acceptLanguage = $request->getHeaderLine('Accept-Language'); + $acceptLanguage = trim($request->getHeaderLine('Accept-Language')); if ($acceptLanguage === '' || $acceptLanguage === '*') { return false; } - $acceptedLanguages = explode(',', $acceptLanguage); - $normalizedLanguage = normalizeLocale($this->matchValue); + $acceptedLanguages = acceptLanguageToLocales($acceptLanguage); + $normalizedLocale = normalizeLocale($this->matchValue); + [$matchLanguage, $matchCountryCode] = splitLocale($normalizedLocale); return some( $acceptedLanguages, - static fn (string $lang) => normalizeLocale($lang) === $normalizedLanguage, + static function (string $lang) use ($matchLanguage, $matchCountryCode): bool { + [$language, $countryCode] = splitLocale($lang); + + if ($matchLanguage !== $language) { + return false; + } + + return $matchCountryCode === null || $matchCountryCode === $countryCode; + }, ); } } diff --git a/module/Core/test-api/Action/RedirectTest.php b/module/Core/test-api/Action/RedirectTest.php index 64c5fd95..cb623edc 100644 --- a/module/Core/test-api/Action/RedirectTest.php +++ b/module/Core/test-api/Action/RedirectTest.php @@ -50,9 +50,7 @@ class RedirectTest extends ApiTestCase ]; yield 'rule: english and foo' => [ [ - RequestOptions::HEADERS => [ - 'Accept-Language' => 'en-UK', - ], + RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'], RequestOptions::QUERY => ['foo' => 'bar'], ], 'https://example.com/english-and-foo-query?foo=bar', @@ -63,11 +61,23 @@ class RedirectTest extends ApiTestCase ], 'https://example.com/multiple-query-params?foo=bar&hello=world', ]; - yield 'rule: english' => [ + yield 'rule: british english' => [ [ RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'], ], 'https://example.com/only-english', ]; + yield 'rule: english' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'en'], + ], + 'https://example.com/only-english', + ]; + yield 'rule: complex matching accept language' => [ + [ + RequestOptions::HEADERS => ['Accept-Language' => 'fr-FR, es;q=08, en;q=0.5, *;q=0.2'], + ], + 'https://example.com/only-english', + ]; } } diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index c52988dc..20178068 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -29,8 +29,12 @@ class RedirectConditionTest extends TestCase #[TestWith(['*', '', false])] // wildcard accept language #[TestWith(['en', 'en', true])] // single language match #[TestWith(['es, en,fr', 'en', true])] // multiple languages match + #[TestWith(['es, en-US,fr', 'EN', true])] // multiple locales match #[TestWith(['es_ES', 'es-ES', true])] // single locale match #[TestWith(['en-UK', 'en-uk', true])] // different casing match + #[TestWith(['en-UK', 'en', true])] // only lang + #[TestWith(['es-AR', 'en', false])] // different only lang + #[TestWith(['fr', 'fr-FR', false])] // less restrictive matching locale public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void { $request = ServerRequestFactory::fromGlobals(); diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php index 8f40af7d..6d321a91 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -24,7 +24,7 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); - $englishCondition = RedirectCondition::forLanguage('en-UK'); + $englishCondition = RedirectCondition::forLanguage('en'); $manager->persist($englishCondition); $fooQueryCondition = RedirectCondition::forQueryParam('foo', 'bar'); From 0fe503fa0e7dbb563e5d029f527b5c0b6ab652b4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 19:59:34 +0100 Subject: [PATCH 68/97] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 522f28c3..50805155 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). +* [#1915](https://github.com/shlinkio/shlink/issues/1915) Add dynamic redirects based on accept language. + + This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). + * [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image. * [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type. From 60fef3de7441b280d04ee7a6c0bf942104ec0b17 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 20:06:15 +0100 Subject: [PATCH 69/97] Merge lines --- module/Core/src/RedirectRule/Entity/RedirectCondition.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 9b7d3b3d..babbbe67 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -66,8 +66,7 @@ class RedirectCondition extends AbstractEntity } $acceptedLanguages = acceptLanguageToLocales($acceptLanguage); - $normalizedLocale = normalizeLocale($this->matchValue); - [$matchLanguage, $matchCountryCode] = splitLocale($normalizedLocale); + [$matchLanguage, $matchCountryCode] = splitLocale(normalizeLocale($this->matchValue)); return some( $acceptedLanguages, From 73864b923d48a22e72c1edb6b101dd04458579c1 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Mon, 26 Feb 2024 23:42:37 +0100 Subject: [PATCH 70/97] Add migration to migrate device_long_urls to redirect_rules --- .../Core/migrations/Version20240226214216.php | 126 ++++++++++++++++++ .../Model/RedirectConditionType.php | 2 +- 2 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 module/Core/migrations/Version20240226214216.php diff --git a/module/Core/migrations/Version20240226214216.php b/module/Core/migrations/Version20240226214216.php new file mode 100644 index 00000000..fadea110 --- /dev/null +++ b/module/Core/migrations/Version20240226214216.php @@ -0,0 +1,126 @@ +skipIf(! $schema->hasTable('device_long_urls')); + + // First create redirect conditions for all device types + $qb = $this->connection->createQueryBuilder(); + $devices = $qb->select('device_type') + ->distinct() + ->from('device_long_urls') + ->executeQuery(); + + $conditionIds = []; + while ($deviceRow = $devices->fetchAssociative()) { + $deviceType = $deviceRow['device_type']; + $conditionQb = $this->connection->createQueryBuilder(); + $conditionQb->insert('redirect_conditions') + ->values([ + 'name' => ':name', + 'type' => ':type', + 'match_value' => ':match_value', + 'match_key' => ':match_key', + ]) + ->setParameters([ + 'name' => 'device-' . $deviceType, + 'type' => 'device', + 'match_value' => $deviceType, + 'match_key' => null, + ]) + ->executeStatement(); + $id = $this->connection->lastInsertId(); + $conditionIds[$deviceType] = $id; + } + + // Then insert a rule per every device_long_url, and link it to the corresponding condition + $qb = $this->connection->createQueryBuilder(); + $rules = $qb->select('short_url_id', 'device_type', 'long_url') + ->from('device_long_urls') + ->executeQuery(); + + $priorities = []; + while ($ruleRow = $rules->fetchAssociative()) { + $shortUrlId = $ruleRow['short_url_id']; + $priority = $priorities[$shortUrlId] ?? 1; + + $ruleQb = $this->connection->createQueryBuilder(); + $ruleQb->insert('short_url_redirect_rules') + ->values([ + 'priority' => ':priority', + 'long_url' => ':long_url', + 'short_url_id' => ':short_url_id', + ]) + ->setParameters([ + 'priority' => $priority, + 'long_url' => $ruleRow['long_url'], + 'short_url_id' => $shortUrlId, + ]) + ->executeStatement(); + $ruleId = $this->connection->lastInsertId(); + + $relationQb = $this->connection->createQueryBuilder(); + $relationQb->insert('redirect_conditions_in_short_url_redirect_rules') + ->values([ + 'redirect_condition_id' => ':redirect_condition_id', + 'short_url_redirect_rule_id' => ':short_url_redirect_rule_id', + ]) + ->setParameters([ + 'redirect_condition_id' => $conditionIds[$ruleRow['device_type']], + 'short_url_redirect_rule_id' => $ruleId, + ]) + ->executeStatement(); + + $priorities[$shortUrlId] = $priority + 1; + } + } + + public function postUp(Schema $schema): void + { + $this->skipIf(! $schema->hasTable('device_long_urls')); + $schema->dropTable('device_long_urls'); + } + + public function down(Schema $schema): void + { + $this->skipIf($schema->hasTable('device_long_urls')); + + $table = $schema->createTable('device_long_urls'); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('device_type', Types::STRING, ['length' => 255]); + $table->addColumn('long_url', Types::TEXT, ['length' => 2048]); + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 764c5a2b..51076068 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model; enum RedirectConditionType: string { -// case DEVICE = 'device'; + case DEVICE = 'device'; case LANGUAGE = 'language'; case QUERY_PARAM = 'query'; } From 4ad3dc0bc7e6a3da1713058bc7cec08e2e38db61 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 27 Feb 2024 09:09:03 +0100 Subject: [PATCH 71/97] Create new migration to drop old device_long_urls table --- .../Core/migrations/Version20240220214031.php | 3 ++ .../Core/migrations/Version20240224115725.php | 3 ++ .../Core/migrations/Version20240226214216.php | 43 ++------------- .../Core/migrations/Version20240227080629.php | 54 +++++++++++++++++++ 4 files changed, 63 insertions(+), 40 deletions(-) create mode 100644 module/Core/migrations/Version20240227080629.php diff --git a/module/Core/migrations/Version20240220214031.php b/module/Core/migrations/Version20240220214031.php index b8dc12fd..adceb7f2 100644 --- a/module/Core/migrations/Version20240220214031.php +++ b/module/Core/migrations/Version20240220214031.php @@ -12,6 +12,9 @@ use Doctrine\Migrations\AbstractMigration; use function in_array; +/** + * Convert all columns containing long URLs to TEXT type + */ final class Version20240220214031 extends AbstractMigration { private const DOMAINS_COLUMNS = ['base_url_redirect', 'regular_not_found_redirect', 'invalid_short_url_redirect']; diff --git a/module/Core/migrations/Version20240224115725.php b/module/Core/migrations/Version20240224115725.php index 2b68174c..1e069d83 100644 --- a/module/Core/migrations/Version20240224115725.php +++ b/module/Core/migrations/Version20240224115725.php @@ -10,6 +10,9 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; +/** + * Create new tables needed for the dynamic rule-based redirections + */ final class Version20240224115725 extends AbstractMigration { public function up(Schema $schema): void diff --git a/module/Core/migrations/Version20240226214216.php b/module/Core/migrations/Version20240226214216.php index fadea110..d7352717 100644 --- a/module/Core/migrations/Version20240226214216.php +++ b/module/Core/migrations/Version20240226214216.php @@ -4,11 +4,12 @@ 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; +/** + * Migrate data from device_long_urls to short_url_redirect_rules + */ final class Version20240226214216 extends AbstractMigration { public function up(Schema $schema): void @@ -85,42 +86,4 @@ final class Version20240226214216 extends AbstractMigration $priorities[$shortUrlId] = $priority + 1; } } - - public function postUp(Schema $schema): void - { - $this->skipIf(! $schema->hasTable('device_long_urls')); - $schema->dropTable('device_long_urls'); - } - - public function down(Schema $schema): void - { - $this->skipIf($schema->hasTable('device_long_urls')); - - $table = $schema->createTable('device_long_urls'); - $table->addColumn('id', Types::BIGINT, [ - 'unsigned' => true, - 'autoincrement' => true, - 'notnull' => true, - ]); - $table->setPrimaryKey(['id']); - - $table->addColumn('device_type', Types::STRING, ['length' => 255]); - $table->addColumn('long_url', Types::TEXT, ['length' => 2048]); - $table->addColumn('short_url_id', Types::BIGINT, [ - 'unsigned' => true, - 'notnull' => true, - ]); - - $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ - 'onDelete' => 'CASCADE', - 'onUpdate' => 'RESTRICT', - ]); - - $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); - } - - public function isTransactional(): bool - { - return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); - } } diff --git a/module/Core/migrations/Version20240227080629.php b/module/Core/migrations/Version20240227080629.php new file mode 100644 index 00000000..ad41dc54 --- /dev/null +++ b/module/Core/migrations/Version20240227080629.php @@ -0,0 +1,54 @@ +skipIf(! $schema->hasTable('device_long_urls')); + $schema->dropTable('device_long_urls'); + } + + public function down(Schema $schema): void + { + $this->skipIf($schema->hasTable('device_long_urls')); + + $table = $schema->createTable('device_long_urls'); + $table->addColumn('id', Types::BIGINT, [ + 'unsigned' => true, + 'autoincrement' => true, + 'notnull' => true, + ]); + $table->setPrimaryKey(['id']); + + $table->addColumn('device_type', Types::STRING, ['length' => 255]); + $table->addColumn('long_url', Types::TEXT, ['length' => 2048]); + $table->addColumn('short_url_id', Types::BIGINT, [ + 'unsigned' => true, + 'notnull' => true, + ]); + + $table->addForeignKeyConstraint('short_urls', ['short_url_id'], ['id'], [ + 'onDelete' => 'CASCADE', + 'onUpdate' => 'RESTRICT', + ]); + + $table->addUniqueIndex(['device_type', 'short_url_id'], 'UQ_device_type_per_short_url'); + } + + public function isTransactional(): bool + { + return ! ($this->connection->getDatabasePlatform() instanceof MySQLPlatform); + } +} From 36749658da894ee4bff93d40994910d8d370c8f5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 27 Feb 2024 18:46:49 +0100 Subject: [PATCH 72/97] Remove device long URLs support --- UPGRADE.md | 4 + docs/async-api/async-api.json | 26 ---- docs/swagger/definitions/DeviceLongUrls.json | 17 --- .../definitions/DeviceLongUrlsEdit.json | 17 --- .../definitions/DeviceLongUrlsResp.json | 7 - docs/swagger/definitions/ShortUrl.json | 4 - docs/swagger/definitions/ShortUrlEdition.json | 3 - docs/swagger/paths/v1_short-urls.json | 23 --- docs/swagger/paths/v1_short-urls_shorten.json | 5 - .../paths/v1_short-urls_{shortCode}.json | 10 -- ...ink.Core.ShortUrl.Entity.DeviceLongUrl.php | 41 ----- ...o.Shlink.Core.ShortUrl.Entity.ShortUrl.php | 7 - .../RedirectRule/Entity/RedirectCondition.php | 17 +++ .../ShortUrlRedirectionResolver.php | 4 +- .../src/ShortUrl/Entity/DeviceLongUrl.php | 34 ----- module/Core/src/ShortUrl/Entity/ShortUrl.php | 42 ------ .../src/ShortUrl/Model/DeviceLongUrlPair.php | 45 ------ .../src/ShortUrl/Model/ShortUrlCreation.php | 8 - .../src/ShortUrl/Model/ShortUrlEdition.php | 13 -- .../Validation/DeviceLongUrlsValidator.php | 57 ------- .../Model/Validation/ShortUrlInputFilter.php | 7 - .../Transformer/ShortUrlDataTransformer.php | 1 - .../PublishingUpdatesGeneratorTest.php | 2 - .../Entity/RedirectConditionTest.php | 2 +- .../ShortUrlRedirectionResolverTest.php | 18 +-- .../test/ShortUrl/Entity/ShortUrlTest.php | 44 ------ .../ShortUrl/Model/ShortUrlCreationTest.php | 38 ----- .../ShortUrl/Model/ShortUrlEditionTest.php | 59 -------- .../DeviceLongUrlsValidatorTest.php | 70 --------- .../test/ShortUrl/ShortUrlServiceTest.php | 20 --- .../test-api/Action/CreateShortUrlTest.php | 28 ---- .../Rest/test-api/Action/EditShortUrlTest.php | 23 --- .../test-api/Action/ListShortUrlsTest.php | 141 ++++++++---------- .../Fixtures/ShortUrlRedirectRulesFixture.php | 23 +++ .../test-api/Fixtures/ShortUrlsFixture.php | 5 - 35 files changed, 118 insertions(+), 747 deletions(-) delete mode 100644 docs/swagger/definitions/DeviceLongUrls.json delete mode 100644 docs/swagger/definitions/DeviceLongUrlsEdit.json delete mode 100644 docs/swagger/definitions/DeviceLongUrlsResp.json delete mode 100644 module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php delete mode 100644 module/Core/src/ShortUrl/Entity/DeviceLongUrl.php delete mode 100644 module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php delete mode 100644 module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php delete mode 100644 module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php delete mode 100644 module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php diff --git a/UPGRADE.md b/UPGRADE.md index e8390213..07862ab7 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -16,6 +16,9 @@ If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. * Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option. * Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition. +* Device long URLs have been migrated to the new Dynamic rule-based redirects system. + All existing short URLs which were using device long URLs will be automatically migrated and continue working as expected, but the API surface has changed. + If you use shlink-web-client and rely on this feature when creating/updating short URLs, **DO NOT UPDATE YET**. Support for dynamic rule-based redirects will be added to shlink-web-client soon, in v4.1.0 ### Changes in REST API @@ -34,6 +37,7 @@ * Endpoints previously returning props like `"visitsCount": {number}` no longer do it. There should be an alternative `"visitsSummary": {}` object with the amount nested on it. * It is no longer possible to order the short URLs list with `orderBy=visitsCount-ASC`/`orderBy=visitsCount-DESC`. Use `orderBy=visits-ASC`/`orderBy=visits-DESC` instead. * It is no longer possible to get tags with stats using `GET /tags?withStats=true`. Use `GET /tags/stats` endpoint instead. +* The `deviceLongUrls` are ignored when calling `POST /short-urls` or `PATCH /short-urls/{shortCode}`. These should now be configured as dynamic rule-based redirects via `POST /short-urls/{shortCode}/redirect-rules`. ### Changes in Docker image diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index d3177b98..7cd838a8 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -111,9 +111,6 @@ "type": "string", "description": "The original long URL." }, - "deviceLongUrls": { - "$ref": "#/components/schemas/DeviceLongUrls" - }, "dateCreated": { "type": "string", "format": "date-time", @@ -150,11 +147,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": "https://store.steampowered.com/android", - "ios": "https://store.steampowered.com/ios", - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -218,24 +210,6 @@ } } }, - "DeviceLongUrls": { - "type": "object", - "required": ["android", "ios", "desktop"], - "properties": { - "android": { - "description": "The long URL to redirect to when the short URL is visited from a device running Android", - "type": "string" - }, - "ios": { - "description": "The long URL to redirect to when the short URL is visited from a device running iOS", - "type": "string" - }, - "desktop": { - "description": "The long URL to redirect to when the short URL is visited from a desktop browser", - "type": "string" - } - } - }, "Visit": { "type": "object", "properties": { diff --git a/docs/swagger/definitions/DeviceLongUrls.json b/docs/swagger/definitions/DeviceLongUrls.json deleted file mode 100644 index 0e8719db..00000000 --- a/docs/swagger/definitions/DeviceLongUrls.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "object", - "properties": { - "android": { - "description": "The long URL to redirect to when the short URL is visited from a device running Android", - "type": ["string"] - }, - "ios": { - "description": "The long URL to redirect to when the short URL is visited from a device running iOS", - "type": ["string"] - }, - "desktop": { - "description": "The long URL to redirect to when the short URL is visited from a desktop browser", - "type": ["string"] - } - } -} diff --git a/docs/swagger/definitions/DeviceLongUrlsEdit.json b/docs/swagger/definitions/DeviceLongUrlsEdit.json deleted file mode 100644 index f1ff255f..00000000 --- a/docs/swagger/definitions/DeviceLongUrlsEdit.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "type": "object", - "allOf": [{ - "$ref": "./DeviceLongUrls.json" - }], - "properties": { - "android": { - "type": ["null"] - }, - "ios": { - "type": ["null"] - }, - "desktop": { - "type": ["null"] - } - } -} diff --git a/docs/swagger/definitions/DeviceLongUrlsResp.json b/docs/swagger/definitions/DeviceLongUrlsResp.json deleted file mode 100644 index 95724581..00000000 --- a/docs/swagger/definitions/DeviceLongUrlsResp.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "type": "object", - "required": ["android", "ios", "desktop"], - "allOf": [{ - "$ref": "./DeviceLongUrlsEdit.json" - }] -} diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index 8a420e9b..1535b65f 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -4,7 +4,6 @@ "shortCode", "shortUrl", "longUrl", - "deviceLongUrls", "dateCreated", "visitsSummary", "tags", @@ -27,9 +26,6 @@ "type": "string", "description": "The original long URL." }, - "deviceLongUrls": { - "$ref": "./DeviceLongUrlsResp.json" - }, "dateCreated": { "type": "string", "format": "date-time", diff --git a/docs/swagger/definitions/ShortUrlEdition.json b/docs/swagger/definitions/ShortUrlEdition.json index baef4f52..edd4c639 100644 --- a/docs/swagger/definitions/ShortUrlEdition.json +++ b/docs/swagger/definitions/ShortUrlEdition.json @@ -5,9 +5,6 @@ "description": "The long URL this short URL will redirect to", "type": "string" }, - "deviceLongUrls": { - "$ref": "./DeviceLongUrlsEdit.json" - }, "validSince": { "description": "The date (in ISO-8601 format) from which this short code will be valid", "type": ["string", "null"] diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index c9bbe68a..150eebc0 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -163,11 +163,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 328, @@ -191,11 +186,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": null, - "ios": "https://shlink.io/ios", - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -218,11 +208,6 @@ "shortCode": "123bA", "shortUrl": "https://example.com/123bA", "longUrl": "https://www.google.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2015-10-01T20:34:16+02:00", "visitsSummary": { "total": 25, @@ -296,9 +281,6 @@ "type": "object", "required": ["longUrl"], "properties": { - "deviceLongUrls": { - "$ref": "../definitions/DeviceLongUrls.json" - }, "customSlug": { "description": "A unique custom slug to be used instead of the generated short code", "type": "string" @@ -338,11 +320,6 @@ "shortCode": "12C18", "shortUrl": "https://s.test/12C18", "longUrl": "https://store.steampowered.com", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-08-21T20:34:16+02:00", "visitsSummary": { "total": 0, diff --git a/docs/swagger/paths/v1_short-urls_shorten.json b/docs/swagger/paths/v1_short-urls_shorten.json index 5c16482c..1136aca1 100644 --- a/docs/swagger/paths/v1_short-urls_shorten.json +++ b/docs/swagger/paths/v1_short-urls_shorten.json @@ -53,11 +53,6 @@ }, "example": { "longUrl": "https://github.com/shlinkio/shlink", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "shortUrl": "https://s.test/abc123", "shortCode": "abc123", "dateCreated": "2016-08-21T20:34:16+02:00", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 408d166c..120dc43e 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -34,11 +34,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": null, - "ios": null, - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, @@ -155,11 +150,6 @@ "shortCode": "12Kb3", "shortUrl": "https://s.test/12Kb3", "longUrl": "https://shlink.io", - "deviceLongUrls": { - "android": "https://shlink.io/android", - "ios": null, - "desktop": null - }, "dateCreated": "2016-05-01T20:34:16+02:00", "visitsSummary": { "total": 1029, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php deleted file mode 100644 index 1e84a292..00000000 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.DeviceLongUrl.php +++ /dev/null @@ -1,41 +0,0 @@ -setTable(determineTableName('device_long_urls', $emConfig)); - - $builder->createField('id', Types::BIGINT) - ->columnName('id') - ->makePrimaryKey() - ->generatedValue('IDENTITY') - ->option('unsigned', true) - ->build(); - - (new FieldBuilder($builder, [ - 'fieldName' => 'deviceType', - 'type' => Types::STRING, - 'enumType' => DeviceType::class, - ]))->columnName('device_type') - ->length(255) - ->build(); - - fieldWithUtf8Charset($builder->createField('longUrl', Types::TEXT), $emConfig) - ->columnName('long_url') - ->length(2048) - ->build(); - - $builder->createManyToOne('shortUrl', ShortUrl\Entity\ShortUrl::class) - ->addJoinColumn('short_url_id', 'id', nullable: false, onDelete: 'CASCADE') - ->build(); -}; diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php index ff933b8c..358ee6bd 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.ShortUrl.Entity.ShortUrl.php @@ -67,13 +67,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->fetchExtraLazy() ->build(); - $builder->createOneToMany('deviceLongUrls', ShortUrl\Entity\DeviceLongUrl::class) - ->mappedBy('shortUrl') - ->cascadePersist() - ->orphanRemoval() - ->setIndexBy('deviceType') - ->build(); - $builder->createManyToMany('tags', Tag\Entity\Tag::class) ->setJoinTable(determineTableName('short_urls_in_tags', $emConfig)) ->addInverseJoinColumn('tag_id', 'id', onDelete: 'CASCADE') diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index babbbe67..9505e81d 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -4,6 +4,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; @@ -11,6 +12,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; use function sprintf; +use function strtolower; use function trim; class RedirectCondition extends AbstractEntity @@ -39,6 +41,14 @@ class RedirectCondition extends AbstractEntity return new self($name, $type, $language); } + public static function forDevice(DeviceType $device): self + { + $type = RedirectConditionType::DEVICE; + $name = sprintf('%s-%s', $type->value, $device->value); + + return new self($name, $type, $device->value); + } + /** * Tells if this condition matches provided request */ @@ -47,6 +57,7 @@ class RedirectCondition extends AbstractEntity return match ($this->type) { RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request), RedirectConditionType::LANGUAGE => $this->matchesLanguage($request), + RedirectConditionType::DEVICE => $this->matchesDevice($request), }; } @@ -81,4 +92,10 @@ class RedirectCondition extends AbstractEntity }, ); } + + private function matchesDevice(ServerRequestInterface $request): bool + { + $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); + return $device !== null && $device->value === strtolower($this->matchValue); + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php index b916d78b..0f811600 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php @@ -4,7 +4,6 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Doctrine\ORM\EntityManagerInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -27,7 +26,6 @@ readonly class ShortUrlRedirectionResolver implements ShortUrlRedirectionResolve } } - $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); - return $shortUrl->longUrlForDevice($device); + return $shortUrl->getLongUrl(); } } diff --git a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php b/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php deleted file mode 100644 index 668741e8..00000000 --- a/module/Core/src/ShortUrl/Entity/DeviceLongUrl.php +++ /dev/null @@ -1,34 +0,0 @@ -deviceType, $pair->longUrl); - } - - public function longUrl(): string - { - return $this->longUrl; - } - - public function updateLongUrl(string $longUrl): void - { - $this->longUrl = $longUrl; - } -} diff --git a/module/Core/src/ShortUrl/Entity/ShortUrl.php b/module/Core/src/ShortUrl/Entity/ShortUrl.php index 411e7bb1..8a577205 100644 --- a/module/Core/src/ShortUrl/Entity/ShortUrl.php +++ b/module/Core/src/ShortUrl/Entity/ShortUrl.php @@ -12,8 +12,6 @@ use Doctrine\Common\Collections\Selectable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; -use Shlinkio\Shlink\Core\Model\DeviceType; -use Shlinkio\Shlink\Core\ShortUrl\Model\DeviceLongUrlPair; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -26,10 +24,7 @@ use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function array_fill_keys; -use function array_map; use function count; -use function Shlinkio\Shlink\Core\enumValues; use function Shlinkio\Shlink\Core\generateRandomShortCode; use function Shlinkio\Shlink\Core\normalizeDate; use function Shlinkio\Shlink\Core\normalizeOptionalDate; @@ -42,8 +37,6 @@ class ShortUrl extends AbstractEntity private Chronos $dateCreated; /** @var Collection & Selectable */ private Collection & Selectable $visits; - /** @var Collection */ - private Collection $deviceLongUrls; /** @var Collection */ private Collection $tags; private ?Chronos $validSince = null; @@ -91,10 +84,6 @@ class ShortUrl extends AbstractEntity $instance->longUrl = $creation->getLongUrl(); $instance->dateCreated = Chronos::now(); $instance->visits = new ArrayCollection(); - $instance->deviceLongUrls = new ArrayCollection(array_map( - fn (DeviceLongUrlPair $pair) => DeviceLongUrl::fromShortUrlAndPair($instance, $pair), - $creation->deviceLongUrls, - )); $instance->tags = $relationResolver->resolveTags($creation->tags); $instance->validSince = $creation->validSince; $instance->validUntil = $creation->validUntil; @@ -177,21 +166,6 @@ class ShortUrl extends AbstractEntity if ($shortUrlEdit->forwardQueryWasProvided()) { $this->forwardQuery = $shortUrlEdit->forwardQuery; } - - // Update device long URLs, removing, editing or creating where appropriate - foreach ($shortUrlEdit->devicesToRemove as $deviceType) { - $this->deviceLongUrls->remove($deviceType->value); - } - foreach ($shortUrlEdit->deviceLongUrls as $deviceLongUrlPair) { - $key = $deviceLongUrlPair->deviceType->value; - $deviceLongUrl = $this->deviceLongUrls->get($key); - - if ($deviceLongUrl !== null) { - $deviceLongUrl->updateLongUrl($deviceLongUrlPair->longUrl); - } else { - $this->deviceLongUrls->set($key, DeviceLongUrl::fromShortUrlAndPair($this, $deviceLongUrlPair)); - } - } } public function getLongUrl(): string @@ -199,12 +173,6 @@ class ShortUrl extends AbstractEntity return $this->longUrl; } - public function longUrlForDevice(?DeviceType $deviceType): string - { - $deviceLongUrl = $deviceType === null ? null : $this->deviceLongUrls->get($deviceType->value); - return $deviceLongUrl?->longUrl() ?? $this->longUrl; - } - public function getShortCode(): string { return $this->shortCode; @@ -332,14 +300,4 @@ class ShortUrl extends AbstractEntity return true; } - - public function deviceLongUrls(): array - { - $data = array_fill_keys(enumValues(DeviceType::class), null); - foreach ($this->deviceLongUrls as $deviceUrl) { - $data[$deviceUrl->deviceType->value] = $deviceUrl->longUrl(); - } - - return $data; - } } diff --git a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php b/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php deleted file mode 100644 index a48c666b..00000000 --- a/module/Core/src/ShortUrl/Model/DeviceLongUrlPair.php +++ /dev/null @@ -1,45 +0,0 @@ - $map - * @return array{array, DeviceType[]} - */ - public static function fromMapToChangeSet(array $map): array - { - $pairsToKeep = []; - $deviceTypesToRemove = []; - - foreach ($map as $deviceType => $longUrl) { - if ($longUrl === null) { - $deviceTypesToRemove[] = DeviceType::from($deviceType); - } else { - $pairsToKeep[$deviceType] = self::fromRawTypeAndLongUrl($deviceType, $longUrl); - } - } - - return [$pairsToKeep, $deviceTypesToRemove]; - } -} diff --git a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php index 4d22a8be..b0c87f99 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlCreation.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlCreation.php @@ -22,12 +22,10 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface { /** * @param string[] $tags - * @param DeviceLongUrlPair[] $deviceLongUrls */ private function __construct( public string $longUrl, public ShortUrlMode $shortUrlMode, - public array $deviceLongUrls = [], public ?Chronos $validSince = null, public ?Chronos $validUntil = null, public ?string $customSlug = null, @@ -55,14 +53,9 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface throw ValidationException::fromInputFilter($inputFilter); } - [$deviceLongUrls] = DeviceLongUrlPair::fromMapToChangeSet( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ); - return new self( longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), shortUrlMode: $options->mode, - deviceLongUrls: $deviceLongUrls, validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntil: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_UNTIL)), customSlug: $inputFilter->getValue(ShortUrlInputFilter::CUSTOM_SLUG), @@ -87,7 +80,6 @@ final readonly class ShortUrlCreation implements TitleResolutionModelInterface return new self( longUrl: $this->longUrl, shortUrlMode: $this->shortUrlMode, - deviceLongUrls: $this->deviceLongUrls, validSince: $this->validSince, validUntil: $this->validUntil, customSlug: $this->customSlug, diff --git a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php index 2502331a..36a99f5f 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlEdition.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlEdition.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Cake\Chronos\Chronos; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Helper\TitleResolutionModelInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; @@ -19,14 +18,10 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface { /** * @param string[] $tags - * @param DeviceLongUrlPair[] $deviceLongUrls - * @param DeviceType[] $devicesToRemove */ private function __construct( private bool $longUrlPropWasProvided = false, public ?string $longUrl = null, - public array $deviceLongUrls = [], - public array $devicesToRemove = [], private bool $validSincePropWasProvided = false, public ?Chronos $validSince = null, private bool $validUntilPropWasProvided = false, @@ -55,15 +50,9 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface throw ValidationException::fromInputFilter($inputFilter); } - [$deviceLongUrls, $devicesToRemove] = DeviceLongUrlPair::fromMapToChangeSet( - $inputFilter->getValue(ShortUrlInputFilter::DEVICE_LONG_URLS) ?? [], - ); - return new self( longUrlPropWasProvided: array_key_exists(ShortUrlInputFilter::LONG_URL, $data), longUrl: $inputFilter->getValue(ShortUrlInputFilter::LONG_URL), - deviceLongUrls: $deviceLongUrls, - devicesToRemove: $devicesToRemove, validSincePropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_SINCE, $data), validSince: normalizeOptionalDate($inputFilter->getValue(ShortUrlInputFilter::VALID_SINCE)), validUntilPropWasProvided: array_key_exists(ShortUrlInputFilter::VALID_UNTIL, $data), @@ -86,8 +75,6 @@ final readonly class ShortUrlEdition implements TitleResolutionModelInterface return new self( longUrlPropWasProvided: $this->longUrlPropWasProvided, longUrl: $this->longUrl, - deviceLongUrls: $this->deviceLongUrls, - devicesToRemove: $this->devicesToRemove, validSincePropWasProvided: $this->validSincePropWasProvided, validSince: $this->validSince, validUntilPropWasProvided: $this->validUntilPropWasProvided, diff --git a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php b/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php deleted file mode 100644 index 82119e4e..00000000 --- a/module/Core/src/ShortUrl/Model/Validation/DeviceLongUrlsValidator.php +++ /dev/null @@ -1,57 +0,0 @@ - 'Provided value is not an array.', - self::INVALID_DEVICE => 'You have provided at least one invalid device identifier.', - self::INVALID_LONG_URL => 'At least one of the long URLs are invalid.', - ]; - - public function __construct(private readonly ValidatorInterface $longUrlValidators) - { - parent::__construct(); - } - - public function isValid(mixed $value): bool - { - if (! is_array($value)) { - $this->error(self::NOT_ARRAY); - return false; - } - - $validValues = enumValues(DeviceType::class); - $keys = array_keys($value); - if (! every($keys, static fn ($key) => contains($key, $validValues))) { - $this->error(self::INVALID_DEVICE); - return false; - } - - $longUrls = array_values($value); - $result = every($longUrls, $this->longUrlValidators->isValid(...)); - if (! $result) { - $this->error(self::INVALID_LONG_URL); - } - - return $result; - } -} diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 287ea746..8818e0f6 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -31,7 +31,6 @@ class ShortUrlInputFilter extends InputFilter // Fields for creation and edition public const LONG_URL = 'longUrl'; - public const DEVICE_LONG_URLS = 'deviceLongUrls'; public const VALID_SINCE = 'validSince'; public const VALID_UNTIL = 'validUntil'; public const MAX_VISITS = 'maxVisits'; @@ -97,12 +96,6 @@ class ShortUrlInputFilter extends InputFilter $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); $this->add($longUrlInput); - $deviceLongUrlsInput = InputFactory::basic(self::DEVICE_LONG_URLS); - $deviceLongUrlsInput->getValidatorChain()->attach( - new DeviceLongUrlsValidator($this->longUrlValidators(allowNull: ! $requireLongUrl)), - ); - $this->add($deviceLongUrlsInput); - $validSince = InputFactory::basic(self::VALID_SINCE); $validSince->getValidatorChain()->attach(new Validator\Date(['format' => DateTimeInterface::ATOM])); $this->add($validSince); diff --git a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php index d0661504..ea694c61 100644 --- a/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php +++ b/module/Core/src/ShortUrl/Transformer/ShortUrlDataTransformer.php @@ -27,7 +27,6 @@ class ShortUrlDataTransformer implements DataTransformerInterface 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => $this->stringifier->stringify($shortUrl), 'longUrl' => $shortUrl->getLongUrl(), - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => array_map(static fn (Tag $tag) => $tag->__toString(), $shortUrl->getTags()->toArray()), 'meta' => $this->buildMeta($shortUrl), diff --git a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index c5ebb1a8..545c5b47 100644 --- a/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -51,7 +51,6 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => [], 'meta' => [ @@ -125,7 +124,6 @@ class PublishingUpdatesGeneratorTest extends TestCase 'shortCode' => $shortUrl->getShortCode(), 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), 'longUrl' => 'https://longUrl', - 'deviceLongUrls' => $shortUrl->deviceLongUrls(), 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), 'tags' => [], 'meta' => [ diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 20178068..da1e28e8 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -1,6 +1,6 @@ 'https://example.com/foo/bar', - 'deviceLongUrls' => [ - DeviceType::ANDROID->value => 'https://example.com/android', - DeviceType::IOS->value => 'https://example.com/ios', - ], ])); $repo = $this->createMock(EntityRepository::class); @@ -75,12 +71,16 @@ class ShortUrlRedirectionResolverTest extends TestCase 'https://example.com/foo/bar', ]; yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), null, 'https://example.com/foo/bar']; - yield 'android user agent' => [ + yield 'matching android device' => [ $request(ANDROID_USER_AGENT), - RedirectCondition::forQueryParam('foo', 'bar'), // This condition won't match - 'https://example.com/android', + RedirectCondition::forDevice(DeviceType::ANDROID), + 'https://example.com/from-rule', + ]; + yield 'matching ios device' => [ + $request(IOS_USER_AGENT), + RedirectCondition::forDevice(DeviceType::IOS), + 'https://example.com/from-rule', ]; - yield 'ios user agent' => [$request(IOS_USER_AGENT), null, 'https://example.com/ios']; yield 'matching language' => [ $request()->withHeader('Accept-Language', 'es-ES'), RedirectCondition::forLanguage('es-ES'), diff --git a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php index eb89df5c..be5b4101 100644 --- a/module/Core/test/ShortUrl/Entity/ShortUrlTest.php +++ b/module/Core/test/ShortUrl/Entity/ShortUrlTest.php @@ -10,11 +10,9 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ShortCodeCannotBeRegeneratedException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -113,48 +111,6 @@ class ShortUrlTest extends TestCase self::assertEquals($expectedShortCodeLength, strlen($shortCode)); } - #[Test] - public function deviceLongUrlsAreUpdated(): void - { - $shortUrl = ShortUrl::withLongUrl('https://foo'); - - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => 'https://android', - DeviceType::IOS->value => 'https://ios', - ], - ])); - self::assertEquals([ - DeviceType::ANDROID->value => 'https://android', - DeviceType::IOS->value => 'https://ios', - DeviceType::DESKTOP->value => null, - ], $shortUrl->deviceLongUrls()); - - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => null, - DeviceType::DESKTOP->value => 'https://desktop', - ], - ])); - self::assertEquals([ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => 'https://ios', - DeviceType::DESKTOP->value => 'https://desktop', - ], $shortUrl->deviceLongUrls()); - - $shortUrl->update(ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - ], - ])); - self::assertEquals([ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - DeviceType::DESKTOP->value => 'https://desktop', - ], $shortUrl->deviceLongUrls()); - } - #[Test] public function generatesLowercaseOnlyShortCodesInLooseMode(): void { diff --git a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php index 47d4648c..b84b5b27 100644 --- a/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php +++ b/module/Core/test/ShortUrl/Model/ShortUrlCreationTest.php @@ -9,7 +9,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Exception\ValidationException; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlMode; @@ -79,43 +78,6 @@ class ShortUrlCreationTest extends TestCase yield [[ ShortUrlInputFilter::LONG_URL => 'missing_schema', ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - 'invalid' => 'https://shlink.io', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::DESKTOP->value => '', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::DESKTOP->value => null, - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::IOS->value => ' ', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::ANDROID->value => 'missing_schema', - ], - ]]; - yield [[ - ShortUrlInputFilter::LONG_URL => 'https://foo', - ShortUrlInputFilter::DEVICE_LONG_URLS => [ - DeviceType::IOS->value => 'https://bar', - DeviceType::ANDROID->value => [], - ], - ]]; } #[Test, DataProvider('provideCustomSlugs')] diff --git a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php b/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php deleted file mode 100644 index 5d77d806..00000000 --- a/module/Core/test/ShortUrl/Model/ShortUrlEditionTest.php +++ /dev/null @@ -1,59 +0,0 @@ - $deviceLongUrls]); - - self::assertEquals($expectedDeviceLongUrls, $edition->deviceLongUrls); - self::assertEquals($expectedDevicesToRemove, $edition->devicesToRemove); - } - - public static function provideDeviceLongUrls(): iterable - { - yield 'null' => [null, [], []]; - yield 'empty' => [[], [], []]; - yield 'only new urls' => [[ - DeviceType::DESKTOP->value => 'https://foo', - DeviceType::IOS->value => 'https://bar', - ], [ - DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl( - DeviceType::DESKTOP->value, - 'https://foo', - ), - DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'https://bar'), - ], []]; - yield 'only urls to remove' => [[ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - ], [], [DeviceType::ANDROID, DeviceType::IOS]]; - yield 'both' => [[ - DeviceType::DESKTOP->value => 'https://bar', - DeviceType::IOS->value => 'https://foo', - DeviceType::ANDROID->value => null, - ], [ - DeviceType::DESKTOP->value => DeviceLongUrlPair::fromRawTypeAndLongUrl( - DeviceType::DESKTOP->value, - 'https://bar', - ), - DeviceType::IOS->value => DeviceLongUrlPair::fromRawTypeAndLongUrl(DeviceType::IOS->value, 'https://foo'), - ], [DeviceType::ANDROID]]; - } -} diff --git a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php b/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php deleted file mode 100644 index 860e2a39..00000000 --- a/module/Core/test/ShortUrl/Model/Validation/DeviceLongUrlsValidatorTest.php +++ /dev/null @@ -1,70 +0,0 @@ -validator = new DeviceLongUrlsValidator(new NotEmpty()); - } - - #[Test, DataProvider('provideNonArrayValues')] - public function nonArrayValuesAreNotValid(mixed $invalidValue): void - { - self::assertFalse($this->validator->isValid($invalidValue)); - self::assertEquals(['NOT_ARRAY' => 'Provided value is not an array.'], $this->validator->getMessages()); - } - - public static function provideNonArrayValues(): iterable - { - yield 'int' => [0]; - yield 'float' => [100.45]; - yield 'string' => ['foo']; - yield 'boolean' => [true]; - yield 'object' => [new stdClass()]; - yield 'null' => [null]; - } - - #[Test] - public function unrecognizedKeysAreNotValid(): void - { - self::assertFalse($this->validator->isValid(['foo' => 'bar'])); - self::assertEquals( - ['INVALID_DEVICE' => 'You have provided at least one invalid device identifier.'], - $this->validator->getMessages(), - ); - } - - #[Test] - public function everyUrlMustMatchLongUrlValidator(): void - { - self::assertFalse($this->validator->isValid([DeviceType::ANDROID->value => ''])); - self::assertEquals( - ['INVALID_LONG_URL' => 'At least one of the long URLs are invalid.'], - $this->validator->getMessages(), - ); - } - - #[Test] - public function validValuesResultInValidResult(): void - { - self::assertTrue($this->validator->isValid([ - DeviceType::ANDROID->value => 'foo', - DeviceType::IOS->value => 'bar', - DeviceType::DESKTOP->value => 'baz', - ])); - } -} diff --git a/module/Core/test/ShortUrl/ShortUrlServiceTest.php b/module/Core/test/ShortUrl/ShortUrlServiceTest.php index dfbf7d75..ae73ba33 100644 --- a/module/Core/test/ShortUrl/ShortUrlServiceTest.php +++ b/module/Core/test/ShortUrl/ShortUrlServiceTest.php @@ -12,7 +12,6 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\Rule\InvocationOrder; use PHPUnit\Framework\MockObject\Rule\InvokedCount; use PHPUnit\Framework\TestCase; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlTitleResolutionHelperInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition; @@ -22,9 +21,6 @@ use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlService; use Shlinkio\Shlink\Rest\Entity\ApiKey; -use function array_fill_keys; -use function Shlinkio\Shlink\Core\enumValues; - class ShortUrlServiceTest extends TestCase { private ShortUrlService $service; @@ -73,21 +69,11 @@ class ShortUrlServiceTest extends TestCase $apiKey, ); - $resolveDeviceLongUrls = function () use ($shortUrlEdit): array { - $result = array_fill_keys(enumValues(DeviceType::class), null); - foreach ($shortUrlEdit->deviceLongUrls ?? [] as $longUrl) { - $result[$longUrl->deviceType->value] = $longUrl->longUrl; - } - - return $result; - }; - self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince, $shortUrl->getValidSince()); self::assertEquals($shortUrlEdit->validUntil, $shortUrl->getValidUntil()); self::assertEquals($shortUrlEdit->maxVisits, $shortUrl->getMaxVisits()); self::assertEquals($shortUrlEdit->longUrl ?? $originalLongUrl, $shortUrl->getLongUrl()); - self::assertEquals($resolveDeviceLongUrls(), $shortUrl->deviceLongUrls()); } public static function provideShortUrlEdits(): iterable @@ -102,11 +88,5 @@ class ShortUrlServiceTest extends TestCase 'maxVisits' => 10, 'longUrl' => 'https://modifiedLongUrl', ]), ApiKey::create()]; - yield 'device redirects' => [new InvokedCount(0), ShortUrlEdition::fromRawData([ - 'deviceLongUrls' => [ - DeviceType::IOS->value => 'https://iosLongUrl', - DeviceType::ANDROID->value => 'https://androidLongUrl', - ], - ]), null]; } } diff --git a/module/Rest/test-api/Action/CreateShortUrlTest.php b/module/Rest/test-api/Action/CreateShortUrlTest.php index 96dc5e7b..42742bbb 100644 --- a/module/Rest/test-api/Action/CreateShortUrlTest.php +++ b/module/Rest/test-api/Action/CreateShortUrlTest.php @@ -250,18 +250,6 @@ class CreateShortUrlTest extends ApiTestCase yield 'empty long url v3' => [['longUrl' => ' '], '3', 'https://shlink.io/api/error/invalid-data']; yield 'missing url schema v2' => [['longUrl' => 'foo.com'], '2', 'https://shlink.io/api/error/invalid-data']; yield 'missing url schema v3' => [['longUrl' => 'foo.com'], '3', 'https://shlink.io/api/error/invalid-data']; - yield 'empty device long url v2' => [[ - 'longUrl' => 'foo', - 'deviceLongUrls' => [ - 'android' => null, - ], - ], '2', 'https://shlink.io/api/error/invalid-data']; - yield 'empty device long url v3' => [[ - 'longUrl' => 'foo', - 'deviceLongUrls' => [ - 'ios' => ' ', - ], - ], '3', 'https://shlink.io/api/error/invalid-data']; } #[Test] @@ -313,22 +301,6 @@ class CreateShortUrlTest extends ApiTestCase self::assertEquals('http://s.test/🦣🦣🦣', $payload['shortUrl']); } - #[Test] - public function canCreateShortUrlsWithDeviceLongUrls(): void - { - [$statusCode, $payload] = $this->createShortUrl([ - 'longUrl' => 'https://github.com/shlinkio/shlink/issues/1557', - 'deviceLongUrls' => [ - 'ios' => 'https://github.com/shlinkio/shlink/ios', - 'android' => 'https://github.com/shlinkio/shlink/android', - ], - ]); - - self::assertEquals(self::STATUS_OK, $statusCode); - self::assertEquals('https://github.com/shlinkio/shlink/ios', $payload['deviceLongUrls']['ios'] ?? null); - self::assertEquals('https://github.com/shlinkio/shlink/android', $payload['deviceLongUrls']['android'] ?? null); - } - #[Test] public function titleIsIgnoredIfLongUrlTimesOut(): void { diff --git a/module/Rest/test-api/Action/EditShortUrlTest.php b/module/Rest/test-api/Action/EditShortUrlTest.php index 07fe84d3..24f91e58 100644 --- a/module/Rest/test-api/Action/EditShortUrlTest.php +++ b/module/Rest/test-api/Action/EditShortUrlTest.php @@ -153,27 +153,4 @@ class EditShortUrlTest extends ApiTestCase ]; yield 'no domain' => [null, 'https://shlink.io/documentation/']; } - - #[Test] - public function deviceLongUrlsCanBeEdited(): void - { - $shortCode = 'def456'; - $url = new Uri(sprintf('/short-urls/%s', $shortCode)); - $editResp = $this->callApiWithKey(self::METHOD_PATCH, (string) $url, [RequestOptions::JSON => [ - 'deviceLongUrls' => [ - 'android' => null, // This one will get removed - 'ios' => 'https://blog.alejandrocelaya.com/ios/edited', // This one will be edited - 'desktop' => 'https://blog.alejandrocelaya.com/desktop', // This one is new and will be created - ], - ]]); - $deviceLongUrls = $this->getJsonResponsePayload($editResp)['deviceLongUrls'] ?? []; - - self::assertEquals(self::STATUS_OK, $editResp->getStatusCode()); - self::assertArrayHasKey('ios', $deviceLongUrls); - self::assertEquals('https://blog.alejandrocelaya.com/ios/edited', $deviceLongUrls['ios']); - self::assertArrayHasKey('desktop', $deviceLongUrls); - self::assertEquals('https://blog.alejandrocelaya.com/desktop', $deviceLongUrls['desktop']); - self::assertArrayHasKey('android', $deviceLongUrls); - self::assertNull($deviceLongUrls['android']); - } } diff --git a/module/Rest/test-api/Action/ListShortUrlsTest.php b/module/Rest/test-api/Action/ListShortUrlsTest.php index 3591ea60..c3b9b41e 100644 --- a/module/Rest/test-api/Action/ListShortUrlsTest.php +++ b/module/Rest/test-api/Action/ListShortUrlsTest.php @@ -8,7 +8,6 @@ use Cake\Chronos\Chronos; use GuzzleHttp\RequestOptions; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase; use function count; @@ -163,123 +162,109 @@ class ListShortUrlsTest extends ApiTestCase public static function provideFilteredLists(): iterable { - $withDeviceLongUrls = static fn (array $shortUrl, ?array $longUrls = null) => [ - ...$shortUrl, - 'deviceLongUrls' => $longUrls ?? [ - DeviceType::ANDROID->value => null, - DeviceType::IOS->value => null, - DeviceType::DESKTOP->value => null, - ], - ]; - $shortUrlMeta = $withDeviceLongUrls(self::SHORT_URL_META, [ - DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', - DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', - DeviceType::DESKTOP->value => null, - ]); - yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + 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, ], 'valid_api_key']; yield [['excludePastValidUntil' => 'true'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + 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, ], 'valid_api_key']; yield [['excludeMaxVisitsReached' => 'true'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_DOCS, ], 'valid_api_key']; yield [['orderBy' => 'shortCode'], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [['orderBy' => 'shortCode-DESC'], [ - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + 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' => 'title-DESC'], [ - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $withDeviceLongUrls(self::SHORT_URL_DOCS), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_META, + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_DOCS, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['startDate' => Chronos::parse('2018-12-01')->toAtomString()], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, + 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()], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN), - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), - $withDeviceLongUrls(self::SHORT_URL_DOCS), + self::SHORT_URL_CUSTOM_SLUG_AND_DOMAIN, + self::SHORT_URL_SHLINK_WITH_TITLE, + self::SHORT_URL_DOCS, ], 'valid_api_key']; yield [['tags' => ['foo']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['bar']], [ - $shortUrlMeta, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'any'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar'], 'tagsMode' => 'all'], [ - $shortUrlMeta, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['tags' => ['foo', 'bar', 'baz']], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + 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()], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['searchTerm' => 'alejandro'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), - $shortUrlMeta, + self::SHORT_URL_CUSTOM_DOMAIN, + self::SHORT_URL_META, ], 'valid_api_key']; yield [['searchTerm' => 'cool'], [ - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'valid_api_key']; yield [['searchTerm' => 'example.com'], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_CUSTOM_DOMAIN, ], 'valid_api_key']; yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_SLUG), - $shortUrlMeta, - $withDeviceLongUrls(self::SHORT_URL_SHLINK_WITH_TITLE), + self::SHORT_URL_CUSTOM_SLUG, + self::SHORT_URL_META, + self::SHORT_URL_SHLINK_WITH_TITLE, ], 'author_api_key']; yield [[], [ - $withDeviceLongUrls(self::SHORT_URL_CUSTOM_DOMAIN), + self::SHORT_URL_CUSTOM_DOMAIN, ], 'domain_api_key']; } diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php index 6d321a91..ab0e8dce 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -8,6 +8,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -33,6 +34,12 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF $helloQueryCondition = RedirectCondition::forQueryParam('hello', 'world'); $manager->persist($helloQueryCondition); + $androidCondition = RedirectCondition::forDevice(DeviceType::ANDROID); + $manager->persist($androidCondition); + + $iosCondition = RedirectCondition::forDevice(DeviceType::IOS); + $manager->persist($iosCondition); + $englishAndFooQueryRule = new ShortUrlRedirectRule( $defShortUrl, 1, @@ -57,6 +64,22 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF ); $manager->persist($onlyEnglishRule); + $androidRule = new ShortUrlRedirectRule( + $defShortUrl, + 4, + 'https://blog.alejandrocelaya.com/android', + new ArrayCollection([$androidCondition]), + ); + $manager->persist($androidRule); + + $iosRule = new ShortUrlRedirectRule( + $defShortUrl, + 5, + 'https://blog.alejandrocelaya.com/ios', + new ArrayCollection([$iosCondition]), + ); + $manager->persist($iosRule); + $manager->flush(); } } diff --git a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php index 03f3f603..31ca6361 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlsFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlsFixture.php @@ -9,7 +9,6 @@ use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Common\DataFixtures\DependentFixtureInterface; use Doctrine\Persistence\ObjectManager; use ReflectionObject; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -49,10 +48,6 @@ class ShortUrlsFixture extends AbstractFixture implements DependentFixtureInterf 'apiKey' => $authorApiKey, 'longUrl' => 'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/', - 'deviceLongUrls' => [ - DeviceType::ANDROID->value => 'https://blog.alejandrocelaya.com/android', - DeviceType::IOS->value => 'https://blog.alejandrocelaya.com/ios', - ], 'tags' => ['foo', 'bar'], ]), $relationResolver), '2019-01-01 00:00:10'); $manager->persist($defShortUrl); From a72e22e046c76e33bcac7dc046107700285b11ad Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 27 Feb 2024 19:22:30 +0100 Subject: [PATCH 73/97] Unit-test RedirectCondition for devices --- .../Entity/RedirectConditionTest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index da1e28e8..1eb7e0f8 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -7,8 +7,13 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; +use const ShlinkioTest\Shlink\ANDROID_USER_AGENT; +use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT; +use const ShlinkioTest\Shlink\IOS_USER_AGENT; + class RedirectConditionTest extends TestCase { #[Test] @@ -47,6 +52,26 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } + #[Test] + #[TestWith([null, DeviceType::ANDROID, false])] + #[TestWith(['unknown', DeviceType::ANDROID, false])] + #[TestWith([ANDROID_USER_AGENT, DeviceType::ANDROID, true])] + #[TestWith([DESKTOP_USER_AGENT, DeviceType::DESKTOP, true])] + #[TestWith([IOS_USER_AGENT, DeviceType::IOS, true])] + #[TestWith([IOS_USER_AGENT, DeviceType::ANDROID, false])] + #[TestWith([DESKTOP_USER_AGENT, DeviceType::IOS, false])] + public function matchesDevice(?string $userAgent, DeviceType $value, bool $expected): void + { + $request = ServerRequestFactory::fromGlobals(); + if ($userAgent !== null) { + $request = $request->withHeader('User-Agent', $userAgent); + } + + $result = RedirectCondition::forDevice($value)->matchesRequest($request); + + self::assertEquals($expected, $result); + } + #[Test, DataProvider('provideNames')] public function generatesExpectedName(RedirectCondition $condition, string $expectedName): void { @@ -59,5 +84,8 @@ class RedirectConditionTest extends TestCase yield [RedirectCondition::forLanguage('en_UK'), 'language-en_UK']; yield [RedirectCondition::forQueryParam('foo', 'bar'), 'query-foo-bar']; yield [RedirectCondition::forQueryParam('baz', 'foo'), 'query-baz-foo']; + yield [RedirectCondition::forDevice(DeviceType::ANDROID), 'device-android']; + yield [RedirectCondition::forDevice(DeviceType::IOS), 'device-ios']; + yield [RedirectCondition::forDevice(DeviceType::DESKTOP), 'device-desktop']; } } From 33729289c731a48b214f8c83c6a808535f25b392 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Tue, 27 Feb 2024 21:00:53 +0100 Subject: [PATCH 74/97] Create endpoint to list redirect rules for a specific short URL --- LICENSE | 2 +- config/autoload/routes.config.php | 12 +- .../definitions/ShortUrlRedirectRule.json | 39 +++++ ...short-urls_{shortCode}_redirect-rules.json | 144 ++++++++++++++++++ docs/swagger/swagger.json | 8 + module/Core/config/dependencies.config.php | 5 +- .../RedirectRule/Entity/RedirectCondition.php | 13 +- .../Entity/ShortUrlRedirectRule.php | 13 +- .../ShortUrlRedirectRuleService.php | 25 +++ .../ShortUrlRedirectRuleServiceInterface.php | 14 ++ .../ShortUrlRedirectionResolver.php | 9 +- module/Core/src/ShortUrl/ShortUrlResolver.php | 3 + module/Core/src/ShortUrl/ShortUrlService.php | 10 +- .../ShortUrlRedirectRuleServiceTest.php | 55 +++++++ .../ShortUrlRedirectionResolverTest.php | 23 ++- module/Rest/config/dependencies.config.php | 6 + .../RedirectRule/ListRedirectRulesAction.php | 35 +++++ 17 files changed, 382 insertions(+), 34 deletions(-) create mode 100644 docs/swagger/definitions/ShortUrlRedirectRule.json create mode 100644 docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json create mode 100644 module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php create mode 100644 module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php create mode 100644 module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php create mode 100644 module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php diff --git a/LICENSE b/LICENSE index c245a4e0..e58a6f71 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2016-2023 Alejandro Celaya +Copyright (c) 2016-2024 Alejandro Celaya Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 051e18dd..785c8341 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -17,7 +17,6 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler; use function sprintf; return (static function (): array { - $contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class; $dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class; $overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class; @@ -32,9 +31,10 @@ return (static function (): array { ...ConfigProvider::applyRoutesPrefix([ Action\HealthAction::getRouteDef(), + // Visits and rules routes must go first, as they have a more specific path, otherwise, when + // multi-segment slugs are enabled, routes with a less-specific path might match first + // Visits. - // These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs - // are enabled, routes with a less-specific path might match first Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), Action\Visit\TagVisitsAction::getRouteDef(), @@ -44,15 +44,17 @@ return (static function (): array { Action\Visit\DeleteOrphanVisitsAction::getRouteDef(), Action\Visit\NonOrphanVisitsAction::getRouteDef(), + //Redirect rules + Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, $dropDomainMiddleware, $overrideDomainMiddleware, Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, ]), Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, + Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class, $overrideDomainMiddleware, ]), Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json new file mode 100644 index 00000000..74cdd216 --- /dev/null +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -0,0 +1,39 @@ +{ + "type": "object", + "required": ["priority", "longUrl", "conditions"], + "properties": { + "longUrl": { + "description": "Long URL to redirect to when this condition matches", + "type": "string" + }, + "priority": { + "description": "Order in which attempting to match the rule. Lower goes first", + "type": "number" + }, + "conditions": { + "description": "List of conditions that need to match in order to consider this rule matches", + "type": "array", + "items": { + "type": "object", + "required": ["name", "type", "matchKey", "matchValue"], + "properties": { + "name": { + "type": "string", + "description": "Unique condition name" + }, + "type": { + "type": "string", + "enum": ["device", "language", "query"], + "description": "The type of the condition, which will condition the logic used to match it" + }, + "matchKey": { + "type": ["string", "null"] + }, + "matchValue": { + "type": "string" + } + } + } + } + } +} diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json new file mode 100644 index 00000000..da4e95f5 --- /dev/null +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -0,0 +1,144 @@ +{ + "get": { + "operationId": "listShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "List short URL redirect rules", + "description": "Returns the list of redirect rules for a short URL.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "name": "device-android", + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "name": "language-en-US", + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "name": "language-fr", + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "name": "query-foo-bar", + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "name": "query-hello-world", + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "API v3 and newer": { + "$ref": "../examples/short-url-not-found-v3.json" + }, + "Previous to API v3": { + "$ref": "../examples/short-url-not-found-v2.json" + } + } + } + } + }, + "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 51655ecf..1b34b470 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -42,6 +42,10 @@ "name": "Short URLs", "description": "Operations that can be performed on short URLs" }, + { + "name": "Redirect rules", + "description": "Handle dynamic rule-based redirects" + }, { "name": "Tags", "description": "Let you handle the list of available tags" @@ -79,6 +83,10 @@ "$ref": "paths/v1_short-urls_{shortCode}.json" }, + "/rest/v{version}/short-urls/{shortCode}/redirect-rules": { + "$ref": "paths/v3_short-urls_{shortCode}_redirect-rules.json" + }, + "/rest/v{version}/tags": { "$ref": "paths/v1_tags.json" }, diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 6246b307..ed64a30e 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -32,6 +32,7 @@ return [ Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'], Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'], + RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class, RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class, ShortUrl\UrlShortener::class => ConfigAbstractFactory::class, @@ -158,7 +159,9 @@ return [ Util\RedirectResponseHelper::class => [Options\RedirectOptions::class], Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'], - RedirectRule\ShortUrlRedirectionResolver::class => ['em'], + + RedirectRule\ShortUrlRedirectRuleService::class => ['em'], + RedirectRule\ShortUrlRedirectionResolver::class => [RedirectRule\ShortUrlRedirectRuleService::class], Action\RedirectAction::class => [ ShortUrl\ShortUrlResolver::class, diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 9505e81d..f5af53af 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -2,6 +2,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; +use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Model\DeviceType; @@ -15,7 +16,7 @@ use function sprintf; use function strtolower; use function trim; -class RedirectCondition extends AbstractEntity +class RedirectCondition extends AbstractEntity implements JsonSerializable { private function __construct( public readonly string $name, @@ -98,4 +99,14 @@ class RedirectCondition extends AbstractEntity $device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent')); return $device !== null && $device->value === strtolower($this->matchValue); } + + public function jsonSerialize(): array + { + return [ + 'name' => $this->name, + 'type' => $this->type->value, + 'matchKey' => $this->matchKey, + 'matchValue' => $this->matchValue, + ]; + } } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 9e84e4fb..72bcfa99 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -4,13 +4,15 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; +use JsonSerializable; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function array_values; use function Shlinkio\Shlink\Core\ArrayUtils\every; -class ShortUrlRedirectRule extends AbstractEntity +class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable { /** * @param Collection $conditions @@ -33,4 +35,13 @@ class ShortUrlRedirectRule extends AbstractEntity static fn (RedirectCondition $condition) => $condition->matchesRequest($request), ); } + + public function jsonSerialize(): array + { + return [ + 'longUrl' => $this->longUrl, + 'priority' => $this->priority, + 'conditions' => array_values($this->conditions->toArray()), + ]; + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php new file mode 100644 index 00000000..03d40095 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -0,0 +1,25 @@ +em->getRepository(ShortUrlRedirectRule::class)->findBy( + criteria: ['shortUrl' => $shortUrl], + orderBy: ['priority' => 'ASC'], + ); + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php new file mode 100644 index 00000000..cda82910 --- /dev/null +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -0,0 +1,14 @@ +em->getRepository(ShortUrlRedirectRule::class)->findBy( - criteria: ['shortUrl' => $shortUrl], - orderBy: ['priority' => 'ASC'], - ); + $rules = $this->ruleService->rulesForShortUrl($shortUrl); foreach ($rules as $rule) { // Return the long URL for the first rule found that matches if ($rule->matchesRequest($request)) { diff --git a/module/Core/src/ShortUrl/ShortUrlResolver.php b/module/Core/src/ShortUrl/ShortUrlResolver.php index 4fd0d015..42d274c0 100644 --- a/module/Core/src/ShortUrl/ShortUrlResolver.php +++ b/module/Core/src/ShortUrl/ShortUrlResolver.php @@ -48,6 +48,9 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface return $shortUrl; } + /** + * @throws ShortUrlNotFoundException + */ public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl { /** @var ShortUrlRepository $shortUrlRepo */ diff --git a/module/Core/src/ShortUrl/ShortUrlService.php b/module/Core/src/ShortUrl/ShortUrlService.php index 1c3e9295..d75f847d 100644 --- a/module/Core/src/ShortUrl/ShortUrlService.php +++ b/module/Core/src/ShortUrl/ShortUrlService.php @@ -13,13 +13,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Rest\Entity\ApiKey; -class ShortUrlService implements ShortUrlServiceInterface +readonly class ShortUrlService implements ShortUrlServiceInterface { public function __construct( - private readonly ORM\EntityManagerInterface $em, - private readonly ShortUrlResolverInterface $urlResolver, - private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private readonly ShortUrlRelationResolverInterface $relationResolver, + private ORM\EntityManagerInterface $em, + private ShortUrlResolverInterface $urlResolver, + private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private ShortUrlRelationResolverInterface $relationResolver, ) { } diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php new file mode 100644 index 00000000..016c5453 --- /dev/null +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -0,0 +1,55 @@ +em = $this->createMock(EntityManagerInterface::class); + $this->ruleService = new ShortUrlRedirectRuleService($this->em); + } + + #[Test] + public function delegatesToRepository(): void + { + $shortUrl = ShortUrl::withLongUrl('https://shlink.io'); + $rules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + RedirectCondition::forLanguage('en-US'), + ])), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com/from-rule-2', new ArrayCollection([ + RedirectCondition::forQueryParam('foo', 'bar'), + RedirectCondition::forDevice(DeviceType::ANDROID), + ])), + ]; + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn($rules); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + + $result = $this->ruleService->rulesForShortUrl($shortUrl); + + self::assertSame($rules, $result); + } +} diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php index f8962b12..5bf435b2 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectionResolverTest.php @@ -3,8 +3,6 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule; use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\EntityRepository; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -15,6 +13,7 @@ use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolver; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; @@ -25,12 +24,12 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT; class ShortUrlRedirectionResolverTest extends TestCase { private ShortUrlRedirectionResolver $resolver; - private EntityManagerInterface & MockObject $em; + private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService; protected function setUp(): void { - $this->em = $this->createMock(EntityManagerInterface::class); - $this->resolver = new ShortUrlRedirectionResolver($this->em); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + $this->resolver = new ShortUrlRedirectionResolver($this->ruleService); } #[Test, DataProvider('provideData')] @@ -43,14 +42,12 @@ class ShortUrlRedirectionResolverTest extends TestCase 'longUrl' => 'https://example.com/foo/bar', ])); - $repo = $this->createMock(EntityRepository::class); - $repo->expects($this->once())->method('findBy')->willReturn($condition !== null ? [ - new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ - $condition, - ])), - ] : []); - $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( - $repo, + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn( + $condition !== null ? [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([ + $condition, + ])), + ] : [], ); $result = $this->resolver->resolveLongUrl($shortUrl, $request); diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 67343e27..4eabfec9 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -12,6 +12,7 @@ use Psr\Log\LoggerInterface; use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options; +use Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Core\Tag\TagService; @@ -46,6 +47,7 @@ return [ Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -103,6 +105,10 @@ return [ Action\Tag\UpdateTagAction::class => [TagService::class], Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class], Action\Domain\DomainRedirectsAction::class => [DomainService::class], + Action\RedirectRule\ListRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php new file mode 100644 index 00000000..c4db3e12 --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php @@ -0,0 +1,35 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $rules = $this->ruleService->rulesForShortUrl($shortUrl); + + return new JsonResponse(['redirectRules' => $rules]); + } +} From c4805b8152809cb0ffb240b3f61d246ea93a6651 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 28 Feb 2024 08:39:26 +0100 Subject: [PATCH 75/97] Remove old error models and examples from swagger spec --- .../examples/short-url-invalid-args-v2.json | 9 --------- .../examples/short-url-not-found-v2.json | 9 --------- docs/swagger/examples/tag-not-found-v2.json | 9 --------- docs/swagger/paths/v1_short-urls.json | 16 ++------------- .../paths/v1_short-urls_{shortCode}.json | 20 ++++--------------- .../v1_short-urls_{shortCode}_visits.json | 10 ++-------- docs/swagger/paths/v1_tags.json | 3 --- docs/swagger/paths/v2_tags_{tag}_visits.json | 6 +----- ...short-urls_{shortCode}_redirect-rules.json | 10 ++++++---- .../RedirectRule/ListRedirectRulesAction.php | 5 ++++- 10 files changed, 19 insertions(+), 78 deletions(-) delete mode 100644 docs/swagger/examples/short-url-invalid-args-v2.json delete mode 100644 docs/swagger/examples/short-url-not-found-v2.json delete mode 100644 docs/swagger/examples/tag-not-found-v2.json diff --git a/docs/swagger/examples/short-url-invalid-args-v2.json b/docs/swagger/examples/short-url-invalid-args-v2.json deleted file mode 100644 index d85a5eed..00000000 --- a/docs/swagger/examples/short-url-invalid-args-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "title": "Invalid data", - "type": "INVALID_ARGUMENT", - "detail": "Provided data is not valid", - "status": 400, - "invalidElements": ["maxVisits", "validSince"] - } -} diff --git a/docs/swagger/examples/short-url-not-found-v2.json b/docs/swagger/examples/short-url-not-found-v2.json deleted file mode 100644 index 4a58c847..00000000 --- a/docs/swagger/examples/short-url-not-found-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "detail": "No URL found with short code \"abc123\"", - "title": "Short URL not found", - "type": "INVALID_SHORTCODE", - "status": 404, - "shortCode": "abc123" - } -} diff --git a/docs/swagger/examples/tag-not-found-v2.json b/docs/swagger/examples/tag-not-found-v2.json deleted file mode 100644 index 46018121..00000000 --- a/docs/swagger/examples/tag-not-found-v2.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "value": { - "detail": "Tag with name \"foo\" could not be found", - "title": "Tag not found", - "type": "TAG_NOT_FOUND", - "status": 404, - "tag": "foo" - } -} diff --git a/docs/swagger/paths/v1_short-urls.json b/docs/swagger/paths/v1_short-urls.json index 150eebc0..7d172ff4 100644 --- a/docs/swagger/paths/v1_short-urls.json +++ b/docs/swagger/paths/v1_short-urls.json @@ -383,10 +383,10 @@ ] }, "examples": { - "Invalid arguments with API v3 and newer": { + "Invalid arguments": { "$ref": "../examples/short-url-invalid-args-v3.json" }, - "Non-unique slug with API v3 and newer": { + "Non-unique slug": { "value": { "title": "Invalid custom slug", "type": "https://shlink.io/api/error/non-unique-slug", @@ -394,18 +394,6 @@ "status": 400, "customSlug": "my-slug" } - }, - "Invalid arguments previous to API v3": { - "$ref": "../examples/short-url-invalid-args-v2.json" - }, - "Non-unique slug previous to API v3": { - "value": { - "title": "Invalid custom slug", - "type": "INVALID_SLUG", - "detail": "Provided slug \"my-slug\" is already in use.", - "status": 400, - "customSlug": "my-slug" - } } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 120dc43e..c1a6eafc 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -81,11 +81,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -202,11 +199,8 @@ ] }, "examples": { - "API v3 and newer": { + "Invalid arguments": { "$ref": "../examples/short-url-invalid-args-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-invalid-args-v2.json" } } } @@ -238,11 +232,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -368,11 +359,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json index 2f102711..71e70148 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}_visits.json @@ -145,11 +145,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Short URL not found with API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Short URL not found previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } @@ -219,11 +216,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - "Short URL not found with API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Short URL not found previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v1_tags.json b/docs/swagger/paths/v1_tags.json index b4f6ef89..752797c8 100644 --- a/docs/swagger/paths/v1_tags.json +++ b/docs/swagger/paths/v1_tags.json @@ -228,9 +228,6 @@ "examples": { "API v3 and newer": { "$ref": "../examples/tag-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/tag-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v2_tags_{tag}_visits.json b/docs/swagger/paths/v2_tags_{tag}_visits.json index d40b7020..2a0148ec 100644 --- a/docs/swagger/paths/v2_tags_{tag}_visits.json +++ b/docs/swagger/paths/v2_tags_{tag}_visits.json @@ -148,12 +148,8 @@ "$ref": "../definitions/Error.json" }, "examples": { - - "API v3 and newer": { + "Tag not found": { "$ref": "../examples/tag-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/tag-not-found-v2.json" } } } diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index da4e95f5..3854e2b9 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -29,7 +29,11 @@ "application/json": { "schema": { "type": "object", + "required": ["defaultLongUrl", "redirectRules"], "properties": { + "defaultLongUrl": { + "type": "string" + }, "redirectRules": { "type": "array", "items": { @@ -39,6 +43,7 @@ } }, "example": { + "defaultLongUrl": "https://example.com", "redirectRules": [ { "longUrl": "https://example.com/android-en-us", @@ -119,11 +124,8 @@ ] }, "examples": { - "API v3 and newer": { + "Short URL not found": { "$ref": "../examples/short-url-not-found-v3.json" - }, - "Previous to API v3": { - "$ref": "../examples/short-url-not-found-v2.json" } } } diff --git a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php index c4db3e12..c4d9754e 100644 --- a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php +++ b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php @@ -30,6 +30,9 @@ class ListRedirectRulesAction extends AbstractRestAction ); $rules = $this->ruleService->rulesForShortUrl($shortUrl); - return new JsonResponse(['redirectRules' => $rules]); + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $rules, + ]); } } From 67bafbe44eb9aa65f609bc6fca705c9031f4e11d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 28 Feb 2024 08:53:35 +0100 Subject: [PATCH 76/97] Add API test for redirect rules list --- .../test-api/Action/ListRedirectRulesTest.php | 104 ++++++++++++++++++ .../Fixtures/ShortUrlRedirectRulesFixture.php | 53 ++++----- 2 files changed, 131 insertions(+), 26 deletions(-) create mode 100644 module/Rest/test-api/Action/ListRedirectRulesTest.php diff --git a/module/Rest/test-api/Action/ListRedirectRulesTest.php b/module/Rest/test-api/Action/ListRedirectRulesTest.php new file mode 100644 index 00000000..5115d2d2 --- /dev/null +++ b/module/Rest/test-api/Action/ListRedirectRulesTest.php @@ -0,0 +1,104 @@ + 'language-en', + 'type' => 'language', + 'matchKey' => null, + 'matchValue' => 'en', + ]; + private const QUERY_FOO_BAR_CONDITION = [ + 'name' => 'query-foo-bar', + 'type' => 'query', + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ]; + + #[Test] + public function errorIsReturnedWhenInvalidUrlIsFetched(): void + { + $response = $this->callApiWithKey(self::METHOD_GET, '/short-urls/invalid/redirect-rules'); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(404, $response->getStatusCode()); + self::assertEquals(404, $payload['status']); + self::assertEquals('invalid', $payload['shortCode']); + self::assertEquals('No URL found with short code "invalid"', $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + #[Test] + #[TestWith(['abc123', []])] + #[TestWith(['def456', [ + [ + 'longUrl' => 'https://example.com/english-and-foo-query', + 'priority' => 1, + 'conditions' => [ + self::LANGUAGE_EN_CONDITION, + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/multiple-query-params', + 'priority' => 2, + 'conditions' => [ + self::QUERY_FOO_BAR_CONDITION, + [ + 'name' => 'query-hello-world', + 'type' => 'query', + 'matchKey' => 'hello', + 'matchValue' => 'world', + ], + ], + ], + [ + 'longUrl' => 'https://example.com/only-english', + 'priority' => 3, + 'conditions' => [self::LANGUAGE_EN_CONDITION], + ], + [ + 'longUrl' => 'https://blog.alejandrocelaya.com/android', + 'priority' => 4, + 'conditions' => [ + [ + 'name' => 'device-android', + 'type' => 'device', + 'matchKey' => null, + 'matchValue' => 'android', + ], + ], + ], + [ + 'longUrl' => 'https://blog.alejandrocelaya.com/ios', + 'priority' => 5, + 'conditions' => [ + [ + 'name' => 'device-ios', + 'type' => 'device', + 'matchKey' => null, + 'matchValue' => 'ios', + ], + ], + ], + ]])] + public function returnsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void + { + $response = $this->callApiWithKey(self::METHOD_GET, sprintf('/short-urls/%s/redirect-rules', $shortCode)); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expectedRules, $payload['redirectRules']); + } +} diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php index ab0e8dce..7607724b 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -40,43 +40,44 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF $iosCondition = RedirectCondition::forDevice(DeviceType::IOS); $manager->persist($iosCondition); - $englishAndFooQueryRule = new ShortUrlRedirectRule( - $defShortUrl, - 1, - 'https://example.com/english-and-foo-query', - new ArrayCollection([$englishCondition, $fooQueryCondition]), - ); - $manager->persist($englishAndFooQueryRule); - + // Create rules disordered to make sure the order by priority works $multipleQueryParamsRule = new ShortUrlRedirectRule( - $defShortUrl, - 2, - 'https://example.com/multiple-query-params', - new ArrayCollection([$helloQueryCondition, $fooQueryCondition]), + shortUrl: $defShortUrl, + priority: 2, + longUrl: 'https://example.com/multiple-query-params', + conditions: new ArrayCollection([$helloQueryCondition, $fooQueryCondition]), ); $manager->persist($multipleQueryParamsRule); - $onlyEnglishRule = new ShortUrlRedirectRule( - $defShortUrl, - 3, - 'https://example.com/only-english', - new ArrayCollection([$englishCondition]), + $englishAndFooQueryRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 1, + longUrl: 'https://example.com/english-and-foo-query', + conditions: new ArrayCollection([$englishCondition, $fooQueryCondition]), ); - $manager->persist($onlyEnglishRule); + $manager->persist($englishAndFooQueryRule); $androidRule = new ShortUrlRedirectRule( - $defShortUrl, - 4, - 'https://blog.alejandrocelaya.com/android', - new ArrayCollection([$androidCondition]), + shortUrl: $defShortUrl, + priority: 4, + longUrl: 'https://blog.alejandrocelaya.com/android', + conditions: new ArrayCollection([$androidCondition]), ); $manager->persist($androidRule); + $onlyEnglishRule = new ShortUrlRedirectRule( + shortUrl: $defShortUrl, + priority: 3, + longUrl: 'https://example.com/only-english', + conditions: new ArrayCollection([$englishCondition]), + ); + $manager->persist($onlyEnglishRule); + $iosRule = new ShortUrlRedirectRule( - $defShortUrl, - 5, - 'https://blog.alejandrocelaya.com/ios', - new ArrayCollection([$iosCondition]), + shortUrl: $defShortUrl, + priority: 5, + longUrl: 'https://blog.alejandrocelaya.com/ios', + conditions: new ArrayCollection([$iosCondition]), ); $manager->persist($iosRule); From ab7824aa85143267d227bc7f6b962579597246b0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 28 Feb 2024 09:14:27 +0100 Subject: [PATCH 77/97] Add unit test for ListRedirectRulesAction --- .../RedirectRule/ListRedirectRulesAction.php | 4 +- .../ListRedirectRulesActionTest.php | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php diff --git a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php index c4d9754e..c6c12fd9 100644 --- a/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php +++ b/module/Rest/src/Action/RedirectRule/ListRedirectRulesAction.php @@ -5,7 +5,7 @@ namespace Shlinkio\Shlink\Rest\Action\RedirectRule; use Laminas\Diactoros\Response\JsonResponse; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; -use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; @@ -18,7 +18,7 @@ class ListRedirectRulesAction extends AbstractRestAction public function __construct( private readonly ShortUrlResolverInterface $urlResolver, - private readonly ShortUrlRedirectRuleService $ruleService, + private readonly ShortUrlRedirectRuleServiceInterface $ruleService, ) { } diff --git a/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php b/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php new file mode 100644 index 00000000..d2e92240 --- /dev/null +++ b/module/Rest/test/Action/RedirectRule/ListRedirectRulesActionTest.php @@ -0,0 +1,58 @@ +urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + + $this->action = new ListRedirectRulesAction($this->urlResolver, $this->ruleService); + } + + #[Test] + public function requestIsHandledAndRulesAreReturned(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); + $conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')]; + $redirectRules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)), + ]; + + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->willReturn($redirectRules); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + $payload = $response->getPayload(); + + self::assertEquals([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $redirectRules, + ], $payload); + } +} From 070d74830ba66b7715e72cf2a9d2747d6badb520 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Feb 2024 09:05:30 +0100 Subject: [PATCH 78/97] Remove name and uniqueness in redirect condition table --- ...short-urls_{shortCode}_redirect-rules.json | 5 -- ....RedirectRule.Entity.RedirectCondition.php | 7 --- ...directRule.Entity.ShortUrlRedirectRule.php | 5 ++ .../Core/migrations/Version20240224115725.php | 2 - .../Core/migrations/Version20240226214216.php | 49 +++++++------------ .../RedirectRule/Entity/RedirectCondition.php | 18 ++----- .../Entity/RedirectConditionTest.php | 18 ------- .../test-api/Action/ListRedirectRulesTest.php | 7 +-- .../Fixtures/ShortUrlRedirectRulesFixture.php | 29 ++++------- 9 files changed, 36 insertions(+), 104 deletions(-) diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index 3854e2b9..44cd2d86 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -50,13 +50,11 @@ "priority": 1, "conditions": [ { - "name": "device-android", "type": "device", "matchValue": "android", "matchKey": null }, { - "name": "language-en-US", "type": "language", "matchValue": "en-US", "matchKey": null @@ -68,7 +66,6 @@ "priority": 2, "conditions": [ { - "name": "language-fr", "type": "language", "matchValue": "fr", "matchKey": null @@ -80,13 +77,11 @@ "priority": 3, "conditions": [ { - "name": "query-foo-bar", "type": "query", "matchKey": "foo", "matchValue": "bar" }, { - "name": "query-hello-world", "type": "query", "matchKey": "hello", "matchValue": "world" diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php index 2c1e1bdc..513089fa 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.RedirectCondition.php @@ -22,13 +22,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - fieldWithUtf8Charset($builder->createField('name', Types::STRING), $emConfig) - ->columnName('name') - ->length(512) - ->build(); - - $builder->addUniqueConstraint(['name'], 'UQ_name'); - (new FieldBuilder($builder, [ 'fieldName' => 'type', 'type' => Types::STRING, diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php index cab72e89..3851de00 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -33,10 +33,15 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->addJoinColumn('short_url_id', 'id', nullable: false, onDelete: 'CASCADE') ->build(); + // We treat this ManyToMany relation as a unidirectional OneToMany, where conditions are persisted and deleted + // together with the rule $builder->createManyToMany('conditions', RedirectRule\Entity\RedirectCondition::class) ->setJoinTable(determineTableName('redirect_conditions_in_short_url_redirect_rules', $emConfig)) ->addInverseJoinColumn('redirect_condition_id', 'id', onDelete: 'CASCADE') ->addJoinColumn('short_url_redirect_rule_id', 'id', onDelete: 'CASCADE') ->fetchEager() // Always fetch the corresponding conditions when loading a rule + ->setOrderBy(['id' => 'ASC']) // Ensure a reliable order in the list of conditions + ->cascadePersist() // Create automatically with the rule + ->orphanRemoval() // Remove conditions when they are not linked to any rule ->build(); }; diff --git a/module/Core/migrations/Version20240224115725.php b/module/Core/migrations/Version20240224115725.php index 1e069d83..292e6dbb 100644 --- a/module/Core/migrations/Version20240224115725.php +++ b/module/Core/migrations/Version20240224115725.php @@ -34,8 +34,6 @@ final class Version20240224115725 extends AbstractMigration ]); $redirectConditions = $this->createTableWithId($schema, 'redirect_conditions'); - $redirectConditions->addColumn('name', Types::STRING, ['length' => 512]); - $redirectConditions->addUniqueIndex(['name'], 'UQ_name'); $redirectConditions->addColumn('type', Types::STRING, ['length' => 255]); $redirectConditions->addColumn('match_key', Types::STRING, [ diff --git a/module/Core/migrations/Version20240226214216.php b/module/Core/migrations/Version20240226214216.php index d7352717..74237ca0 100644 --- a/module/Core/migrations/Version20240226214216.php +++ b/module/Core/migrations/Version20240226214216.php @@ -16,36 +16,7 @@ final class Version20240226214216 extends AbstractMigration { $this->skipIf(! $schema->hasTable('device_long_urls')); - // First create redirect conditions for all device types - $qb = $this->connection->createQueryBuilder(); - $devices = $qb->select('device_type') - ->distinct() - ->from('device_long_urls') - ->executeQuery(); - - $conditionIds = []; - while ($deviceRow = $devices->fetchAssociative()) { - $deviceType = $deviceRow['device_type']; - $conditionQb = $this->connection->createQueryBuilder(); - $conditionQb->insert('redirect_conditions') - ->values([ - 'name' => ':name', - 'type' => ':type', - 'match_value' => ':match_value', - 'match_key' => ':match_key', - ]) - ->setParameters([ - 'name' => 'device-' . $deviceType, - 'type' => 'device', - 'match_value' => $deviceType, - 'match_key' => null, - ]) - ->executeStatement(); - $id = $this->connection->lastInsertId(); - $conditionIds[$deviceType] = $id; - } - - // Then insert a rule per every device_long_url, and link it to the corresponding condition + // Insert a rule per every device_long_url, and link it to the corresponding condition $qb = $this->connection->createQueryBuilder(); $rules = $qb->select('short_url_id', 'device_type', 'long_url') ->from('device_long_urls') @@ -71,6 +42,22 @@ final class Version20240226214216 extends AbstractMigration ->executeStatement(); $ruleId = $this->connection->lastInsertId(); + $deviceType = $ruleRow['device_type']; + $conditionQb = $this->connection->createQueryBuilder(); + $conditionQb->insert('redirect_conditions') + ->values([ + 'type' => ':type', + 'match_value' => ':match_value', + 'match_key' => ':match_key', + ]) + ->setParameters([ + 'type' => 'device', + 'match_value' => $deviceType, + 'match_key' => null, + ]) + ->executeStatement(); + $conditionId = $this->connection->lastInsertId(); + $relationQb = $this->connection->createQueryBuilder(); $relationQb->insert('redirect_conditions_in_short_url_redirect_rules') ->values([ @@ -78,7 +65,7 @@ final class Version20240226214216 extends AbstractMigration 'short_url_redirect_rule_id' => ':short_url_redirect_rule_id', ]) ->setParameters([ - 'redirect_condition_id' => $conditionIds[$ruleRow['device_type']], + 'redirect_condition_id' => $conditionId, 'short_url_redirect_rule_id' => $ruleId, ]) ->executeStatement(); diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index f5af53af..e5f0bae7 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -12,14 +12,12 @@ use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; -use function sprintf; use function strtolower; use function trim; class RedirectCondition extends AbstractEntity implements JsonSerializable { private function __construct( - public readonly string $name, private readonly RedirectConditionType $type, private readonly string $matchValue, private readonly ?string $matchKey = null, @@ -28,26 +26,17 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable public static function forQueryParam(string $param, string $value): self { - $type = RedirectConditionType::QUERY_PARAM; - $name = sprintf('%s-%s-%s', $type->value, $param, $value); - - return new self($name, $type, $value, $param); + return new self(RedirectConditionType::QUERY_PARAM, $value, $param); } public static function forLanguage(string $language): self { - $type = RedirectConditionType::LANGUAGE; - $name = sprintf('%s-%s', $type->value, $language); - - return new self($name, $type, $language); + return new self(RedirectConditionType::LANGUAGE, $language); } public static function forDevice(DeviceType $device): self { - $type = RedirectConditionType::DEVICE; - $name = sprintf('%s-%s', $type->value, $device->value); - - return new self($name, $type, $device->value); + return new self(RedirectConditionType::DEVICE, $device->value); } /** @@ -103,7 +92,6 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable public function jsonSerialize(): array { return [ - 'name' => $this->name, 'type' => $this->type->value, 'matchKey' => $this->matchKey, 'matchValue' => $this->matchValue, diff --git a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php index 1eb7e0f8..eaea4c25 100644 --- a/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php +++ b/module/Core/test/RedirectRule/Entity/RedirectConditionTest.php @@ -3,7 +3,6 @@ namespace ShlinkioTest\Shlink\Core\RedirectRule\Entity; use Laminas\Diactoros\ServerRequestFactory; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; @@ -71,21 +70,4 @@ class RedirectConditionTest extends TestCase self::assertEquals($expected, $result); } - - #[Test, DataProvider('provideNames')] - public function generatesExpectedName(RedirectCondition $condition, string $expectedName): void - { - self::assertEquals($expectedName, $condition->name); - } - - public static function provideNames(): iterable - { - yield [RedirectCondition::forLanguage('es-ES'), 'language-es-ES']; - yield [RedirectCondition::forLanguage('en_UK'), 'language-en_UK']; - yield [RedirectCondition::forQueryParam('foo', 'bar'), 'query-foo-bar']; - yield [RedirectCondition::forQueryParam('baz', 'foo'), 'query-baz-foo']; - yield [RedirectCondition::forDevice(DeviceType::ANDROID), 'device-android']; - yield [RedirectCondition::forDevice(DeviceType::IOS), 'device-ios']; - yield [RedirectCondition::forDevice(DeviceType::DESKTOP), 'device-desktop']; - } } diff --git a/module/Rest/test-api/Action/ListRedirectRulesTest.php b/module/Rest/test-api/Action/ListRedirectRulesTest.php index 5115d2d2..b86683c9 100644 --- a/module/Rest/test-api/Action/ListRedirectRulesTest.php +++ b/module/Rest/test-api/Action/ListRedirectRulesTest.php @@ -13,13 +13,11 @@ use function sprintf; class ListRedirectRulesTest extends ApiTestCase { private const LANGUAGE_EN_CONDITION = [ - 'name' => 'language-en', 'type' => 'language', 'matchKey' => null, 'matchValue' => 'en', ]; private const QUERY_FOO_BAR_CONDITION = [ - 'name' => 'query-foo-bar', 'type' => 'query', 'matchKey' => 'foo', 'matchValue' => 'bar', @@ -54,13 +52,12 @@ class ListRedirectRulesTest extends ApiTestCase 'longUrl' => 'https://example.com/multiple-query-params', 'priority' => 2, 'conditions' => [ - self::QUERY_FOO_BAR_CONDITION, [ - 'name' => 'query-hello-world', 'type' => 'query', 'matchKey' => 'hello', 'matchValue' => 'world', ], + self::QUERY_FOO_BAR_CONDITION, ], ], [ @@ -73,7 +70,6 @@ class ListRedirectRulesTest extends ApiTestCase 'priority' => 4, 'conditions' => [ [ - 'name' => 'device-android', 'type' => 'device', 'matchKey' => null, 'matchValue' => 'android', @@ -85,7 +81,6 @@ class ListRedirectRulesTest extends ApiTestCase 'priority' => 5, 'conditions' => [ [ - 'name' => 'device-ios', 'type' => 'device', 'matchKey' => null, 'matchValue' => 'ios', diff --git a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php index 7607724b..267969f1 100644 --- a/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php +++ b/module/Rest/test-api/Fixtures/ShortUrlRedirectRulesFixture.php @@ -25,27 +25,14 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF /** @var ShortUrl $defShortUrl */ $defShortUrl = $this->getReference('def456_short_url'); - $englishCondition = RedirectCondition::forLanguage('en'); - $manager->persist($englishCondition); - - $fooQueryCondition = RedirectCondition::forQueryParam('foo', 'bar'); - $manager->persist($fooQueryCondition); - - $helloQueryCondition = RedirectCondition::forQueryParam('hello', 'world'); - $manager->persist($helloQueryCondition); - - $androidCondition = RedirectCondition::forDevice(DeviceType::ANDROID); - $manager->persist($androidCondition); - - $iosCondition = RedirectCondition::forDevice(DeviceType::IOS); - $manager->persist($iosCondition); - // Create rules disordered to make sure the order by priority works $multipleQueryParamsRule = new ShortUrlRedirectRule( shortUrl: $defShortUrl, priority: 2, longUrl: 'https://example.com/multiple-query-params', - conditions: new ArrayCollection([$helloQueryCondition, $fooQueryCondition]), + conditions: new ArrayCollection( + [RedirectCondition::forQueryParam('hello', 'world'), RedirectCondition::forQueryParam('foo', 'bar')], + ), ); $manager->persist($multipleQueryParamsRule); @@ -53,7 +40,9 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF shortUrl: $defShortUrl, priority: 1, longUrl: 'https://example.com/english-and-foo-query', - conditions: new ArrayCollection([$englishCondition, $fooQueryCondition]), + conditions: new ArrayCollection( + [RedirectCondition::forLanguage('en'), RedirectCondition::forQueryParam('foo', 'bar')], + ), ); $manager->persist($englishAndFooQueryRule); @@ -61,7 +50,7 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF shortUrl: $defShortUrl, priority: 4, longUrl: 'https://blog.alejandrocelaya.com/android', - conditions: new ArrayCollection([$androidCondition]), + conditions: new ArrayCollection([RedirectCondition::forDevice(DeviceType::ANDROID)]), ); $manager->persist($androidRule); @@ -69,7 +58,7 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF shortUrl: $defShortUrl, priority: 3, longUrl: 'https://example.com/only-english', - conditions: new ArrayCollection([$englishCondition]), + conditions: new ArrayCollection([RedirectCondition::forLanguage('en')]), ); $manager->persist($onlyEnglishRule); @@ -77,7 +66,7 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF shortUrl: $defShortUrl, priority: 5, longUrl: 'https://blog.alejandrocelaya.com/ios', - conditions: new ArrayCollection([$iosCondition]), + conditions: new ArrayCollection([RedirectCondition::forDevice(DeviceType::IOS)]), ); $manager->persist($iosRule); From d9286765e16a77fde6e432c03d112dadc828968b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Wed, 28 Feb 2024 20:24:16 +0100 Subject: [PATCH 79/97] Create endpoint to set redirect rules for a short URL --- config/autoload/mercure.local.php.dist | 2 +- config/autoload/routes.config.php | 1 + docker-compose.yml | 2 +- .../definitions/ShortUrlRedirectRule.json | 6 +- ...short-urls_{shortCode}_redirect-rules.json | 159 ++++++++++++++++++ .../Entity/ShortUrlRedirectRule.php | 5 + .../RedirectRule/Model/RedirectRulesData.php | 25 +++ .../Validation/RedirectRulesInputFilter.php | 92 ++++++++++ .../ShortUrlRedirectRuleService.php | 56 ++++++ .../ShortUrlRedirectRuleServiceInterface.php | 6 + .../Model/Validation/ShortUrlInputFilter.php | 4 +- module/Rest/config/dependencies.config.php | 5 + .../RedirectRule/SetRedirectRulesAction.php | 43 +++++ 13 files changed, 397 insertions(+), 9 deletions(-) create mode 100644 module/Core/src/RedirectRule/Model/RedirectRulesData.php create mode 100644 module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php create mode 100644 module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index e818404b..13a74022 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -5,7 +5,7 @@ declare(strict_types=1); return [ 'mercure' => [ - 'public_hub_url' => 'http://localhost:8001', + 'public_hub_url' => 'http://localhost:8002', 'internal_hub_url' => 'http://shlink_mercure_proxy', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', ], diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 785c8341..6d072228 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -46,6 +46,7 @@ return (static function (): array { //Redirect rules Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ diff --git a/docker-compose.yml b/docker-compose.yml index 5416136d..3f65e4bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,7 @@ services: container_name: shlink_mercure_proxy image: nginx:1.25-alpine ports: - - "8001:80" + - "8002:80" volumes: - ./:/home/shlink/www - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json index 74cdd216..8fde6e90 100644 --- a/docs/swagger/definitions/ShortUrlRedirectRule.json +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -15,12 +15,8 @@ "type": "array", "items": { "type": "object", - "required": ["name", "type", "matchKey", "matchValue"], + "required": ["type", "matchKey", "matchValue"], "properties": { - "name": { - "type": "string", - "description": "Unique condition name" - }, "type": { "type": "string", "enum": ["device", "language", "query"], diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index 44cd2d86..cd2904d4 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -137,5 +137,164 @@ } } } + }, + + "post": { + "operationId": "setShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "Set short URL redirect rules", + "description": "Overwrites redirect rules for a short URL with the ones provided here.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "requestBody": { + "description": "Request body.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["defaultLongUrl", "redirectRules"], + "properties": { + "defaultLongUrl": { + "type": "string" + }, + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "defaultLongUrl": "https://example.com", + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found-v3.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } } } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 72bcfa99..4469a620 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable ); } + public function clearConditions(): void + { + $this->conditions->clear(); + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/RedirectRule/Model/RedirectRulesData.php b/module/Core/src/RedirectRule/Model/RedirectRulesData.php new file mode 100644 index 00000000..6eb7dada --- /dev/null +++ b/module/Core/src/RedirectRule/Model/RedirectRulesData.php @@ -0,0 +1,25 @@ +isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)); + } +} diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php new file mode 100644 index 00000000..745b8914 --- /dev/null +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -0,0 +1,92 @@ +setInputFilter(self::createRedirectRuleInputFilter()); + + $instance = new self(); + $instance->add($redirectRulesInputFilter, self::REDIRECT_RULES); + + $instance->setData($rawData); + return $instance; + } + + private static function createRedirectRuleInputFilter(): InputFilter + { + $redirectRuleInputFilter = new InputFilter(); + + $redirectRuleInputFilter->add(InputFactory::numeric(self::RULE_PRIORITY, required: true)); + + $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); + $longUrl->setValidatorChain(ShortUrlInputFilter::longUrlValidators()); + $redirectRuleInputFilter->add($longUrl); + + $conditionsInputFilter = new CollectionInputFilter(); + $conditionsInputFilter->setInputFilter(self::createRedirectConditionInputFilter()) + ->setIsRequired(true); + $redirectRuleInputFilter->add($conditionsInputFilter, self::RULE_CONDITIONS); + + return $redirectRuleInputFilter; + } + + private static function createRedirectConditionInputFilter(): InputFilter + { + $redirectConditionInputFilter = new InputFilter(); + + $type = InputFactory::basic(self::CONDITION_TYPE, required: true); + $type->getValidatorChain()->attach(new InArray([ + 'haystack' => enumValues(RedirectConditionType::class), + 'strict' => InArray::COMPARE_STRICT, + ])); + $redirectConditionInputFilter->add($type); + + $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); + $value->getValidatorChain()->attach(new Callback(function (string $value, array $context) { + if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) { + return contains($value, enumValues(DeviceType::class)); + } + + return true; + })); + $redirectConditionInputFilter->add($value); + + $redirectConditionInputFilter->add( + InputFactory::basic(self::CONDITION_MATCH_KEY, required: true)->setAllowEmpty(true), + ); + + return $redirectConditionInputFilter; + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 03d40095..b3ad1f07 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -2,10 +2,18 @@ namespace Shlinkio\Shlink\Core\RedirectRule; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\Model\DeviceType; +use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function array_map; + readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface { public function __construct(private EntityManagerInterface $em) @@ -22,4 +30,52 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic orderBy: ['priority' => 'ASC'], ); } + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array + { + return $this->em->wrapInTransaction(function () use ($shortUrl, $data): array { + // First, delete existing rules for the short URL + $oldRules = $this->rulesForShortUrl($shortUrl); + foreach ($oldRules as $oldRule) { + $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions + $this->em->remove($oldRule); + } + $this->em->flush(); + + // Then insert new rules + $rules = []; + foreach ($data->rules as $rule) { + $rule = new ShortUrlRedirectRule( + shortUrl: $shortUrl, + priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY], + longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], + conditions: new ArrayCollection(array_map( + fn (array $conditionData) => $this->createCondition($conditionData), + $rule[RedirectRulesInputFilter::RULE_CONDITIONS], + )), + ); + + $rules[] = $rule; + $this->em->persist($rule); + } + + return $rules; + }); + } + + private function createCondition(array $rawConditionData): RedirectCondition + { + $type = RedirectConditionType::from($rawConditionData[RedirectRulesInputFilter::CONDITION_TYPE]); + $value = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; + $key = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_KEY]; + + return match ($type) { + RedirectConditionType::DEVICE => RedirectCondition::forDevice(DeviceType::from($value)), + RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage($value), + RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam($key, $value), + }; + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php index cda82910..7fc34a1b 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface ShortUrlRedirectRuleServiceInterface @@ -11,4 +12,9 @@ interface ShortUrlRedirectRuleServiceInterface * @return ShortUrlRedirectRule[] */ public function rulesForShortUrl(ShortUrl $shortUrl): array; + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array; } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 8818e0f6..22000e2c 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -93,7 +93,7 @@ class ShortUrlInputFilter extends InputFilter private function initializeForEdition(bool $requireLongUrl = false): void { $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); - $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); + $longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl)); $this->add($longUrlInput); $validSince = InputFactory::basic(self::VALID_SINCE); @@ -124,7 +124,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($apiKeyInput); } - private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain + public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain { $emptyModifiers = [ Validator\NotEmpty::OBJECT, diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 4eabfec9..9396dd38 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -48,6 +48,7 @@ return [ Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -109,6 +110,10 @@ return [ ShortUrl\ShortUrlResolver::class, RedirectRule\ShortUrlRedirectRuleService::class, ], + Action\RedirectRule\SetRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php new file mode 100644 index 00000000..913a833d --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php @@ -0,0 +1,43 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $data = RedirectRulesData::fromRawData((array) $request->getParsedBody()); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $result, + ]); + } +} From f9e4d6d617d0c630997334d59d3e151b25e6e695 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Feb 2024 19:17:10 +0100 Subject: [PATCH 80/97] Create RedirectRulesDataTest --- composer.json | 1 + .../RedirectRule/Entity/RedirectCondition.php | 10 +++ .../RedirectRule/Model/RedirectRulesData.php | 17 +++-- .../ShortUrlRedirectRuleService.php | 69 ++++++++----------- .../Entity/ShortUrlRedirectRuleTest.php | 21 ++++-- .../Model/RedirectRulesDataTest.php | 66 ++++++++++++++++++ 6 files changed, 136 insertions(+), 48 deletions(-) create mode 100644 module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php diff --git a/composer.json b/composer.json index a09eafa0..65605957 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^3.0.2", + "doctrine/dbal": "^4.0", "doctrine/migrations": "^3.6", "doctrine/orm": "^3.0", "endroid/qr-code": "^5.0", diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index e5f0bae7..72cfdf49 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -7,6 +7,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; @@ -39,6 +40,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::DEVICE, $device->value); } + public static function fromRawData(array $rawData): self + { + $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); + $value = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; + $key = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_KEY] ?? null; + + return new self($type, $value, $key); + } + /** * Tells if this condition matches provided request */ diff --git a/module/Core/src/RedirectRule/Model/RedirectRulesData.php b/module/Core/src/RedirectRule/Model/RedirectRulesData.php index 6eb7dada..cf660ccd 100644 --- a/module/Core/src/RedirectRule/Model/RedirectRulesData.php +++ b/module/Core/src/RedirectRule/Model/RedirectRulesData.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\RedirectRule\Model; +use Laminas\InputFilter\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; @@ -15,11 +16,17 @@ readonly class RedirectRulesData public static function fromRawData(array $rawData): self { - $inputFilter = RedirectRulesInputFilter::initialize($rawData); - if (! $inputFilter->isValid()) { - throw ValidationException::fromInputFilter($inputFilter); - } + try { + $inputFilter = RedirectRulesInputFilter::initialize($rawData); + if (! $inputFilter->isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } - return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)); + return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)); + } catch (InvalidArgumentException) { + throw ValidationException::fromArray( + [RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES], + ); + } } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index b3ad1f07..89f61c84 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -4,10 +4,8 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; -use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; -use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -36,46 +34,39 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic */ public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array { - return $this->em->wrapInTransaction(function () use ($shortUrl, $data): array { - // First, delete existing rules for the short URL - $oldRules = $this->rulesForShortUrl($shortUrl); - foreach ($oldRules as $oldRule) { - $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions - $this->em->remove($oldRule); - } - $this->em->flush(); - - // Then insert new rules - $rules = []; - foreach ($data->rules as $rule) { - $rule = new ShortUrlRedirectRule( - shortUrl: $shortUrl, - priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY], - longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], - conditions: new ArrayCollection(array_map( - fn (array $conditionData) => $this->createCondition($conditionData), - $rule[RedirectRulesInputFilter::RULE_CONDITIONS], - )), - ); - - $rules[] = $rule; - $this->em->persist($rule); - } - - return $rules; - }); + return $this->em->wrapInTransaction(fn () => $this->doSetRulesForShortUrl($shortUrl, $data)); } - private function createCondition(array $rawConditionData): RedirectCondition + /** + * @return ShortUrlRedirectRule[] + */ + private function doSetRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array { - $type = RedirectConditionType::from($rawConditionData[RedirectRulesInputFilter::CONDITION_TYPE]); - $value = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; - $key = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_KEY]; + // First, delete existing rules for the short URL + $oldRules = $this->rulesForShortUrl($shortUrl); + foreach ($oldRules as $oldRule) { + $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions + $this->em->remove($oldRule); + } + $this->em->flush(); - return match ($type) { - RedirectConditionType::DEVICE => RedirectCondition::forDevice(DeviceType::from($value)), - RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage($value), - RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam($key, $value), - }; + // Then insert new rules + $rules = []; + foreach ($data->rules as $rule) { + $rule = new ShortUrlRedirectRule( + shortUrl: $shortUrl, + priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY], + longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], + conditions: new ArrayCollection(array_map( + RedirectCondition::fromRawData(...), + $rule[RedirectRulesInputFilter::RULE_CONDITIONS], + )), + ); + + $rules[] = $rule; + $this->em->persist($rule); + } + + return $rules; } } diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php index 244e4f2f..19e431db 100644 --- a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -20,7 +20,7 @@ class ShortUrlRedirectRuleTest extends TestCase ->withHeader('Accept-Language', 'en-UK') ->withQueryParams(['foo' => 'bar']); - $result = $this->createRule($conditions)->matchesRequest($request); + $result = $this->createRule(new ArrayCollection($conditions))->matchesRequest($request); self::assertEquals($expectedResult, $result); } @@ -38,12 +38,25 @@ class ShortUrlRedirectRuleTest extends TestCase ]; } + #[Test] + public function conditionsCanBeCleared(): void + { + $conditions = new ArrayCollection( + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + ); + $rule = $this->createRule($conditions); + + self::assertNotEmpty($conditions); + $rule->clearConditions(); + self::assertEmpty($conditions); + } + /** - * @param RedirectCondition[] $conditions + * @param ArrayCollection $conditions */ - private function createRule(array $conditions): ShortUrlRedirectRule + private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule { $shortUrl = ShortUrl::withLongUrl('https://s.test'); - return new ShortUrlRedirectRule($shortUrl, 1, '', new ArrayCollection($conditions)); + return new ShortUrlRedirectRule($shortUrl, 1, '', $conditions); } } diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php new file mode 100644 index 00000000..835905ad --- /dev/null +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -0,0 +1,66 @@ + ['foo']]])] + #[TestWith([['redirectRules' => [ + ['priority' => 'foo'], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'priority' => 4, + 'longUrl' => 34, + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'priority' => 4, + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'invalid', + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'priority' => 4, + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'device', + 'matchValue' => 'invalid-device', + 'matchKey' => null, + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'priority' => 4, + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'language', + ], + ], + ], + ]]])] + public function throwsWhenProvidedDataIsInvalid(array $invalidData): void + { + $this->expectException(ValidationException::class); + RedirectRulesData::fromRawData($invalidData); + } +} From f700abd65d9c474ff5a1346f9fa2695cd9532dd9 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Feb 2024 19:55:34 +0100 Subject: [PATCH 81/97] Add tests for ShortUrlRedirectionRuleService::setRulesForShortUrl --- .../RedirectRule/Model/RedirectRulesData.php | 4 +- .../Validation/RedirectRulesInputFilter.php | 3 - .../ShortUrlRedirectRuleService.php | 4 +- .../Model/RedirectRulesDataTest.php | 7 -- .../ShortUrlRedirectRuleServiceTest.php | 82 ++++++++++++++++++- 5 files changed, 86 insertions(+), 14 deletions(-) diff --git a/module/Core/src/RedirectRule/Model/RedirectRulesData.php b/module/Core/src/RedirectRule/Model/RedirectRulesData.php index cf660ccd..d9a9db18 100644 --- a/module/Core/src/RedirectRule/Model/RedirectRulesData.php +++ b/module/Core/src/RedirectRule/Model/RedirectRulesData.php @@ -8,6 +8,8 @@ use Laminas\InputFilter\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; +use function array_values; + readonly class RedirectRulesData { private function __construct(public array $rules) @@ -22,7 +24,7 @@ readonly class RedirectRulesData throw ValidationException::fromInputFilter($inputFilter); } - return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)); + return new self(array_values($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES))); } catch (InvalidArgumentException) { throw ValidationException::fromArray( [RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES], diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php index 745b8914..42b83c76 100644 --- a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -20,7 +20,6 @@ class RedirectRulesInputFilter extends InputFilter { public const REDIRECT_RULES = 'redirectRules'; - public const RULE_PRIORITY = 'priority'; public const RULE_LONG_URL = 'longUrl'; public const RULE_CONDITIONS = 'conditions'; @@ -48,8 +47,6 @@ class RedirectRulesInputFilter extends InputFilter { $redirectRuleInputFilter = new InputFilter(); - $redirectRuleInputFilter->add(InputFactory::numeric(self::RULE_PRIORITY, required: true)); - $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); $longUrl->setValidatorChain(ShortUrlInputFilter::longUrlValidators()); $redirectRuleInputFilter->add($longUrl); diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 89f61c84..1a770ae9 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -52,10 +52,10 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic // Then insert new rules $rules = []; - foreach ($data->rules as $rule) { + foreach ($data->rules as $index => $rule) { $rule = new ShortUrlRedirectRule( shortUrl: $shortUrl, - priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY], + priority: $index + 1, longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], conditions: new ArrayCollection(array_map( RedirectCondition::fromRawData(...), diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php index 835905ad..f0ded32b 100644 --- a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -14,18 +14,13 @@ class RedirectRulesDataTest extends TestCase { #[Test] #[TestWith([['redirectRules' => ['foo']]])] - #[TestWith([['redirectRules' => [ - ['priority' => 'foo'], - ]]])] #[TestWith([['redirectRules' => [ [ - 'priority' => 4, 'longUrl' => 34, ], ]]])] #[TestWith([['redirectRules' => [ [ - 'priority' => 4, 'longUrl' => 'https://example.com', 'conditions' => [ [ @@ -36,7 +31,6 @@ class RedirectRulesDataTest extends TestCase ]]])] #[TestWith([['redirectRules' => [ [ - 'priority' => 4, 'longUrl' => 'https://example.com', 'conditions' => [ [ @@ -49,7 +43,6 @@ class RedirectRulesDataTest extends TestCase ]]])] #[TestWith([['redirectRules' => [ [ - 'priority' => 4, 'longUrl' => 'https://example.com', 'conditions' => [ [ diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php index 016c5453..b0b6d4f2 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -11,6 +11,9 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -26,7 +29,7 @@ class ShortUrlRedirectRuleServiceTest extends TestCase } #[Test] - public function delegatesToRepository(): void + public function rulesForShortUrlDelegatesToRepository(): void { $shortUrl = ShortUrl::withLongUrl('https://shlink.io'); $rules = [ @@ -52,4 +55,81 @@ class ShortUrlRedirectRuleServiceTest extends TestCase self::assertSame($rules, $result); } + + #[Test] + public function setRulesForShortUrlParsesProvidedData(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [ + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/first', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::ANDROID->value, + ], + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::QUERY_PARAM->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => 'foo', + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => 'bar', + ], + ], + ], + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/second', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::IOS->value, + ], + ], + ], + ], + ]); + + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->exactly(2))->method('persist'); + $this->em->expects($this->never())->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(2, $result); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]); + } + + #[Test] + public function setRulesForShortUrlRemovesOldRules(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [], + ]); + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn([ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com'), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com'), + ]); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->exactly(2))->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(0, $result); + } } From 8f233221e5da9ba619e114501db083450f34e6bf Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Feb 2024 20:14:15 +0100 Subject: [PATCH 82/97] Add SetRedirectRulesAction unit test --- .../Validation/RedirectRulesInputFilter.php | 2 +- .../SetRedirectRulesActionTest.php | 58 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php index 42b83c76..5decaf4c 100644 --- a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -48,7 +48,7 @@ class RedirectRulesInputFilter extends InputFilter $redirectRuleInputFilter = new InputFilter(); $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); - $longUrl->setValidatorChain(ShortUrlInputFilter::longUrlValidators()); + $longUrl->getValidatorChain()->merge(ShortUrlInputFilter::longUrlValidators()); $redirectRuleInputFilter->add($longUrl); $conditionsInputFilter = new CollectionInputFilter(); diff --git a/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php new file mode 100644 index 00000000..e330839c --- /dev/null +++ b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php @@ -0,0 +1,58 @@ +urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + + $this->action = new SetRedirectRulesAction($this->urlResolver, $this->ruleService); + } + + #[Test] + public function requestIsHandledAndRulesAreReturned(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); + $conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')]; + $redirectRules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)), + ]; + + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('setRulesForShortUrl')->willReturn($redirectRules); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + $payload = $response->getPayload(); + + self::assertEquals([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $redirectRules, + ], $payload); + } +} From 7f560e6a656e2194500d821ede6bdcae0e14b27a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 29 Feb 2024 20:20:04 +0100 Subject: [PATCH 83/97] Add SetRedirectRulesAction API test --- .../test-api/Action/SetRedirectRulesTest.php | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 module/Rest/test-api/Action/SetRedirectRulesTest.php diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php new file mode 100644 index 00000000..c70fd0ea --- /dev/null +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -0,0 +1,98 @@ + 'language', + 'matchKey' => null, + 'matchValue' => 'en', + ]; + private const QUERY_FOO_BAR_CONDITION = [ + 'type' => 'query', + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ]; + + #[Test] + public function errorIsReturnedWhenInvalidUrlProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules'); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(404, $response->getStatusCode()); + self::assertEquals(404, $payload['status']); + self::assertEquals('invalid', $payload['shortCode']); + self::assertEquals('No URL found with short code "invalid"', $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + #[Test] + public function errorIsReturnedWhenInvalidDataProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [ + RequestOptions::JSON => [ + 'redirectRules' => [ + [ + 'longUrl' => 'invalid', + ], + ], + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(400, $response->getStatusCode()); + self::assertEquals(400, $payload['status']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); + } + + #[Test] + #[TestWith(['def456', []])] + #[TestWith(['abc123', [ + [ + 'longUrl' => 'https://example.com/english-and-foo-query', + 'priority' => 1, + 'conditions' => [ + self::LANGUAGE_EN_CONDITION, + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/multiple-query-params', + 'priority' => 2, + 'conditions' => [ + [ + 'type' => 'query', + 'matchKey' => 'hello', + 'matchValue' => 'world', + ], + self::QUERY_FOO_BAR_CONDITION, + ], + ], + ]])] + public function setsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void + { + $response = $this->callApiWithKey(self::METHOD_POST, sprintf('/short-urls/%s/redirect-rules', $shortCode), [ + RequestOptions::JSON => [ + 'redirectRules' => $expectedRules, + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expectedRules, $payload['redirectRules']); + } +} From 52150b30679647f4dd47bd5417d75144de911156 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 1 Mar 2024 08:56:03 +0100 Subject: [PATCH 84/97] Define different swagger models for get and set redirect rules --- .../definitions/SetShortUrlRedirectRule.json | 31 ++++++++++++ .../definitions/ShortUrlRedirectRule.json | 32 ++----------- ...short-urls_{shortCode}_redirect-rules.json | 48 ++++++++++++++++++- 3 files changed, 82 insertions(+), 29 deletions(-) create mode 100644 docs/swagger/definitions/SetShortUrlRedirectRule.json diff --git a/docs/swagger/definitions/SetShortUrlRedirectRule.json b/docs/swagger/definitions/SetShortUrlRedirectRule.json new file mode 100644 index 00000000..fd794712 --- /dev/null +++ b/docs/swagger/definitions/SetShortUrlRedirectRule.json @@ -0,0 +1,31 @@ +{ + "type": "object", + "required": ["longUrl", "conditions"], + "properties": { + "longUrl": { + "description": "Long URL to redirect to when this condition matches", + "type": "string" + }, + "conditions": { + "description": "List of conditions that need to match in order to consider this rule matches", + "type": "array", + "items": { + "type": "object", + "required": ["type", "matchKey", "matchValue"], + "properties": { + "type": { + "type": "string", + "enum": ["device", "language", "query"], + "description": "The type of the condition, which will condition the logic used to match it" + }, + "matchKey": { + "type": ["string", "null"] + }, + "matchValue": { + "type": "string" + } + } + } + } + } +} diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json index 8fde6e90..40a478fd 100644 --- a/docs/swagger/definitions/ShortUrlRedirectRule.json +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -1,35 +1,13 @@ { "type": "object", - "required": ["priority", "longUrl", "conditions"], + "required": ["priority"], "properties": { - "longUrl": { - "description": "Long URL to redirect to when this condition matches", - "type": "string" - }, "priority": { "description": "Order in which attempting to match the rule. Lower goes first", "type": "number" - }, - "conditions": { - "description": "List of conditions that need to match in order to consider this rule matches", - "type": "array", - "items": { - "type": "object", - "required": ["type", "matchKey", "matchValue"], - "properties": { - "type": { - "type": "string", - "enum": ["device", "language", "query"], - "description": "The type of the condition, which will condition the logic used to match it" - }, - "matchKey": { - "type": ["string", "null"] - }, - "matchValue": { - "type": "string" - } - } - } } - } + }, + "allOf": [{ + "$ref": "./SetShortUrlRedirectRule.json" + }] } diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index cd2904d4..b87e26cb 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -145,7 +145,7 @@ "Redirect rules" ], "summary": "Set short URL redirect rules", - "description": "Overwrites redirect rules for a short URL with the ones provided here.", + "description": "Sets redirect rules for a short URL, with priorities matching the order in which they are provided.", "parameters": [ { "$ref": "../parameters/version.json" @@ -173,10 +173,54 @@ "redirectRules": { "type": "array", "items": { - "$ref": "../definitions/ShortUrlRedirectRule.json" + "$ref": "../definitions/SetShortUrlRedirectRule.json" } } } + }, + "example": { + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] } } } From d8ede3263fd6fd32e9ee43c7f5321fc3632ef274 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 2 Mar 2024 22:44:22 +0100 Subject: [PATCH 85/97] Implement command to manage redirect rules for a short URL --- config/autoload/rabbit.local.php.dist | 1 + config/test/test_config.global.php | 2 +- docker-compose.yml | 6 +- module/CLI/config/cli.config.php | 3 + module/CLI/config/dependencies.config.php | 8 + .../ManageRedirectRulesCommand.php | 272 ++++++++++++++++++ module/Core/functions/array-utils.php | 17 ++ .../RedirectRule/Entity/RedirectCondition.php | 14 + .../Entity/ShortUrlRedirectRule.php | 12 +- .../Model/RedirectConditionType.php | 2 +- .../ShortUrlRedirectRuleService.php | 40 +-- .../ShortUrlRedirectRuleServiceInterface.php | 5 + .../Model/Validation/ShortUrlInputFilter.php | 3 + .../test-api/Action/ListRedirectRulesTest.php | 4 +- .../test-api/Action/SetRedirectRulesTest.php | 4 +- 15 files changed, 365 insertions(+), 28 deletions(-) create mode 100644 module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php diff --git a/config/autoload/rabbit.local.php.dist b/config/autoload/rabbit.local.php.dist index 83cd4a88..b758528e 100644 --- a/config/autoload/rabbit.local.php.dist +++ b/config/autoload/rabbit.local.php.dist @@ -7,6 +7,7 @@ return [ 'rabbitmq' => [ 'enabled' => true, 'host' => 'shlink_rabbitmq', + 'port' => '5673', 'user' => 'rabbit', 'password' => 'rabbit', ], diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 55f06bbf..aad5e9d0 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -52,7 +52,7 @@ $buildDbConnection = static function (): array { 'postgres' => [ 'driver' => 'pdo_pgsql', 'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres', - 'port' => $isCi ? '5433' : '5432', + 'port' => $isCi ? '5434' : '5432', 'user' => 'postgres', 'password' => 'root', 'dbname' => 'shlink_test', diff --git a/docker-compose.yml b/docker-compose.yml index 3f65e4bb..ccc5fc2d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,7 +81,7 @@ services: container_name: shlink_db_postgres image: postgres:12.2-alpine ports: - - "5433:5432" + - "5434:5432" volumes: - ./:/home/shlink/www - ./data/infra/database_pg:/var/lib/postgresql/data @@ -153,8 +153,8 @@ services: container_name: shlink_rabbitmq image: rabbitmq:3.11-management-alpine ports: - - "15672:15672" - - "5672:5672" + - "15673:15672" + - "5673:5672" environment: RABBITMQ_DEFAULT_USER: "rabbit" RABBITMQ_DEFAULT_PASS: "rabbit" diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index bcd4fd3c..94237c15 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -37,6 +37,9 @@ return [ Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, + + Command\RedirectRule\ManageRedirectRulesCommand::NAME => + Command\RedirectRule\ManageRedirectRulesCommand::class, ], ], diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 2736a21e..c2b61e19 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; +use Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Tag\TagService; @@ -66,6 +67,8 @@ return [ Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, + + Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class, ], ], @@ -117,6 +120,11 @@ return [ Command\Domain\DomainRedirectsCommand::class => [DomainService::class], Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], + Command\RedirectRule\ManageRedirectRulesCommand::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], + Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, Util\ProcessRunner::class, diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php new file mode 100644 index 00000000..84741bbe --- /dev/null +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -0,0 +1,272 @@ +setName(self::NAME) + ->setDescription('Set redirect rules for a short URL') + ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which rules we want to set.') + ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $identifier = ShortUrlIdentifier::fromCli($input); + + try { + $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier); + } catch (ShortUrlNotFoundException) { + $io->error(sprintf('Short URL for %s not found', $identifier->__toString())); + return ExitCode::EXIT_FAILURE; + } + + $rulesToSave = $this->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl)); + if ($rulesToSave !== null) { + $this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave); + } + + return ExitCode::EXIT_SUCCESS; + } + + /** + * @param ShortUrlRedirectRule[] $rules + * @return ShortUrlRedirectRule[]|null + */ + private function processRules(ShortUrl $shortUrl, SymfonyStyle $io, array $rules): ?array + { + $amountOfRules = count($rules); + + if ($amountOfRules === 0) { + $io->comment('No rules found.'); + } else { + $listing = map( + $rules, + function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array { + $priority = ((int) $index) + 1; + $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf( + '%s', + $condition->toHumanFriendly(), + )); + + return [ + str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT), + implode(' AND ', $conditions), + $rule->longUrl, + ]; + }, + ); + $io->table(['Priority', 'Conditions', 'Redirect to'], $listing); + } + + $action = $io->choice( + 'What do you want to do next?', + [ + 'Add new rule', + 'Remove existing rule', + 'Re-arrange rule', + 'Discard changes', + 'Save and exit', + ], + 'Save and exit', + ); + + return match ($action) { + 'Add new rule' => $this->processRules($shortUrl, $io, $this->addRule($shortUrl, $io, $rules)), + 'Remove existing rule' => $this->processRules($shortUrl, $io, $this->removeRule($io, $rules)), + 'Re-arrange rule' => $this->processRules($shortUrl, $io, $this->reArrangeRule($io, $rules)), + 'Save and exit' => $rules, + default => null, + }; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function addRule(ShortUrl $shortUrl, SymfonyStyle $io, array $currentRules): array + { + $higherPriority = count($currentRules); + $priority = $this->askPriority($io, $higherPriority + 1); + $longUrl = $this->askLongUrl($io); + $conditions = []; + + do { + $type = RedirectConditionType::from( + $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)), + ); + $conditions[] = match ($type) { + RedirectConditionType::DEVICE => RedirectCondition::forDevice( + DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))), + ), + RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage( + $this->askMandatory('Language to match?', $io), + ), + RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam( + $this->askMandatory('Query param name?', $io), + $this->askOptional('Query param value?', $io), + ), + }; + + $continue = $io->confirm('Do you want to add another condition?'); + } while ($continue); + + $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions)); + $rulesBefore = array_slice($currentRules, 0, $priority - 1); + $rulesAfter = array_slice($currentRules, $priority - 1); + + return [...$rulesBefore, $newRule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function removeRule(SymfonyStyle $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to remove'); + return $currentRules; + } + + $index = $this->askRule('What rule do you want to delete?', $io, $currentRules); + unset($currentRules[$index]); + return array_values($currentRules); + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function reArrangeRule(SymfonyStyle $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to re-arrange'); + return $currentRules; + } + + $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules); + $newIndex = $this->askPriority($io, count($currentRules)) - 1; + + // Temporarily get rule from array and unset it + $rule = $currentRules[$oldIndex]; + unset($currentRules[$oldIndex]); + + // Reindex remaining rules + $currentRules = array_values($currentRules); + + $rulesBefore = array_slice($currentRules, 0, $newIndex); + $rulesAfter = array_slice($currentRules, $newIndex); + + return [...$rulesBefore, $rule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function askRule(string $message, SymfonyStyle $io, array $currentRules): int + { + $choices = []; + foreach ($currentRules as $index => $rule) { + $choices[$rule->longUrl] = $index + 1; + } + + $resp = $io->choice($message, array_flip($choices)); + return $choices[$resp] - 1; + } + + private function askPriority(SymfonyStyle $io, int $max): int + { + return $io->ask( + 'Rule priority (the lower the value, the higher the priority)', + (string) $max, + function (string $answer) use ($max): int { + if (! is_numeric($answer)) { + throw new InvalidArgumentException('The priority must be a numeric positive value'); + } + + $priority = (int) $answer; + return max(1, min($max, $priority)); + }, + ); + } + + private function askLongUrl(SymfonyStyle $io): string + { + return $io->ask( + 'Long URL to redirect when the rule matches', + validator: function (string $answer): string { + $validator = ShortUrlInputFilter::longUrlValidators(); + if (! $validator->isValid($answer)) { + throw new InvalidArgumentException(implode(', ', $validator->getMessages())); + } + + return $answer; + }, + ); + } + + private function askMandatory(string $message, SymfonyStyle $io): string + { + return $io->ask($message, validator: function (?string $answer): string { + if ($answer === null) { + throw new InvalidArgumentException('The value is mandatory'); + } + return trim($answer); + }); + } + + private function askOptional(string $message, SymfonyStyle $io): string + { + return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); + } +} diff --git a/module/Core/functions/array-utils.php b/module/Core/functions/array-utils.php index 5fb636e6..7b9ca7e5 100644 --- a/module/Core/functions/array-utils.php +++ b/module/Core/functions/array-utils.php @@ -72,3 +72,20 @@ function select_keys(array $array, array $keys): array ARRAY_FILTER_USE_KEY, ); } + +/** + * @template T + * @template R + * @param iterable $collection + * @param callable(T $value, string|number $key): R $callback + * @return R[] + */ +function map(iterable $collection, callable $callback): array +{ + $aggregation = []; + foreach ($collection as $key => $value) { + $aggregation[$key] = $callback($value, $key); + } + + return $aggregation; +} diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index 72cfdf49..29123733 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -13,6 +13,7 @@ use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; use function Shlinkio\Shlink\Core\normalizeLocale; use function Shlinkio\Shlink\Core\splitLocale; +use function sprintf; use function strtolower; use function trim; @@ -107,4 +108,17 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable 'matchValue' => $this->matchValue, ]; } + + public function toHumanFriendly(): string + { + return match ($this->type) { + RedirectConditionType::DEVICE => sprintf('device is %s', $this->matchValue), + RedirectConditionType::LANGUAGE => sprintf('%s language is accepted', $this->matchValue), + RedirectConditionType::QUERY_PARAM => sprintf( + 'query string contains %s=%s', + $this->matchKey, + $this->matchValue, + ), + }; + } } diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 4469a620..57ad7092 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\every; class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable { /** - * @param Collection $conditions + * @param Collection $conditions */ public function __construct( private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine @@ -41,6 +41,16 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable $this->conditions->clear(); } + /** + * @template R + * @param callable(RedirectCondition $condition): R $callback + * @return R[] + */ + public function mapConditions(callable $callback): array + { + return $this->conditions->map($callback(...))->toArray(); + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/RedirectRule/Model/RedirectConditionType.php b/module/Core/src/RedirectRule/Model/RedirectConditionType.php index 51076068..c00cca7f 100644 --- a/module/Core/src/RedirectRule/Model/RedirectConditionType.php +++ b/module/Core/src/RedirectRule/Model/RedirectConditionType.php @@ -6,5 +6,5 @@ enum RedirectConditionType: string { case DEVICE = 'device'; case LANGUAGE = 'language'; - case QUERY_PARAM = 'query'; + case QUERY_PARAM = 'query-param'; } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 1a770ae9..40bbb0de 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -34,23 +34,6 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic */ public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array { - return $this->em->wrapInTransaction(fn () => $this->doSetRulesForShortUrl($shortUrl, $data)); - } - - /** - * @return ShortUrlRedirectRule[] - */ - private function doSetRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array - { - // First, delete existing rules for the short URL - $oldRules = $this->rulesForShortUrl($shortUrl); - foreach ($oldRules as $oldRule) { - $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions - $this->em->remove($oldRule); - } - $this->em->flush(); - - // Then insert new rules $rules = []; foreach ($data->rules as $index => $rule) { $rule = new ShortUrlRedirectRule( @@ -64,9 +47,30 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic ); $rules[] = $rule; - $this->em->persist($rule); } + $this->saveRulesForShortUrl($shortUrl, $rules); return $rules; } + + /** + * @param ShortUrlRedirectRule[] $rules + */ + public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void + { + $this->em->wrapInTransaction(function () use ($shortUrl, $rules): void { + // First, delete existing rules for the short URL + $oldRules = $this->rulesForShortUrl($shortUrl); + foreach ($oldRules as $oldRule) { + $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions + $this->em->remove($oldRule); + } + $this->em->flush(); + + // Then insert new rules + foreach ($rules as $rule) { + $this->em->persist($rule); + } + }); + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php index 7fc34a1b..186be87e 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -17,4 +17,9 @@ interface ShortUrlRedirectRuleServiceInterface * @return ShortUrlRedirectRule[] */ public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array; + + /** + * @param ShortUrlRedirectRule[] $rules + */ + public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void; } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 22000e2c..e8d35284 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -124,6 +124,9 @@ class ShortUrlInputFilter extends InputFilter $this->add($apiKeyInput); } + /** + * @todo Extract to its own validator class + */ public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain { $emptyModifiers = [ diff --git a/module/Rest/test-api/Action/ListRedirectRulesTest.php b/module/Rest/test-api/Action/ListRedirectRulesTest.php index b86683c9..c53986c1 100644 --- a/module/Rest/test-api/Action/ListRedirectRulesTest.php +++ b/module/Rest/test-api/Action/ListRedirectRulesTest.php @@ -18,7 +18,7 @@ class ListRedirectRulesTest extends ApiTestCase 'matchValue' => 'en', ]; private const QUERY_FOO_BAR_CONDITION = [ - 'type' => 'query', + 'type' => 'query-param', 'matchKey' => 'foo', 'matchValue' => 'bar', ]; @@ -53,7 +53,7 @@ class ListRedirectRulesTest extends ApiTestCase 'priority' => 2, 'conditions' => [ [ - 'type' => 'query', + 'type' => 'query-param', 'matchKey' => 'hello', 'matchValue' => 'world', ], diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php index c70fd0ea..a1172d65 100644 --- a/module/Rest/test-api/Action/SetRedirectRulesTest.php +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -19,7 +19,7 @@ class SetRedirectRulesTest extends ApiTestCase 'matchValue' => 'en', ]; private const QUERY_FOO_BAR_CONDITION = [ - 'type' => 'query', + 'type' => 'query-param', 'matchKey' => 'foo', 'matchValue' => 'bar', ]; @@ -75,7 +75,7 @@ class SetRedirectRulesTest extends ApiTestCase 'priority' => 2, 'conditions' => [ [ - 'type' => 'query', + 'type' => 'query-param', 'matchKey' => 'hello', 'matchValue' => 'world', ], From 3bfb29a51c0b4e2cd2635a069cf96a7e9be3d8f4 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 08:47:31 +0100 Subject: [PATCH 86/97] Test new methods for RedirectCondition and ShortUrlRedirectRule --- .../Entity/ShortUrlRedirectRuleTest.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php index 19e431db..15df256f 100644 --- a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -6,9 +6,11 @@ use Doctrine\Common\Collections\ArrayCollection; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; class ShortUrlRedirectRuleTest extends TestCase @@ -51,6 +53,39 @@ class ShortUrlRedirectRuleTest extends TestCase self::assertEmpty($conditions); } + #[Test, DataProvider('provideConditionMappingCallbacks')] + public function conditionsCanBeMapped(callable $callback, array $expectedResult): void + { + $conditions = new ArrayCollection( + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + ); + $rule = $this->createRule($conditions); + + $result = $rule->mapConditions($callback); + + self::assertEquals($expectedResult, $result); + } + + public static function provideConditionMappingCallbacks(): iterable + { + yield 'json-serialized conditions' => [fn (RedirectCondition $cond) => $cond->jsonSerialize(), [ + [ + 'type' => RedirectConditionType::LANGUAGE->value, + 'matchKey' => null, + 'matchValue' => 'en-UK', + ], + [ + 'type' => RedirectConditionType::QUERY_PARAM->value, + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ], + ]]; + yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [ + 'en-UK language is accepted', + 'query string contains foo=bar', + ]]; + } + /** * @param ArrayCollection $conditions */ From a843c59d779e62aea2d4ef0cb92acb751ff3d070 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 09:09:19 +0100 Subject: [PATCH 87/97] Fix inconsistencies when editing rules and saving a mix of new and old ones --- .../ManageRedirectRulesCommand.php | 1 + .../Entity/ShortUrlRedirectRule.php | 10 ++++++++++ .../ShortUrlRedirectRuleService.php | 20 ++++++++++++++++++- .../Entity/ShortUrlRedirectRuleTest.php | 1 - 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php index 84741bbe..ee8dc328 100644 --- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -76,6 +76,7 @@ class ManageRedirectRulesCommand extends Command $rulesToSave = $this->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl)); if ($rulesToSave !== null) { $this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave); + $io->success('Rules properly saved'); } return ExitCode::EXIT_SUCCESS; diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 57ad7092..5f76d998 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -25,6 +25,16 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable ) { } + public function withPriority(int $newPriority): self + { + return new self( + $this->shortUrl, + $newPriority, + $this->longUrl, + $this->conditions, + ); + } + /** * Tells if this condition matches provided request */ diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 40bbb0de..01ba0a8f 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use function array_map; +use function Shlinkio\Shlink\Core\ArrayUtils\map; readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface { @@ -49,7 +50,7 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic $rules[] = $rule; } - $this->saveRulesForShortUrl($shortUrl, $rules); + $this->doSetRulesForShortUrl($shortUrl, $rules); return $rules; } @@ -57,6 +58,23 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic * @param ShortUrlRedirectRule[] $rules */ public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void + { + $normalizedAndDetachedRules = map($rules, function (ShortUrlRedirectRule $rule, int|string|float $priority) { + // Make sure all rules and conditions are detached so that the EM considers them new. + $rule->mapConditions(fn (RedirectCondition $cond) => $this->em->detach($cond)); + $this->em->detach($rule); + + // Normalize priorities so that they are sequential + return $rule->withPriority(((int) $priority) + 1); + }); + + $this->doSetRulesForShortUrl($shortUrl, $normalizedAndDetachedRules); + } + + /** + * @param ShortUrlRedirectRule[] $rules + */ + public function doSetRulesForShortUrl(ShortUrl $shortUrl, array $rules): void { $this->em->wrapInTransaction(function () use ($shortUrl, $rules): void { // First, delete existing rules for the short URL diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php index 15df256f..d61bc6fa 100644 --- a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -6,7 +6,6 @@ use Doctrine\Common\Collections\ArrayCollection; use Laminas\Diactoros\ServerRequestFactory; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\TestWith; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; From a45550b0c60fba317e638cceaaeab2b65c5c35f0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 09:48:56 +0100 Subject: [PATCH 88/97] Extract logic to determine list of rules from ManageRedirectRulesCommand to a helper service --- module/CLI/config/dependencies.config.php | 6 +- .../ManageRedirectRulesCommand.php | 216 +---------------- module/CLI/src/Input/DateOption.php | 10 +- .../src/RedirectRule/RedirectRuleHandler.php | 221 ++++++++++++++++++ .../RedirectRuleHandlerInterface.php | 20 ++ 5 files changed, 251 insertions(+), 222 deletions(-) create mode 100644 module/CLI/src/RedirectRule/RedirectRuleHandler.php create mode 100644 module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index c2b61e19..0c709788 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,7 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory; use Shlinkio\Shlink\Core\Domain\DomainService; use Shlinkio\Shlink\Core\Options\TrackingOptions; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; -use Shlinkio\Shlink\Core\RedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; use Shlinkio\Shlink\Core\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\Tag\TagService; @@ -34,6 +34,7 @@ return [ PhpExecutableFinder::class => InvokableFactory::class, GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class, + RedirectRule\RedirectRuleHandler::class => InvokableFactory::class, Util\ProcessRunner::class => ConfigAbstractFactory::class, ApiKey\RoleResolver::class => ConfigAbstractFactory::class, @@ -122,7 +123,8 @@ return [ Command\RedirectRule\ManageRedirectRulesCommand::class => [ ShortUrl\ShortUrlResolver::class, - RedirectRule\ShortUrlRedirectRuleService::class, + ShortUrlRedirectRuleService::class, + RedirectRule\RedirectRuleHandler::class, ], Command\Db\CreateDatabaseCommand::class => [ diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php index ee8dc328..e36fcf59 100644 --- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -4,18 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\RedirectRule; -use Doctrine\Common\Collections\ArrayCollection; +use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface; use Shlinkio\Shlink\CLI\Util\ExitCode; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\Model\DeviceType; -use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; -use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; -use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; -use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; -use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputArgument; @@ -24,22 +17,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use function array_flip; -use function array_slice; -use function array_values; -use function count; -use function implode; -use function is_numeric; -use function max; -use function min; -use function Shlinkio\Shlink\Core\ArrayUtils\map; -use function Shlinkio\Shlink\Core\enumValues; use function sprintf; -use function str_pad; -use function strlen; -use function trim; - -use const STR_PAD_LEFT; class ManageRedirectRulesCommand extends Command { @@ -48,6 +26,7 @@ class ManageRedirectRulesCommand extends Command public function __construct( protected readonly ShortUrlResolverInterface $shortUrlResolver, protected readonly ShortUrlRedirectRuleServiceInterface $ruleService, + protected readonly RedirectRuleHandlerInterface $ruleHandler, ) { parent::__construct(); } @@ -73,7 +52,7 @@ class ManageRedirectRulesCommand extends Command return ExitCode::EXIT_FAILURE; } - $rulesToSave = $this->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl)); + $rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl)); if ($rulesToSave !== null) { $this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave); $io->success('Rules properly saved'); @@ -81,193 +60,4 @@ class ManageRedirectRulesCommand extends Command return ExitCode::EXIT_SUCCESS; } - - /** - * @param ShortUrlRedirectRule[] $rules - * @return ShortUrlRedirectRule[]|null - */ - private function processRules(ShortUrl $shortUrl, SymfonyStyle $io, array $rules): ?array - { - $amountOfRules = count($rules); - - if ($amountOfRules === 0) { - $io->comment('No rules found.'); - } else { - $listing = map( - $rules, - function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array { - $priority = ((int) $index) + 1; - $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf( - '%s', - $condition->toHumanFriendly(), - )); - - return [ - str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT), - implode(' AND ', $conditions), - $rule->longUrl, - ]; - }, - ); - $io->table(['Priority', 'Conditions', 'Redirect to'], $listing); - } - - $action = $io->choice( - 'What do you want to do next?', - [ - 'Add new rule', - 'Remove existing rule', - 'Re-arrange rule', - 'Discard changes', - 'Save and exit', - ], - 'Save and exit', - ); - - return match ($action) { - 'Add new rule' => $this->processRules($shortUrl, $io, $this->addRule($shortUrl, $io, $rules)), - 'Remove existing rule' => $this->processRules($shortUrl, $io, $this->removeRule($io, $rules)), - 'Re-arrange rule' => $this->processRules($shortUrl, $io, $this->reArrangeRule($io, $rules)), - 'Save and exit' => $rules, - default => null, - }; - } - - /** - * @param ShortUrlRedirectRule[] $currentRules - */ - private function addRule(ShortUrl $shortUrl, SymfonyStyle $io, array $currentRules): array - { - $higherPriority = count($currentRules); - $priority = $this->askPriority($io, $higherPriority + 1); - $longUrl = $this->askLongUrl($io); - $conditions = []; - - do { - $type = RedirectConditionType::from( - $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)), - ); - $conditions[] = match ($type) { - RedirectConditionType::DEVICE => RedirectCondition::forDevice( - DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))), - ), - RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage( - $this->askMandatory('Language to match?', $io), - ), - RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam( - $this->askMandatory('Query param name?', $io), - $this->askOptional('Query param value?', $io), - ), - }; - - $continue = $io->confirm('Do you want to add another condition?'); - } while ($continue); - - $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions)); - $rulesBefore = array_slice($currentRules, 0, $priority - 1); - $rulesAfter = array_slice($currentRules, $priority - 1); - - return [...$rulesBefore, $newRule, ...$rulesAfter]; - } - - /** - * @param ShortUrlRedirectRule[] $currentRules - */ - private function removeRule(SymfonyStyle $io, array $currentRules): array - { - if (empty($currentRules)) { - $io->warning('There are no rules to remove'); - return $currentRules; - } - - $index = $this->askRule('What rule do you want to delete?', $io, $currentRules); - unset($currentRules[$index]); - return array_values($currentRules); - } - - /** - * @param ShortUrlRedirectRule[] $currentRules - */ - private function reArrangeRule(SymfonyStyle $io, array $currentRules): array - { - if (empty($currentRules)) { - $io->warning('There are no rules to re-arrange'); - return $currentRules; - } - - $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules); - $newIndex = $this->askPriority($io, count($currentRules)) - 1; - - // Temporarily get rule from array and unset it - $rule = $currentRules[$oldIndex]; - unset($currentRules[$oldIndex]); - - // Reindex remaining rules - $currentRules = array_values($currentRules); - - $rulesBefore = array_slice($currentRules, 0, $newIndex); - $rulesAfter = array_slice($currentRules, $newIndex); - - return [...$rulesBefore, $rule, ...$rulesAfter]; - } - - /** - * @param ShortUrlRedirectRule[] $currentRules - */ - private function askRule(string $message, SymfonyStyle $io, array $currentRules): int - { - $choices = []; - foreach ($currentRules as $index => $rule) { - $choices[$rule->longUrl] = $index + 1; - } - - $resp = $io->choice($message, array_flip($choices)); - return $choices[$resp] - 1; - } - - private function askPriority(SymfonyStyle $io, int $max): int - { - return $io->ask( - 'Rule priority (the lower the value, the higher the priority)', - (string) $max, - function (string $answer) use ($max): int { - if (! is_numeric($answer)) { - throw new InvalidArgumentException('The priority must be a numeric positive value'); - } - - $priority = (int) $answer; - return max(1, min($max, $priority)); - }, - ); - } - - private function askLongUrl(SymfonyStyle $io): string - { - return $io->ask( - 'Long URL to redirect when the rule matches', - validator: function (string $answer): string { - $validator = ShortUrlInputFilter::longUrlValidators(); - if (! $validator->isValid($answer)) { - throw new InvalidArgumentException(implode(', ', $validator->getMessages())); - } - - return $answer; - }, - ); - } - - private function askMandatory(string $message, SymfonyStyle $io): string - { - return $io->ask($message, validator: function (?string $answer): string { - if ($answer === null) { - throw new InvalidArgumentException('The value is mandatory'); - } - return trim($answer); - }); - } - - private function askOptional(string $message, SymfonyStyle $io): string - { - return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); - } } diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 41407d23..6183a6c5 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -14,14 +14,10 @@ use Throwable; use function is_string; use function sprintf; -class DateOption +readonly class DateOption { - public function __construct( - private readonly Command $command, - private readonly string $name, - string $shortcut, - string $description, - ) { + public function __construct(private Command $command, private string $name, string $shortcut, string $description) + { $command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php new file mode 100644 index 00000000..8b1592b7 --- /dev/null +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -0,0 +1,221 @@ +newLine(); + $io->text('// No rules found.'); + } else { + $listing = map( + $rules, + function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array { + $priority = ((int) $index) + 1; + $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf( + '%s', + $condition->toHumanFriendly(), + )); + + return [ + str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT), + implode(' AND ', $conditions), + $rule->longUrl, + ]; + }, + ); + $io->table(['Priority', 'Conditions', 'Redirect to'], $listing); + } + + $action = $io->choice( + 'What do you want to do next?', + [ + 'Add new rule', + 'Remove existing rule', + 'Re-arrange rule', + 'Discard changes', + 'Save and exit', + ], + 'Save and exit', + ); + + return match ($action) { + 'Add new rule' => $this->manageRules($io, $shortUrl, $this->addRule($shortUrl, $io, $rules)), + 'Remove existing rule' => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)), + 'Re-arrange rule' => $this->manageRules($io, $shortUrl, $this->reArrangeRule($io, $rules)), + 'Save and exit' => $rules, + default => null, + }; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentRules): array + { + $higherPriority = count($currentRules); + $priority = $this->askPriority($io, $higherPriority + 1); + $longUrl = $this->askLongUrl($io); + $conditions = []; + + do { + $type = RedirectConditionType::from( + $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)), + ); + $conditions[] = match ($type) { + RedirectConditionType::DEVICE => RedirectCondition::forDevice( + DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))), + ), + RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage( + $this->askMandatory('Language to match?', $io), + ), + RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam( + $this->askMandatory('Query param name?', $io), + $this->askOptional('Query param value?', $io), + ), + }; + + $continue = $io->confirm('Do you want to add another condition?'); + } while ($continue); + + $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions)); + $rulesBefore = array_slice($currentRules, 0, $priority - 1); + $rulesAfter = array_slice($currentRules, $priority - 1); + + return [...$rulesBefore, $newRule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function removeRule(StyleInterface $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to remove'); + return $currentRules; + } + + $index = $this->askRule('What rule do you want to delete?', $io, $currentRules); + unset($currentRules[$index]); + return array_values($currentRules); + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function reArrangeRule(StyleInterface $io, array $currentRules): array + { + if (empty($currentRules)) { + $io->warning('There are no rules to re-arrange'); + return $currentRules; + } + + $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules); + $newIndex = $this->askPriority($io, count($currentRules)) - 1; + + // Temporarily get rule from array and unset it + $rule = $currentRules[$oldIndex]; + unset($currentRules[$oldIndex]); + + // Reindex remaining rules + $currentRules = array_values($currentRules); + + $rulesBefore = array_slice($currentRules, 0, $newIndex); + $rulesAfter = array_slice($currentRules, $newIndex); + + return [...$rulesBefore, $rule, ...$rulesAfter]; + } + + /** + * @param ShortUrlRedirectRule[] $currentRules + */ + private function askRule(string $message, StyleInterface $io, array $currentRules): int + { + $choices = []; + foreach ($currentRules as $index => $rule) { + $choices[$rule->longUrl] = $index + 1; + } + + $resp = $io->choice($message, array_flip($choices)); + return $choices[$resp] - 1; + } + + private function askPriority(StyleInterface $io, int $max): int + { + return $io->ask( + 'Rule priority (the lower the value, the higher the priority)', + (string) $max, + function (string $answer) use ($max): int { + if (! is_numeric($answer)) { + throw new InvalidArgumentException('The priority must be a numeric positive value'); + } + + $priority = (int) $answer; + return max(1, min($max, $priority)); + }, + ); + } + + private function askLongUrl(StyleInterface $io): string + { + return $io->ask( + 'Long URL to redirect when the rule matches', + validator: function (string $answer): string { + $validator = ShortUrlInputFilter::longUrlValidators(); + if (! $validator->isValid($answer)) { + throw new InvalidArgumentException(implode(', ', $validator->getMessages())); + } + + return $answer; + }, + ); + } + + private function askMandatory(string $message, StyleInterface $io): string + { + return $io->ask($message, validator: function (?string $answer): string { + if ($answer === null) { + throw new InvalidArgumentException('The value is mandatory'); + } + return trim($answer); + }); + } + + private function askOptional(string $message, StyleInterface $io): string + { + return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer)); + } +} diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php new file mode 100644 index 00000000..16022768 --- /dev/null +++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php @@ -0,0 +1,20 @@ + Date: Sun, 3 Mar 2024 10:10:39 +0100 Subject: [PATCH 89/97] Reduce duplicated code when parsing short codes and domains from CLI --- .../ManageRedirectRulesCommand.php | 17 ++++++---- .../ShortUrl/DeleteShortUrlCommand.php | 18 +++++----- .../ShortUrl/DeleteShortUrlVisitsCommand.php | 26 ++++++-------- .../ShortUrl/GetShortUrlVisitsCommand.php | 19 ++++++----- .../Command/ShortUrl/ResolveUrlCommand.php | 19 ++++++----- module/CLI/src/Input/EndDateOption.php | 4 +-- .../CLI/src/Input/ShortUrlIdentifierInput.php | 34 +++++++++++++++++++ module/CLI/src/Input/StartDateOption.php | 4 +-- .../src/ShortUrl/Model/ShortUrlIdentifier.php | 13 ------- 9 files changed, 89 insertions(+), 65 deletions(-) create mode 100644 module/CLI/src/Input/ShortUrlIdentifierInput.php diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php index e36fcf59..13b6d1cc 100644 --- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php +++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php @@ -4,16 +4,14 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\RedirectRule; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -23,27 +21,32 @@ class ManageRedirectRulesCommand extends Command { public const NAME = 'short-url:manage-rules'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct( protected readonly ShortUrlResolverInterface $shortUrlResolver, protected readonly ShortUrlRedirectRuleServiceInterface $ruleService, protected readonly RedirectRuleHandlerInterface $ruleHandler, ) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code which rules we want to set.', + domainDesc: 'The domain for the short code.', + ); } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Set redirect rules for a short URL') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which rules we want to set.') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); + ->setDescription('Set redirect rules for a short URL'); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); try { $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier); diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index 8196bbfe..63e9dab5 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception; use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -21,9 +21,16 @@ class DeleteShortUrlCommand extends Command { public const NAME = 'short-url:delete'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code for the short URL to be deleted', + domainDesc: 'The domain if the short code does not belong to the default one', + ); } protected function configure(): void @@ -31,26 +38,19 @@ class DeleteShortUrlCommand extends Command $this ->setName(self::NAME) ->setDescription('Deletes a short URL') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code for the short URL to be deleted') ->addOption( 'ignore-threshold', 'i', InputOption::VALUE_NONE, 'Ignores the safety visits threshold check, which could make short URLs with many visits to be ' . 'accidentally deleted', - ) - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'The domain if the short code does not belong to the default one', ); } protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); $ignoreThreshold = $input->getOption('ignore-threshold'); try { diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php index 5d122ea7..a720e12d 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlVisitsCommand.php @@ -5,13 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Style\SymfonyStyle; use function sprintf; @@ -20,32 +18,28 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand { public const NAME = 'short-url:visits-delete'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code for the short URL which visits will be deleted', + domainDesc: 'The domain if the short code does not belong to the default one', + ); } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Deletes visits from a short URL') - ->addArgument( - 'shortCode', - InputArgument::REQUIRED, - 'The short code for the short URL which visits will be deleted', - ) - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'The domain if the short code does not belong to the default one', - ); + ->setDescription('Deletes visits from a short URL'); } protected function doExecute(InputInterface $input, SymfonyStyle $io): int { - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); try { $result = $this->deleter->deleteShortUrlVisits($identifier); $io->success(sprintf('Successfully deleted %s visits', $result->affectedItems)); diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index a6a4f31d..8a662209 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -5,14 +5,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\Model\VisitsParams; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -20,18 +18,23 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'short-url:visits'; + private ShortUrlIdentifierInput $shortUrlIdentifierInput; + protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Returns the detailed visits information for provided short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.'); + ->setDescription('Returns the detailed visits information for provided short code'); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code which visits we want to get.', + domainDesc: 'The domain for the short code.', + ); } protected function interact(InputInterface $input, OutputInterface $output): void { - $shortCode = $input->getArgument('shortCode'); + $shortCode = $this->shortUrlIdentifierInput->shortCode($input); if (! empty($shortCode)) { return; } @@ -45,7 +48,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator { - $identifier = ShortUrlIdentifier::fromCli($input); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); } diff --git a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php index d41d292e..0a207b68 100644 --- a/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/ResolveUrlCommand.php @@ -4,14 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; @@ -21,23 +19,28 @@ class ResolveUrlCommand extends Command { public const NAME = 'short-url:parse'; + private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; + public function __construct(private readonly ShortUrlResolverInterface $urlResolver) { parent::__construct(); + $this->shortUrlIdentifierInput = new ShortUrlIdentifierInput( + $this, + shortCodeDesc: 'The short code to parse', + domainDesc: 'The domain to which the short URL is attached.', + ); } protected function configure(): void { $this ->setName(self::NAME) - ->setDescription('Returns the long URL behind a short code') - ->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse') - ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain to which the short URL is attached.'); + ->setDescription('Returns the long URL behind a short code'); } protected function interact(InputInterface $input, OutputInterface $output): void { - $shortCode = $input->getArgument('shortCode'); + $shortCode = $this->shortUrlIdentifierInput->shortCode($input); if (! empty($shortCode)) { return; } @@ -54,7 +57,7 @@ class ResolveUrlCommand extends Command $io = new SymfonyStyle($input, $output); try { - $url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input)); + $url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input)); $output->writeln(sprintf('Long URL: %s', $url->getLongUrl())); return ExitCode::EXIT_SUCCESS; } catch (ShortUrlNotFoundException $e) { diff --git a/module/CLI/src/Input/EndDateOption.php b/module/CLI/src/Input/EndDateOption.php index 000a135e..8e6df28a 100644 --- a/module/CLI/src/Input/EndDateOption.php +++ b/module/CLI/src/Input/EndDateOption.php @@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -class EndDateOption +readonly final class EndDateOption { - private readonly DateOption $dateOption; + private DateOption $dateOption; public function __construct(Command $command, string $descriptionHint) { diff --git a/module/CLI/src/Input/ShortUrlIdentifierInput.php b/module/CLI/src/Input/ShortUrlIdentifierInput.php new file mode 100644 index 00000000..c07de779 --- /dev/null +++ b/module/CLI/src/Input/ShortUrlIdentifierInput.php @@ -0,0 +1,34 @@ +addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc) + ->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc); + } + + public function shortCode(InputInterface $input): ?string + { + return $input->getArgument('shortCode'); + } + + public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier + { + $shortCode = $input->getArgument('shortCode'); + $domain = $input->getOption('domain'); + + return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain); + } +} diff --git a/module/CLI/src/Input/StartDateOption.php b/module/CLI/src/Input/StartDateOption.php index 0954e82f..6a7857d7 100644 --- a/module/CLI/src/Input/StartDateOption.php +++ b/module/CLI/src/Input/StartDateOption.php @@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface; use function sprintf; -class StartDateOption +readonly final class StartDateOption { - private readonly DateOption $dateOption; + private DateOption $dateOption; public function __construct(Command $command, string $descriptionHint) { diff --git a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php index 7ec19df6..a7c2e2ff 100644 --- a/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php +++ b/module/Core/src/ShortUrl/Model/ShortUrlIdentifier.php @@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; -use Symfony\Component\Console\Input\InputInterface; use function sprintf; @@ -32,18 +31,6 @@ final readonly class ShortUrlIdentifier return new self($shortCode, $domain); } - public static function fromCli(InputInterface $input): self - { - // Using getArguments and getOptions instead of getArgument(...) and getOption(...) because - // the later throw an exception if requested options are not defined - /** @var string $shortCode */ - $shortCode = $input->getArguments()['shortCode'] ?? ''; - /** @var string|null $domain */ - $domain = $input->getOptions()['domain'] ?? null; - - return new self($shortCode, $domain); - } - public static function fromShortUrl(ShortUrl $shortUrl): self { $domain = $shortUrl->getDomain(); From c9d1a955b997a6606bee94943719fcf8cd2d15a8 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 10:27:21 +0100 Subject: [PATCH 90/97] Add ManageRedirectRulesCommand unit test --- .../ManageRedirectRulesCommandTest.php | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php diff --git a/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php new file mode 100644 index 00000000..79859d23 --- /dev/null +++ b/module/CLI/test/Command/RedirectRule/ManageRedirectRulesCommandTest.php @@ -0,0 +1,95 @@ +shortUrlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + $this->ruleHandler = $this->createMock(RedirectRuleHandlerInterface::class); + + $this->commandTester = CliTestUtils::testerForCommand(new ManageRedirectRulesCommand( + $this->shortUrlResolver, + $this->ruleService, + $this->ruleHandler, + )); + } + + #[Test] + public function errorIsReturnedIfShortUrlCannotBeFound(): void + { + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willThrowException(new ShortUrlNotFoundException('')); + $this->ruleService->expects($this->never())->method('rulesForShortUrl'); + $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); + $this->ruleHandler->expects($this->never())->method('manageRules'); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + self::assertStringContainsString('Short URL for foo not found', $output); + } + + #[Test] + public function savesNoRulesIfManageResultIsNull(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]); + $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null); + $this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringNotContainsString('Rules properly saved', $output); + } + + #[Test] + public function savesRulesIfManageResultIsAnArray(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + + $this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with( + ShortUrlIdentifier::fromShortCodeAndDomain('foo'), + )->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]); + $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]); + $this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []); + + $exitCode = $this->commandTester->execute(['shortCode' => 'foo']); + $output = $this->commandTester->getDisplay(); + + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + self::assertStringContainsString('Rules properly saved', $output); + } +} From eb40dc2d5d1ba9dcb7e85f3702026163da3f18d3 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 10:36:17 +0100 Subject: [PATCH 91/97] Add unit test for ShortUrlRedirectRuleService::saveRulesForShortUrl --- .../ShortUrlRedirectRuleServiceTest.php | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php index b0b6d4f2..103c6fd0 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -132,4 +132,40 @@ class ShortUrlRedirectRuleServiceTest extends TestCase self::assertCount(0, $result); } + + #[Test] + public function saveRulesForShortUrlDetachesAllEntitiesAndArrangesPriorities(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $rules = [ + new ShortUrlRedirectRule($shortUrl, 8, 'https://example.com', new ArrayCollection([ + RedirectCondition::forLanguage('es-ES'), + RedirectCondition::forDevice(DeviceType::ANDROID), + ])), + new ShortUrlRedirectRule($shortUrl, 3, 'https://example.com', new ArrayCollection([ + RedirectCondition::forQueryParam('foo', 'bar'), + RedirectCondition::forQueryParam('bar', 'foo'), + ])), + new ShortUrlRedirectRule($shortUrl, 15, 'https://example.com', new ArrayCollection([ + RedirectCondition::forDevice(DeviceType::IOS), + ])), + ]; + + // Detach will be called 8 times: 3 rules + 5 conditions + $this->em->expects($this->exactly(8))->method('detach'); + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + + // Persist will be called for each of the three rules. Their priorities should be consecutive starting at 1 + $cont = 0; + $this->em->expects($this->exactly(3))->method('persist')->with($this->callback( + function (ShortUrlRedirectRule $rule) use (&$cont): bool { + $cont++; + return $rule->jsonSerialize()['priority'] === $cont; + }, + )); + + $this->ruleService->saveRulesForShortUrl($shortUrl, $rules); + } } From 8751d6c31525c0c07ec7f8fdb0851600bdd82936 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 12:51:17 +0100 Subject: [PATCH 92/97] Add unit test for RedirectRuleHandler --- .../src/RedirectRule/RedirectRuleHandler.php | 32 +-- .../RedirectRuleHandlerAction.php | 12 + .../RedirectRule/RedirectRuleHandlerTest.php | 252 ++++++++++++++++++ 3 files changed, 281 insertions(+), 15 deletions(-) create mode 100644 module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php create mode 100644 module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 8b1592b7..7470d3b5 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -60,24 +60,26 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface $io->table(['Priority', 'Conditions', 'Redirect to'], $listing); } - $action = $io->choice( + $action = RedirectRuleHandlerAction::from($io->choice( 'What do you want to do next?', - [ - 'Add new rule', - 'Remove existing rule', - 'Re-arrange rule', - 'Discard changes', - 'Save and exit', - ], - 'Save and exit', - ); + enumValues(RedirectRuleHandlerAction::class), + RedirectRuleHandlerAction::SAVE->value, + )); return match ($action) { - 'Add new rule' => $this->manageRules($io, $shortUrl, $this->addRule($shortUrl, $io, $rules)), - 'Remove existing rule' => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)), - 'Re-arrange rule' => $this->manageRules($io, $shortUrl, $this->reArrangeRule($io, $rules)), - 'Save and exit' => $rules, - default => null, + RedirectRuleHandlerAction::ADD => $this->manageRules( + $io, + $shortUrl, + $this->addRule($shortUrl, $io, $rules), + ), + RedirectRuleHandlerAction::REMOVE => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)), + RedirectRuleHandlerAction::RE_ARRANGE => $this->manageRules( + $io, + $shortUrl, + $this->reArrangeRule($io, $rules), + ), + RedirectRuleHandlerAction::SAVE => $rules, + RedirectRuleHandlerAction::DISCARD => null, }; } diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php new file mode 100644 index 00000000..a3aff06d --- /dev/null +++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php @@ -0,0 +1,12 @@ +io = $this->createMock(StyleInterface::class); + $this->shortUrl = ShortUrl::withLongUrl('https://example.com'); + $this->cond1 = RedirectCondition::forLanguage('es-AR'); + $this->cond2 = RedirectCondition::forQueryParam('foo', 'bar'); + $this->cond3 = RedirectCondition::forDevice(DeviceType::ANDROID); + $this->rules = [ + new ShortUrlRedirectRule($this->shortUrl, 3, 'https://example.com/one', new ArrayCollection( + [$this->cond1], + )), + new ShortUrlRedirectRule($this->shortUrl, 8, 'https://example.com/two', new ArrayCollection( + [$this->cond2, $this->cond3], + )), + new ShortUrlRedirectRule($this->shortUrl, 5, 'https://example.com/three', new ArrayCollection( + [$this->cond1, $this->cond3], + )), + ]; + + $this->handler = new RedirectRuleHandler(); + } + + #[Test, DataProvider('provideExitActions')] + public function commentIsDisplayedWhenRulesListIsEmpty( + RedirectRuleHandlerAction $action, + ?array $expectedResult, + ): void { + $this->io->expects($this->once())->method('choice')->willReturn($action->value); + $this->io->expects($this->once())->method('newLine'); + $this->io->expects($this->once())->method('text')->with('// No rules found.'); + $this->io->expects($this->never())->method('table'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, []); + + self::assertEquals($expectedResult, $result); + } + + #[Test, DataProvider('provideExitActions')] + public function rulesAreDisplayedWhenRulesListIsEmpty( + RedirectRuleHandlerAction $action, + ): void { + $comment = fn (string $value) => sprintf('%s', $value); + + $this->io->expects($this->once())->method('choice')->willReturn($action->value); + $this->io->expects($this->never())->method('newLine'); + $this->io->expects($this->never())->method('text'); + $this->io->expects($this->once())->method('table')->with($this->isType('array'), [ + ['1', $comment($this->cond1->toHumanFriendly()), 'https://example.com/one'], + [ + '2', + $comment($this->cond2->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()), + 'https://example.com/two', + ], + [ + '3', + $comment($this->cond1->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()), + 'https://example.com/three', + ], + ]); + + $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + } + + public static function provideExitActions(): iterable + { + yield 'discard' => [RedirectRuleHandlerAction::DISCARD, null]; + yield 'save' => [RedirectRuleHandlerAction::SAVE, []]; + } + + #[Test, DataProvider('provideDeviceConditions')] + /** + * @param RedirectCondition[] $expectedConditions + */ + public function newRulesCanBeAdded( + RedirectConditionType $type, + array $expectedConditions, + bool $continue = false, + ): void { + $this->io->expects($this->any())->method('ask')->willReturnCallback( + fn (string $message): string|int => match ($message) { + 'Rule priority (the lower the value, the higher the priority)' => 2, // Add in between existing rules + 'Long URL to redirect when the rule matches' => 'https://example.com/new-two', + 'Language to match?' => 'en-US', + 'Query param name?' => 'foo', + 'Query param value?' => 'bar', + default => '', + }, + ); + $this->io->expects($this->any())->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex, $type): string { + $callIndex++; + + if ($message === 'Type of the condition?') { + return $type->value; + } elseif ($message === 'Device to match?') { + return DeviceType::ANDROID->value; + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::ADD : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + + $continueCallCount = 0; + $this->io->method('confirm')->willReturnCallback(function () use (&$continueCallCount, $continue) { + $continueCallCount++; + return $continueCallCount < 2 && $continue; + }); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([ + $this->rules[0], + new ShortUrlRedirectRule($this->shortUrl, 2, 'https://example.com/new-two', new ArrayCollection( + $expectedConditions, + )), + $this->rules[1], + $this->rules[2], + ], $result); + } + + public static function provideDeviceConditions(): iterable + { + yield 'device' => [RedirectConditionType::DEVICE, [RedirectCondition::forDevice(DeviceType::ANDROID)]]; + yield 'language' => [RedirectConditionType::LANGUAGE, [RedirectCondition::forLanguage('en-US')]]; + yield 'query param' => [RedirectConditionType::QUERY_PARAM, [RedirectCondition::forQueryParam('foo', 'bar')]]; + yield 'multiple query params' => [ + RedirectConditionType::QUERY_PARAM, + [RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')], + true, + ]; + } + + #[Test] + public function existingRulesCanBeRemoved(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(3))->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex): string { + $callIndex++; + + if ($message === 'What rule do you want to delete?') { + return 'https://example.com/two'; // Second rule to be removed + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + $this->io->expects($this->never())->method('warning'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([$this->rules[0], $this->rules[2]], $result); + } + + #[Test] + public function warningIsPrintedWhenTryingToRemoveRuleFromEmptyList(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(2))->method('choice')->willReturnCallback( + function () use (&$callIndex): string { + $callIndex++; + $action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::DISCARD; + return $action->value; + }, + ); + $this->io->expects($this->once())->method('warning')->with('There are no rules to remove'); + + $this->handler->manageRules($this->io, $this->shortUrl, []); + } + + #[Test] + public function existingRulesCanBeReArranged(): void + { + $this->io->expects($this->any())->method('ask')->willReturnCallback( + fn (string $message): string|int => match ($message) { + 'Rule priority (the lower the value, the higher the priority)' => 1, + default => '', + }, + ); + $this->io->expects($this->exactly(3))->method('choice')->willReturnCallback( + function (string $message) use (&$callIndex): string { + $callIndex++; + + if ($message === 'What rule do you want to re-arrange?') { + return 'https://example.com/two'; // Second rule to be re-arrange + } + + // First we select remove action to trigger code branch, then save to finish execution + $action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::SAVE; + return $action->value; + }, + ); + $this->io->expects($this->never())->method('warning'); + + $result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules); + + self::assertEquals([$this->rules[1], $this->rules[0], $this->rules[2]], $result); + } + + #[Test] + public function warningIsPrintedWhenTryingToReArrangeRuleFromEmptyList(): void + { + $callIndex = 0; + $this->io->expects($this->exactly(2))->method('choice')->willReturnCallback( + function () use (&$callIndex): string { + $callIndex++; + $action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::DISCARD; + return $action->value; + }, + ); + $this->io->expects($this->once())->method('warning')->with('There are no rules to re-arrange'); + + $this->handler->manageRules($this->io, $this->shortUrl, []); + } +} From 63c533fa62359beacd9a5e469d9592fd7c3d7429 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 12:59:58 +0100 Subject: [PATCH 93/97] Fix incorrect rule selection when deleting rules with same long URL --- module/CLI/src/RedirectRule/RedirectRuleHandler.php | 4 +++- module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php index 7470d3b5..068cdc74 100644 --- a/module/CLI/src/RedirectRule/RedirectRuleHandler.php +++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php @@ -168,7 +168,9 @@ class RedirectRuleHandler implements RedirectRuleHandlerInterface { $choices = []; foreach ($currentRules as $index => $rule) { - $choices[$rule->longUrl] = $index + 1; + $priority = $index + 1; + $key = sprintf('%s - %s', $priority, $rule->longUrl); + $choices[$key] = $priority; } $resp = $io->choice($message, array_flip($choices)); diff --git a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php index df448a8d..0c0b7d12 100644 --- a/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php +++ b/module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php @@ -174,7 +174,7 @@ class RedirectRuleHandlerTest extends TestCase $callIndex++; if ($message === 'What rule do you want to delete?') { - return 'https://example.com/two'; // Second rule to be removed + return '2 - https://example.com/two'; // Second rule to be removed } // First we select remove action to trigger code branch, then save to finish execution @@ -219,7 +219,7 @@ class RedirectRuleHandlerTest extends TestCase $callIndex++; if ($message === 'What rule do you want to re-arrange?') { - return 'https://example.com/two'; // Second rule to be re-arrange + return '2 - https://example.com/two'; // Second rule to be re-arrange } // First we select remove action to trigger code branch, then save to finish execution From 4aa65f750e28379f07c1c4b75cc1b8f352733a86 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 13:16:37 +0100 Subject: [PATCH 94/97] Add CLI test for manage redirects command, to cover validation errors --- .../Command/ManageRedirectRulesTest.php | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 module/CLI/test-cli/Command/ManageRedirectRulesTest.php diff --git a/module/CLI/test-cli/Command/ManageRedirectRulesTest.php b/module/CLI/test-cli/Command/ManageRedirectRulesTest.php new file mode 100644 index 00000000..3da0a767 --- /dev/null +++ b/module/CLI/test-cli/Command/ManageRedirectRulesTest.php @@ -0,0 +1,33 @@ +exec([ManageRedirectRulesCommand::NAME, 'abc123'], [ + '0', // Add new rule + 'not-a-number', // Invalid priority + '1', // Valid priority, to continue execution + 'invalid-long-url', // Invalid long URL + 'https://example.com', // Valid long URL, to continue execution + '1', // Language condition type + '', // Invalid required language + 'es-ES', // Valid language, to continue execution + 'no', // Do not add more conditions + '4', // Discard changes + ]); + + self::assertStringContainsString('The priority must be a numeric positive value', $output); + self::assertStringContainsString('The input is not valid', $output); + self::assertStringContainsString('The value is mandatory', $output); + } +} From 9809f050ef818dafb796614e8069c97445c9e1be Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 18:19:40 +0100 Subject: [PATCH 95/97] Update changelog --- CHANGELOG.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50805155..362524a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,12 @@ 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] +## [4.0.0] ### Added +* [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL. + + Rules can be based on things like the presence of specific params, headers, locations, etc. This version ships with three initial rule condition types: device, query param and language. + * [#1902](https://github.com/shlinkio/shlink/issues/1902) Add dynamic redirects based on query parameters. This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912). From 97cb30565cb39932c9c676785af2e16bca93b4a2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 18:50:23 +0100 Subject: [PATCH 96/97] Add v4.0.0 --- CHANGELOG.md | 2 +- composer.json | 12 ++++++------ ...Core.RedirectRule.Entity.ShortUrlRedirectRule.php | 1 - 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 362524a8..c59a0a53 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). -## [4.0.0] +## [4.0.0] - 2024-03-03 ### Added * [#1914](https://github.com/shlinkio/shlink/issues/1914) Add new dynamic redirects engine based on rules. Rules are conditions checked against the visitor's request, and when matching, they can result in a redirect to a different long URL. diff --git a/composer.json b/composer.json index 65605957..a2cb2131 100644 --- a/composer.json +++ b/composer.json @@ -43,12 +43,12 @@ "pugx/shortid-php": "^1.1", "ramsey/uuid": "^4.7", "shlinkio/doctrine-specification": "^2.1.1", - "shlinkio/shlink-common": "dev-main#3e5bf59 as 6.0", - "shlinkio/shlink-config": "dev-main#a43b380 as 3.0", - "shlinkio/shlink-event-dispatcher": "dev-main#aa9023c as 4.0", - "shlinkio/shlink-importer": "dev-main#65a9a30 as 5.3", - "shlinkio/shlink-installer": "dev-develop#5943255 as 9.0", - "shlinkio/shlink-ip-geolocation": "dev-main#a807668 as 3.5", + "shlinkio/shlink-common": "^6.0", + "shlinkio/shlink-config": "^3.0", + "shlinkio/shlink-event-dispatcher": "^4.0", + "shlinkio/shlink-importer": "^5.3", + "shlinkio/shlink-installer": "^9.0", + "shlinkio/shlink-ip-geolocation": "^3.5", "shlinkio/shlink-json": "^1.1", "spiral/roadrunner": "^2023.3", "spiral/roadrunner-cli": "^2.6", diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php index 3851de00..eaedd590 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.RedirectRule.Entity.ShortUrlRedirectRule.php @@ -39,7 +39,6 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->setJoinTable(determineTableName('redirect_conditions_in_short_url_redirect_rules', $emConfig)) ->addInverseJoinColumn('redirect_condition_id', 'id', onDelete: 'CASCADE') ->addJoinColumn('short_url_redirect_rule_id', 'id', onDelete: 'CASCADE') - ->fetchEager() // Always fetch the corresponding conditions when loading a rule ->setOrderBy(['id' => 'ASC']) // Ensure a reliable order in the list of conditions ->cascadePersist() // Create automatically with the rule ->orphanRemoval() // Remove conditions when they are not linked to any rule From febca6d4412bc79b562f09092c50f668df63dbc0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 3 Mar 2024 19:16:46 +0100 Subject: [PATCH 97/97] Small reword in UPGRADE guide --- UPGRADE.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 07862ab7..bbb7c3a4 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -16,8 +16,7 @@ If you want to keep previous behavior, pass `QR_CODE_FOR_DISABLED_SHORT_URLS=false` or the equivalent configuration option. * Long URL title resolution is now enabled by default. You can still disable it by passing `AUTO_RESOLVE_TITLES=false` or the equivalent configuration option. * Shlink no longer allows to opt-in for long URL verification. Long URLs are unconditionally considered correct during short URL creation/edition. -* Device long URLs have been migrated to the new Dynamic rule-based redirects system. - All existing short URLs which were using device long URLs will be automatically migrated and continue working as expected, but the API surface has changed. +* Device long URLs have been migrated to the new Dynamic rule-based redirects system and will continue to work as expected, but the API surface has changed. If you use shlink-web-client and rely on this feature when creating/updating short URLs, **DO NOT UPDATE YET**. Support for dynamic rule-based redirects will be added to shlink-web-client soon, in v4.1.0 ### Changes in REST API