diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb51e305..b1ce0d0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,10 +10,10 @@ on: jobs: static-analysis: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] command: ['cs', 'stan', 'swagger:validate'] steps: - name: Checkout code @@ -25,14 +25,15 @@ jobs: tools: composer extensions: openswoole-4.11.1 coverage: none - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - run: composer ${{ matrix.command }} tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] test-group: ['unit', 'api'] steps: - name: Checkout code @@ -48,10 +49,11 @@ jobs: extensions: openswoole-4.11.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - run: composer test:${{ matrix.test-group }}:ci - uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' }} + if: ${{ matrix.php-version == '8.1' }} with: name: coverage-${{ matrix.test-group }} path: | @@ -59,10 +61,10 @@ jobs: build/coverage-${{ matrix.test-group }}.cov db-tests: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] platform: ['sqlite:ci', 'mysql', 'maria', 'postgres', 'ms'] env: LC_ALL: C @@ -80,10 +82,11 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.0 + extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - name: Create test database if: ${{ matrix.platform == 'ms' }} run: docker-compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" @@ -91,7 +94,7 @@ jobs: run: composer test:db:${{ matrix.platform }} - name: Upload code coverage uses: actions/upload-artifact@v2 - if: ${{ matrix.php-version == '8.0' && matrix.platform == 'sqlite:ci' }} + if: ${{ matrix.php-version == '8.1' && matrix.platform == 'sqlite:ci' }} with: name: coverage-db path: | @@ -102,10 +105,10 @@ jobs: needs: - tests - db-tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] test-group: ['unit', 'db', 'api'] steps: - name: Checkout code @@ -118,7 +121,8 @@ jobs: extensions: openswoole-4.11.1 coverage: pcov ini-values: pcov.directory=module - - run: composer install --no-interaction --prefer-dist + - name: Install dependencies + run: composer install --no-interaction --prefer-dist - uses: actions/download-artifact@v2 with: path: build @@ -133,10 +137,10 @@ jobs: needs: - tests - db-tests - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -152,8 +156,8 @@ jobs: - run: mv build/coverage-unit/coverage-unit.cov build/coverage-unit.cov - 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: wget https://phar.phpunit.de/phpcov-8.2.0.phar - - run: php phpcov-8.2.0.phar merge build --clover build/clover.xml + - run: wget https://phar.phpunit.de/phpcov-8.2.1.phar + - run: php phpcov-8.2.1.phar merge build --clover build/clover.xml - name: Publish coverage uses: codecov/codecov-action@v1 with: @@ -163,7 +167,7 @@ jobs: needs: - mutation-tests - upload-coverage - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - uses: geekyeggo/delete-artifact@v1 with: @@ -173,7 +177,7 @@ jobs: coverage-api build-docker-image: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index a4f47026..fb24e60b 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -9,7 +9,7 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 45c48bd2..4903fe52 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -7,10 +7,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0', '8.1'] + php-version: ['8.1'] swoole: ['yes', 'no'] steps: - name: Checkout code @@ -32,7 +32,7 @@ jobs: publish: needs: ['build'] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - name: Checkout code uses: actions/checkout@v2 @@ -50,11 +50,11 @@ jobs: delete-artifacts: needs: ['publish'] - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: [ '8.0', '8.1' ] - swoole: [ 'yes', 'no' ] + php-version: ['8.1'] + swoole: ['yes', 'no'] steps: - uses: geekyeggo/delete-artifact@v1 with: diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 43313920..83864389 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -7,10 +7,10 @@ on: jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: matrix: - php-version: ['8.0'] + php-version: ['8.1'] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cfe581b..c11faf60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,42 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [3.2.0] - 2022-08-05 +### Added +* [#854](https://github.com/shlinkio/shlink/issues/854) Added support for multi-segment custom slugs. + + The feature is disabled by default, but you can optionally opt in. If you do, you will be able to create short URLs with multiple segments in the custom slug, like `https://example.com/foo/bar/baz`. + +* [#1280](https://github.com/shlinkio/shlink/issues/1280) Added missing visit-related commands. + + Now you can run `tag:visits`, `domain:visits`, `visit:orphan` or `visit:non-orphan` to get the corresponding list of visits from the command line. + +* [#962](https://github.com/shlinkio/shlink/issues/962) Added new real-time update for new short URLs. + + You can now subscribe to the `https://shlink.io/new-short-url` topic on any of the supported async updates technologies in order to get notified when a short URL is created. + +* [#1367](https://github.com/shlinkio/shlink/issues/1367) Added support to publish real-time updates in redis pub/sub. + + The publishing will happen in the same redis instance/cluster configured for caching. + +### Changed +* [#1452](https://github.com/shlinkio/shlink/issues/1452) Updated to monolog 3 +* [#1485](https://github.com/shlinkio/shlink/issues/1485) Changed payload published in RabbitMQ for all visits events, in order to conform with the Async API spec. + + Since this is a breaking change, also provided a new `RABBITMQ_LEGACY_VISITS_PUBLISHING=true` env var that can be provided in order to keep the old payload. + + This env var is considered deprecated and will be removed in Shlink 4, when the legacy format will no longer be supported. + +### Deprecated +* *Nothing* + +### Removed +* [#1280](https://github.com/shlinkio/shlink/issues/1280) Dropped support for PHP 8.0 + +### Fixed +* [#1471](https://github.com/shlinkio/shlink/issues/1471) Fixed error when running `visit:locate` command with any extra parameter (like `--retry`). + + ## [3.1.2] - 2022-06-04 ### Added * *Nothing* @@ -605,7 +641,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * *Nothing* ### Fixed -* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short RULs list. +* [#979](https://github.com/shlinkio/shlink/issues/979) Added missing `itemsPerPage` query param to swagger docs for short URLs list. * [#980](https://github.com/shlinkio/shlink/issues/980) Fixed value used for `Access-Control-Allow-Origin`, that could not work as expected when including an IP address. * [#947](https://github.com/shlinkio/shlink/issues/947) Fixed incorrect value returned in `Access-Control-Allow-Methods` header, which always contained all methods. @@ -1253,7 +1289,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this Endpoints and commands which create short URLs support providing the `domain` now (via query param or CLI flag). If not provided, the short URLs will still be "attached" to the default domain. - Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-compaign` and `https://example.com/my-campaign`, under the same shlink instance. + Custom slugs can be created on multiple domains, allowing to share links like `https://doma.in/my-campaign` and `https://example.com/my-campaign`, under the same shlink instance. When resolving a short URL to redirect end users, the following rules are applied: @@ -1503,7 +1539,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Fixed * [#309](https://github.com/shlinkio/shlink/issues/309) Added missing favicon to prevent 404 errors logged when an error page is loaded in a browser. -* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddlware` to be always piped. Now the check is not even made, which simplifies everything. +* [#310](https://github.com/shlinkio/shlink/issues/310) Fixed execution context not being properly detected, making `CloseDbConnectionMiddleware` to be always piped. Now the check is not even made, which simplifies everything. ## [1.15.0] - 2018-12-02 @@ -1568,7 +1604,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this ### Changed * [#241](https://github.com/shlinkio/shlink/issues/241) Fixed columns in `visit_locations` table, to be snake_case instead of camelCase. -* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monlog's `PsrLogMessageProcessor`. +* [#228](https://github.com/shlinkio/shlink/issues/228) Updated how exceptions are serialized into logs, by using monolog's `PsrLogMessageProcessor`. * [#225](https://github.com/shlinkio/shlink/issues/225) Performance and maintainability slightly improved by enforcing via code sniffer that all global namespace classes, functions and constants are explicitly imported. * [#196](https://github.com/shlinkio/shlink/issues/196) Reduced anemic model in entities, defining more expressive public APIs instead. * [#249](https://github.com/shlinkio/shlink/issues/249) Added [functional-php](https://github.com/lstrojny/functional-php) to ease collections handling. diff --git a/Dockerfile b/Dockerfile index 940364a3..2944db45 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM php:8.1.5-alpine3.15 as base +FROM php:8.1.9-alpine3.16 as base ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ENV OPENSWOOLE_VERSION 4.11.1 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV LC_ALL "C" diff --git a/README.md b/README.md index 6f4afd37..1fe3b89c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ The idea is that you can just generate a container using the image and provide t First, make sure the host where you are going to run shlink fulfills these requirements: -* PHP 8.0 or 8.1 +* PHP 8.1 * The next PHP extensions: json, curl, pdo, intl, gd and gmp/bcmath. * apcu extension is recommended if you don't plan to use openswoole. * xml extension is required if you want to generate QR codes in svg format. @@ -66,7 +66,9 @@ In order to run Shlink, you will need a built version of the project. There are After that, you will have a dist file inside the `build` directory, that you need to decompress in the location of your choice. - > This is the process used when releasing new shlink versions. After tagging the new version with git, the Github release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. + > **Note** + > + > This is the process used when releasing new Shlink versions. After tagging the new version with git, the GitHub release is automatically created by a [GitHub workflow](https://github.com/shlinkio/shlink/actions?query=workflow%3A%22Publish+release%22), attaching the generated dist file to it. ### Configure diff --git a/UPGRADE.md b/UPGRADE.md index bce1bdde..6bef9dbc 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -76,7 +76,7 @@ These routes have been removed, but have a direct replacement: * `/qr/{shortCode}[/{size}]` -> `/{shortCode}/qr-code[/{size}]` * `PUT /rest/v{version}/short-urls/{shortCode}` -> `PATCH /rest/v{version}/short-urls/{shortCode}` -When using the old ones, a 404 status will me returned now. +When using the old ones, a 404 status will be returned now. ### Removed command and route aliases diff --git a/composer.json b/composer.json index 4268f85b..c6e293bd 100644 --- a/composer.json +++ b/composer.json @@ -12,53 +12,48 @@ } ], "require": { - "php": "^8.0", + "php": "^8.1", "ext-json": "*", "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^2.3", - "doctrine/migrations": "^3.3", - "doctrine/orm": "^2.11", + "doctrine/migrations": "^3.5", + "doctrine/orm": "^2.12", "endroid/qr-code": "^4.4", "geoip2/geoip2": "^2.12", "guzzlehttp/guzzle": "^7.4", "happyr/doctrine-specification": "^2.0", "jaybizzle/crawler-detect": "^1.2.110", "laminas/laminas-config": "^3.7", - "laminas/laminas-config-aggregator": "^1.7", - "laminas/laminas-diactoros": "^2.8", - "laminas/laminas-inputfilter": "^2.13", - "laminas/laminas-servicemanager": "^3.11.2", - "laminas/laminas-stdlib": "^3.6", + "laminas/laminas-config-aggregator": "^1.8", + "laminas/laminas-diactoros": "^2.14", + "laminas/laminas-inputfilter": "^2.19", + "laminas/laminas-servicemanager": "^3.16", + "laminas/laminas-stdlib": "^3.11", "lcobucci/jwt": "^4.1", - "league/uri": "^6.4", + "league/uri": "^6.7", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.7", - "mezzio/mezzio-fastroute": "^3.3", - "mezzio/mezzio-problem-details": "^1.5", - "mezzio/mezzio-swoole": "^4.0", - "mlocati/ip-lib": "^1.17", - "monolog/monolog": "^2.3", - "nikolaposa/monolog-factory": "^3.1", - "ocramius/proxy-manager": "^2.11", - "pagerfanta/core": "^3.5", - "php-amqplib/php-amqplib": "^3.1", + "mezzio/mezzio": "^3.11", + "mezzio/mezzio-fastroute": "^3.5", + "mezzio/mezzio-problem-details": "^1.6", + "mezzio/mezzio-swoole": "^4.3", + "mlocati/ip-lib": "^1.18", + "ocramius/proxy-manager": "^2.14", + "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", - "predis/predis": "^1.1", "pugx/shortid-php": "^1.0", - "ramsey/uuid": "^4.2", - "shlinkio/shlink-common": "^4.4", + "ramsey/uuid": "^4.3", + "shlinkio/shlink-common": "^4.5", "shlinkio/shlink-config": "^1.6", - "shlinkio/shlink-event-dispatcher": "^2.3", + "shlinkio/shlink-event-dispatcher": "^2.4", "shlinkio/shlink-importer": "^3.0", - "shlinkio/shlink-installer": "^7.1", + "shlinkio/shlink-installer": "^8.0", "shlinkio/shlink-ip-geolocation": "^2.2", - "symfony/console": "^6.0", - "symfony/filesystem": "^6.0", - "symfony/lock": "^6.0", - "symfony/mercure": "^0.6", - "symfony/process": "^6.0", - "symfony/string": "^6.0" + "symfony/console": "^6.1", + "symfony/filesystem": "^6.1", + "symfony/lock": "^6.1", + "symfony/process": "^6.1", + "symfony/string": "^6.1" }, "require-dev": { "cebe/php-openapi": "^1.7", @@ -67,15 +62,15 @@ "infection/infection": "^0.26.5", "openswoole/ide-helper": "~4.11.1", "phpspec/prophecy-phpunit": "^2.0", - "phpstan/phpstan": "^1.2", - "phpstan/phpstan-doctrine": "^1.0", - "phpstan/phpstan-symfony": "^1.0", + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-doctrine": "^1.3", + "phpstan/phpstan-symfony": "^1.2", "phpunit/php-code-coverage": "^9.2", "phpunit/phpunit": "^9.5", "roave/security-advisories": "dev-master", - "shlinkio/php-coding-standard": "~2.2.0", + "shlinkio/php-coding-standard": "~2.3.0", "shlinkio/shlink-test-utils": "^3.0.1", - "symfony/var-dumper": "^6.0", + "symfony/var-dumper": "^6.1", "veewee/composer-run-parallel": "^1.1" }, "autoload": { @@ -176,7 +171,7 @@ "test:db:mysql": "Runs database test suites on a MySQL database", "test:db:maria": "Runs database test suites on a MariaDB database", "test:db:postgres": "Runs database test suites on a PostgreSQL database", - "test:db:ms": "Runs database test suites on a Miscrosoft SQL Server database", + "test:db:ms": "Runs database test suites on a Microsoft SQL Server database", "test:api": "Runs API test suites", "test:api:ci": "Runs API test suites, and generates code coverage reports", "infect:ci": "Checks unit and db tests quality applying mutation testing with existing reports and logs", diff --git a/config/autoload/delete_short_urls.global.php b/config/autoload/delete_short_urls.global.php index 3d562f78..2d203ea1 100644 --- a/config/autoload/delete_short_urls.global.php +++ b/config/autoload/delete_short_urls.global.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink; use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD()->loadFromEnv(); + $threshold = EnvVars::DELETE_SHORT_URL_THRESHOLD->loadFromEnv(); return [ diff --git a/config/autoload/entity-manager.global.php b/config/autoload/entity-manager.global.php index d98d37dc..5a75ca6b 100644 --- a/config/autoload/entity-manager.global.php +++ b/config/autoload/entity-manager.global.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use function Functional\contains; return (static function (): array { - $driver = EnvVars::DB_DRIVER()->loadFromEnv(); + $driver = EnvVars::DB_DRIVER->loadFromEnv(); $isMysqlCompatible = contains(['maria', 'mysql'], $driver); $resolveDriver = static fn () => match ($driver) { @@ -35,12 +35,12 @@ return (static function (): array { ], default => [ 'driver' => $resolveDriver(), - 'dbname' => EnvVars::DB_NAME()->loadFromEnv('shlink'), - 'user' => EnvVars::DB_USER()->loadFromEnv(), - 'password' => EnvVars::DB_PASSWORD()->loadFromEnv(), - 'host' => EnvVars::DB_HOST()->loadFromEnv(EnvVars::DB_UNIX_SOCKET()->loadFromEnv()), - 'port' => EnvVars::DB_PORT()->loadFromEnv($resolveDefaultPort()), - 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET()->loadFromEnv() : null, + 'dbname' => EnvVars::DB_NAME->loadFromEnv('shlink'), + 'user' => EnvVars::DB_USER->loadFromEnv(), + 'password' => EnvVars::DB_PASSWORD->loadFromEnv(), + 'host' => EnvVars::DB_HOST->loadFromEnv(EnvVars::DB_UNIX_SOCKET->loadFromEnv()), + 'port' => EnvVars::DB_PORT->loadFromEnv($resolveDefaultPort()), + 'unix_socket' => $isMysqlCompatible ? EnvVars::DB_UNIX_SOCKET->loadFromEnv() : null, 'charset' => $resolveCharset(), ], }; diff --git a/config/autoload/geolite2.global.php b/config/autoload/geolite2.global.php index cf1f57fc..b31cfc6d 100644 --- a/config/autoload/geolite2.global.php +++ b/config/autoload/geolite2.global.php @@ -9,7 +9,7 @@ return [ 'geolite2' => [ 'db_location' => __DIR__ . '/../../data/GeoLite2-City.mmdb', 'temp_dir' => __DIR__ . '/../../data', - 'license_key' => EnvVars::GEOLITE_LICENSE_KEY()->loadFromEnv(), + 'license_key' => EnvVars::GEOLITE_LICENSE_KEY->loadFromEnv(), ], ]; diff --git a/config/autoload/installer.global.php b/config/autoload/installer.global.php index 3cada5db..2e120e35 100644 --- a/config/autoload/installer.global.php +++ b/config/autoload/installer.global.php @@ -32,6 +32,7 @@ return [ Option\Worker\WebWorkerNumConfigOption::class, Option\Redis\RedisServersConfigOption::class, Option\Redis\RedisSentinelServiceConfigOption::class, + Option\Redis\RedisPubSubConfigOption::class, Option\UrlShortener\ShortCodeLengthOption::class, Option\Mercure\EnableMercureConfigOption::class, Option\Mercure\MercurePublicUrlConfigOption::class, @@ -42,6 +43,7 @@ return [ Option\UrlShortener\RedirectCacheLifeTimeConfigOption::class, Option\UrlShortener\AutoResolveTitlesConfigOption::class, Option\UrlShortener\AppendExtraPathConfigOption::class, + Option\UrlShortener\EnableMultiSegmentSlugsConfigOption::class, Option\Tracking\IpAnonymizationConfigOption::class, Option\Tracking\OrphanVisitsTrackingConfigOption::class, Option\Tracking\DisableTrackParamConfigOption::class, @@ -64,13 +66,13 @@ return [ ], 'installation_commands' => [ - InstallationCommand::DB_CREATE_SCHEMA => [ + InstallationCommand::DB_CREATE_SCHEMA->value => [ 'command' => 'bin/cli ' . Command\Db\CreateDatabaseCommand::NAME, ], - InstallationCommand::DB_MIGRATE => [ + InstallationCommand::DB_MIGRATE->value => [ 'command' => 'bin/cli ' . Command\Db\MigrateDatabaseCommand::NAME, ], - InstallationCommand::GEOLITE_DOWNLOAD_DB => [ + InstallationCommand::GEOLITE_DOWNLOAD_DB->value => [ 'command' => 'bin/cli ' . Command\Visit\DownloadGeoLiteDbCommand::NAME, ], ], diff --git a/config/autoload/locks.global.php b/config/autoload/locks.global.php index bdbdb8e5..5e37e770 100644 --- a/config/autoload/locks.global.php +++ b/config/autoload/locks.global.php @@ -3,7 +3,7 @@ declare(strict_types=1); use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Predis\ClientInterface as PredisClient; +use Shlinkio\Shlink\Common\Cache\RedisFactory; use Shlinkio\Shlink\Common\Logger\LoggerAwareDelegatorFactory; use Shlinkio\Shlink\Core\Config\EnvVars; use Symfony\Component\Lock; @@ -24,7 +24,7 @@ return [ LOCAL_LOCK_FACTORY => ConfigAbstractFactory::class, ], 'aliases' => [ - 'lock_store' => EnvVars::REDIS_SERVERS()->existsInEnv() ? 'redis_lock_store' : 'local_lock_store', + 'lock_store' => EnvVars::REDIS_SERVERS->existsInEnv() ? 'redis_lock_store' : 'local_lock_store', 'redis_lock_store' => Lock\Store\RedisStore::class, 'local_lock_store' => Lock\Store\FlockStore::class, @@ -38,7 +38,7 @@ return [ ConfigAbstractFactory::class => [ Lock\Store\FlockStore::class => ['config.locks.locks_dir'], - Lock\Store\RedisStore::class => [PredisClient::class], + Lock\Store\RedisStore::class => [RedisFactory::SERVICE_NAME], Lock\LockFactory::class => ['lock_store'], LOCAL_LOCK_FACTORY => ['local_lock_store'], ], diff --git a/config/autoload/logger.global.php b/config/autoload/logger.global.php index e6fcd43c..2da1eda3 100644 --- a/config/autoload/logger.global.php +++ b/config/autoload/logger.global.php @@ -4,72 +4,36 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Monolog\Formatter; -use Monolog\Handler; +use Monolog\Level; use Monolog\Logger; -use Monolog\Processor; -use MonologFactory\DiContainerLoggerFactory; use PhpMiddleware\RequestId; use Psr\Log\LoggerInterface; +use Shlinkio\Shlink\Common\Logger\LoggerFactory; +use Shlinkio\Shlink\Common\Logger\LoggerType; -use const PHP_EOL; - -$processors = [ - 'exception_with_new_line' => [ - 'name' => Common\Logger\Processor\ExceptionWithNewLineProcessor::class, - ], - 'psr3' => [ - 'name' => Processor\PsrLogMessageProcessor::class, - ], - 'request_id' => RequestId\MonologProcessor::class, -]; -$formatter = [ - 'name' => Formatter\LineFormatter::class, - 'params' => [ - 'format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%' . PHP_EOL, - 'allow_inline_line_breaks' => true, - ], +$common = [ + 'level' => Level::Info->value, + 'processors' => [RequestId\MonologProcessor::class], + 'line_format' => '[%datetime%] [%extra.request_id%] %channel%.%level_name% - %message%', ]; return [ 'logger' => [ 'Shlink' => [ - 'name' => 'Shlink', - 'handlers' => [ - 'shlink_handler' => [ - 'name' => Handler\RotatingFileHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'filename' => 'data/log/shlink_log.log', - 'max_files' => 30, - 'file_permission' => 0666, - ], - 'formatter' => $formatter, - ], - ], - 'processors' => $processors, + 'type' => LoggerType::FILE->value, + ...$common, ], 'Access' => [ - 'name' => 'Access', - 'handlers' => [ - 'access_handler' => [ - 'name' => Handler\StreamHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - ], - 'formatter' => $formatter, - ], - ], - 'processors' => $processors, + 'type' => LoggerType::STREAM->value, + ...$common, ], ], 'dependencies' => [ 'factories' => [ - 'Logger_Shlink' => [DiContainerLoggerFactory::class, 'Shlink'], - 'Logger_Access' => [DiContainerLoggerFactory::class, 'Access'], + 'Logger_Shlink' => [LoggerFactory::class, 'Shlink'], + 'Logger_Access' => [LoggerFactory::class, 'Access'], ], 'aliases' => [ 'logger' => 'Logger_Shlink', diff --git a/config/autoload/logger.local.php.dist b/config/autoload/logger.local.php.dist index 1da0384b..7288ed06 100644 --- a/config/autoload/logger.local.php.dist +++ b/config/autoload/logger.local.php.dist @@ -2,33 +2,18 @@ declare(strict_types=1); -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Monolog\Level; +use Shlinkio\Shlink\Common\Logger\LoggerType; $isSwoole = extension_loaded('openswoole'); -// For swoole, send logs to standard output -$handler = $isSwoole - ? [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::DEBUG, - 'stream' => 'php://stdout', - ], - ] - : [ - 'params' => [ - 'level' => Logger::DEBUG, - ], - ]; - return [ 'logger' => [ 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => $handler, - ], + // For swoole, send logs as stream + 'type' => $isSwoole ? LoggerType::STREAM->value : LoggerType::FILE->value, + 'level' => Level::Debug->value, ], ], diff --git a/config/autoload/mercure.global.php b/config/autoload/mercure.global.php index ba261369..67143919 100644 --- a/config/autoload/mercure.global.php +++ b/config/autoload/mercure.global.php @@ -9,14 +9,14 @@ use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\HubInterface; return (static function (): array { - $publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL()->loadFromEnv(); + $publicUrl = EnvVars::MERCURE_PUBLIC_HUB_URL->loadFromEnv(); return [ 'mercure' => [ 'public_hub_url' => $publicUrl, - 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL()->loadFromEnv($publicUrl), - 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET()->loadFromEnv(), + 'internal_hub_url' => EnvVars::MERCURE_INTERNAL_HUB_URL->loadFromEnv($publicUrl), + 'jwt_secret' => EnvVars::MERCURE_JWT_SECRET->loadFromEnv(), 'jwt_issuer' => 'Shlink', ], diff --git a/config/autoload/qr-codes.global.php b/config/autoload/qr-codes.global.php index d72198af..dc4f5f9e 100644 --- a/config/autoload/qr-codes.global.php +++ b/config/autoload/qr-codes.global.php @@ -13,13 +13,13 @@ use const Shlinkio\Shlink\DEFAULT_QR_CODE_SIZE; return [ 'qr_codes' => [ - 'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE()->loadFromEnv(DEFAULT_QR_CODE_SIZE), - 'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN()->loadFromEnv(DEFAULT_QR_CODE_MARGIN), - 'format' => EnvVars::DEFAULT_QR_CODE_FORMAT()->loadFromEnv(DEFAULT_QR_CODE_FORMAT), - 'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION()->loadFromEnv( + 'size' => (int) EnvVars::DEFAULT_QR_CODE_SIZE->loadFromEnv(DEFAULT_QR_CODE_SIZE), + 'margin' => (int) EnvVars::DEFAULT_QR_CODE_MARGIN->loadFromEnv(DEFAULT_QR_CODE_MARGIN), + 'format' => EnvVars::DEFAULT_QR_CODE_FORMAT->loadFromEnv(DEFAULT_QR_CODE_FORMAT), + 'error_correction' => EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION->loadFromEnv( DEFAULT_QR_CODE_ERROR_CORRECTION, ), - 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE()->loadFromEnv( + 'round_block_size' => (bool) EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE->loadFromEnv( DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, ), ], diff --git a/config/autoload/rabbit.global.php b/config/autoload/rabbit.global.php index faa5f569..ea003809 100644 --- a/config/autoload/rabbit.global.php +++ b/config/autoload/rabbit.global.php @@ -2,46 +2,20 @@ declare(strict_types=1); -use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use Laminas\ServiceManager\Proxy\LazyServiceFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Shlinkio\Shlink\Core\Config\EnvVars; return [ 'rabbitmq' => [ - 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED()->loadFromEnv(false), - 'host' => EnvVars::RABBITMQ_HOST()->loadFromEnv(), - 'port' => (int) EnvVars::RABBITMQ_PORT()->loadFromEnv('5672'), - 'user' => EnvVars::RABBITMQ_USER()->loadFromEnv(), - 'password' => EnvVars::RABBITMQ_PASSWORD()->loadFromEnv(), - 'vhost' => EnvVars::RABBITMQ_VHOST()->loadFromEnv('/'), - ], + 'enabled' => (bool) EnvVars::RABBITMQ_ENABLED->loadFromEnv(false), + 'host' => EnvVars::RABBITMQ_HOST->loadFromEnv(), + 'port' => (int) EnvVars::RABBITMQ_PORT->loadFromEnv('5672'), + 'user' => EnvVars::RABBITMQ_USER->loadFromEnv(), + 'password' => EnvVars::RABBITMQ_PASSWORD->loadFromEnv(), + 'vhost' => EnvVars::RABBITMQ_VHOST->loadFromEnv('/'), - 'dependencies' => [ - 'factories' => [ - AMQPStreamConnection::class => ConfigAbstractFactory::class, - ], - 'delegators' => [ - AMQPStreamConnection::class => [ - LazyServiceFactory::class, - ], - ], - 'lazy_services' => [ - 'class_map' => [ - AMQPStreamConnection::class => AMQPStreamConnection::class, - ], - ], - ], - - ConfigAbstractFactory::class => [ - AMQPStreamConnection::class => [ - 'config.rabbitmq.host', - 'config.rabbitmq.port', - 'config.rabbitmq.user', - 'config.rabbitmq.password', - 'config.rabbitmq.vhost', - ], + // Deprecated + 'legacy_visits_publishing' => (bool) EnvVars::RABBITMQ_LEGACY_VISITS_PUBLISHING->loadFromEnv(false), ], ]; diff --git a/config/autoload/redirects.global.php b/config/autoload/redirects.global.php index 08439b2a..426bb2ac 100644 --- a/config/autoload/redirects.global.php +++ b/config/autoload/redirects.global.php @@ -10,14 +10,14 @@ use const Shlinkio\Shlink\DEFAULT_REDIRECT_STATUS_CODE; return [ 'not_found_redirects' => [ - 'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT()->loadFromEnv(), - 'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT()->loadFromEnv(), - 'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT()->loadFromEnv(), + 'invalid_short_url' => EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT->loadFromEnv(), + 'regular_404' => EnvVars::DEFAULT_REGULAR_404_REDIRECT->loadFromEnv(), + 'base_url' => EnvVars::DEFAULT_BASE_URL_REDIRECT->loadFromEnv(), ], 'redirects' => [ - 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE()->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), - 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME()->loadFromEnv( + 'redirect_status_code' => (int) EnvVars::REDIRECT_STATUS_CODE->loadFromEnv(DEFAULT_REDIRECT_STATUS_CODE), + 'redirect_cache_lifetime' => (int) EnvVars::REDIRECT_CACHE_LIFETIME->loadFromEnv( DEFAULT_REDIRECT_CACHE_LIFETIME, ), ], diff --git a/config/autoload/redis.global.php b/config/autoload/redis.global.php index f87d77f3..1d035055 100644 --- a/config/autoload/redis.global.php +++ b/config/autoload/redis.global.php @@ -5,17 +5,23 @@ declare(strict_types=1); use Shlinkio\Shlink\Core\Config\EnvVars; return (static function (): array { - $redisServers = EnvVars::REDIS_SERVERS()->loadFromEnv(); + $redisServers = EnvVars::REDIS_SERVERS->loadFromEnv(); + $pubSub = [ + 'redis' => [ + 'pub_sub_enabled' => $redisServers !== null && EnvVars::REDIS_PUB_SUB_ENABLED->loadFromEnv(false), + ], + ]; return match ($redisServers) { - null => [], + null => $pubSub, default => [ 'cache' => [ 'redis' => [ 'servers' => $redisServers, - 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE()->loadFromEnv(), + 'sentinel_service' => EnvVars::REDIS_SENTINEL_SERVICE->loadFromEnv(), ], ], + ...$pubSub, ], }; })(); diff --git a/config/autoload/redis.local.php.local b/config/autoload/redis.local.php.local index 08dbae32..9bd8fea6 100644 --- a/config/autoload/redis.local.php.local +++ b/config/autoload/redis.local.php.local @@ -7,12 +7,13 @@ return [ 'cache' => [ 'redis' => [ 'servers' => 'tcp://shlink_redis:6379', -// 'servers' => [ -// 'tcp://shlink_redis:6379', -// ], ], ], + 'redis' => [ + 'pub_sub_enabled' => true, + ], + 'dependencies' => [ 'aliases' => [ // With this config, a user could alias 'lock_store' => 'redis_lock_store' to override the default diff --git a/config/autoload/request_id.global.php b/config/autoload/request_id.global.php index f057bb09..5525849a 100644 --- a/config/autoload/request_id.global.php +++ b/config/autoload/request_id.global.php @@ -5,6 +5,7 @@ declare(strict_types=1); use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; use Laminas\ServiceManager\Factory\InvokableFactory; use PhpMiddleware\RequestId; +use Shlinkio\Shlink\Common\Logger\Processor\BackwardsCompatibleMonologProcessorDelegator; return [ @@ -20,6 +21,11 @@ return [ RequestId\RequestIdMiddleware::class => ConfigAbstractFactory::class, RequestId\MonologProcessor::class => ConfigAbstractFactory::class, ], + 'delegators' => [ + RequestId\MonologProcessor::class => [ + BackwardsCompatibleMonologProcessorDelegator::class, + ], + ], ], ConfigAbstractFactory::class => [ diff --git a/config/autoload/router.global.php b/config/autoload/router.global.php index fd1f9525..8b5e856e 100644 --- a/config/autoload/router.global.php +++ b/config/autoload/router.global.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; return [ 'router' => [ - 'base_path' => EnvVars::BASE_PATH()->loadFromEnv(''), + 'base_path' => EnvVars::BASE_PATH->loadFromEnv(''), 'fastroute' => [ FastRouteRouter::CONFIG_CACHE_ENABLED => true, diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php new file mode 100644 index 00000000..e7e24916 --- /dev/null +++ b/config/autoload/routes.config.php @@ -0,0 +1,107 @@ +loadFromEnv(false); + + return [ + + 'routes' => [ + // Rest + ...ConfigProvider::applyRoutesPrefix([ + Action\HealthAction::getRouteDef(), + + // Visits + Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), + Action\Visit\TagVisitsAction::getRouteDef(), + Action\Visit\DomainVisitsAction::getRouteDef(), + Action\Visit\GlobalVisitsAction::getRouteDef(), + Action\Visit\OrphanVisitsAction::getRouteDef(), + Action\Visit\NonOrphanVisitsAction::getRouteDef(), + + // Short URLs + Action\ShortUrl\CreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $dropDomainMiddleware, + $overrideDomainMiddleware, + Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, + ]), + Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ + $contentNegotiationMiddleware, + $overrideDomainMiddleware, + ]), + Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), + Action\ShortUrl\ListShortUrlsAction::getRouteDef(), + + // Tags + Action\Tag\ListTagsAction::getRouteDef(), + Action\Tag\TagsStatsAction::getRouteDef(), + Action\Tag\DeleteTagsAction::getRouteDef(), + Action\Tag\UpdateTagAction::getRouteDef(), + + // Domains + Action\Domain\ListDomainsAction::getRouteDef(), + Action\Domain\DomainRedirectsAction::getRouteDef(), + + Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), + ], $multiSegment), + + // Non-rest + [ + 'name' => CoreAction\RobotsAction::class, + 'path' => '/robots.txt', + 'middleware' => [ + CoreAction\RobotsAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\PixelAction::class, + 'path' => sprintf('/{shortCode%s}/track', $multiSegment ? ':.+' : ''), + 'middleware' => [ + IpAddress::class, + CoreAction\PixelAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\QrCodeAction::class, + 'path' => sprintf('/{shortCode%s}/qr-code', $multiSegment ? ':.+' : ''), + 'middleware' => [ + CoreAction\QrCodeAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + [ + 'name' => CoreAction\RedirectAction::class, + 'path' => sprintf('/{shortCode%s}', $multiSegment ? ':.+' : ''), + 'middleware' => [ + IpAddress::class, + CoreAction\RedirectAction::class, + ], + 'allowed_methods' => [RequestMethodInterface::METHOD_GET], + ], + ], + + ]; +})(); diff --git a/config/autoload/swoole.global.php b/config/autoload/swoole.global.php index 987c967e..36cba24f 100644 --- a/config/autoload/swoole.global.php +++ b/config/autoload/swoole.global.php @@ -7,7 +7,7 @@ use Shlinkio\Shlink\Core\Config\EnvVars; use const Shlinkio\Shlink\MIN_TASK_WORKERS; return (static function (): array { - $taskWorkers = (int) EnvVars::TASK_WORKER_NUM()->loadFromEnv(16); + $taskWorkers = (int) EnvVars::TASK_WORKER_NUM->loadFromEnv(16); return [ @@ -17,11 +17,11 @@ return (static function (): array { 'swoole-http-server' => [ 'host' => '0.0.0.0', - 'port' => (int) EnvVars::PORT()->loadFromEnv(8080), + 'port' => (int) EnvVars::PORT->loadFromEnv(8080), 'process-name' => 'shlink', 'options' => [ - 'worker_num' => (int) EnvVars::WEB_WORKER_NUM()->loadFromEnv(16), + 'worker_num' => (int) EnvVars::WEB_WORKER_NUM->loadFromEnv(16), 'task_worker_num' => max($taskWorkers, MIN_TASK_WORKERS), ], ], diff --git a/config/autoload/tracking.global.php b/config/autoload/tracking.global.php index b2596830..0637301a 100644 --- a/config/autoload/tracking.global.php +++ b/config/autoload/tracking.global.php @@ -9,28 +9,28 @@ return [ 'tracking' => [ // Tells if IP addresses should be anonymized before persisting, to fulfil data protection regulations // This applies only if IP address tracking is enabled - 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR()->loadFromEnv(true), + 'anonymize_remote_addr' => (bool) EnvVars::ANONYMIZE_REMOTE_ADDR->loadFromEnv(true), // Tells if visits to not-found URLs should be tracked. The disable_tracking option takes precedence - 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS()->loadFromEnv(true), + 'track_orphan_visits' => (bool) EnvVars::TRACK_ORPHAN_VISITS->loadFromEnv(true), // A query param that, if provided, will disable tracking of one particular visit. Always takes precedence - 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM()->loadFromEnv(), + 'disable_track_param' => EnvVars::DISABLE_TRACK_PARAM->loadFromEnv(), // If true, visits will not be tracked at all - 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING()->loadFromEnv(false), + 'disable_tracking' => (bool) EnvVars::DISABLE_TRACKING->loadFromEnv(false), // If true, visits will be tracked, but neither the IP address, nor the location will be resolved - 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING()->loadFromEnv(false), + 'disable_ip_tracking' => (bool) EnvVars::DISABLE_IP_TRACKING->loadFromEnv(false), // If true, the referrer will not be tracked - 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING()->loadFromEnv(false), + 'disable_referrer_tracking' => (bool) EnvVars::DISABLE_REFERRER_TRACKING->loadFromEnv(false), // If true, the user agent will not be tracked - 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING()->loadFromEnv(false), + 'disable_ua_tracking' => (bool) EnvVars::DISABLE_UA_TRACKING->loadFromEnv(false), // A list of IP addresses, patterns or CIDR blocks from which tracking is disabled by default - 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM()->loadFromEnv(), + 'disable_tracking_from' => EnvVars::DISABLE_TRACKING_FROM->loadFromEnv(), ], ]; diff --git a/config/autoload/url-shortener.global.php b/config/autoload/url-shortener.global.php index 58c12f05..bf9ecb93 100644 --- a/config/autoload/url-shortener.global.php +++ b/config/autoload/url-shortener.global.php @@ -9,7 +9,7 @@ use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; return (static function (): array { $shortCodesLength = max( - (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH()->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), + (int) EnvVars::DEFAULT_SHORT_CODES_LENGTH->loadFromEnv(DEFAULT_SHORT_CODES_LENGTH), MIN_SHORT_CODES_LENGTH, ); @@ -17,12 +17,13 @@ return (static function (): array { 'url_shortener' => [ 'domain' => [ // TODO Refactor this structure to url_shortener.schema and url_shortener.default_domain - 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED()->loadFromEnv(true)) ? 'https' : 'http', - 'hostname' => EnvVars::DEFAULT_DOMAIN()->loadFromEnv(''), + 'schema' => ((bool) EnvVars::IS_HTTPS_ENABLED->loadFromEnv(true)) ? 'https' : 'http', + 'hostname' => EnvVars::DEFAULT_DOMAIN->loadFromEnv(''), ], 'default_short_codes_length' => $shortCodesLength, - 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES()->loadFromEnv(false), - 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH()->loadFromEnv(false), + 'auto_resolve_titles' => (bool) EnvVars::AUTO_RESOLVE_TITLES->loadFromEnv(false), + 'append_extra_path' => (bool) EnvVars::REDIRECT_APPEND_EXTRA_PATH->loadFromEnv(false), + 'multi_segment_slugs_enabled' => (bool) EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->loadFromEnv(false), ], ]; diff --git a/config/autoload/webhooks.global.php b/config/autoload/webhooks.global.php index 5de7c53b..e72c4904 100644 --- a/config/autoload/webhooks.global.php +++ b/config/autoload/webhooks.global.php @@ -6,14 +6,14 @@ use Shlinkio\Shlink\Core\Config\EnvVars; // Deprecated. Webhooks are no longer supported. To be removed in Shlink 4.0.0 return (static function (): array { - $webhooks = EnvVars::VISITS_WEBHOOKS()->loadFromEnv(); + $webhooks = EnvVars::VISITS_WEBHOOKS->loadFromEnv(); return [ 'visits_webhooks' => [ 'webhooks' => $webhooks === null ? [] : explode(',', $webhooks), 'notify_orphan_visits_to_webhooks' => - (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS()->loadFromEnv(false), + (bool) EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS->loadFromEnv(false), ], ]; diff --git a/config/config.php b/config/config.php index 3dad2105..a1e0428a 100644 --- a/config/config.php +++ b/config/config.php @@ -43,6 +43,8 @@ return (new ConfigAggregator\ConfigAggregator([ $isTestEnv ? new ConfigAggregator\PhpFileProvider('config/test/*.global.php') : new ConfigAggregator\ArrayProvider([]), + // Routes have to be loaded last + new ConfigAggregator\PhpFileProvider('config/autoload/routes.config.php'), ], 'data/cache/app_config.php', [ Core\Config\BasePathPrefixer::class, ]))->getMergedConfig(); diff --git a/config/container.php b/config/container.php index 074502cd..6e95e84d 100644 --- a/config/container.php +++ b/config/container.php @@ -13,7 +13,7 @@ chdir(dirname(__DIR__)); require 'vendor/autoload.php'; // This is one of the first files loaded. Configure the timezone here -date_default_timezone_set(EnvVars::TIMEZONE()->loadFromEnv(date_default_timezone_get())); +date_default_timezone_set(EnvVars::TIMEZONE->loadFromEnv(date_default_timezone_get())); // This class alias tricks the ConfigAbstractFactory to return Lock\Factory instances even with a different service name // It needs to be placed here as individual config files will not be loaded once config is cached diff --git a/config/test/test_config.global.php b/config/test/test_config.global.php index 89807b26..b9bac12d 100644 --- a/config/test/test_config.global.php +++ b/config/test/test_config.global.php @@ -8,8 +8,7 @@ use GuzzleHttp\Client; use Laminas\ConfigAggregator\ConfigAggregator; use Laminas\Diactoros\Response\EmptyResponse; use Laminas\ServiceManager\Factory\InvokableFactory; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Monolog\Level; use PHPUnit\Runner\Version; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -20,6 +19,7 @@ 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 function Laminas\Stratigility\middleware; use function Shlinkio\Shlink\Config\env; @@ -76,16 +76,10 @@ $buildDbConnection = static function (): array { }; }; -$buildTestLoggerConfig = fn (string $handlerName, string $filename) => [ - 'handlers' => [ - $handlerName => [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::DEBUG, - 'stream' => sprintf('data/log/api-tests/%s', $filename), - ], - ], - ], +$buildTestLoggerConfig = static fn (string $filename) => [ + 'level' => Level::Debug->value, + 'type' => LoggerType::STREAM->value, + 'destination' => sprintf('data/log/api-tests/%s', $filename), ]; return [ @@ -183,8 +177,8 @@ return [ ], 'logger' => [ - 'Shlink' => $buildTestLoggerConfig('shlink_handler', 'shlink.log'), - 'Access' => $buildTestLoggerConfig('access_handler', 'access.log'), + 'Shlink' => $buildTestLoggerConfig('shlink.log'), + 'Access' => $buildTestLoggerConfig('access.log'), ], ]; diff --git a/data/infra/examples/nginx-vhost.conf b/data/infra/examples/nginx-vhost.conf index 80ff8afd..5e05481a 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.0-fpm.sock; + fastcgi_pass unix:/var/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi.conf; } diff --git a/data/infra/php.Dockerfile b/data/infra/php.Dockerfile index 3c01294d..a2066752 100644 --- a/data/infra/php.Dockerfile +++ b/data/infra/php.Dockerfile @@ -1,8 +1,8 @@ -FROM php:8.1.5-fpm-alpine3.15 +FROM php:8.1.9-fpm-alpine3.16 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 144eeb08..21a2fe5e 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -1,10 +1,10 @@ -FROM php:8.1.5-alpine3.15 +FROM php:8.1.9-alpine3.16 MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 ENV OPENSWOOLE_VERSION 4.11.1 -ENV PDO_SQLSRV_VERSION 5.10.0 +ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 RUN apk update diff --git a/data/migrations/Version20210207100807.php b/data/migrations/Version20210207100807.php index 706132cc..cd0b0b12 100644 --- a/data/migrations/Version20210207100807.php +++ b/data/migrations/Version20210207100807.php @@ -8,8 +8,8 @@ use Doctrine\DBAL\Platforms\MySQLPlatform; use Doctrine\DBAL\Schema\Schema; use Doctrine\DBAL\Types\Types; use Doctrine\Migrations\AbstractMigration; -use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; final class Version20210207100807 extends AbstractMigration { @@ -27,7 +27,7 @@ final class Version20210207100807 extends AbstractMigration ]); $visits->addColumn('type', Types::STRING, [ 'length' => 255, - 'default' => Visit::TYPE_VALID_SHORT_URL, + 'default' => VisitType::VALID_SHORT_URL->value, ]); } diff --git a/docker/config/shlink_in_docker.local.php b/docker/config/shlink_in_docker.local.php index 73cc3fdc..4fba24b6 100644 --- a/docker/config/shlink_in_docker.local.php +++ b/docker/config/shlink_in_docker.local.php @@ -4,22 +4,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; +use Shlinkio\Shlink\Common\Logger\LoggerType; return [ 'logger' => [ 'Shlink' => [ - 'handlers' => [ - 'shlink_handler' => [ - 'name' => StreamHandler::class, - 'params' => [ - 'level' => Logger::INFO, - 'stream' => 'php://stdout', - ], - ], - ], + 'type' => LoggerType::STREAM->value, ], ], diff --git a/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md b/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md index 4c3b6c52..16dea9d3 100644 --- a/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md +++ b/docs/adr/2021-01-17-support-restrictions-and-permissions-in-api-keys.md @@ -16,7 +16,7 @@ The intention is to implement a system that allows adding to API keys as many of Supporting more restrictions in the future is also desirable. -## Considered option +## Considered options * Using an ACL/RBAC library, and checking roles in a middleware. * Using a service that, provided an API key, tells if certain resource is reachable while it also allows building queries dynamically. diff --git a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md index 983410d1..f4e5a288 100644 --- a/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md +++ b/docs/adr/2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md @@ -11,7 +11,7 @@ However, it does not track visits to any of those, just to valid short URLs. The intention is to change that, and allow users to track the cases mentioned above. -## Considered option +## Considered options * Create a new table to track visits o this kind. * Reuse the existing `visits` table, by making `short_url_id` nullable and adding a couple of other fields. diff --git a/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md b/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md index aa19f160..11cf4fc6 100644 --- a/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md +++ b/docs/adr/2021-08-05-migrate-to-a-new-caching-library.md @@ -13,7 +13,7 @@ However, after the creation of the caching PSRs ([PSR-6 - Cache](https://www.php Also, Shlink needs support for Redis clusters and Redis sentinels, which is not supported by `doctrine/cache` Redis adapters. -## Considered option +## Considered options After some research, the only packages that seem to support the capabilities required by Shlink and also seem healthy, are these: diff --git a/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md index df11538c..e5b72b09 100644 --- a/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md +++ b/docs/adr/2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md @@ -11,7 +11,7 @@ It is potentially possible to combine both, but if you do so, you will find out A [Twitter survey](https://twitter.com/shlinkio/status/1480614855006732289) has also showed up all participants also found the behavior should be the opposite. -## Considered option +## Considered options * Move the logic to read env vars to another config file which always overrides installer options. * Move the logic to read env vars to a config post-processor which overrides config dynamically, only if the appropriate env var had been defined. diff --git a/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md b/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md new file mode 100644 index 00000000..99d82668 --- /dev/null +++ b/docs/adr/2022-08-05-support-multi-segment-custom-slugs.md @@ -0,0 +1,42 @@ +# Support multi-segment custom slugs + +* Status: Accepted +* Date: 2022-08-05 + +## Context and problem statement + +There's a new requirement to support multi-segment custom slugs (as in `https://exam.ple/foo/bar/baz`). + +The internal router does not support this at the moment, as it only matches the shortCode in one of the segments. + +## Considered options + +* Tweak the internal router, so that it is capable of matching multiple segments for the slug, in every route that requires it. +* Define a new set of routes with a short prefix that allows configuring multi-segment in those, without touching the existing routes. +* Let the router fail, and use a middleware to fall back to the proper route (similar to what was done for the extra path forwarding feature). + +## Decision outcome + +Even though I was initially inclined to use a fallback middleware, that has turned out to be harder than anticipated, because there are several possible routes where the slug is used, and we would still need some kind of router to determine which one matches. + +Because of that, the selected approach has been to tweak the existing router, so that it can match multiple segments, and moving the configuration of routes to a common place so that they can be defined in the proper order that prevents conflicts. + +## Pros and Cons of the Options + +### Tweaking the router + +* Bad: It requires routes to be defined in a specific order, and remember it in the future if more routes are added. +* Good: It initially requires fewer changes. +* Good: Once routes are defined in the proper order, all the internal logic works out of the box. + +### Defining new routes + +* Bad: The end-user experience gets affected. +* Bad: Probably a lot of side effects would happen when it comes to assembling short URLs. +* Bad: Routing needs to be configured twice, resolving the same logic. +* Bad: It turns out to still conflict with some routes, even with the prefix, which defeats what looked like its main benefit. + +### Let routing fail and fall back in middleware + +* Good: Does not require changing routes configuration, which means less side effects. +* Bad: Since many routes can potentially end up in the middleware, there's still the need to have some kind of routing logic. diff --git a/docs/adr/README.md b/docs/adr/README.md index 8fd4a662..7cfccdf7 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -2,6 +2,7 @@ Here listed you will find the different architectural decisions taken in the project, including all the reasoning behind it, options considered, and final outcome. +* [2022-08-05 Support multi-segment custom slugs](2022-08-05-support-multi-segment-custom-slugs.md) * [2022-01-15 Update env vars behavior to have precedence over installer options](2022-01-15-update-env-vars-behavior-to-have-precedence-over-installer-options.md) * [2021-08-05 Migrate to a new caching library](2021-08-05-migrate-to-a-new-caching-library.md) * [2021-02-07 Track visits to 'base_url', 'invalid_short_url' and 'regular_404'](2021-02-07-track-visits-to-base-url-invalid-short-url-and-regular-404.md) diff --git a/docs/async-api/async-api.json b/docs/async-api/async-api.json index 82da91c5..3b59e8e5 100644 --- a/docs/async-api/async-api.json +++ b/docs/async-api/async-api.json @@ -1,8 +1,8 @@ { - "asyncapi": "2.0.0", + "asyncapi": "2.4.0", "info": { "title": "Shlink", - "version": "2.0.0", + "version": "3.0.0", "description": "Shlink, the self-hosted URL shortener", "license": { "name": "MIT", @@ -75,6 +75,23 @@ } } } + }, + "https://shlink.io/new-short-url": { + "subscribe": { + "summary": "Receive information about any new short URL.", + "operationId": "newshortUrl", + "message": { + "payload": { + "type": "object", + "additionalProperties": false, + "properties": { + "shortUrl": { + "$ref": "#/components/schemas/ShortUrl" + } + } + } + } + } } }, "components": { @@ -101,7 +118,7 @@ }, "visitsCount": { "type": "integer", - "description": "The number of visits that this short URL has recieved." + "description": "The number of visits that this short URL has received." }, "tags": { "type": "array", diff --git a/docs/swagger/definitions/ShortUrl.json b/docs/swagger/definitions/ShortUrl.json index a5dee481..f09e8d7b 100644 --- a/docs/swagger/definitions/ShortUrl.json +++ b/docs/swagger/definitions/ShortUrl.json @@ -33,7 +33,7 @@ }, "visitsCount": { "type": "integer", - "description": "The number of visits that this short URL has recieved." + "description": "The number of visits that this short URL has received." }, "tags": { "type": "array", diff --git a/docs/swagger/paths/v1_short-urls_{shortCode}.json b/docs/swagger/paths/v1_short-urls_{shortCode}.json index 2f7a9600..9065ff89 100644 --- a/docs/swagger/paths/v1_short-urls_{shortCode}.json +++ b/docs/swagger/paths/v1_short-urls_{shortCode}.json @@ -312,7 +312,7 @@ }, "threshold": { "type": "number", - "description": "The amount of visits currently configured as threshold to allow deleting short UYRLs or not" + "description": "The amount of visits currently configured as threshold to allow deleting short URLs or not" } } } diff --git a/docs/swagger/paths/{shortCode}_qr-code.json b/docs/swagger/paths/{shortCode}_qr-code.json index 104860eb..dd5c8b8a 100644 --- a/docs/swagger/paths/{shortCode}_qr-code.json +++ b/docs/swagger/paths/{shortCode}_qr-code.json @@ -53,7 +53,7 @@ { "name": "errorCorrection", "in": "query", - "description": "The error correction level to apply to the the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", + "description": "The error correction level to apply to the QR code: **[L]**ow, **[M]**edium, **[Q]**uartile or **[H]**igh. See [docs](https://www.qrcode.com/en/about/error_correction.html).", "required": false, "schema": { "type": "string", diff --git a/docs/swagger/swagger.json b/docs/swagger/swagger.json index 06f57c41..840ac84e 100644 --- a/docs/swagger/swagger.json +++ b/docs/swagger/swagger.json @@ -3,7 +3,7 @@ "info": { "title": "Shlink", "description": "Shlink, the self-hosted URL shortener", - "version": "1.0" + "version": "2.0" }, "externalDocs": { diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 2b5b5afd..7629d855 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -11,11 +11,13 @@ return [ Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class, Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class, Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class, - Command\ShortUrl\GetVisitsCommand::NAME => Command\ShortUrl\GetVisitsCommand::class, + Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, Command\ShortUrl\DeleteShortUrlCommand::NAME => Command\ShortUrl\DeleteShortUrlCommand::class, Command\Visit\LocateVisitsCommand::NAME => Command\Visit\LocateVisitsCommand::class, Command\Visit\DownloadGeoLiteDbCommand::NAME => Command\Visit\DownloadGeoLiteDbCommand::class, + Command\Visit\GetOrphanVisitsCommand::NAME => Command\Visit\GetOrphanVisitsCommand::class, + Command\Visit\GetNonOrphanVisitsCommand::NAME => Command\Visit\GetNonOrphanVisitsCommand::class, Command\Api\GenerateKeyCommand::NAME => Command\Api\GenerateKeyCommand::class, Command\Api\DisableKeyCommand::NAME => Command\Api\DisableKeyCommand::class, @@ -24,9 +26,11 @@ return [ Command\Tag\ListTagsCommand::NAME => Command\Tag\ListTagsCommand::class, Command\Tag\RenameTagCommand::NAME => Command\Tag\RenameTagCommand::class, Command\Tag\DeleteTagsCommand::NAME => Command\Tag\DeleteTagsCommand::class, + Command\Tag\GetTagVisitsCommand::NAME => Command\Tag\GetTagVisitsCommand::class, Command\Domain\ListDomainsCommand::NAME => Command\Domain\ListDomainsCommand::class, Command\Domain\DomainRedirectsCommand::NAME => Command\Domain\DomainRedirectsCommand::class, + Command\Domain\GetDomainVisitsCommand::NAME => Command\Domain\GetDomainVisitsCommand::class, Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class, Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index 137bdd7a..6920e839 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -11,6 +11,7 @@ use Laminas\ServiceManager\Factory\InvokableFactory; 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\Service; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; @@ -42,11 +43,13 @@ return [ Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class, - Command\ShortUrl\GetVisitsCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\DeleteShortUrlCommand::class => ConfigAbstractFactory::class, Command\Visit\DownloadGeoLiteDbCommand::class => ConfigAbstractFactory::class, Command\Visit\LocateVisitsCommand::class => ConfigAbstractFactory::class, + Command\Visit\GetOrphanVisitsCommand::class => ConfigAbstractFactory::class, + Command\Visit\GetNonOrphanVisitsCommand::class => ConfigAbstractFactory::class, Command\Api\GenerateKeyCommand::class => ConfigAbstractFactory::class, Command\Api\DisableKeyCommand::class => ConfigAbstractFactory::class, @@ -55,12 +58,14 @@ return [ Command\Tag\ListTagsCommand::class => ConfigAbstractFactory::class, Command\Tag\RenameTagCommand::class => ConfigAbstractFactory::class, Command\Tag\DeleteTagsCommand::class => ConfigAbstractFactory::class, + Command\Tag\GetTagVisitsCommand::class => ConfigAbstractFactory::class, Command\Db\CreateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Db\MigrateDatabaseCommand::class => ConfigAbstractFactory::class, Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class, Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class, + Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class, ], ], @@ -77,15 +82,14 @@ return [ Command\ShortUrl\CreateShortUrlCommand::class => [ Service\UrlShortener::class, ShortUrlStringifier::class, - 'config.url_shortener.default_short_codes_length', - 'config.url_shortener.domain.hostname', + UrlShortenerOptions::class, ], Command\ShortUrl\ResolveUrlCommand::class => [Service\ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ Service\ShortUrlService::class, ShortUrlDataTransformer::class, ], - Command\ShortUrl\GetVisitsCommand::class => [Visit\VisitsStatsHelper::class], + Command\ShortUrl\GetShortUrlVisitsCommand::class => [Visit\VisitsStatsHelper::class], Command\ShortUrl\DeleteShortUrlCommand::class => [Service\ShortUrl\DeleteShortUrlService::class], Command\Visit\DownloadGeoLiteDbCommand::class => [Util\GeolocationDbUpdater::class], @@ -94,6 +98,8 @@ return [ IpLocationResolverInterface::class, LockFactory::class, ], + Command\Visit\GetOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class], + Command\Visit\GetNonOrphanVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Api\GenerateKeyCommand::class => [ApiKeyService::class, ApiKey\RoleResolver::class], Command\Api\DisableKeyCommand::class => [ApiKeyService::class], @@ -102,9 +108,11 @@ return [ Command\Tag\ListTagsCommand::class => [TagService::class], Command\Tag\RenameTagCommand::class => [TagService::class], Command\Tag\DeleteTagsCommand::class => [TagService::class], + Command\Tag\GetTagVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Domain\ListDomainsCommand::class => [DomainService::class], Command\Domain\DomainRedirectsCommand::class => [DomainService::class], + Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class], Command\Db\CreateDatabaseCommand::class => [ LockFactory::class, diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index 2655d1fb..b24619ef 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -73,13 +73,16 @@ class GenerateKeyCommand extends Command $authorOnly, 'a', InputOption::VALUE_NONE, - sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS), + sprintf('Adds the "%s" role to the new API key.', Role::AUTHORED_SHORT_URLS->value), ) ->addOption( $domainOnly, 'd', InputOption::VALUE_REQUIRED, - sprintf('Adds the "%s" role to the new API key, with the domain provided.', Role::DOMAIN_SPECIFIC), + sprintf( + 'Adds the "%s" role to the new API key, with the domain provided.', + Role::DOMAIN_SPECIFIC->value, + ), ) ->setHelp($help); } @@ -99,7 +102,7 @@ class GenerateKeyCommand extends Command if (! $apiKey->isAdmin()) { ShlinkTable::default($io)->render( ['Role name', 'Role metadata'], - $apiKey->mapRoles(fn (string $name, array $meta) => [$name, arrayToString($meta, 0)]), + $apiKey->mapRoles(fn (Role $role, array $meta) => [$role->value, arrayToString($meta, 0)]), null, 'Roles', ); diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 0a331086..0e98af31 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -60,10 +60,10 @@ class ListKeysCommand extends Command } $rowData[] = $expiration?->toAtomString() ?? '-'; $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( - fn (string $roleName, array $meta) => + fn (Role $role, array $meta) => empty($meta) - ? Role::toFriendlyName($roleName) - : sprintf('%s: %s', Role::toFriendlyName($roleName), Role::domainAuthorityFromMeta($meta)), + ? Role::toFriendlyName($role) + : sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)), )); return $rowData; diff --git a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php index 90cfd1f7..c546fd5b 100644 --- a/module/CLI/src/Command/Domain/DomainRedirectsCommand.php +++ b/module/CLI/src/Command/Domain/DomainRedirectsCommand.php @@ -53,7 +53,7 @@ class DomainRedirectsCommand extends Command /** @var string[] $availableDomains */ $availableDomains = invoke( - filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault()), + filter($this->domainService->listDomains(), static fn (DomainItem $item) => ! $item->isDefault), 'toString', ); if (empty($availableDomains)) { diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php new file mode 100644 index 00000000..00c811c1 --- /dev/null +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -0,0 +1,50 @@ +setName(self::NAME) + ->setDescription('Returns the list of visits for provided domain.') + ->addArgument('domain', InputArgument::REQUIRED, 'The domain which visits we want to get.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $domain = $input->getArgument('domain'); + return $this->visitsHelper->visitsForDomain($domain, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Domain/ListDomainsCommand.php b/module/CLI/src/Command/Domain/ListDomainsCommand.php index 447bf92f..8f2ee22c 100644 --- a/module/CLI/src/Command/Domain/ListDomainsCommand.php +++ b/module/CLI/src/Command/Domain/ListDomainsCommand.php @@ -48,12 +48,12 @@ class ListDomainsCommand extends Command $table->render( $showRedirects ? [...$commonFields, '"Not found" redirects'] : $commonFields, map($domains, function (DomainItem $domain) use ($showRedirects) { - $commonValues = [$domain->toString(), $domain->isDefault() ? 'Yes' : 'No']; + $commonValues = [$domain->toString(), $domain->isDefault ? 'Yes' : 'No']; return $showRedirects ? [ ...$commonValues, - $this->notFoundRedirectsToString($domain->notFoundRedirectConfig()), + $this->notFoundRedirectsToString($domain->notFoundRedirectConfig), ] : $commonValues; }), diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 3334ae6a..6b4cce1a 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -5,9 +5,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; use Shlinkio\Shlink\CLI\Util\ExitCodes; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -19,6 +21,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_map; +use function explode; use function Functional\curry; use function Functional\flatten; use function Functional\unique; @@ -29,14 +32,15 @@ class CreateShortUrlCommand extends Command public const NAME = 'short-url:create'; private ?SymfonyStyle $io; + private string $defaultDomain; public function __construct( - private UrlShortenerInterface $urlShortener, - private ShortUrlStringifierInterface $stringifier, - private int $defaultShortCodeLength, - private string $defaultDomain, + private readonly UrlShortenerInterface $urlShortener, + private readonly ShortUrlStringifierInterface $stringifier, + private readonly UrlShortenerOptions $options, ) { parent::__construct(); + $this->defaultDomain = $this->options->domain()['hostname'] ?? ''; } protected function configure(): void @@ -150,11 +154,11 @@ class CreateShortUrlCommand extends Command return ExitCodes::EXIT_FAILURE; } - $explodeWithComma = curry('explode')(','); + $explodeWithComma = curry(explode(...))(','); $tags = 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->defaultShortCodeLength; + $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength(); $doValidateUrl = $input->getOption('validate-url'); try { @@ -171,6 +175,7 @@ class CreateShortUrlCommand extends Command ShortUrlInputFilter::TAGS => $tags, ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), + EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $this->options->multiSegmentSlugsEnabled(), ])); $io->writeln([ diff --git a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php index fc4e8331..db1b1dfd 100644 --- a/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/DeleteShortUrlCommand.php @@ -81,6 +81,6 @@ class DeleteShortUrlCommand extends Command private function runDelete(SymfonyStyle $io, ShortUrlIdentifier $identifier, bool $ignoreThreshold): void { $this->deleteShortUrlService->deleteByShortCode($identifier, $ignoreThreshold); - $io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode())); + $io->success(sprintf('Short URL with short code "%s" successfully deleted.', $identifier->shortCode)); } } diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php new file mode 100644 index 00000000..49c390f8 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -0,0 +1,59 @@ +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.'); + } + + protected function interact(InputInterface $input, OutputInterface $output): void + { + $shortCode = $input->getArgument('shortCode'); + if (! empty($shortCode)) { + return; + } + + $io = new SymfonyStyle($input, $output); + $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?'); + if (! empty($shortCode)) { + $input->setArgument('shortCode', $shortCode); + } + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $identifier = ShortUrlIdentifier::fromCli($input); + return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + return []; + } +} diff --git a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php deleted file mode 100644 index bb2f0229..00000000 --- a/module/CLI/src/Command/ShortUrl/GetVisitsCommand.php +++ /dev/null @@ -1,88 +0,0 @@ -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.'); - } - - protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName); - } - - protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName); - } - - protected function interact(InputInterface $input, OutputInterface $output): void - { - $shortCode = $input->getArgument('shortCode'); - if (! empty($shortCode)) { - return; - } - - $io = new SymfonyStyle($input, $output); - $shortCode = $io->ask('A short code was not provided. Which short code do you want to use?'); - if (! empty($shortCode)) { - $input->setArgument('shortCode', $shortCode); - } - } - - protected function execute(InputInterface $input, OutputInterface $output): ?int - { - $identifier = ShortUrlIdentifier::fromCli($input); - $startDate = $this->getStartDateOption($input, $output); - $endDate = $this->getEndDateOption($input, $output); - - $paginator = $this->visitsHelper->visitsForShortUrl( - $identifier, - new VisitsParams(buildDateRange($startDate, $endDate)), - ); - - $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) { - $rowData = $visit->jsonSerialize(); - $rowData['country'] = ($visit->getVisitLocation() ?? new UnknownVisitLocation())->getCountryName(); - return select_keys($rowData, ['referer', 'date', 'userAgent', 'country']); - }); - ShlinkTable::default($output)->render(['Referer', 'Date', 'User agent', 'Country'], $rows); - - return ExitCodes::EXIT_SUCCESS; - } -} diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index ebc9e783..fc0f19a0 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -13,6 +13,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -120,9 +121,7 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $page = (int) $input->getOption('page'); $searchTerm = $input->getOption('search-term'); $tags = $input->getOption('tags'); - $tagsMode = $input->getOption('including-all-tags') === true - ? ShortUrlsParams::TAGS_MODE_ALL - : ShortUrlsParams::TAGS_MODE_ANY; + $tagsMode = $input->getOption('including-all-tags') === true ? TagsMode::ALL->value : TagsMode::ANY->value; $tags = ! empty($tags) ? explode(',', $tags) : []; $all = $input->getOption('all'); $startDate = $this->getStartDateOption($input, $output); diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php new file mode 100644 index 00000000..ac0157bc --- /dev/null +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -0,0 +1,50 @@ +setName(self::NAME) + ->setDescription('Returns the list of visits for provided tag.') + ->addArgument('tag', InputArgument::REQUIRED, 'The tag which visits we want to get.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + $tag = $input->getArgument('tag'); + return $this->visitsHelper->visitsForTag($tag, new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Tag/ListTagsCommand.php b/module/CLI/src/Command/Tag/ListTagsCommand.php index 9c7269fa..cd820169 100644 --- a/module/CLI/src/Command/Tag/ListTagsCommand.php +++ b/module/CLI/src/Command/Tag/ListTagsCommand.php @@ -46,7 +46,7 @@ class ListTagsCommand extends Command return map( $tags, - static fn (TagInfo $tagInfo) => [$tagInfo->tag(), $tagInfo->shortUrlsCount(), $tagInfo->visitsCount()], + static fn (TagInfo $tagInfo) => [$tagInfo->tag, $tagInfo->shortUrlsCount, $tagInfo->visitsCount], ); } } diff --git a/module/CLI/src/Command/Tag/RenameTagCommand.php b/module/CLI/src/Command/Tag/RenameTagCommand.php index 23c1568d..85377a18 100644 --- a/module/CLI/src/Command/Tag/RenameTagCommand.php +++ b/module/CLI/src/Command/Tag/RenameTagCommand.php @@ -19,7 +19,7 @@ class RenameTagCommand extends Command { public const NAME = 'tag:rename'; - public function __construct(private TagServiceInterface $tagService) + public function __construct(private readonly TagServiceInterface $tagService) { parent::__construct(); } diff --git a/module/CLI/src/Command/Util/AbstractLockedCommand.php b/module/CLI/src/Command/Util/AbstractLockedCommand.php index 9482694b..d1e45fd8 100644 --- a/module/CLI/src/Command/Util/AbstractLockedCommand.php +++ b/module/CLI/src/Command/Util/AbstractLockedCommand.php @@ -14,7 +14,7 @@ use function sprintf; abstract class AbstractLockedCommand extends Command { - public function __construct(private LockFactory $locker) + public function __construct(private readonly LockFactory $locker) { parent::__construct(); } @@ -22,11 +22,11 @@ abstract class AbstractLockedCommand extends Command final protected function execute(InputInterface $input, OutputInterface $output): ?int { $lockConfig = $this->getLockConfig(); - $lock = $this->locker->createLock($lockConfig->lockName(), $lockConfig->ttl(), $lockConfig->isBlocking()); + $lock = $this->locker->createLock($lockConfig->lockName, $lockConfig->ttl, $lockConfig->isBlocking); - if (! $lock->acquire($lockConfig->isBlocking())) { + if (! $lock->acquire($lockConfig->isBlocking)) { $output->writeln( - sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName()), + sprintf('Command "%s" is already in progress. Skipping.', $lockConfig->lockName), ); return ExitCodes::EXIT_WARNING; } diff --git a/module/CLI/src/Command/Util/LockedCommandConfig.php b/module/CLI/src/Command/Util/LockedCommandConfig.php index f053d99a..8e357329 100644 --- a/module/CLI/src/Command/Util/LockedCommandConfig.php +++ b/module/CLI/src/Command/Util/LockedCommandConfig.php @@ -9,9 +9,9 @@ final class LockedCommandConfig public const DEFAULT_TTL = 600.0; // 10 minutes private function __construct( - private string $lockName, - private bool $isBlocking, - private float $ttl = self::DEFAULT_TTL, + public readonly string $lockName, + public readonly bool $isBlocking, + public readonly float $ttl = self::DEFAULT_TTL, ) { } @@ -24,19 +24,4 @@ final class LockedCommandConfig { return new self($lockName, false); } - - public function lockName(): string - { - return $this->lockName; - } - - public function isBlocking(): bool - { - return $this->isBlocking; - } - - public function ttl(): float - { - return $this->ttl; - } } diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php new file mode 100644 index 00000000..257c7f26 --- /dev/null +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -0,0 +1,83 @@ +getStartDateOption($input, $output); + $endDate = $this->getEndDateOption($input, $output); + $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); + [$rows, $headers] = $this->resolveRowsAndHeaders($paginator); + + ShlinkTable::default($output)->render($headers, $rows); + + return ExitCodes::EXIT_SUCCESS; + } + + private function resolveRowsAndHeaders(Paginator $paginator): array + { + $extraKeys = []; + $rows = map($paginator->getCurrentPageResults(), function (Visit $visit) use (&$extraKeys) { + $extraFields = $this->mapExtraFields($visit); + $extraKeys = array_keys($extraFields); + + $rowData = [ + ...$visit->jsonSerialize(), + 'country' => $visit->getVisitLocation()?->getCountryName() ?? 'Unknown', + 'city' => $visit->getVisitLocation()?->getCityName() ?? 'Unknown', + ...$extraFields, + ]; + + return select_keys($rowData, ['referer', 'date', 'userAgent', 'country', 'city', ...$extraKeys]); + }); + $extra = map($extraKeys, camelCaseToHumanFriendly(...)); + + return [ + $rows, + ['Referer', 'Date', 'User agent', 'Country', 'City', ...$extra], + ]; + } + + abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator; + + /** + * @return array + */ + abstract protected function mapExtraFields(Visit $visit): array; +} diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php new file mode 100644 index 00000000..76c35990 --- /dev/null +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -0,0 +1,46 @@ +setName(self::NAME) + ->setDescription('Returns the list of non-orphan visits.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + return $this->visitsHelper->nonOrphanVisits(new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + $shortUrl = $visit->getShortUrl(); + return $shortUrl === null ? [] : ['shortUrl' => $this->shortUrlStringifier->stringify($shortUrl)]; + } +} diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php new file mode 100644 index 00000000..ec675a69 --- /dev/null +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -0,0 +1,36 @@ +setName(self::NAME) + ->setDescription('Returns the list of orphan visits.'); + } + + protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator + { + return $this->visitsHelper->orphanVisits(new VisitsParams($dateRange)); + } + + /** + * @return array + */ + protected function mapExtraFields(Visit $visit): array + { + return ['type' => $visit->type()->value]; + } +} diff --git a/module/CLI/src/Command/Visit/LocateVisitsCommand.php b/module/CLI/src/Command/Visit/LocateVisitsCommand.php index de66e84e..fe898dbb 100644 --- a/module/CLI/src/Command/Visit/LocateVisitsCommand.php +++ b/module/CLI/src/Command/Visit/LocateVisitsCommand.php @@ -17,6 +17,7 @@ use Shlinkio\Shlink\IpGeolocation\Exception\WrongIpException; use Shlinkio\Shlink\IpGeolocation\Model\Location; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -80,12 +81,12 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat ); } - if ($all && $retry && ! $this->warnAndVerifyContinue($input)) { + if ($all && $retry && ! $this->warnAndVerifyContinue()) { throw new RuntimeException('Execution aborted'); } } - private function warnAndVerifyContinue(InputInterface $input): bool + private function warnAndVerifyContinue(): bool { $this->io->warning([ 'You are about to process the location of all existing visits your short URLs received.', @@ -103,7 +104,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $all = $retry && $input->getOption('all'); try { - $this->checkDbUpdate($input); + $this->checkDbUpdate(); if ($all) { $this->visitLocator->locateAllVisits($this); @@ -166,7 +167,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat $this->io->writeln($message); } - private function checkDbUpdate(InputInterface $input): void + private function checkDbUpdate(): void { $cliApp = $this->getApplication(); if ($cliApp === null) { @@ -174,7 +175,7 @@ class LocateVisitsCommand extends AbstractLockedCommand implements VisitGeolocat } $downloadDbCommand = $cliApp->find(DownloadGeoLiteDbCommand::NAME); - $exitCode = $downloadDbCommand->run($input, $this->io); + $exitCode = $downloadDbCommand->run(new ArrayInput([]), $this->io); if ($exitCode === ExitCodes::EXIT_FAILURE) { throw new RuntimeException('It is not possible to locate visits without a GeoLite2 db file.'); diff --git a/module/CLI/src/Exception/InvalidRoleConfigException.php b/module/CLI/src/Exception/InvalidRoleConfigException.php index 51adb234..ae483766 100644 --- a/module/CLI/src/Exception/InvalidRoleConfigException.php +++ b/module/CLI/src/Exception/InvalidRoleConfigException.php @@ -16,7 +16,7 @@ class InvalidRoleConfigException extends InvalidArgumentException implements Exc return new self(sprintf( 'You cannot create an API key with the "%s" role attached to the default domain. ' . 'The role is currently limited to non-default domains.', - Role::DOMAIN_SPECIFIC, + Role::DOMAIN_SPECIFIC->value, )); } } diff --git a/module/CLI/src/Util/ShlinkTable.php b/module/CLI/src/Util/ShlinkTable.php index 1d4143c1..cd38e5cd 100644 --- a/module/CLI/src/Util/ShlinkTable.php +++ b/module/CLI/src/Util/ShlinkTable.php @@ -15,7 +15,7 @@ final class ShlinkTable private const DEFAULT_STYLE_NAME = 'default'; private const TABLE_TITLE_STYLE = ' %s '; - private function __construct(private Table $baseTable, private bool $withRowSeparators) + private function __construct(private readonly Table $baseTable, private readonly bool $withRowSeparators) { } diff --git a/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php new file mode 100644 index 00000000..f94a2000 --- /dev/null +++ b/module/CLI/test/Command/Domain/GetDomainVisitsCommandTest.php @@ -0,0 +1,71 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetDomainVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $domain = 'doma.in'; + $getVisits = $this->visitsHelper->visitsForDomain($domain, Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute(['domain' => $domain]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php index 3ec90412..73d2b785 100644 --- a/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/CreateShortUrlCommandTest.php @@ -14,6 +14,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; @@ -38,8 +39,7 @@ class CreateShortUrlCommandTest extends TestCase $command = new CreateShortUrlCommand( $this->urlShortener->reveal(), $this->stringifier->reveal(), - 5, - self::DEFAULT_DOMAIN, + new UrlShortenerOptions(['defaultShortCodesLength' => 5, 'domain' => ['hostname' => self::DEFAULT_DOMAIN]]), ); $this->commandTester = $this->testerForCommand($command); } diff --git a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php index 10a363c7..947b7443 100644 --- a/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/DeleteShortUrlCommandTest.php @@ -36,10 +36,11 @@ class DeleteShortUrlCommandTest extends TestCase public function successMessageIsPrintedIfUrlIsProperlyDeleted(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->will( - function (): void { - }, - ); + $deleteByShortCode = $this->service->deleteByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + false, + )->will(function (): void { + }); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -55,7 +56,7 @@ class DeleteShortUrlCommandTest extends TestCase public function invalidShortCodePrintsMessage(): void { $shortCode = 'abc123'; - $identifier = new ShortUrlIdentifier($shortCode); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $deleteByShortCode = $this->service->deleteByShortCode($identifier, false)->willThrow( Exception\ShortUrlNotFoundException::fromNotFound($identifier), ); @@ -77,7 +78,7 @@ class DeleteShortUrlCommandTest extends TestCase string $expectedMessage, ): void { $shortCode = 'abc123'; - $identifier = new ShortUrlIdentifier($shortCode); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode); $deleteByShortCode = $this->service->deleteByShortCode($identifier, Argument::type('bool'))->will( function (array $args) use ($shortCode): void { $ignoreThreshold = array_pop($args); @@ -114,12 +115,13 @@ class DeleteShortUrlCommandTest extends TestCase public function deleteIsNotRetriedWhenThresholdIsReachedAndQuestionIsDeclined(): void { $shortCode = 'abc123'; - $deleteByShortCode = $this->service->deleteByShortCode(new ShortUrlIdentifier($shortCode), false)->willThrow( - Exception\DeleteShortUrlException::fromVisitsThreshold( - 10, - ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - ), - ); + $deleteByShortCode = $this->service->deleteByShortCode( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + false, + )->willThrow(Exception\DeleteShortUrlException::fromVisitsThreshold( + 10, + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + )); $this->commandTester->setInputs(['no']); $this->commandTester->execute(['shortCode' => $shortCode]); diff --git a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php similarity index 64% rename from module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php rename to module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php index ca9e0981..316c762e 100644 --- a/module/CLI/test/Command/ShortUrl/GetVisitsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/GetShortUrlVisitsCommandTest.php @@ -9,7 +9,7 @@ use Pagerfanta\Adapter\ArrayAdapter; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\Prophecy\ObjectProphecy; -use Shlinkio\Shlink\CLI\Command\ShortUrl\GetVisitsCommand; +use Shlinkio\Shlink\CLI\Command\ShortUrl\GetShortUrlVisitsCommand; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -23,9 +23,10 @@ use Shlinkio\Shlink\IpGeolocation\Model\Location; use ShlinkioTest\Shlink\CLI\CliTestUtilsTrait; use Symfony\Component\Console\Tester\CommandTester; +use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; -class GetVisitsCommandTest extends TestCase +class GetShortUrlVisitsCommandTest extends TestCase { use CliTestUtilsTrait; @@ -35,7 +36,7 @@ class GetVisitsCommandTest extends TestCase public function setUp(): void { $this->visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); - $command = new GetVisitsCommand($this->visitsHelper->reveal()); + $command = new GetShortUrlVisitsCommand($this->visitsHelper->reveal()); $this->commandTester = $this->testerForCommand($command); } @@ -44,8 +45,8 @@ class GetVisitsCommandTest extends TestCase { $shortCode = 'abc123'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::emptyInstance()), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(DateRange::allTime()), ) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); @@ -60,8 +61,8 @@ class GetVisitsCommandTest extends TestCase $startDate = '2016-01-01'; $endDate = '2016-02-01'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::withStartAndEndDate(Chronos::parse($startDate), Chronos::parse($endDate))), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(buildDateRange(Chronos::parse($startDate), Chronos::parse($endDate))), ) ->willReturn(new Paginator(new ArrayAdapter([]))) ->shouldBeCalledOnce(); @@ -79,8 +80,8 @@ class GetVisitsCommandTest extends TestCase $shortCode = 'abc123'; $startDate = 'foo'; $info = $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), - new VisitsParams(DateRange::emptyInstance()), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + new VisitsParams(DateRange::allTime()), )->willReturn(new Paginator(new ArrayAdapter([]))); $this->commandTester->execute([ @@ -99,19 +100,30 @@ class GetVisitsCommandTest extends TestCase /** @test */ public function outputIsProperlyGenerated(): void { + $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), Argument::any())->willReturn( - new Paginator(new ArrayAdapter([ - Visit::forValidShortUrl(ShortUrl::createEmpty(), new Visitor('bar', 'foo', '', ''))->locate( - VisitLocation::fromGeolocation(new Location('', 'Spain', '', '', 0, 0, '')), - ), - ])), + $this->visitsHelper->visitsForShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + Argument::any(), + )->willReturn( + new Paginator(new ArrayAdapter([$visit])), )->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); - self::assertStringContainsString('foo', $output); - self::assertStringContainsString('Spain', $output); - self::assertStringContainsString('bar', $output); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | + +---------+---------------------------+------------+---------+--------+ + + OUTPUT, + $output, + ); } } diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 38d3bcd3..f9d701cb 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Service\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; use Shlinkio\Shlink\Rest\ApiKey\Model\ApiKeyMeta; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -205,23 +206,23 @@ class ListShortUrlsCommandTest extends TestCase public function provideArgs(): iterable { - yield [[], 1, null, [], ShortUrlsParams::TAGS_MODE_ANY]; - yield [['--page' => $page = 3], $page, null, [], ShortUrlsParams::TAGS_MODE_ANY]; - yield [['--including-all-tags' => true], 1, null, [], ShortUrlsParams::TAGS_MODE_ALL]; - yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], ShortUrlsParams::TAGS_MODE_ANY]; + yield [[], 1, null, [], TagsMode::ANY->value]; + yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; + yield [['--including-all-tags' => true], 1, null, [], TagsMode::ALL->value]; + yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value]; yield [ ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tags' => $tags = 'foo,bar'], $page, $searchTerm, explode(',', $tags), - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, ]; yield [ ['--start-date' => $startDate = '2019-01-01'], 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, $startDate, ]; yield [ @@ -229,7 +230,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, null, $endDate, ]; @@ -238,7 +239,7 @@ class ListShortUrlsCommandTest extends TestCase 1, null, [], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY->value, $startDate, $endDate, ]; @@ -276,7 +277,7 @@ class ListShortUrlsCommandTest extends TestCase 'page' => 1, 'searchTerm' => null, 'tags' => [], - 'tagsMode' => ShortUrlsParams::TAGS_MODE_ANY, + 'tagsMode' => TagsMode::ANY->value, 'startDate' => null, 'endDate' => null, 'orderBy' => null, diff --git a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php index 2a816207..12e29eaf 100644 --- a/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ResolveUrlCommandTest.php @@ -37,8 +37,9 @@ class ResolveUrlCommandTest extends TestCase $shortCode = 'abc123'; $expectedUrl = 'http://domain.com/foo/bar'; $shortUrl = ShortUrl::withLongUrl($expectedUrl); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode))->willReturn($shortUrl) - ->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode))->willReturn( + $shortUrl, + )->shouldBeCalledOnce(); $this->commandTester->execute(['shortCode' => $shortCode]); $output = $this->commandTester->getDisplay(); @@ -48,8 +49,8 @@ class ResolveUrlCommandTest extends TestCase /** @test */ public function incorrectShortCodeOutputsErrorMessage(): void { - $identifier = new ShortUrlIdentifier('abc123'); - $shortCode = $identifier->shortCode(); + $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('abc123'); + $shortCode = $identifier->shortCode; $this->urlResolver->resolveShortUrl($identifier) ->willThrow(ShortUrlNotFoundException::fromNotFound($identifier)) diff --git a/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php new file mode 100644 index 00000000..95036a7f --- /dev/null +++ b/module/CLI/test/Command/Tag/GetTagVisitsCommandTest.php @@ -0,0 +1,71 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetTagVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $tag = 'abc123'; + $getVisits = $this->visitsHelper->visitsForTag($tag, Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute(['tag' => $tag]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php new file mode 100644 index 00000000..d6888bf5 --- /dev/null +++ b/module/CLI/test/Command/Visit/GetNonOrphanVisitsCommandTest.php @@ -0,0 +1,70 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->stringifier = $this->prophesize(ShortUrlStringifierInterface::class); + + $this->commandTester = $this->testerForCommand( + new GetNonOrphanVisitsCommand($this->visitsHelper->reveal(), $this->stringifier->reveal()), + ); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $shortUrl = ShortUrl::createEmpty(); + $visit = Visit::forValidShortUrl($shortUrl, new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $getVisits = $this->visitsHelper->nonOrphanVisits(Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + $stringify = $this->stringifier->stringify($shortUrl)->willReturn('the_short_url'); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | the_short_url | + +---------+---------------------------+------------+---------+--------+---------------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + $stringify->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php new file mode 100644 index 00000000..c8c10aad --- /dev/null +++ b/module/CLI/test/Command/Visit/GetOrphanVisitsCommandTest.php @@ -0,0 +1,60 @@ +visitsHelper = $this->prophesize(VisitsStatsHelperInterface::class); + $this->commandTester = $this->testerForCommand(new GetOrphanVisitsCommand($this->visitsHelper->reveal())); + } + + /** @test */ + public function outputIsProperlyGenerated(): void + { + $visit = Visit::forBasePath(new Visitor('bar', 'foo', '', ''))->locate( + VisitLocation::fromGeolocation(new Location('', 'Spain', '', 'Madrid', 0, 0, '')), + ); + $getVisits = $this->visitsHelper->orphanVisits(Argument::any())->willReturn( + new Paginator(new ArrayAdapter([$visit])), + ); + + $this->commandTester->execute([]); + $output = $this->commandTester->getDisplay(); + + self::assertEquals( + <<getDate()->toAtomString()} | bar | Spain | Madrid | base_url | + +---------+---------------------------+------------+---------+--------+----------+ + + OUTPUT, + $output, + ); + $getVisits->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php b/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php index 3b89b505..99c66ea4 100644 --- a/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php +++ b/module/CLI/test/Exception/InvalidRoleConfigExceptionTest.php @@ -20,7 +20,7 @@ class InvalidRoleConfigExceptionTest extends TestCase self::assertEquals(sprintf( 'You cannot create an API key with the "%s" role attached to the default domain. ' . 'The role is currently limited to non-default domains.', - Role::DOMAIN_SPECIFIC, + Role::DOMAIN_SPECIFIC->value, ), $e->getMessage()); } } diff --git a/module/Core/config/dependencies.config.php b/module/Core/config/dependencies.config.php index 516ad8a1..9edc5fc2 100644 --- a/module/Core/config/dependencies.config.php +++ b/module/Core/config/dependencies.config.php @@ -27,6 +27,7 @@ return [ Options\UrlShortenerOptions::class => ConfigAbstractFactory::class, Options\TrackingOptions::class => ConfigAbstractFactory::class, Options\QrCodeOptions::class => ConfigAbstractFactory::class, + Options\RabbitMqOptions::class => ConfigAbstractFactory::class, Options\WebhookOptions::class => ConfigAbstractFactory::class, Service\UrlShortener::class => ConfigAbstractFactory::class, @@ -63,7 +64,7 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class => ConfigAbstractFactory::class, ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => ConfigAbstractFactory::class, - Mercure\MercureUpdatesGenerator::class => ConfigAbstractFactory::class, + EventDispatcher\PublishingUpdatesGenerator::class => ConfigAbstractFactory::class, Importer\ImportedLinksProcessor::class => ConfigAbstractFactory::class, @@ -91,6 +92,7 @@ return [ Options\UrlShortenerOptions::class => ['config.url_shortener'], Options\TrackingOptions::class => ['config.tracking'], Options\QrCodeOptions::class => ['config.qr_codes'], + Options\RabbitMqOptions::class => ['config.rabbitmq'], Options\WebhookOptions::class => ['config.visits_webhooks'], Service\UrlShortener::class => [ @@ -98,6 +100,7 @@ return [ 'em', ShortUrl\Resolver\PersistenceShortUrlRelationResolver::class, Service\ShortUrl\ShortCodeUniquenessHelper::class, + EventDispatcherInterface::class, ], Visit\VisitsTracker::class => [ 'em', @@ -157,7 +160,7 @@ return [ Options\UrlShortenerOptions::class, ], - Mercure\MercureUpdatesGenerator::class => [ + EventDispatcher\PublishingUpdatesGenerator::class => [ ShortUrl\Transformer\ShortUrlDataTransformer::class, Visit\Transformer\OrphanVisitDataTransformer::class, ], diff --git a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php index 969bfd1d..147c37e7 100644 --- a/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php +++ b/module/Core/config/entities-mappings/Shlinkio.Shlink.Core.Entity.Visit.php @@ -6,9 +6,11 @@ namespace Shlinkio\Shlink\Core; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; +use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; use Shlinkio\Shlink\Common\Doctrine\Type\ChronosDateTimeType; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; return static function (ClassMetadata $metadata, array $emConfig): void { $builder = new ClassMetadataBuilder($metadata); @@ -61,10 +63,13 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->nullable() ->build(); - $builder->createField('type', Types::STRING) - ->columnName('type') - ->length(255) - ->build(); + (new FieldBuilder($builder, [ + 'fieldName' => 'type', + 'type' => Types::STRING, + 'enumType' => VisitType::class, + ]))->columnName('type') + ->length(255) + ->build(); $builder->createField('potentialBot', Types::BOOLEAN) ->columnName('potential_bot') diff --git a/module/Core/config/event_dispatcher.config.php b/module/Core/config/event_dispatcher.config.php index d47cc128..467f63cc 100644 --- a/module/Core/config/event_dispatcher.config.php +++ b/module/Core/config/event_dispatcher.config.php @@ -5,12 +5,13 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core; use Laminas\ServiceManager\AbstractFactory\ConfigAbstractFactory; -use PhpAmqpLib\Connection\AMQPStreamConnection; use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\CLI\Util\GeolocationDbUpdater; +use Shlinkio\Shlink\Common\Cache\RedisPublishingHelper; +use Shlinkio\Shlink\Common\Mercure\MercureHubPublishingHelper; +use Shlinkio\Shlink\Common\RabbitMq\RabbitMqPublishingHelper; use Shlinkio\Shlink\IpGeolocation\GeoLite2\DbUpdater; use Shlinkio\Shlink\IpGeolocation\Resolver\IpLocationResolverInterface; -use Symfony\Component\Mercure\Hub; return [ @@ -22,11 +23,17 @@ return [ ], 'async' => [ EventDispatcher\Event\VisitLocated::class => [ - EventDispatcher\NotifyVisitToMercure::class, - EventDispatcher\NotifyVisitToRabbitMq::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class, EventDispatcher\NotifyVisitToWebHooks::class, EventDispatcher\UpdateGeoLiteDb::class, ], + EventDispatcher\Event\ShortUrlCreated::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class, + ], ], ], @@ -34,16 +41,32 @@ return [ 'factories' => [ EventDispatcher\LocateVisit::class => ConfigAbstractFactory::class, EventDispatcher\NotifyVisitToWebHooks::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToMercure::class => ConfigAbstractFactory::class, - EventDispatcher\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => ConfigAbstractFactory::class, + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => ConfigAbstractFactory::class, EventDispatcher\UpdateGeoLiteDb::class => ConfigAbstractFactory::class, ], 'delegators' => [ - EventDispatcher\NotifyVisitToMercure::class => [ + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], - EventDispatcher\NotifyVisitToRabbitMq::class => [ + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ + EventDispatcher\CloseDbConnectionEventListenerDelegator::class, + ], + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ EventDispatcher\CloseDbConnectionEventListenerDelegator::class, ], EventDispatcher\NotifyVisitToWebHooks::class => [ @@ -68,18 +91,46 @@ return [ ShortUrl\Transformer\ShortUrlDataTransformer::class, Options\AppOptions::class, ], - EventDispatcher\NotifyVisitToMercure::class => [ - Hub::class, - Mercure\MercureUpdatesGenerator::class, + EventDispatcher\Mercure\NotifyVisitToMercure::class => [ + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, 'em', 'Logger_Shlink', ], - EventDispatcher\NotifyVisitToRabbitMq::class => [ - AMQPStreamConnection::class, + EventDispatcher\Mercure\NotifyNewShortUrlToMercure::class => [ + MercureHubPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + ], + EventDispatcher\RabbitMq\NotifyVisitToRabbitMq::class => [ + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, 'em', 'Logger_Shlink', Visit\Transformer\OrphanVisitDataTransformer::class, - 'config.rabbitmq.enabled', + Options\RabbitMqOptions::class, + ], + EventDispatcher\RabbitMq\NotifyNewShortUrlToRabbitMq::class => [ + RabbitMqPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + Options\RabbitMqOptions::class, + ], + EventDispatcher\RedisPubSub\NotifyVisitToRedis::class => [ + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', + ], + EventDispatcher\RedisPubSub\NotifyNewShortUrlToRedis::class => [ + RedisPublishingHelper::class, + EventDispatcher\PublishingUpdatesGenerator::class, + 'em', + 'Logger_Shlink', + 'config.redis.pub_sub_enabled', ], EventDispatcher\UpdateGeoLiteDb::class => [GeolocationDbUpdater::class, 'Logger_Shlink'], ], diff --git a/module/Core/config/routes.config.php b/module/Core/config/routes.config.php deleted file mode 100644 index 07e33c73..00000000 --- a/module/Core/config/routes.config.php +++ /dev/null @@ -1,48 +0,0 @@ - [ - [ - 'name' => Action\RobotsAction::class, - 'path' => '/robots.txt', - 'middleware' => [ - Action\RobotsAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\RedirectAction::class, - 'path' => '/{shortCode}', - 'middleware' => [ - IpAddress::class, - Action\RedirectAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\PixelAction::class, - 'path' => '/{shortCode}/track', - 'middleware' => [ - IpAddress::class, - Action\PixelAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - [ - 'name' => Action\QrCodeAction::class, - 'path' => '/{shortCode}/qr-code', - 'middleware' => [ - Action\QrCodeAction::class, - ], - 'allowed_methods' => [RequestMethod::METHOD_GET], - ], - ], - -]; diff --git a/module/Core/functions/functions.php b/module/Core/functions/functions.php index db9a11b9..c5186e41 100644 --- a/module/Core/functions/functions.php +++ b/module/Core/functions/functions.php @@ -8,6 +8,7 @@ use Cake\Chronos\Chronos; use DateTimeInterface; use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Jaybizzle\CrawlerDetect\CrawlerDetect; +use Laminas\Filter\Word\CamelCaseToSeparator; use Laminas\InputFilter\InputFilter; use PUGX\Shortid\Factory as ShortIdFactory; use Shlinkio\Shlink\Common\Util\DateRange; @@ -19,6 +20,7 @@ use function print_r; use function Shlinkio\Shlink\Common\buildDateRange; use function sprintf; use function str_repeat; +use function ucfirst; function generateRandomShortCode(int $length): string { @@ -115,3 +117,13 @@ function fieldWithUtf8Charset(FieldBuilder $field, array $emConfig, string $coll default => $field, }; } + +function camelCaseToHumanFriendly(string $value): string +{ + static $filter; + if ($filter === null) { + $filter = new CamelCaseToSeparator(' '); + } + + return ucfirst($filter->filter($value)); +} diff --git a/module/Core/src/Action/Model/QrCodeParams.php b/module/Core/src/Action/Model/QrCodeParams.php index 42d643d3..7c1f0e34 100644 --- a/module/Core/src/Action/Model/QrCodeParams.php +++ b/module/Core/src/Action/Model/QrCodeParams.php @@ -29,11 +29,11 @@ final class QrCodeParams private const SUPPORTED_FORMATS = ['png', 'svg']; private function __construct( - private int $size, - private int $margin, - private WriterInterface $writer, - private ErrorCorrectionLevelInterface $errorCorrectionLevel, - private RoundBlockSizeModeInterface $roundBlockSizeMode, + public readonly int $size, + public readonly int $margin, + public readonly WriterInterface $writer, + public readonly ErrorCorrectionLevelInterface $errorCorrectionLevel, + public readonly RoundBlockSizeModeInterface $roundBlockSizeMode, ) { } @@ -105,29 +105,4 @@ final class QrCodeParams { return strtolower(trim($param)); } - - public function size(): int - { - return $this->size; - } - - public function margin(): int - { - return $this->margin; - } - - public function writer(): WriterInterface - { - return $this->writer; - } - - public function errorCorrectionLevel(): ErrorCorrectionLevelInterface - { - return $this->errorCorrectionLevel; - } - - public function roundBlockSizeMode(): RoundBlockSizeModeInterface - { - return $this->roundBlockSizeMode; - } } diff --git a/module/Core/src/Action/QrCodeAction.php b/module/Core/src/Action/QrCodeAction.php index 7772a5c8..17bdbdfd 100644 --- a/module/Core/src/Action/QrCodeAction.php +++ b/module/Core/src/Action/QrCodeAction.php @@ -42,11 +42,11 @@ class QrCodeAction implements MiddlewareInterface $params = QrCodeParams::fromRequest($request, $this->defaultOptions); $qrCodeBuilder = Builder::create() ->data($this->stringifier->stringify($shortUrl)) - ->size($params->size()) - ->margin($params->margin()) - ->writer($params->writer()) - ->errorCorrectionLevel($params->errorCorrectionLevel()) - ->roundBlockSizeMode($params->roundBlockSizeMode()); + ->size($params->size) + ->margin($params->margin) + ->writer($params->writer) + ->errorCorrectionLevel($params->errorCorrectionLevel) + ->roundBlockSizeMode($params->roundBlockSizeMode); return new QrCodeResponse($qrCodeBuilder->build()); } diff --git a/module/Core/src/Config/EnvVars.php b/module/Core/src/Config/EnvVars.php index 112b7599..8f8689be 100644 --- a/module/Core/src/Config/EnvVars.php +++ b/module/Core/src/Config/EnvVars.php @@ -4,153 +4,70 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Config; -use ReflectionClass; -use ReflectionClassConstant; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; - -use function array_values; -use function Functional\contains; use function Shlinkio\Shlink\Config\env; -// TODO Convert to enum after dropping PHP 8.0 support - -/** - * @method static EnvVars DELETE_SHORT_URL_THRESHOLD() - * @method static EnvVars DB_DRIVER() - * @method static EnvVars DB_NAME() - * @method static EnvVars DB_USER() - * @method static EnvVars DB_PASSWORD() - * @method static EnvVars DB_HOST() - * @method static EnvVars DB_UNIX_SOCKET() - * @method static EnvVars DB_PORT() - * @method static EnvVars GEOLITE_LICENSE_KEY() - * @method static EnvVars REDIS_SERVERS() - * @method static EnvVars REDIS_SENTINEL_SERVICE() - * @method static EnvVars MERCURE_PUBLIC_HUB_URL() - * @method static EnvVars MERCURE_INTERNAL_HUB_URL() - * @method static EnvVars MERCURE_JWT_SECRET() - * @method static EnvVars DEFAULT_QR_CODE_SIZE() - * @method static EnvVars DEFAULT_QR_CODE_MARGIN() - * @method static EnvVars DEFAULT_QR_CODE_FORMAT() - * @method static EnvVars DEFAULT_QR_CODE_ERROR_CORRECTION() - * @method static EnvVars DEFAULT_QR_CODE_ROUND_BLOCK_SIZE() - * @method static EnvVars RABBITMQ_ENABLED() - * @method static EnvVars RABBITMQ_HOST() - * @method static EnvVars RABBITMQ_PORT() - * @method static EnvVars RABBITMQ_USER() - * @method static EnvVars RABBITMQ_PASSWORD() - * @method static EnvVars RABBITMQ_VHOST() - * @method static EnvVars DEFAULT_INVALID_SHORT_URL_REDIRECT() - * @method static EnvVars DEFAULT_REGULAR_404_REDIRECT() - * @method static EnvVars DEFAULT_BASE_URL_REDIRECT() - * @method static EnvVars REDIRECT_STATUS_CODE() - * @method static EnvVars REDIRECT_CACHE_LIFETIME() - * @method static EnvVars BASE_PATH() - * @method static EnvVars PORT() - * @method static EnvVars TASK_WORKER_NUM() - * @method static EnvVars WEB_WORKER_NUM() - * @method static EnvVars ANONYMIZE_REMOTE_ADDR() - * @method static EnvVars TRACK_ORPHAN_VISITS() - * @method static EnvVars DISABLE_TRACK_PARAM() - * @method static EnvVars DISABLE_TRACKING() - * @method static EnvVars DISABLE_IP_TRACKING() - * @method static EnvVars DISABLE_REFERRER_TRACKING() - * @method static EnvVars DISABLE_UA_TRACKING() - * @method static EnvVars DISABLE_TRACKING_FROM() - * @method static EnvVars DEFAULT_SHORT_CODES_LENGTH() - * @method static EnvVars IS_HTTPS_ENABLED() - * @method static EnvVars DEFAULT_DOMAIN() - * @method static EnvVars AUTO_RESOLVE_TITLES() - * @method static EnvVars REDIRECT_APPEND_EXTRA_PATH() - * @method static EnvVars TIMEZONE() - * @method static EnvVars VISITS_WEBHOOKS() - * @method static EnvVars NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS() - */ -final class EnvVars +enum EnvVars: string { - public const DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD'; - public const DB_DRIVER = 'DB_DRIVER'; - public const DB_NAME = 'DB_NAME'; - public const DB_USER = 'DB_USER'; - public const DB_PASSWORD = 'DB_PASSWORD'; - public const DB_HOST = 'DB_HOST'; - public const DB_UNIX_SOCKET = 'DB_UNIX_SOCKET'; - public const DB_PORT = 'DB_PORT'; - public const GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY'; - public const REDIS_SERVERS = 'REDIS_SERVERS'; - public const REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; - public const MERCURE_PUBLIC_HUB_URL = 'MERCURE_PUBLIC_HUB_URL'; - public const MERCURE_INTERNAL_HUB_URL = 'MERCURE_INTERNAL_HUB_URL'; - public const MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET'; - public const DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; - public const DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; - public const DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; - public const DEFAULT_QR_CODE_ERROR_CORRECTION = 'DEFAULT_QR_CODE_ERROR_CORRECTION'; - public const DEFAULT_QR_CODE_ROUND_BLOCK_SIZE = 'DEFAULT_QR_CODE_ROUND_BLOCK_SIZE'; - public const RABBITMQ_ENABLED = 'RABBITMQ_ENABLED'; - public const RABBITMQ_HOST = 'RABBITMQ_HOST'; - public const RABBITMQ_PORT = 'RABBITMQ_PORT'; - public const RABBITMQ_USER = 'RABBITMQ_USER'; - public const RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; - public const RABBITMQ_VHOST = 'RABBITMQ_VHOST'; - public const DEFAULT_INVALID_SHORT_URL_REDIRECT = 'DEFAULT_INVALID_SHORT_URL_REDIRECT'; - public const DEFAULT_REGULAR_404_REDIRECT = 'DEFAULT_REGULAR_404_REDIRECT'; - public const DEFAULT_BASE_URL_REDIRECT = 'DEFAULT_BASE_URL_REDIRECT'; - public const REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE'; - public const REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; - public const BASE_PATH = 'BASE_PATH'; - public const PORT = 'PORT'; - public const TASK_WORKER_NUM = 'TASK_WORKER_NUM'; - public const WEB_WORKER_NUM = 'WEB_WORKER_NUM'; - public const ANONYMIZE_REMOTE_ADDR = 'ANONYMIZE_REMOTE_ADDR'; - public const TRACK_ORPHAN_VISITS = 'TRACK_ORPHAN_VISITS'; - public const DISABLE_TRACK_PARAM = 'DISABLE_TRACK_PARAM'; - public const DISABLE_TRACKING = 'DISABLE_TRACKING'; - public const DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING'; - public const DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING'; - public const DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING'; - public const DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM'; - public const DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH'; - public const IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED'; - public const DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; - public const AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; - public const REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; - public const TIMEZONE = 'TIMEZONE'; + case DELETE_SHORT_URL_THRESHOLD = 'DELETE_SHORT_URL_THRESHOLD'; + case DB_DRIVER = 'DB_DRIVER'; + case DB_NAME = 'DB_NAME'; + case DB_USER = 'DB_USER'; + case DB_PASSWORD = 'DB_PASSWORD'; + case DB_HOST = 'DB_HOST'; + case DB_UNIX_SOCKET = 'DB_UNIX_SOCKET'; + case DB_PORT = 'DB_PORT'; + case GEOLITE_LICENSE_KEY = 'GEOLITE_LICENSE_KEY'; + case REDIS_SERVERS = 'REDIS_SERVERS'; + case REDIS_SENTINEL_SERVICE = 'REDIS_SENTINEL_SERVICE'; + 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'; + case MERCURE_JWT_SECRET = 'MERCURE_JWT_SECRET'; + case DEFAULT_QR_CODE_SIZE = 'DEFAULT_QR_CODE_SIZE'; + case DEFAULT_QR_CODE_MARGIN = 'DEFAULT_QR_CODE_MARGIN'; + case DEFAULT_QR_CODE_FORMAT = 'DEFAULT_QR_CODE_FORMAT'; + 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 RABBITMQ_ENABLED = 'RABBITMQ_ENABLED'; + case RABBITMQ_HOST = 'RABBITMQ_HOST'; + case RABBITMQ_PORT = 'RABBITMQ_PORT'; + case RABBITMQ_USER = 'RABBITMQ_USER'; + case RABBITMQ_PASSWORD = 'RABBITMQ_PASSWORD'; + case RABBITMQ_VHOST = 'RABBITMQ_VHOST'; /** @deprecated */ - public const VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; + case RABBITMQ_LEGACY_VISITS_PUBLISHING = 'RABBITMQ_LEGACY_VISITS_PUBLISHING'; + 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'; + case REDIRECT_STATUS_CODE = 'REDIRECT_STATUS_CODE'; + case REDIRECT_CACHE_LIFETIME = 'REDIRECT_CACHE_LIFETIME'; + case BASE_PATH = 'BASE_PATH'; + 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'; + case DISABLE_TRACKING = 'DISABLE_TRACKING'; + case DISABLE_IP_TRACKING = 'DISABLE_IP_TRACKING'; + case DISABLE_REFERRER_TRACKING = 'DISABLE_REFERRER_TRACKING'; + case DISABLE_UA_TRACKING = 'DISABLE_UA_TRACKING'; + case DISABLE_TRACKING_FROM = 'DISABLE_TRACKING_FROM'; + case DEFAULT_SHORT_CODES_LENGTH = 'DEFAULT_SHORT_CODES_LENGTH'; + case IS_HTTPS_ENABLED = 'IS_HTTPS_ENABLED'; + case DEFAULT_DOMAIN = 'DEFAULT_DOMAIN'; + case AUTO_RESOLVE_TITLES = 'AUTO_RESOLVE_TITLES'; + case REDIRECT_APPEND_EXTRA_PATH = 'REDIRECT_APPEND_EXTRA_PATH'; + case TIMEZONE = 'TIMEZONE'; + case MULTI_SEGMENT_SLUGS_ENABLED = 'MULTI_SEGMENT_SLUGS_ENABLED'; /** @deprecated */ - public const NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; - - /** - * @return string[] - */ - public static function cases(): array - { - static $constants; - if ($constants !== null) { - return $constants; - } - - $ref = new ReflectionClass(self::class); - return $constants = array_values($ref->getConstants(ReflectionClassConstant::IS_PUBLIC)); - } - - private function __construct(private string $envVar) - { - } - - public static function __callStatic(string $name, array $arguments): self - { - if (! contains(self::cases(), $name)) { - throw new InvalidArgumentException('Invalid env var: "' . $name . '"'); - } - - return new self($name); - } + case VISITS_WEBHOOKS = 'VISITS_WEBHOOKS'; + /** @deprecated */ + case NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS = 'NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS'; public function loadFromEnv(mixed $default = null): mixed { - return env($this->envVar, $default); + return env($this->value, $default); } public function existsInEnv(): bool diff --git a/module/Core/src/Config/NotFoundRedirectResolver.php b/module/Core/src/Config/NotFoundRedirectResolver.php index caa100c3..3ab2e740 100644 --- a/module/Core/src/Config/NotFoundRedirectResolver.php +++ b/module/Core/src/Config/NotFoundRedirectResolver.php @@ -13,7 +13,9 @@ use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use function Functional\compose; +use function Functional\id; use function str_replace; +use function urlencode; class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface { @@ -71,10 +73,10 @@ class NotFoundRedirectResolver implements NotFoundRedirectResolverInterface $replacePlaceholderForPattern(self::ORIGINAL_PATH_PLACEHOLDER, $path, $modifier), ); $replacePlaceholdersInPath = compose( - $replacePlaceholders('\Functional\id'), - static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), // Fix duplicated bars + $replacePlaceholders(id(...)), + static fn (?string $path) => $path === null ? null : str_replace('//', '/', $path), ); - $replacePlaceholdersInQuery = $replacePlaceholders('\urlencode'); + $replacePlaceholdersInQuery = $replacePlaceholders(urlencode(...)); return $redirectUri ->withPath($replacePlaceholdersInPath($redirectUri->getPath())) diff --git a/module/Core/src/Config/NotFoundRedirects.php b/module/Core/src/Config/NotFoundRedirects.php index 492a00bc..48437924 100644 --- a/module/Core/src/Config/NotFoundRedirects.php +++ b/module/Core/src/Config/NotFoundRedirects.php @@ -9,9 +9,9 @@ use JsonSerializable; final class NotFoundRedirects implements JsonSerializable { private function __construct( - private ?string $baseUrlRedirect, - private ?string $regular404Redirect, - private ?string $invalidShortUrlRedirect, + public readonly ?string $baseUrlRedirect, + public readonly ?string $regular404Redirect, + public readonly ?string $invalidShortUrlRedirect, ) { } @@ -33,21 +33,6 @@ final class NotFoundRedirects implements JsonSerializable return new self($config->baseUrlRedirect(), $config->regular404Redirect(), $config->invalidShortUrlRedirect()); } - public function baseUrlRedirect(): ?string - { - return $this->baseUrlRedirect; - } - - public function regular404Redirect(): ?string - { - return $this->regular404Redirect; - } - - public function invalidShortUrlRedirect(): ?string - { - return $this->invalidShortUrlRedirect; - } - public function jsonSerialize(): array { return [ diff --git a/module/Core/src/Domain/Model/DomainItem.php b/module/Core/src/Domain/Model/DomainItem.php index 5547fe8d..cc968e95 100644 --- a/module/Core/src/Domain/Model/DomainItem.php +++ b/module/Core/src/Domain/Model/DomainItem.php @@ -12,9 +12,9 @@ use Shlinkio\Shlink\Core\Entity\Domain; final class DomainItem implements JsonSerializable { private function __construct( - private string $authority, - private NotFoundRedirectConfigInterface $notFoundRedirectConfig, - private bool $isDefault, + private readonly string $authority, + public readonly NotFoundRedirectConfigInterface $notFoundRedirectConfig, + public readonly bool $isDefault, ) { } @@ -23,9 +23,9 @@ final class DomainItem implements JsonSerializable return new self($domain->getAuthority(), $domain, false); } - public static function forDefaultDomain(string $authority, NotFoundRedirectConfigInterface $config): self + public static function forDefaultDomain(string $defaultDomain, NotFoundRedirectConfigInterface $config): self { - return new self($authority, $config, true); + return new self($defaultDomain, $config, true); } public function jsonSerialize(): array @@ -41,14 +41,4 @@ final class DomainItem implements JsonSerializable { return $this->authority; } - - public function isDefault(): bool - { - return $this->isDefault; - } - - public function notFoundRedirectConfig(): NotFoundRedirectConfigInterface - { - return $this->notFoundRedirectConfig; - } } diff --git a/module/Core/src/Domain/Repository/DomainRepository.php b/module/Core/src/Domain/Repository/DomainRepository.php index 0a99b3c6..60c32499 100644 --- a/module/Core/src/Domain/Repository/DomainRepository.php +++ b/module/Core/src/Domain/Repository/DomainRepository.php @@ -7,7 +7,6 @@ namespace Shlinkio\Shlink\Core\Domain\Repository; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Happyr\DoctrineSpecification\Repository\EntitySpecificationRepository; -use Happyr\DoctrineSpecification\Spec; use Shlinkio\Shlink\Core\Domain\Spec\IsDomain; use Shlinkio\Shlink\Core\Entity\Domain; use Shlinkio\Shlink\Core\Entity\ShortUrl; @@ -77,10 +76,9 @@ class DomainRepository extends EntitySpecificationRepository implements DomainRe // FIXME The $apiKey->spec() method cannot be used here, as it returns a single spec which assumes the // ShortUrl is the root entity. Here, the Domain is the root entity. // Think on a way to centralize the conditional behavior and make $apiKey->spec() more flexible. - yield from $apiKey?->mapRoles(fn (string $roleName, array $meta) => match ($roleName) { + yield from $apiKey?->mapRoles(fn (Role $role, array $meta) => match ($role) { Role::DOMAIN_SPECIFIC => ['d', new IsDomain(Role::domainIdFromMeta($meta))], Role::AUTHORED_SHORT_URLS => ['s', new BelongsToApiKey($apiKey)], - default => [null, Spec::andX()], }) ?? []; } } diff --git a/module/Core/src/Entity/Domain.php b/module/Core/src/Entity/Domain.php index 65ca8ce6..9c31bbe2 100644 --- a/module/Core/src/Entity/Domain.php +++ b/module/Core/src/Entity/Domain.php @@ -66,8 +66,8 @@ class Domain extends AbstractEntity implements JsonSerializable, NotFoundRedirec public function configureNotFoundRedirects(NotFoundRedirects $redirects): void { - $this->baseUrlRedirect = $redirects->baseUrlRedirect(); - $this->regular404Redirect = $redirects->regular404Redirect(); - $this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect(); + $this->baseUrlRedirect = $redirects->baseUrlRedirect; + $this->regular404Redirect = $redirects->regular404Redirect; + $this->invalidShortUrlRedirect = $redirects->invalidShortUrlRedirect; } } diff --git a/module/Core/src/Entity/ShortUrl.php b/module/Core/src/Entity/ShortUrl.php index 9fff1509..28c4c446 100644 --- a/module/Core/src/Entity/ShortUrl.php +++ b/module/Core/src/Entity/ShortUrl.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\Resolver\SimpleShortUrlRelationResolver; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -174,7 +175,7 @@ class ShortUrl extends AbstractEntity { /** @var Selectable $visits */ $visits = $this->visits; - $criteria = Criteria::create()->where(Criteria::expr()->eq('type', Visit::TYPE_IMPORTED)) + $criteria = Criteria::create()->where(Criteria::expr()->eq('type', VisitType::IMPORTED)) ->orderBy(['id' => 'DESC']) ->setMaxResults(1); diff --git a/module/Core/src/Entity/Visit.php b/module/Core/src/Entity/Visit.php index c509bcc3..9bff9db9 100644 --- a/module/Core/src/Entity/Visit.php +++ b/module/Core/src/Entity/Visit.php @@ -10,30 +10,24 @@ use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Common\Exception\InvalidArgumentException; use Shlinkio\Shlink\Common\Util\IpAddress; use Shlinkio\Shlink\Core\Model\Visitor; -use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisit; use function Shlinkio\Shlink\Core\isCrawler; class Visit extends AbstractEntity implements JsonSerializable { - public const TYPE_VALID_SHORT_URL = 'valid_short_url'; - public const TYPE_IMPORTED = 'imported'; - public const TYPE_INVALID_SHORT_URL = 'invalid_short_url'; - public const TYPE_BASE_URL = 'base_url'; - public const TYPE_REGULAR_404 = 'regular_404'; - private string $referer; private Chronos $date; private ?string $remoteAddr = null; private ?string $visitedUrl = null; private string $userAgent; - private string $type; + private VisitType $type; private ?ShortUrl $shortUrl; private ?VisitLocation $visitLocation = null; private bool $potentialBot; - private function __construct(?ShortUrl $shortUrl, string $type) + private function __construct(?ShortUrl $shortUrl, VisitType $type) { $this->shortUrl = $shortUrl; $this->date = Chronos::now(); @@ -42,7 +36,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forValidShortUrl(ShortUrl $shortUrl, Visitor $visitor, bool $anonymize = true): self { - $instance = new self($shortUrl, self::TYPE_VALID_SHORT_URL); + $instance = new self($shortUrl, VisitType::VALID_SHORT_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -50,7 +44,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function fromImport(ShortUrl $shortUrl, ImportedShlinkVisit $importedVisit): self { - $instance = new self($shortUrl, self::TYPE_IMPORTED); + $instance = new self($shortUrl, VisitType::IMPORTED); $instance->userAgent = $importedVisit->userAgent(); $instance->potentialBot = isCrawler($instance->userAgent); $instance->referer = $importedVisit->referer(); @@ -64,7 +58,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forBasePath(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_BASE_URL); + $instance = new self(null, VisitType::BASE_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -72,7 +66,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forInvalidShortUrl(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_INVALID_SHORT_URL); + $instance = new self(null, VisitType::INVALID_SHORT_URL); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -80,7 +74,7 @@ class Visit extends AbstractEntity implements JsonSerializable public static function forRegularNotFound(Visitor $visitor, bool $anonymize = true): self { - $instance = new self(null, self::TYPE_REGULAR_404); + $instance = new self(null, VisitType::REGULAR_404); $instance->hydrateFromVisitor($visitor, $anonymize); return $instance; @@ -88,10 +82,10 @@ class Visit extends AbstractEntity implements JsonSerializable private function hydrateFromVisitor(Visitor $visitor, bool $anonymize = true): void { - $this->userAgent = $visitor->getUserAgent(); - $this->referer = $visitor->getReferer(); - $this->remoteAddr = $this->processAddress($anonymize, $visitor->getRemoteAddress()); - $this->visitedUrl = $visitor->getVisitedUrl(); + $this->userAgent = $visitor->userAgent; + $this->referer = $visitor->referer; + $this->remoteAddr = $this->processAddress($anonymize, $visitor->remoteAddress); + $this->visitedUrl = $visitor->visitedUrl; $this->potentialBot = $visitor->isPotentialBot(); } @@ -124,7 +118,7 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->shortUrl; } - public function getVisitLocation(): ?VisitLocationInterface + public function getVisitLocation(): ?VisitLocation { return $this->visitLocation; } @@ -150,7 +144,7 @@ class Visit extends AbstractEntity implements JsonSerializable return $this->visitedUrl; } - public function type(): string + public function type(): VisitType { return $this->type; } @@ -159,11 +153,19 @@ class Visit extends AbstractEntity implements JsonSerializable * Needed only for ArrayCollections to be able to apply criteria filtering * @internal */ - public function getType(): string + public function getType(): VisitType { return $this->type(); } + /** + * @internal + */ + public function getDate(): Chronos + { + return $this->date; + } + public function jsonSerialize(): array { return [ @@ -174,12 +176,4 @@ class Visit extends AbstractEntity implements JsonSerializable 'potentialBot' => $this->potentialBot, ]; } - - /** - * @internal - */ - public function getDate(): Chronos - { - return $this->date; - } } diff --git a/module/Core/src/Entity/VisitLocation.php b/module/Core/src/Entity/VisitLocation.php index 594126a7..239338d6 100644 --- a/module/Core/src/Entity/VisitLocation.php +++ b/module/Core/src/Entity/VisitLocation.php @@ -4,12 +4,12 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Entity; +use JsonSerializable; use Shlinkio\Shlink\Common\Entity\AbstractEntity; -use Shlinkio\Shlink\Core\Visit\Model\VisitLocationInterface; use Shlinkio\Shlink\Importer\Model\ImportedShlinkVisitLocation; use Shlinkio\Shlink\IpGeolocation\Model\Location; -class VisitLocation extends AbstractEntity implements VisitLocationInterface +class VisitLocation extends AbstractEntity implements JsonSerializable { private string $countryCode; private string $countryName; diff --git a/module/Core/src/ErrorHandler/Model/NotFoundType.php b/module/Core/src/ErrorHandler/Model/NotFoundType.php index 39970dea..99f7fbe6 100644 --- a/module/Core/src/ErrorHandler/Model/NotFoundType.php +++ b/module/Core/src/ErrorHandler/Model/NotFoundType.php @@ -7,27 +7,27 @@ namespace Shlinkio\Shlink\Core\ErrorHandler\Model; use Mezzio\Router\RouteResult; use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Core\Action\RedirectAction; -use Shlinkio\Shlink\Core\Entity\Visit; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use function rtrim; class NotFoundType { - private function __construct(private string $type) + private function __construct(private readonly ?VisitType $type) { } public static function fromRequest(ServerRequestInterface $request, string $basePath): self { /** @var RouteResult $routeResult */ - $routeResult = $request->getAttribute(RouteResult::class, RouteResult::fromRouteFailure(null)); + $routeResult = $request->getAttribute(RouteResult::class) ?? RouteResult::fromRouteFailure(null); $isBaseUrl = rtrim($request->getUri()->getPath(), '/') === $basePath; $type = match (true) { - $isBaseUrl => Visit::TYPE_BASE_URL, - $routeResult->isFailure() => Visit::TYPE_REGULAR_404, - $routeResult->getMatchedRouteName() === RedirectAction::class => Visit::TYPE_INVALID_SHORT_URL, - default => self::class, + $isBaseUrl => VisitType::BASE_URL, + $routeResult->isFailure() => VisitType::REGULAR_404, + $routeResult->getMatchedRouteName() === RedirectAction::class => VisitType::INVALID_SHORT_URL, + default => null, }; return new self($type); @@ -35,16 +35,16 @@ class NotFoundType public function isBaseUrl(): bool { - return $this->type === Visit::TYPE_BASE_URL; + return $this->type === VisitType::BASE_URL; } public function isRegularNotFound(): bool { - return $this->type === Visit::TYPE_REGULAR_404; + return $this->type === VisitType::REGULAR_404; } public function isInvalidShortUrl(): bool { - return $this->type === Visit::TYPE_INVALID_SHORT_URL; + return $this->type === VisitType::INVALID_SHORT_URL; } } diff --git a/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php b/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php new file mode 100644 index 00000000..ae8391db --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/AbstractAsyncListener.php @@ -0,0 +1,12 @@ +isEnabled()) { + return; + } + + $shortUrlId = $shortUrlCreated->shortUrlId; + $shortUrl = $this->em->find(ShortUrl::class, $shortUrlId); + $name = $this->getRemoteSystem()->value; + + if ($shortUrl === null) { + $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId, 'name' => $name], + ); + return; + } + + try { + $this->publishingHelper->publishUpdate($this->updatesGenerator->newShortUrlUpdate($shortUrl)); + } catch (Throwable $e) { + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => $name], + ); + } + } +} diff --git a/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php new file mode 100644 index 00000000..5852b032 --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/AbstractNotifyVisitListener.php @@ -0,0 +1,72 @@ +isEnabled()) { + return; + } + + $visitId = $visitLocated->visitId; + $visit = $this->em->find(Visit::class, $visitId); + $name = $this->getRemoteSystem()->value; + + if ($visit === null) { + $this->logger->warning( + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => $name], + ); + return; + } + + $updates = $this->determineUpdatesForVisit($visit); + + try { + each($updates, fn (Update $update) => $this->publishingHelper->publishUpdate($update)); + } catch (Throwable $e) { + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => $name], + ); + } + } + + /** + * @return Update[] + */ + protected function determineUpdatesForVisit(Visit $visit): array + { + if ($visit->isOrphan()) { + return [$this->updatesGenerator->newOrphanVisitUpdate($visit)]; + } + + return [ + $this->updatesGenerator->newShortUrlVisitUpdate($visit), + $this->updatesGenerator->newVisitUpdate($visit), + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Async/RemoteSystem.php b/module/Core/src/EventDispatcher/Async/RemoteSystem.php new file mode 100644 index 00000000..2cdda1d9 --- /dev/null +++ b/module/Core/src/EventDispatcher/Async/RemoteSystem.php @@ -0,0 +1,12 @@ +visitId; - } - public function jsonSerialize(): array { return ['visitId' => $this->visitId]; diff --git a/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php new file mode 100644 index 00000000..9786808f --- /dev/null +++ b/module/Core/src/EventDispatcher/Event/ShortUrlCreated.php @@ -0,0 +1,21 @@ + $this->shortUrlId, + ]; + } +} diff --git a/module/Core/src/EventDispatcher/Event/UrlVisited.php b/module/Core/src/EventDispatcher/Event/UrlVisited.php index 633b439e..02452a3e 100644 --- a/module/Core/src/EventDispatcher/Event/UrlVisited.php +++ b/module/Core/src/EventDispatcher/Event/UrlVisited.php @@ -6,13 +6,8 @@ namespace Shlinkio\Shlink\Core\EventDispatcher\Event; final class UrlVisited extends AbstractVisitEvent { - public function __construct(string $visitId, private ?string $originalIpAddress = null) + public function __construct(string $visitId, public readonly ?string $originalIpAddress = null) { parent::__construct($visitId); } - - public function originalIpAddress(): ?string - { - return $this->originalIpAddress; - } } diff --git a/module/Core/src/EventDispatcher/LocateVisit.php b/module/Core/src/EventDispatcher/LocateVisit.php index fbd32962..197ce9a0 100644 --- a/module/Core/src/EventDispatcher/LocateVisit.php +++ b/module/Core/src/EventDispatcher/LocateVisit.php @@ -30,7 +30,7 @@ class LocateVisit public function __invoke(UrlVisited $shortUrlVisited): void { - $visitId = $shortUrlVisited->visitId(); + $visitId = $shortUrlVisited->visitId; /** @var Visit|null $visit */ $visit = $this->em->find(Visit::class, $visitId); @@ -41,7 +41,7 @@ class LocateVisit return; } - $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress(), $visit); + $this->locateVisit($visitId, $shortUrlVisited->originalIpAddress, $visit); $this->eventDispatcher->dispatch(new VisitLocated($visitId)); } diff --git a/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php new file mode 100644 index 00000000..cccee78d --- /dev/null +++ b/module/Core/src/EventDispatcher/Mercure/NotifyNewShortUrlToMercure.php @@ -0,0 +1,21 @@ +visitId(); - - /** @var Visit|null $visit */ - $visit = $this->em->find(Visit::class, $visitId); - if ($visit === null) { - $this->logger->warning('Tried to notify mercure for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - try { - each($this->determineUpdatesForVisit($visit), fn (Update $update) => $this->hub->publish($update)); - } catch (Throwable $e) { - $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ - 'e' => $e, - ]); - } - } - - /** - * @return Update[] - */ - private function determineUpdatesForVisit(Visit $visit): array - { - if ($visit->isOrphan()) { - return [$this->updatesGenerator->newOrphanVisitUpdate($visit)]; - } - - return [ - $this->updatesGenerator->newShortUrlVisitUpdate($visit), - $this->updatesGenerator->newVisitUpdate($visit), - ]; - } -} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php deleted file mode 100644 index f05ecf64..00000000 --- a/module/Core/src/EventDispatcher/NotifyVisitToRabbitMq.php +++ /dev/null @@ -1,102 +0,0 @@ -isEnabled) { - return; - } - - $visitId = $shortUrlLocated->visitId(); - $visit = $this->em->find(Visit::class, $visitId); - - if ($visit === null) { - $this->logger->warning('Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', [ - 'visitId' => $visitId, - ]); - return; - } - - if (! $this->connection->isConnected()) { - $this->connection->reconnect(); - } - - $queues = $this->determineQueuesToPublishTo($visit); - $message = $this->visitToMessage($visit); - - try { - $channel = $this->connection->channel(); - - foreach ($queues as $queue) { - // Declare an exchange and a queue that will persist server restarts - $exchange = $queue; // We use the same name for the exchange and the queue - $channel->exchange_declare($exchange, AMQPExchangeType::DIRECT, false, true, false); - $channel->queue_declare($queue, false, true, false, false); - - // Bind the exchange and the queue together, and publish the message - $channel->queue_bind($queue, $exchange); - $channel->basic_publish($message, $exchange); - } - - $channel->close(); - } catch (Throwable $e) { - $this->logger->debug('Error while trying to notify RabbitMQ with new visit. {e}', ['e' => $e]); - } finally { - $this->connection->close(); - } - } - - /** - * @return string[] - */ - private function determineQueuesToPublishTo(Visit $visit): array - { - if ($visit->isOrphan()) { - return [self::NEW_ORPHAN_VISIT_QUEUE]; - } - - return [ - self::NEW_VISIT_QUEUE, - sprintf('%s/%s', self::NEW_VISIT_QUEUE, $visit->getShortUrl()?->getShortCode()), - ]; - } - - private function visitToMessage(Visit $visit): AMQPMessage - { - $messageBody = json_encode(! $visit->isOrphan() ? $visit : $this->orphanVisitTransformer->transform($visit)); - return new AMQPMessage($messageBody, [ - 'content_type' => 'application/json', - 'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT, - ]); - } -} diff --git a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php index 73ff9266..1bf09517 100644 --- a/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php +++ b/module/Core/src/EventDispatcher/NotifyVisitToWebHooks.php @@ -40,7 +40,7 @@ class NotifyVisitToWebHooks return; } - $visitId = $shortUrlLocated->visitId(); + $visitId = $shortUrlLocated->visitId; /** @var Visit|null $visit */ $visit = $this->em->find(Visit::class, $visitId); diff --git a/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php new file mode 100644 index 00000000..0f7de480 --- /dev/null +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGenerator.php @@ -0,0 +1,52 @@ +value, [ + 'shortUrl' => $this->shortUrlTransformer->transform($visit->getShortUrl()), + 'visit' => $visit->jsonSerialize(), + ]); + } + + public function newOrphanVisitUpdate(Visit $visit): Update + { + return Update::forTopicAndPayload(Topic::NEW_ORPHAN_VISIT->value, [ + 'visit' => $this->orphanVisitTransformer->transform($visit), + ]); + } + + public function newShortUrlVisitUpdate(Visit $visit): Update + { + $shortUrl = $visit->getShortUrl(); + $topic = Topic::newShortUrlVisit($shortUrl?->getShortCode()); + + return Update::forTopicAndPayload($topic, [ + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), + 'visit' => $visit->jsonSerialize(), + ]); + } + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update + { + return Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, [ + 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), + ]); + } +} diff --git a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php b/module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php similarity index 51% rename from module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php rename to module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php index 951e805c..826157eb 100644 --- a/module/Core/src/Mercure/MercureUpdatesGeneratorInterface.php +++ b/module/Core/src/EventDispatcher/PublishingUpdatesGeneratorInterface.php @@ -2,16 +2,19 @@ declare(strict_types=1); -namespace Shlinkio\Shlink\Core\Mercure; +namespace Shlinkio\Shlink\Core\EventDispatcher; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; +use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Symfony\Component\Mercure\Update; -interface MercureUpdatesGeneratorInterface +interface PublishingUpdatesGeneratorInterface { public function newVisitUpdate(Visit $visit): Update; public function newOrphanVisitUpdate(Visit $visit): Update; public function newShortUrlVisitUpdate(Visit $visit): Update; + + public function newShortUrlUpdate(ShortUrl $shortUrl): Update; } diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php new file mode 100644 index 00000000..488247d7 --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMq.php @@ -0,0 +1,36 @@ +options->isEnabled(); + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::RABBIT_MQ; + } +} diff --git a/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php new file mode 100644 index 00000000..0faa795c --- /dev/null +++ b/module/Core/src/EventDispatcher/RabbitMq/NotifyVisitToRabbitMq.php @@ -0,0 +1,71 @@ +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->isEnabled(); + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::RABBIT_MQ; + } +} diff --git a/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php b/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php new file mode 100644 index 00000000..5cee9d5e --- /dev/null +++ b/module/Core/src/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedis.php @@ -0,0 +1,35 @@ +enabled; + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::REDIS_PUB_SUB; + } +} diff --git a/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php b/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php new file mode 100644 index 00000000..ae349495 --- /dev/null +++ b/module/Core/src/EventDispatcher/RedisPubSub/NotifyVisitToRedis.php @@ -0,0 +1,35 @@ +enabled; + } + + protected function getRemoteSystem(): RemoteSystem + { + return RemoteSystem::REDIS_PUB_SUB; + } +} diff --git a/module/Core/src/EventDispatcher/Topic.php b/module/Core/src/EventDispatcher/Topic.php new file mode 100644 index 00000000..0cba5a09 --- /dev/null +++ b/module/Core/src/EventDispatcher/Topic.php @@ -0,0 +1,19 @@ +value, $shortCode ?? ''); + } +} diff --git a/module/Core/src/Exception/DeleteShortUrlException.php b/module/Core/src/Exception/DeleteShortUrlException.php index e6f3bd0d..0d331400 100644 --- a/module/Core/src/Exception/DeleteShortUrlException.php +++ b/module/Core/src/Exception/DeleteShortUrlException.php @@ -20,8 +20,8 @@ class DeleteShortUrlException extends DomainException implements ProblemDetailsE public static function fromVisitsThreshold(int $threshold, ShortUrlIdentifier $identifier): self { - $shortCode = $identifier->shortCode(); - $domain = $identifier->domain(); + $shortCode = $identifier->shortCode; + $domain = $identifier->domain; $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf( 'Impossible to delete short URL with short code "%s"%s, since it has more than "%s" visits.', diff --git a/module/Core/src/Exception/ShortUrlNotFoundException.php b/module/Core/src/Exception/ShortUrlNotFoundException.php index 0ae29da5..c59c20ef 100644 --- a/module/Core/src/Exception/ShortUrlNotFoundException.php +++ b/module/Core/src/Exception/ShortUrlNotFoundException.php @@ -20,8 +20,8 @@ class ShortUrlNotFoundException extends DomainException implements ProblemDetail public static function fromNotFound(ShortUrlIdentifier $identifier): self { - $shortCode = $identifier->shortCode(); - $domain = $identifier->domain(); + $shortCode = $identifier->shortCode; + $domain = $identifier->domain; $suffix = $domain === null ? '' : sprintf(' for domain "%s"', $domain); $e = new self(sprintf('No URL found with short code "%s"%s', $shortCode, $suffix)); diff --git a/module/Core/src/Importer/ShortUrlImporting.php b/module/Core/src/Importer/ShortUrlImporting.php index e1680517..4aa87166 100644 --- a/module/Core/src/Importer/ShortUrlImporting.php +++ b/module/Core/src/Importer/ShortUrlImporting.php @@ -14,7 +14,7 @@ use function sprintf; final class ShortUrlImporting { - private function __construct(private ShortUrl $shortUrl, private bool $isNew) + private function __construct(private readonly ShortUrl $shortUrl, private readonly bool $isNew) { } diff --git a/module/Core/src/Mercure/MercureUpdatesGenerator.php b/module/Core/src/Mercure/MercureUpdatesGenerator.php deleted file mode 100644 index 74b85388..00000000 --- a/module/Core/src/Mercure/MercureUpdatesGenerator.php +++ /dev/null @@ -1,50 +0,0 @@ - $this->shortUrlTransformer->transform($visit->getShortUrl()), - 'visit' => $visit, - ])); - } - - public function newOrphanVisitUpdate(Visit $visit): Update - { - return new Update(self::NEW_ORPHAN_VISIT_TOPIC, json_encode([ - 'visit' => $this->orphanVisitTransformer->transform($visit), - ])); - } - - public function newShortUrlVisitUpdate(Visit $visit): Update - { - $shortUrl = $visit->getShortUrl(); - $topic = sprintf('%s/%s', self::NEW_VISIT_TOPIC, $shortUrl?->getShortCode()); - - return new Update($topic, json_encode([ - 'shortUrl' => $this->shortUrlTransformer->transform($shortUrl), - 'visit' => $visit, - ])); - } -} diff --git a/module/Core/src/Model/AbstractInfinitePaginableListParams.php b/module/Core/src/Model/AbstractInfinitePaginableListParams.php index ae107fdc..d4b2aaab 100644 --- a/module/Core/src/Model/AbstractInfinitePaginableListParams.php +++ b/module/Core/src/Model/AbstractInfinitePaginableListParams.php @@ -10,8 +10,8 @@ abstract class AbstractInfinitePaginableListParams { private const FIRST_PAGE = 1; - private int $page; - private int $itemsPerPage; + public readonly int $page; + public readonly int $itemsPerPage; protected function __construct(?int $page, ?int $itemsPerPage) { @@ -28,14 +28,4 @@ abstract class AbstractInfinitePaginableListParams { return $itemsPerPage === null || $itemsPerPage < 0 ? Paginator::ALL_ITEMS : $itemsPerPage; } - - public function getPage(): int - { - return $this->page; - } - - public function getItemsPerPage(): int - { - return $this->itemsPerPage; - } } diff --git a/module/Core/src/Model/Ordering.php b/module/Core/src/Model/Ordering.php index bd648227..5adbb161 100644 --- a/module/Core/src/Model/Ordering.php +++ b/module/Core/src/Model/Ordering.php @@ -8,7 +8,7 @@ final class Ordering { private const DEFAULT_DIR = 'ASC'; - private function __construct(private ?string $field, private string $dir) + private function __construct(public readonly ?string $field, public readonly string $direction) { } @@ -26,16 +26,6 @@ final class Ordering return self::fromTuple([null, null]); } - public function orderField(): ?string - { - return $this->field; - } - - public function orderDirection(): string - { - return $this->dir; - } - public function hasOrderField(): bool { return $this->field !== null; diff --git a/module/Core/src/Model/ShortUrlEdit.php b/module/Core/src/Model/ShortUrlEdit.php index 325ee339..2d39d657 100644 --- a/module/Core/src/Model/ShortUrlEdit.php +++ b/module/Core/src/Model/ShortUrlEdit.php @@ -14,6 +14,7 @@ use function Shlinkio\Shlink\Core\getOptionalBoolFromInputFilter; use function Shlinkio\Shlink\Core\getOptionalIntFromInputFilter; use function Shlinkio\Shlink\Core\normalizeDate; +// TODO Rename to ShortUrlEdition final class ShortUrlEdit implements TitleResolutionModelInterface { private bool $longUrlPropWasProvided = false; diff --git a/module/Core/src/Model/ShortUrlIdentifier.php b/module/Core/src/Model/ShortUrlIdentifier.php index 815a5313..d2d6cbbc 100644 --- a/module/Core/src/Model/ShortUrlIdentifier.php +++ b/module/Core/src/Model/ShortUrlIdentifier.php @@ -10,7 +10,7 @@ use Symfony\Component\Console\Input\InputInterface; final class ShortUrlIdentifier { - public function __construct(private string $shortCode, private ?string $domain = null) + private function __construct(public readonly string $shortCode, public readonly ?string $domain = null) { } @@ -54,14 +54,4 @@ final class ShortUrlIdentifier { return new self($shortCode, $domain); } - - public function shortCode(): string - { - return $this->shortCode; - } - - public function domain(): ?string - { - return $this->domain; - } } diff --git a/module/Core/src/Model/ShortUrlMeta.php b/module/Core/src/Model/ShortUrlMeta.php index f43f929d..e5b621c2 100644 --- a/module/Core/src/Model/ShortUrlMeta.php +++ b/module/Core/src/Model/ShortUrlMeta.php @@ -16,6 +16,7 @@ use function Shlinkio\Shlink\Core\normalizeDate; use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; +// TODO Rename to ShortUrlCreation final class ShortUrlMeta implements TitleResolutionModelInterface { private string $longUrl; diff --git a/module/Core/src/Model/ShortUrlsParams.php b/module/Core/src/Model/ShortUrlsParams.php index 95cf4df6..bd6dc556 100644 --- a/module/Core/src/Model/ShortUrlsParams.php +++ b/module/Core/src/Model/ShortUrlsParams.php @@ -6,6 +6,7 @@ namespace Shlinkio\Shlink\Core\Model; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Exception\ValidationException; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\Validation\ShortUrlsParamsInputFilter; use function Shlinkio\Shlink\Common\buildDateRange; @@ -15,15 +16,12 @@ final class ShortUrlsParams { public const ORDERABLE_FIELDS = ['longUrl', 'shortCode', 'dateCreated', 'title', 'visits']; public const DEFAULT_ITEMS_PER_PAGE = 10; - public const TAGS_MODE_ANY = 'any'; - public const TAGS_MODE_ALL = 'all'; private int $page; private int $itemsPerPage; private ?string $searchTerm; private array $tags; - /** @var self::TAGS_MODE_ANY|self::TAGS_MODE_ALL */ - private string $tagsMode = self::TAGS_MODE_ANY; + private TagsMode $tagsMode = TagsMode::ANY; private Ordering $orderBy; private ?DateRange $dateRange; @@ -68,7 +66,16 @@ final class ShortUrlsParams $this->itemsPerPage = (int) ( $inputFilter->getValue(ShortUrlsParamsInputFilter::ITEMS_PER_PAGE) ?? self::DEFAULT_ITEMS_PER_PAGE ); - $this->tagsMode = $inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE) ?? self::TAGS_MODE_ANY; + $this->tagsMode = $this->resolveTagsMode($inputFilter->getValue(ShortUrlsParamsInputFilter::TAGS_MODE)); + } + + private function resolveTagsMode(?string $rawTagsMode): TagsMode + { + if ($rawTagsMode === null) { + return TagsMode::ANY; + } + + return TagsMode::tryFrom($rawTagsMode) ?? TagsMode::ANY; } public function page(): int @@ -101,10 +108,7 @@ final class ShortUrlsParams return $this->dateRange; } - /** - * @return self::TAGS_MODE_ANY|self::TAGS_MODE_ALL - */ - public function tagsMode(): string + public function tagsMode(): TagsMode { return $this->tagsMode; } diff --git a/module/Core/src/Model/Visitor.php b/module/Core/src/Model/Visitor.php index 9436e900..2207fad8 100644 --- a/module/Core/src/Model/Visitor.php +++ b/module/Core/src/Model/Visitor.php @@ -18,10 +18,10 @@ final class Visitor public const REMOTE_ADDRESS_MAX_LENGTH = 256; public const VISITED_URL_MAX_LENGTH = 2048; - private string $userAgent; - private string $referer; - private string $visitedUrl; - private ?string $remoteAddress; + public readonly string $userAgent; + public readonly string $referer; + public readonly string $visitedUrl; + public readonly ?string $remoteAddress; private bool $potentialBot; public function __construct(string $userAgent, string $referer, ?string $remoteAddress, string $visitedUrl) @@ -61,26 +61,6 @@ final class Visitor return new self('cf-facebook', '', null, ''); } - public function getUserAgent(): string - { - return $this->userAgent; - } - - public function getReferer(): string - { - return $this->referer; - } - - public function getRemoteAddress(): ?string - { - return $this->remoteAddress; - } - - public function getVisitedUrl(): string - { - return $this->visitedUrl; - } - public function isPotentialBot(): bool { return $this->potentialBot; diff --git a/module/Core/src/Model/VisitsParams.php b/module/Core/src/Model/VisitsParams.php index 718a4bc5..dfc4663d 100644 --- a/module/Core/src/Model/VisitsParams.php +++ b/module/Core/src/Model/VisitsParams.php @@ -10,16 +10,16 @@ use function Shlinkio\Shlink\Core\parseDateRangeFromQuery; final class VisitsParams extends AbstractInfinitePaginableListParams { - private DateRange $dateRange; + public readonly DateRange $dateRange; public function __construct( ?DateRange $dateRange = null, ?int $page = null, ?int $itemsPerPage = null, - private bool $excludeBots = false, + public readonly bool $excludeBots = false, ) { parent::__construct($page, $itemsPerPage); - $this->dateRange = $dateRange ?? DateRange::emptyInstance(); + $this->dateRange = $dateRange ?? DateRange::allTime(); } public static function fromRawData(array $query): self @@ -31,14 +31,4 @@ final class VisitsParams extends AbstractInfinitePaginableListParams isset($query['excludeBots']), ); } - - public function getDateRange(): DateRange - { - return $this->dateRange; - } - - public function excludeBots(): bool - { - return $this->excludeBots; - } } diff --git a/module/Core/src/Options/RabbitMqOptions.php b/module/Core/src/Options/RabbitMqOptions.php new file mode 100644 index 00000000..388cd2ea --- /dev/null +++ b/module/Core/src/Options/RabbitMqOptions.php @@ -0,0 +1,40 @@ +enabled; + } + + protected function setEnabled(bool $enabled): self + { + $this->enabled = $enabled; + return $this; + } + + /** @deprecated */ + public function legacyVisitsPublishing(): bool + { + return $this->legacyVisitsPublishing; + } + + /** @deprecated */ + protected function setLegacyVisitsPublishing(bool $legacyVisitsPublishing): self + { + $this->legacyVisitsPublishing = $legacyVisitsPublishing; + return $this; + } +} diff --git a/module/Core/src/Options/UrlShortenerOptions.php b/module/Core/src/Options/UrlShortenerOptions.php index 57f4bc37..38e185c2 100644 --- a/module/Core/src/Options/UrlShortenerOptions.php +++ b/module/Core/src/Options/UrlShortenerOptions.php @@ -6,12 +6,39 @@ namespace Shlinkio\Shlink\Core\Options; use Laminas\Stdlib\AbstractOptions; +use const Shlinkio\Shlink\DEFAULT_SHORT_CODES_LENGTH; + class UrlShortenerOptions extends AbstractOptions { protected $__strictMode__ = false; // phpcs:ignore + private array $domain = []; + private int $defaultShortCodesLength = DEFAULT_SHORT_CODES_LENGTH; private bool $autoResolveTitles = false; private bool $appendExtraPath = false; + private bool $multiSegmentSlugsEnabled = false; + + public function domain(): array + { + return $this->domain; + } + + protected function setDomain(array $domain): self + { + $this->domain = $domain; + return $this; + } + + public function defaultShortCodesLength(): int + { + return $this->defaultShortCodesLength; + } + + protected function setDefaultShortCodesLength(int $defaultShortCodesLength): self + { + $this->defaultShortCodesLength = $defaultShortCodesLength; + return $this; + } public function autoResolveTitles(): bool { @@ -32,4 +59,14 @@ class UrlShortenerOptions extends AbstractOptions { $this->appendExtraPath = $appendExtraPath; } + + public function multiSegmentSlugsEnabled(): bool + { + return $this->multiSegmentSlugsEnabled; + } + + protected function setMultiSegmentSlugsEnabled(bool $multiSegmentSlugsEnabled): void + { + $this->multiSegmentSlugsEnabled = $multiSegmentSlugsEnabled; + } } diff --git a/module/Core/src/Repository/ShortUrlRepository.php b/module/Core/src/Repository/ShortUrlRepository.php index d16d9993..5946f255 100644 --- a/module/Core/src/Repository/ShortUrlRepository.php +++ b/module/Core/src/Repository/ShortUrlRepository.php @@ -15,7 +15,7 @@ use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Importer\Model\ImportedShlinkUrl; @@ -47,8 +47,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU private function processOrderByForList(QueryBuilder $qb, Ordering $orderBy): array { - $fieldName = $orderBy->orderField(); - $order = $orderBy->orderDirection(); + $fieldName = $orderBy->field; + $order = $orderBy->direction; if ($fieldName === 'visits') { // FIXME This query is inefficient. @@ -111,8 +111,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU ]; // Apply tag conditions, only when not filtering by all provided tags - $tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY; - if (empty($tags) || $tagsMode === ShortUrlsParams::TAGS_MODE_ANY) { + $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; + if (empty($tags) || $tagsMode === TagsMode::ANY) { $conditions[] = $qb->expr()->like('t.name', ':searchPattern'); } @@ -123,8 +123,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU // Filter by tags if provided if (! empty($tags)) { - $tagsMode = $filtering->tagsMode() ?? ShortUrlsParams::TAGS_MODE_ANY; - $tagsMode === ShortUrlsParams::TAGS_MODE_ANY + $tagsMode = $filtering->tagsMode() ?? TagsMode::ANY; + $tagsMode === TagsMode::ANY ? $qb->join('s.tags', 't')->andWhere($qb->expr()->in('t.name', $tags)) : $this->joinAllTags($qb, $tags); } @@ -153,8 +153,8 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $query = $this->getEntityManager()->createQuery($dql); $query->setMaxResults(1) ->setParameters([ - 'shortCode' => $identifier->shortCode(), - 'domain' => $identifier->domain(), + 'shortCode' => $identifier->shortCode, + 'domain' => $identifier->domain, ]); // Since we ordered by domain, we will have first the URL matching provided domain, followed by the one @@ -205,10 +205,10 @@ class ShortUrlRepository extends EntitySpecificationRepository implements ShortU $qb->from(ShortUrl::class, 's') ->where($qb->expr()->isNotNull('s.shortCode')) ->andWhere($qb->expr()->eq('s.shortCode', ':slug')) - ->setParameter('slug', $identifier->shortCode()) + ->setParameter('slug', $identifier->shortCode) ->setMaxResults(1); - $this->whereDomainIs($qb, $identifier->domain()); + $this->whereDomainIs($qb, $identifier->domain); $this->applySpecification($qb, $spec, 's'); diff --git a/module/Core/src/Repository/TagRepository.php b/module/Core/src/Repository/TagRepository.php index aa24e0a1..2c4e8db6 100644 --- a/module/Core/src/Repository/TagRepository.php +++ b/module/Core/src/Repository/TagRepository.php @@ -41,8 +41,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito */ public function findTagsWithInfo(?TagsListFiltering $filtering = null): array { - $orderField = $filtering?->orderBy()?->orderField(); - $orderDir = $filtering?->orderBy()?->orderDirection(); + $orderField = $filtering?->orderBy?->field; + $orderDir = $filtering?->orderBy?->direction; $orderMainQuery = contains(['shortUrlsCount', 'visitsCount'], $orderField); $conn = $this->getEntityManager()->getConnection(); @@ -51,16 +51,16 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito if (! $orderMainQuery) { $subQb->orderBy('t.name', $orderDir ?? 'ASC') - ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset() ?? 0); + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); } - $searchTerm = $filtering?->searchTerm(); + $searchTerm = $filtering?->searchTerm; if ($searchTerm !== null) { $subQb->andWhere($subQb->expr()->like('t.name', $conn->quote('%' . $searchTerm . '%'))); } - $apiKey = $filtering?->apiKey(); + $apiKey = $filtering?->apiKey; $this->applySpecification($subQb, new WithInlinedApiKeySpecsEnsuringJoin($apiKey), 't'); // A native query builder needs to be used here, because DQL and ORM query builders do not support @@ -74,21 +74,20 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito 'COUNT(DISTINCT s.id) AS short_urls_count', 'COUNT(DISTINCT v.id) AS visits_count', ) - ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') + ->from('(' . $subQb->getQuery()->getSQL() . ')', 't') // @phpstan-ignore-line ->leftJoin('t', 'short_urls_in_tags', 'st', $nativeQb->expr()->eq('t.id_0', 'st.tag_id')) ->leftJoin('st', 'short_urls', 's', $nativeQb->expr()->eq('s.id', 'st.short_url_id')) ->leftJoin('st', 'visits', 'v', $nativeQb->expr()->eq('s.id', 'v.short_url_id')) ->groupBy('t.id_0', 't.name_1'); // Apply API key role conditions to the native query too, as they will affect the amounts on the aggregates - $apiKey?->mapRoles(static fn (string $roleName, array $meta) => match ($roleName) { + $apiKey?->mapRoles(static fn (Role $role, array $meta) => match ($role) { Role::DOMAIN_SPECIFIC => $nativeQb->andWhere( $nativeQb->expr()->eq('s.domain_id', $conn->quote(Role::domainIdFromMeta($meta))), ), Role::AUTHORED_SHORT_URLS => $nativeQb->andWhere( $nativeQb->expr()->eq('s.author_api_key_id', $conn->quote($apiKey->getId())), ), - default => $nativeQb, }); if ($orderMainQuery) { @@ -97,8 +96,8 @@ class TagRepository extends EntitySpecificationRepository implements TagReposito $orderField === 'shortUrlsCount' ? 'short_urls_count' : 'visits_count', $orderDir ?? 'ASC', ) - ->setMaxResults($filtering?->limit() ?? PHP_INT_MAX) - ->setFirstResult($filtering?->offset() ?? 0); + ->setMaxResults($filtering?->limit ?? PHP_INT_MAX) + ->setFirstResult($filtering?->offset ?? 0); } // Add ordering by tag name, as a fallback in case of same amount, or as default ordering diff --git a/module/Core/src/Repository/VisitRepository.php b/module/Core/src/Repository/VisitRepository.php index 51a0c333..3e33d60a 100644 --- a/module/Core/src/Repository/VisitRepository.php +++ b/module/Core/src/Repository/VisitRepository.php @@ -86,7 +86,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsListFiltering $filtering): array { $qb = $this->createVisitsByShortCodeQueryBuilder($identifier, $filtering); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countVisitsByShortCode(ShortUrlIdentifier $identifier, VisitsCountFiltering $filtering): int @@ -103,7 +103,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ): QueryBuilder { /** @var ShortUrlRepositoryInterface $shortUrlRepo */ $shortUrlRepo = $this->getEntityManager()->getRepository(ShortUrl::class); - $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey()?->spec())?->getId() ?? '-1'; + $shortUrlId = $shortUrlRepo->findOne($identifier, $filtering->apiKey?->spec())?->getId() ?? '-1'; // Parameters in this query need to be part of the query itself, as we need to use it as sub-query later // Since they are not provided by the caller, it's reasonably safe @@ -111,12 +111,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb->from(Visit::class, 'v') ->where($qb->expr()->eq('v.shortUrl', $shortUrlId)); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } // Apply date range filtering - $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applyDatesInline($qb, $filtering->dateRange); return $qb; } @@ -124,7 +124,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findVisitsByTag(string $tag, VisitsListFiltering $filtering): array { $qb = $this->createVisitsByTagQueryBuilder($tag, $filtering); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countVisitsByTag(string $tag, VisitsCountFiltering $filtering): int @@ -144,12 +144,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ->join('s.tags', 't') ->where($qb->expr()->eq('t.name', $this->getEntityManager()->getConnection()->quote($tag))); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } - $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); + $this->applyDatesInline($qb, $filtering->dateRange); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v'); return $qb; } @@ -160,7 +160,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo public function findVisitsByDomain(string $domain, VisitsListFiltering $filtering): array { $qb = $this->createVisitsByDomainQueryBuilder($domain, $filtering); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countVisitsByDomain(string $domain, VisitsCountFiltering $filtering): int @@ -185,12 +185,12 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo ->where($qb->expr()->eq('d.authority', $this->getEntityManager()->getConnection()->quote($domain))); } - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } - $this->applyDatesInline($qb, $filtering->dateRange()); - $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec(), 'v'); + $this->applyDatesInline($qb, $filtering->dateRange); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec(), 'v'); return $qb; } @@ -199,7 +199,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo { $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNull('v.shortUrl')); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countOrphanVisits(VisitsCountFiltering $filtering): int @@ -215,9 +215,9 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->createAllVisitsQueryBuilder($filtering); $qb->andWhere($qb->expr()->isNotNull('v.shortUrl')); - $this->applySpecification($qb, $filtering->apiKey()?->inlinedSpec()); + $this->applySpecification($qb, $filtering->apiKey?->inlinedSpec()); - return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit(), $filtering->offset()); + return $this->resolveVisitsWithNativeQuery($qb, $filtering->limit, $filtering->offset); } public function countNonOrphanVisits(VisitsCountFiltering $filtering): int @@ -232,11 +232,11 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $qb = $this->getEntityManager()->createQueryBuilder(); $qb->from(Visit::class, 'v'); - if ($filtering->excludeBots()) { + if ($filtering->excludeBots) { $qb->andWhere($qb->expr()->eq('v.potentialBot', 'false')); } - $this->applyDatesInline($qb, $filtering->dateRange()); + $this->applyDatesInline($qb, $filtering->dateRange); return $qb; } @@ -272,6 +272,7 @@ class VisitRepository extends EntitySpecificationRepository implements VisitRepo $nativeQb = $this->getEntityManager()->getConnection()->createQueryBuilder(); $nativeQb->select('v.id AS visit_id', 'v.*', 'vl.*') ->from('visits', 'v') + // @phpstan-ignore-next-line ->join('v', '(' . $subQuery . ')', 'sq', $nativeQb->expr()->eq('sq.id_0', 'v.id')) ->leftJoin('v', 'visit_locations', 'vl', $nativeQb->expr()->eq('v.visit_location_id', 'vl.id')) ->orderBy('v.id', 'DESC'); diff --git a/module/Core/src/Service/UrlShortener.php b/module/Core/src/Service/UrlShortener.php index 8fa54493..21afb6b0 100644 --- a/module/Core/src/Service/UrlShortener.php +++ b/module/Core/src/Service/UrlShortener.php @@ -5,7 +5,9 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Core\Service; use Doctrine\ORM\EntityManagerInterface; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; +use Shlinkio\Shlink\Core\EventDispatcher\Event\ShortUrlCreated; use Shlinkio\Shlink\Core\Exception\InvalidUrlException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -17,10 +19,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface; class UrlShortener implements UrlShortenerInterface { public function __construct( - private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, - private EntityManagerInterface $em, - private ShortUrlRelationResolverInterface $relationResolver, - private ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper, + private readonly EntityManagerInterface $em, + private readonly ShortUrlRelationResolverInterface $relationResolver, + private readonly ShortCodeUniquenessHelperInterface $shortCodeHelper, + private readonly EventDispatcherInterface $eventDispatcher, ) { } @@ -39,7 +42,8 @@ class UrlShortener implements UrlShortenerInterface /** @var ShortUrlMeta $meta */ $meta = $this->titleResolutionHelper->processTitleAndValidateUrl($meta); - return $this->em->transactional(function () use ($meta) { + /** @var ShortUrl $newShortUrl */ + $newShortUrl = $this->em->wrapInTransaction(function () use ($meta) { $shortUrl = ShortUrl::fromMeta($meta, $this->relationResolver); $this->verifyShortCodeUniqueness($meta, $shortUrl); @@ -47,6 +51,10 @@ class UrlShortener implements UrlShortenerInterface return $shortUrl; }); + + $this->eventDispatcher->dispatch(new ShortUrlCreated($newShortUrl->getId())); + + return $newShortUrl; } private function findExistingShortUrlIfExists(ShortUrlMeta $meta): ?ShortUrl diff --git a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php index 9d92067c..bb350aa2 100644 --- a/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php +++ b/module/Core/src/ShortUrl/Middleware/ExtraPathRedirectMiddleware.php @@ -18,19 +18,21 @@ use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilderInterface; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; -use function array_pad; +use function array_slice; +use function count; use function explode; +use function implode; use function sprintf; use function trim; class ExtraPathRedirectMiddleware implements MiddlewareInterface { public function __construct( - private ShortUrlResolverInterface $resolver, - private RequestTrackerInterface $requestTracker, - private ShortUrlRedirectionBuilderInterface $redirectionBuilder, - private RedirectResponseHelperInterface $redirectResponseHelper, - private UrlShortenerOptions $urlShortenerOptions, + private readonly ShortUrlResolverInterface $resolver, + private readonly RequestTrackerInterface $requestTracker, + private readonly ShortUrlRedirectionBuilderInterface $redirectionBuilder, + private readonly RedirectResponseHelperInterface $redirectResponseHelper, + private readonly UrlShortenerOptions $urlShortenerOptions, ) { } @@ -38,15 +40,36 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface { /** @var NotFoundType|null $notFoundType */ $notFoundType = $request->getAttribute(NotFoundType::class); - - // We'll apply this logic only if actively opted in and current URL is potentially /{shortCode}/[...] - if (! $notFoundType?->isRegularNotFound() || ! $this->urlShortenerOptions->appendExtraPath()) { + if (! $this->shouldApplyLogic($notFoundType)) { return $handler->handle($request); } + return $this->tryToResolveRedirect($request, $handler); + } + + private function shouldApplyLogic(?NotFoundType $notFoundType): bool + { + if ($notFoundType === null || ! $this->urlShortenerOptions->appendExtraPath()) { + return false; + } + + return ( + // If multi-segment slugs are enabled, the appropriate not-found type is "invalid_short_url" + $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isInvalidShortUrl() + ) || ( + // If multi-segment slugs are disabled, the appropriate not-found type is "regular_404" + ! $this->urlShortenerOptions->multiSegmentSlugsEnabled() && $notFoundType->isRegularNotFound() + ); + } + + private function tryToResolveRedirect( + ServerRequestInterface $request, + RequestHandlerInterface $handler, + int $shortCodeSegments = 1, + ): ResponseInterface { $uri = $request->getUri(); $query = $request->getQueryParams(); - [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri); + [$potentialShortCode, $extraPath] = $this->resolvePotentialShortCodeAndExtraPath($uri, $shortCodeSegments); $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($potentialShortCode, $uri->getAuthority()); try { @@ -56,18 +79,23 @@ class ExtraPathRedirectMiddleware implements MiddlewareInterface $longUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $query, $extraPath); return $this->redirectResponseHelper->buildRedirectResponse($longUrl); } catch (ShortUrlNotFoundException) { - return $handler->handle($request); + if ($extraPath === null || ! $this->urlShortenerOptions->multiSegmentSlugsEnabled()) { + return $handler->handle($request); + } + + return $this->tryToResolveRedirect($request, $handler, $shortCodeSegments + 1); } } /** * @return array{0: string, 1: string|null} */ - private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri): array + private function resolvePotentialShortCodeAndExtraPath(UriInterface $uri, int $shortCodeSegments): array { - $pathParts = explode('/', trim($uri->getPath(), '/'), 2); - [$potentialShortCode, $extraPath] = array_pad($pathParts, 2, null); + $parts = explode('/', trim($uri->getPath(), '/')); + $shortCode = array_slice($parts, 0, $shortCodeSegments); + $extraPath = array_slice($parts, $shortCodeSegments); - return [$potentialShortCode, $extraPath === null ? null : sprintf('/%s', $extraPath)]; + return [implode('/', $shortCode), count($extraPath) > 0 ? sprintf('/%s', implode('/', $extraPath)) : null]; } } diff --git a/module/Core/src/ShortUrl/Model/TagsMode.php b/module/Core/src/ShortUrl/Model/TagsMode.php new file mode 100644 index 00000000..593d6d83 --- /dev/null +++ b/module/Core/src/ShortUrl/Model/TagsMode.php @@ -0,0 +1,11 @@ +tags; } - public function tagsMode(): ?string + public function tagsMode(): ?TagsMode { return $this->tagsMode; } diff --git a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php index 089915e3..04645126 100644 --- a/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php +++ b/module/Core/src/ShortUrl/Persistence/ShortUrlsListFiltering.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Persistence; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Rest\Entity\ApiKey; class ShortUrlsListFiltering extends ShortUrlsCountFiltering @@ -17,7 +18,7 @@ class ShortUrlsListFiltering extends ShortUrlsCountFiltering private Ordering $orderBy, ?string $searchTerm = null, array $tags = [], - ?string $tagsMode = null, + ?TagsMode $tagsMode = null, ?DateRange $dateRange = null, ?ApiKey $apiKey = null, ) { diff --git a/module/Core/src/Tag/Model/TagInfo.php b/module/Core/src/Tag/Model/TagInfo.php index 6e917399..8a4f196b 100644 --- a/module/Core/src/Tag/Model/TagInfo.php +++ b/module/Core/src/Tag/Model/TagInfo.php @@ -8,23 +8,11 @@ use JsonSerializable; final class TagInfo implements JsonSerializable { - public function __construct(private string $tag, private int $shortUrlsCount, private int $visitsCount) - { - } - - public function tag(): string - { - return $this->tag; - } - - public function shortUrlsCount(): int - { - return $this->shortUrlsCount; - } - - public function visitsCount(): int - { - return $this->visitsCount; + public function __construct( + public readonly string $tag, + public readonly int $shortUrlsCount, + public readonly int $visitsCount, + ) { } public function jsonSerialize(): array diff --git a/module/Core/src/Tag/Model/TagRenaming.php b/module/Core/src/Tag/Model/TagRenaming.php index 3bdae21c..9c523b8b 100644 --- a/module/Core/src/Tag/Model/TagRenaming.php +++ b/module/Core/src/Tag/Model/TagRenaming.php @@ -10,7 +10,7 @@ use function sprintf; final class TagRenaming { - private function __construct(private string $oldName, private string $newName) + private function __construct(public readonly string $oldName, public readonly string $newName) { } @@ -31,16 +31,6 @@ final class TagRenaming return self::fromNames($payload['oldName'], $payload['newName']); } - public function oldName(): string - { - return $this->oldName; - } - - public function newName(): string - { - return $this->newName; - } - public function nameChanged(): bool { return $this->oldName !== $this->newName; diff --git a/module/Core/src/Tag/Model/TagsListFiltering.php b/module/Core/src/Tag/Model/TagsListFiltering.php index 8f078788..236dde4a 100644 --- a/module/Core/src/Tag/Model/TagsListFiltering.php +++ b/module/Core/src/Tag/Model/TagsListFiltering.php @@ -10,41 +10,16 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class TagsListFiltering { public function __construct( - private ?int $limit = null, - private ?int $offset = null, - private ?string $searchTerm = null, - private ?Ordering $orderBy = null, - private ?ApiKey $apiKey = null, + public readonly ?int $limit = null, + public readonly ?int $offset = null, + public readonly ?string $searchTerm = null, + public readonly ?Ordering $orderBy = null, + public readonly ?ApiKey $apiKey = null, ) { } public static function fromRangeAndParams(int $limit, int $offset, TagsParams $params, ?ApiKey $apiKey): self { - return new self($limit, $offset, $params->searchTerm(), $params->orderBy(), $apiKey); - } - - public function limit(): ?int - { - return $this->limit; - } - - public function offset(): ?int - { - return $this->offset; - } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function orderBy(): ?Ordering - { - return $this->orderBy; - } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; + return new self($limit, $offset, $params->searchTerm, $params->orderBy, $apiKey); } } diff --git a/module/Core/src/Tag/Model/TagsParams.php b/module/Core/src/Tag/Model/TagsParams.php index 3f40debe..633fd5f2 100644 --- a/module/Core/src/Tag/Model/TagsParams.php +++ b/module/Core/src/Tag/Model/TagsParams.php @@ -12,9 +12,9 @@ use function Shlinkio\Shlink\Common\parseOrderBy; final class TagsParams extends AbstractInfinitePaginableListParams { private function __construct( - private ?string $searchTerm, - private Ordering $orderBy, - private bool $withStats, + public readonly ?string $searchTerm, + public readonly Ordering $orderBy, + public readonly bool $withStats, ?int $page, ?int $itemsPerPage, ) { @@ -31,19 +31,4 @@ final class TagsParams extends AbstractInfinitePaginableListParams isset($query['itemsPerPage']) ? (int) $query['itemsPerPage'] : null, ); } - - public function searchTerm(): ?string - { - return $this->searchTerm; - } - - public function orderBy(): Ordering - { - return $this->orderBy; - } - - public function withStats(): bool - { - return $this->withStats; - } } diff --git a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php index ba6bc78d..ee0086cd 100644 --- a/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/AbstractTagsPaginatorAdapter.php @@ -30,7 +30,7 @@ abstract class AbstractTagsPaginatorAdapter implements AdapterInterface new WithApiKeySpecsEnsuringJoin($this->apiKey), ]; - $searchTerm = $this->params->searchTerm(); + $searchTerm = $this->params->searchTerm; if ($searchTerm !== null) { $conditions[] = Spec::like('name', $searchTerm); } diff --git a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php index d6bc0b7b..7d54940e 100644 --- a/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php +++ b/module/Core/src/Tag/Paginator/Adapter/TagsPaginatorAdapter.php @@ -15,13 +15,13 @@ class TagsPaginatorAdapter extends AbstractTagsPaginatorAdapter new WithApiKeySpecsEnsuringJoin($this->apiKey), Spec::orderBy( 'name', // Ordering by other fields makes no sense here - $this->params->orderBy()->orderDirection(), + $this->params->orderBy->direction, ), Spec::limit($length), Spec::offset($offset), ]; - $searchTerm = $this->params->searchTerm(); + $searchTerm = $this->params->searchTerm; if ($searchTerm !== null) { $conditions[] = Spec::like('name', $searchTerm); } diff --git a/module/Core/src/Tag/TagService.php b/module/Core/src/Tag/TagService.php index 40eb413f..b8d7f710 100644 --- a/module/Core/src/Tag/TagService.php +++ b/module/Core/src/Tag/TagService.php @@ -49,8 +49,8 @@ class TagService implements TagServiceInterface private function createPaginator(AdapterInterface $adapter, TagsParams $params): Paginator { return (new Paginator($adapter)) - ->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + ->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); } /** @@ -83,17 +83,17 @@ class TagService implements TagServiceInterface $repo = $this->em->getRepository(Tag::class); /** @var Tag|null $tag */ - $tag = $repo->findOneBy(['name' => $renaming->oldName()]); + $tag = $repo->findOneBy(['name' => $renaming->oldName]); if ($tag === null) { - throw TagNotFoundException::fromTag($renaming->oldName()); + throw TagNotFoundException::fromTag($renaming->oldName); } - $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName()]) > 0; + $newNameExists = $renaming->nameChanged() && $repo->count(['name' => $renaming->newName]) > 0; if ($newNameExists) { throw TagConflictException::forExistingTag($renaming); } - $tag->rename($renaming->newName()); + $tag->rename($renaming->newName); $this->em->flush(); return $tag; diff --git a/module/Core/src/Validation/ShortUrlInputFilter.php b/module/Core/src/Validation/ShortUrlInputFilter.php index 6cd578fb..283d9a94 100644 --- a/module/Core/src/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/Validation/ShortUrlInputFilter.php @@ -10,11 +10,13 @@ use Laminas\InputFilter\Input; use Laminas\InputFilter\InputFilter; use Laminas\Validator; use Shlinkio\Shlink\Common\Validation; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function is_string; use function str_replace; use function substr; +use function trim; use const Shlinkio\Shlink\MIN_SHORT_CODES_LENGTH; @@ -39,7 +41,7 @@ class ShortUrlInputFilter extends InputFilter private function __construct(array $data, bool $requireLongUrl) { - $this->initialize($requireLongUrl); + $this->initialize($requireLongUrl, $data[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] ?? false); $this->setData($data); } @@ -53,7 +55,7 @@ class ShortUrlInputFilter extends InputFilter return new self($data, false); } - private function initialize(bool $requireLongUrl): void + private function initialize(bool $requireLongUrl, bool $multiSegmentEnabled): void { $longUrlInput = $this->createInput(self::LONG_URL, $requireLongUrl); $longUrlInput->getValidatorChain()->attach(new Validator\NotEmpty([ @@ -76,9 +78,10 @@ class ShortUrlInputFilter extends InputFilter // FIXME The only way to enforce the NotEmpty validator to be evaluated when the value is provided but it's // empty, is by using the deprecated setContinueIfEmpty $customSlug = $this->createInput(self::CUSTOM_SLUG, false)->setContinueIfEmpty(true); - $customSlug->getFilterChain()->attach(new Filter\Callback( - static fn (mixed $value) => is_string($value) ? str_replace([' ', '/'], '-', $value) : $value, - )); + $customSlug->getFilterChain()->attach(new Filter\Callback(match ($multiSegmentEnabled) { + true => static fn (mixed $v) => is_string($v) ? trim(str_replace(' ', '-', $v), '/') : $v, + false => static fn (mixed $v) => is_string($v) ? str_replace([' ', '/'], '-', $v) : $v, + })); $customSlug->getValidatorChain()->attach(new Validator\NotEmpty([ Validator\NotEmpty::STRING, Validator\NotEmpty::SPACE, diff --git a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php index 6c0443aa..50953310 100644 --- a/module/Core/src/Validation/ShortUrlsParamsInputFilter.php +++ b/module/Core/src/Validation/ShortUrlsParamsInputFilter.php @@ -9,6 +9,7 @@ use Laminas\Validator\InArray; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Validation; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; class ShortUrlsParamsInputFilter extends InputFilter { @@ -43,7 +44,7 @@ class ShortUrlsParamsInputFilter extends InputFilter $tagsMode = $this->createInput(self::TAGS_MODE, false); $tagsMode->getValidatorChain()->attach(new InArray([ - 'haystack' => [ShortUrlsParams::TAGS_MODE_ALL, ShortUrlsParams::TAGS_MODE_ANY], + 'haystack' => [TagsMode::ALL->value, TagsMode::ANY->value], 'strict' => InArray::COMPARE_STRICT, ])); $this->add($tagsMode); diff --git a/module/Core/src/Visit/Model/UnknownVisitLocation.php b/module/Core/src/Visit/Model/UnknownVisitLocation.php deleted file mode 100644 index b8926bd5..00000000 --- a/module/Core/src/Visit/Model/UnknownVisitLocation.php +++ /dev/null @@ -1,41 +0,0 @@ - 'Unknown', - 'countryName' => 'Unknown', - 'regionName' => 'Unknown', - 'cityName' => 'Unknown', - 'latitude' => 0.0, - 'longitude' => 0.0, - 'timezone' => 'Unknown', - ]; - } -} diff --git a/module/Core/src/Visit/Model/VisitLocationInterface.php b/module/Core/src/Visit/Model/VisitLocationInterface.php deleted file mode 100644 index 9a296a28..00000000 --- a/module/Core/src/Visit/Model/VisitLocationInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -visitRepository->countVisitsByDomain( $this->domain, new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, ), ); @@ -38,8 +38,8 @@ class DomainVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte return $this->visitRepository->findVisitsByDomain( $this->domain, new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, diff --git a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php index ba5b6663..5f06ea09 100644 --- a/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapter.php @@ -23,8 +23,8 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda protected function doCount(): int { return $this->repo->countNonOrphanVisits(new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, )); } @@ -32,8 +32,8 @@ class NonOrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAda public function getSlice(int $offset, int $length): iterable { return $this->repo->findNonOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, diff --git a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php index 8a47c9d7..f18dbb05 100644 --- a/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapter.php @@ -19,16 +19,16 @@ class OrphanVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapte protected function doCount(): int { return $this->repo->countOrphanVisits(new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, )); } public function getSlice(int $offset, int $length): iterable { return $this->repo->findOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, null, $length, $offset, diff --git a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php index 2e47fbf8..5169c327 100644 --- a/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapter.php @@ -27,8 +27,8 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap return $this->visitRepository->findVisitsByShortCode( $this->identifier, new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, @@ -41,8 +41,8 @@ class ShortUrlVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdap return $this->visitRepository->countVisitsByShortCode( $this->identifier, new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, ), ); diff --git a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php index 162b6cba..aed79d02 100644 --- a/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php +++ b/module/Core/src/Visit/Paginator/Adapter/TagVisitsPaginatorAdapter.php @@ -26,8 +26,8 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter return $this->visitRepository->findVisitsByTag( $this->tag, new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $length, $offset, @@ -40,8 +40,8 @@ class TagVisitsPaginatorAdapter extends AbstractCacheableCountPaginatorAdapter return $this->visitRepository->countVisitsByTag( $this->tag, new VisitsCountFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, ), ); diff --git a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php index 140ec9b9..f839a945 100644 --- a/module/Core/src/Visit/Persistence/VisitsCountFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsCountFiltering.php @@ -10,9 +10,9 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; class VisitsCountFiltering { public function __construct( - private ?DateRange $dateRange = null, - private bool $excludeBots = false, - private ?ApiKey $apiKey = null, + public readonly ?DateRange $dateRange = null, + public readonly bool $excludeBots = false, + public readonly ?ApiKey $apiKey = null, ) { } @@ -20,19 +20,4 @@ class VisitsCountFiltering { return new self(null, false, $apiKey); } - - public function dateRange(): ?DateRange - { - return $this->dateRange; - } - - public function excludeBots(): bool - { - return $this->excludeBots; - } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; - } } diff --git a/module/Core/src/Visit/Persistence/VisitsListFiltering.php b/module/Core/src/Visit/Persistence/VisitsListFiltering.php index b17964a6..747a3ce0 100644 --- a/module/Core/src/Visit/Persistence/VisitsListFiltering.php +++ b/module/Core/src/Visit/Persistence/VisitsListFiltering.php @@ -13,19 +13,9 @@ final class VisitsListFiltering extends VisitsCountFiltering ?DateRange $dateRange = null, bool $excludeBots = false, ?ApiKey $apiKey = null, - private ?int $limit = null, - private ?int $offset = null, + public readonly ?int $limit = null, + public readonly ?int $offset = null, ) { parent::__construct($dateRange, $excludeBots, $apiKey); } - - public function limit(): ?int - { - return $this->limit; - } - - public function offset(): ?int - { - return $this->offset; - } } diff --git a/module/Core/src/Visit/RequestTracker.php b/module/Core/src/Visit/RequestTracker.php index dc45e12f..1887dbfd 100644 --- a/module/Core/src/Visit/RequestTracker.php +++ b/module/Core/src/Visit/RequestTracker.php @@ -24,8 +24,10 @@ use function str_contains; class RequestTracker implements RequestTrackerInterface, RequestMethodInterface { - public function __construct(private VisitsTrackerInterface $visitsTracker, private TrackingOptions $trackingOptions) - { + public function __construct( + private readonly VisitsTrackerInterface $visitsTracker, + private readonly TrackingOptions $trackingOptions, + ) { } public function trackIfApplicable(ShortUrl $shortUrl, ServerRequestInterface $request): void @@ -45,10 +47,11 @@ class RequestTracker implements RequestTrackerInterface, RequestMethodInterface $notFoundType = $request->getAttribute(NotFoundType::class); $visitor = Visitor::fromRequest($request); - match (true) { // @phpstan-ignore-line + match (true) { $notFoundType?->isBaseUrl() => $this->visitsTracker->trackBaseUrlVisit($visitor), $notFoundType?->isRegularNotFound() => $this->visitsTracker->trackRegularNotFoundVisit($visitor), $notFoundType?->isInvalidShortUrl() => $this->visitsTracker->trackInvalidShortUrlVisit($visitor), + default => null, }; } diff --git a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php index 52be52a8..a7b2a1d6 100644 --- a/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfNonOrphanVisits.php @@ -22,14 +22,14 @@ class CountOfNonOrphanVisits extends BaseSpecification { $conditions = [ Spec::isNotNull('shortUrl'), - new InDateRange($this->filtering->dateRange()), + new InDateRange($this->filtering->dateRange), ]; - if ($this->filtering->excludeBots()) { + if ($this->filtering->excludeBots) { $conditions[] = Spec::eq('potentialBot', false); } - $apiKey = $this->filtering->apiKey(); + $apiKey = $this->filtering->apiKey; if ($apiKey !== null) { $conditions[] = new WithApiKeySpecsEnsuringJoin($apiKey, 'shortUrl'); } diff --git a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php index d8e6b2d2..106350c6 100644 --- a/module/Core/src/Visit/Spec/CountOfOrphanVisits.php +++ b/module/Core/src/Visit/Spec/CountOfOrphanVisits.php @@ -21,10 +21,10 @@ class CountOfOrphanVisits extends BaseSpecification { $conditions = [ Spec::isNull('shortUrl'), - new InDateRange($this->filtering->dateRange()), + new InDateRange($this->filtering->dateRange), ]; - if ($this->filtering->excludeBots()) { + if ($this->filtering->excludeBots) { $conditions[] = Spec::eq('potentialBot', false); } diff --git a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php index 9f4842f5..0da5f4ba 100644 --- a/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php +++ b/module/Core/src/Visit/Transformer/OrphanVisitDataTransformer.php @@ -11,13 +11,12 @@ class OrphanVisitDataTransformer implements DataTransformerInterface { /** * @param Visit $visit - * @return array */ public function transform($visit): array // phpcs:ignore { $serializedVisit = $visit->jsonSerialize(); $serializedVisit['visitedUrl'] = $visit->visitedUrl(); - $serializedVisit['type'] = $visit->type(); + $serializedVisit['type'] = $visit->type()->value; return $serializedVisit; } diff --git a/module/Core/src/Visit/VisitsStatsHelper.php b/module/Core/src/Visit/VisitsStatsHelper.php index 007ed334..4f19103f 100644 --- a/module/Core/src/Visit/VisitsStatsHelper.php +++ b/module/Core/src/Visit/VisitsStatsHelper.php @@ -129,8 +129,8 @@ class VisitsStatsHelper implements VisitsStatsHelperInterface private function createPaginator(AdapterInterface $adapter, VisitsParams $params): Paginator { $paginator = new Paginator($adapter); - $paginator->setMaxPerPage($params->getItemsPerPage()) - ->setCurrentPage($params->getPage()); + $paginator->setMaxPerPage($params->itemsPerPage) + ->setCurrentPage($params->page); return $paginator; } diff --git a/module/Core/src/Visit/VisitsTracker.php b/module/Core/src/Visit/VisitsTracker.php index f4e5bf92..3aef46df 100644 --- a/module/Core/src/Visit/VisitsTracker.php +++ b/module/Core/src/Visit/VisitsTracker.php @@ -72,6 +72,6 @@ class VisitsTracker implements VisitsTrackerInterface $this->em->persist($visit); $this->em->flush(); - $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->getRemoteAddress())); + $this->eventDispatcher->dispatch(new UrlVisited($visit->getId(), $visitor->remoteAddress)); } } diff --git a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php index c67ff34b..3143e90c 100644 --- a/module/Core/test-db/Repository/ShortUrlRepositoryTest.php +++ b/module/Core/test-db/Repository/ShortUrlRepositoryTest.php @@ -14,9 +14,9 @@ use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Model\Ordering; use Shlinkio\Shlink\Core\Model\ShortUrlIdentifier; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; -use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\Repository\ShortUrlRepository; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; use Shlinkio\Shlink\Core\ShortUrl\Resolver\PersistenceShortUrlRelationResolver; @@ -152,23 +152,23 @@ class ShortUrlRepositoryTest extends DatabaseTestCase self::assertSame($bar, $result[0]); $result = $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withEndDate( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::until( Chronos::now()->subDays(2), )), ); self::assertCount(1, $result); - self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::withEndDate( + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, [], null, DateRange::until( Chronos::now()->subDays(2), )))); self::assertSame($foo2, $result[0]); self::assertCount(2, $this->repo->findList( - new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::withStartDate( + new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, [], null, DateRange::since( Chronos::now()->subDays(2), )), )); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, [], null, DateRange::withStartDate(Chronos::now()->subDays(2))), + new ShortUrlsCountFiltering(null, [], null, DateRange::since(Chronos::now()->subDays(2))), )); } @@ -233,7 +233,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(1, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -241,15 +241,11 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar']))); - self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ANY), - )); - self::assertEquals(1, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar'], ShortUrlsParams::TAGS_MODE_ALL), - )); + self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ANY))); + self::assertEquals(1, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar'], TagsMode::ALL))); self::assertCount(4, $this->repo->findList( new ShortUrlsListFiltering(null, null, Ordering::emptyInstance(), null, ['bar', 'baz']), @@ -260,7 +256,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(2, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -268,14 +264,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(4, $this->repo->countList(new ShortUrlsCountFiltering(null, ['bar', 'baz']))); self::assertEquals(4, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY), + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ANY), )); self::assertEquals(2, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL), + new ShortUrlsCountFiltering(null, ['bar', 'baz'], TagsMode::ALL), )); self::assertCount(5, $this->repo->findList( @@ -287,7 +283,7 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ANY, + TagsMode::ANY, ))); self::assertCount(0, $this->repo->findList(new ShortUrlsListFiltering( null, @@ -295,14 +291,14 @@ class ShortUrlRepositoryTest extends DatabaseTestCase Ordering::emptyInstance(), null, ['foo', 'bar', 'baz'], - ShortUrlsParams::TAGS_MODE_ALL, + TagsMode::ALL, ))); self::assertEquals(5, $this->repo->countList(new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz']))); self::assertEquals(5, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ANY), + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ANY), )); self::assertEquals(0, $this->repo->countList( - new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], ShortUrlsParams::TAGS_MODE_ALL), + new ShortUrlsCountFiltering(null, ['foo', 'bar', 'baz'], TagsMode::ALL), )); } diff --git a/module/Core/test-db/Repository/TagRepositoryTest.php b/module/Core/test-db/Repository/TagRepositoryTest.php index fe544376..87cd7280 100644 --- a/module/Core/test-db/Repository/TagRepositoryTest.php +++ b/module/Core/test-db/Repository/TagRepositoryTest.php @@ -64,7 +64,7 @@ class TagRepositoryTest extends DatabaseTestCase $this->getEntityManager()->persist(new Tag($name)); } - $apiKey = $filtering?->apiKey(); + $apiKey = $filtering?->apiKey; if ($apiKey !== null) { $this->getEntityManager()->persist($apiKey); } @@ -101,9 +101,9 @@ class TagRepositoryTest extends DatabaseTestCase self::assertCount(count($expectedList), $result); foreach ($expectedList as $index => [$tag, $shortUrlsCount, $visitsCount]) { - self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount()); - self::assertEquals($visitsCount, $result[$index]->visitsCount()); - self::assertEquals($tag, $result[$index]->tag()); + self::assertEquals($shortUrlsCount, $result[$index]->shortUrlsCount); + self::assertEquals($visitsCount, $result[$index]->visitsCount); + self::assertEquals($tag, $result[$index]->tag); } } diff --git a/module/Core/test-db/Repository/VisitRepositoryTest.php b/module/Core/test-db/Repository/VisitRepositoryTest.php index b16c3382..041f5e4c 100644 --- a/module/Core/test-db/Repository/VisitRepositoryTest.php +++ b/module/Core/test-db/Repository/VisitRepositoryTest.php @@ -114,16 +114,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(2, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ), )); self::assertCount(4, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsListFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertCount(1, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), - new VisitsListFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsListFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertCount(3, $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), @@ -163,16 +163,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(2, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ), )); self::assertEquals(4, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); self::assertEquals(1, $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain), - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2016-01-03'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2016-01-03'))), )); } @@ -227,10 +227,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(18, $this->repo->findVisitsByTag($foo, new VisitsListFiltering())); self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering(null, true))); self::assertCount(6, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertCount(12, $this->repo->findVisitsByTag($foo, new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -249,10 +249,10 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(12, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering())); self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering(null, true))); self::assertEquals(4, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertEquals(8, $this->repo->countVisitsByTag($foo, new VisitsCountFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -267,16 +267,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertCount(3, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering())); self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); self::assertCount(2, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertCount(1, $this->repo->findVisitsByDomain('doma.in', new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); self::assertCount(2, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertCount(4, $this->repo->findVisitsByDomain('DEFAULT', new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -291,16 +291,16 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(3, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering())); self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering(null, true))); self::assertEquals(2, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertEquals(1, $this->repo->countVisitsByDomain('doma.in', new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); self::assertEquals(2, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), + DateRange::between(Chronos::parse('2016-01-02'), Chronos::parse('2016-01-03')), ))); self::assertEquals(4, $this->repo->countVisitsByDomain('DEFAULT', new VisitsListFiltering( - DateRange::withStartDate(Chronos::parse('2016-01-03')), + DateRange::since(Chronos::parse('2016-01-03')), ))); } @@ -349,13 +349,13 @@ class VisitRepositoryTest extends DatabaseTestCase self::assertEquals(4, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey1))); self::assertEquals(5 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($apiKey2))); self::assertEquals(4 + 7, $this->repo->countNonOrphanVisits(VisitsCountFiltering::withApiKey($domainApiKey))); - self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(4, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-05')->startOfDay(), )))); - self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(2, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-03')->startOfDay(), ), false, $apiKey1))); - self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::withStartDate( + self::assertEquals(1, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(DateRange::since( Chronos::parse('2016-01-07')->startOfDay(), ), false, $apiKey2))); self::assertEquals(3 + 5, $this->repo->countNonOrphanVisits(new VisitsCountFiltering(null, true, $apiKey2))); @@ -395,20 +395,20 @@ class VisitRepositoryTest extends DatabaseTestCase 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::withStartDate(Chronos::parse('2020-01-04')), + DateRange::since(Chronos::parse('2020-01-04')), false, null, 15, ))); self::assertCount(2, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), false, null, 6, 4, ))); self::assertCount(3, $this->repo->findOrphanVisits(new VisitsListFiltering( - DateRange::withEndDate(Chronos::parse('2020-01-01')), + DateRange::until(Chronos::parse('2020-01-01')), ))); } @@ -437,15 +437,15 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering())); - self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::emptyInstance()))); + self::assertEquals(18, $this->repo->countOrphanVisits(new VisitsCountFiltering(DateRange::allTime()))); self::assertEquals(9, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::withStartDate(Chronos::parse('2020-01-04'))), + new VisitsCountFiltering(DateRange::since(Chronos::parse('2020-01-04'))), )); self::assertEquals(6, $this->repo->countOrphanVisits(new VisitsCountFiltering( - DateRange::withStartAndEndDate(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), + DateRange::between(Chronos::parse('2020-01-02'), Chronos::parse('2020-01-03')), ))); self::assertEquals(3, $this->repo->countOrphanVisits( - new VisitsCountFiltering(DateRange::withEndDate(Chronos::parse('2020-01-01'))), + new VisitsCountFiltering(DateRange::until(Chronos::parse('2020-01-01'))), )); } @@ -467,22 +467,22 @@ class VisitRepositoryTest extends DatabaseTestCase $this->getEntityManager()->flush(); self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering())); - self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::emptyInstance()))); - self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartDate( + self::assertCount(21, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::allTime()))); + self::assertCount(7, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::since( Chronos::parse('2016-01-05')->endOfDay(), )))); - self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withEndDate( + self::assertCount(12, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::until( Chronos::parse('2016-01-04')->endOfDay(), )))); - self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(6, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-04')->endOfDay(), )))); - self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(13, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-08')->endOfDay(), )))); - self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::withStartAndEndDate( + self::assertCount(3, $this->repo->findNonOrphanVisits(new VisitsListFiltering(DateRange::between( Chronos::parse('2016-01-03')->startOfDay(), Chronos::parse('2016-01-08')->endOfDay(), ), false, null, 10, 10))); diff --git a/module/Core/test/Action/PixelActionTest.php b/module/Core/test/Action/PixelActionTest.php index 3eb1ad79..fdd291a5 100644 --- a/module/Core/test/Action/PixelActionTest.php +++ b/module/Core/test/Action/PixelActionTest.php @@ -37,9 +37,10 @@ class PixelActionTest extends TestCase public function imageIsReturned(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, ''))->willReturn( - ShortUrl::withLongUrl('http://domain.com/foo/bar'), - )->shouldBeCalledOnce(); + $this->urlResolver->resolveEnabledShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) + ->shouldBeCalledOnce(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode); diff --git a/module/Core/test/Action/QrCodeActionTest.php b/module/Core/test/Action/QrCodeActionTest.php index 419febec..fb9e4e6a 100644 --- a/module/Core/test/Action/QrCodeActionTest.php +++ b/module/Core/test/Action/QrCodeActionTest.php @@ -59,7 +59,7 @@ class QrCodeActionTest extends TestCase public function aNotFoundShortCodeWillDelegateIntoNextMiddleware(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -74,7 +74,7 @@ class QrCodeActionTest extends TestCase public function aCorrectRequestReturnsTheQrCodeResponse(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willReturn(ShortUrl::createEmpty()) ->shouldBeCalledOnce(); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -100,7 +100,7 @@ class QrCodeActionTest extends TestCase ): void { $this->options->setFromArray(['format' => $defaultFormat]); $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), ); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -134,7 +134,7 @@ class QrCodeActionTest extends TestCase ): void { $this->options->setFromArray($defaults); $code = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::createEmpty(), ); $delegate = $this->prophesize(RequestHandlerInterface::class); @@ -214,7 +214,7 @@ class QrCodeActionTest extends TestCase ->withQueryParams(['size' => 250, 'roundBlockSize' => $roundBlockSize]) ->withAttribute('shortCode', $code); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($code, ''))->willReturn( + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($code, ''))->willReturn( ShortUrl::withLongUrl('https://shlink.io'), ); $delegate = $this->prophesize(RequestHandlerInterface::class); diff --git a/module/Core/test/Action/RedirectActionTest.php b/module/Core/test/Action/RedirectActionTest.php index b3017fad..cde2b9aa 100644 --- a/module/Core/test/Action/RedirectActionTest.php +++ b/module/Core/test/Action/RedirectActionTest.php @@ -54,7 +54,7 @@ class RedirectActionTest extends TestCase $shortCode = 'abc123'; $shortUrl = ShortUrl::withLongUrl(self::LONG_URL); $shortCodeToUrl = $this->urlResolver->resolveEnabledShortUrl( - new ShortUrlIdentifier($shortCode, ''), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, ''), )->willReturn($shortUrl); $track = $this->requestTracker->trackIfApplicable(Argument::cetera())->will(function (): void { }); @@ -74,7 +74,7 @@ class RedirectActionTest extends TestCase public function nextMiddlewareIsInvokedIfLongUrlIsNotFound(): void { $shortCode = 'abc123'; - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode, '')) + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, '')) ->willThrow(ShortUrlNotFoundException::class) ->shouldBeCalledOnce(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotBeCalled(); diff --git a/module/Core/test/Config/EnvVarsTest.php b/module/Core/test/Config/EnvVarsTest.php index 51a7a088..6d4b1394 100644 --- a/module/Core/test/Config/EnvVarsTest.php +++ b/module/Core/test/Config/EnvVarsTest.php @@ -6,7 +6,6 @@ namespace ShlinkioTest\Shlink\Core\Config; use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Config\EnvVars; -use Shlinkio\Shlink\Core\Exception\InvalidArgumentException; use function putenv; @@ -14,92 +13,14 @@ class EnvVarsTest extends TestCase { protected function setUp(): void { - putenv(EnvVars::BASE_PATH . '=the_base_path'); - putenv(EnvVars::DB_NAME . '=shlink'); + putenv(EnvVars::BASE_PATH->value . '=the_base_path'); + putenv(EnvVars::DB_NAME->value . '=shlink'); } protected function tearDown(): void { - putenv(EnvVars::BASE_PATH . '='); - putenv(EnvVars::DB_NAME . '='); - } - - /** @test */ - public function casesReturnsTheSameListEveryTime(): void - { - $list = EnvVars::cases(); - self::assertSame($list, EnvVars::cases()); - self::assertSame([ - EnvVars::DELETE_SHORT_URL_THRESHOLD, - EnvVars::DB_DRIVER, - EnvVars::DB_NAME, - EnvVars::DB_USER, - EnvVars::DB_PASSWORD, - EnvVars::DB_HOST, - EnvVars::DB_UNIX_SOCKET, - EnvVars::DB_PORT, - EnvVars::GEOLITE_LICENSE_KEY, - EnvVars::REDIS_SERVERS, - EnvVars::REDIS_SENTINEL_SERVICE, - EnvVars::MERCURE_PUBLIC_HUB_URL, - EnvVars::MERCURE_INTERNAL_HUB_URL, - EnvVars::MERCURE_JWT_SECRET, - EnvVars::DEFAULT_QR_CODE_SIZE, - EnvVars::DEFAULT_QR_CODE_MARGIN, - EnvVars::DEFAULT_QR_CODE_FORMAT, - EnvVars::DEFAULT_QR_CODE_ERROR_CORRECTION, - EnvVars::DEFAULT_QR_CODE_ROUND_BLOCK_SIZE, - EnvVars::RABBITMQ_ENABLED, - EnvVars::RABBITMQ_HOST, - EnvVars::RABBITMQ_PORT, - EnvVars::RABBITMQ_USER, - EnvVars::RABBITMQ_PASSWORD, - EnvVars::RABBITMQ_VHOST, - EnvVars::DEFAULT_INVALID_SHORT_URL_REDIRECT, - EnvVars::DEFAULT_REGULAR_404_REDIRECT, - EnvVars::DEFAULT_BASE_URL_REDIRECT, - EnvVars::REDIRECT_STATUS_CODE, - EnvVars::REDIRECT_CACHE_LIFETIME, - EnvVars::BASE_PATH, - EnvVars::PORT, - EnvVars::TASK_WORKER_NUM, - EnvVars::WEB_WORKER_NUM, - EnvVars::ANONYMIZE_REMOTE_ADDR, - EnvVars::TRACK_ORPHAN_VISITS, - EnvVars::DISABLE_TRACK_PARAM, - EnvVars::DISABLE_TRACKING, - EnvVars::DISABLE_IP_TRACKING, - EnvVars::DISABLE_REFERRER_TRACKING, - EnvVars::DISABLE_UA_TRACKING, - EnvVars::DISABLE_TRACKING_FROM, - EnvVars::DEFAULT_SHORT_CODES_LENGTH, - EnvVars::IS_HTTPS_ENABLED, - EnvVars::DEFAULT_DOMAIN, - EnvVars::AUTO_RESOLVE_TITLES, - EnvVars::REDIRECT_APPEND_EXTRA_PATH, - EnvVars::TIMEZONE, - EnvVars::VISITS_WEBHOOKS, - EnvVars::NOTIFY_ORPHAN_VISITS_TO_WEBHOOKS, - ], $list); - } - - /** - * @test - * @dataProvider provideInvalidEnvVars - */ - public function exceptionIsThrownWhenTryingToLoadInvalidEnvVar(string $envVar): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid env var: "' . $envVar . '"'); - - EnvVars::{$envVar}(); - } - - public function provideInvalidEnvVars(): iterable - { - yield 'foo' => ['foo']; - yield 'bar' => ['bar']; - yield 'invalid' => ['invalid']; + putenv(EnvVars::BASE_PATH->value . '='); + putenv(EnvVars::DB_NAME->value . '='); } /** @@ -113,10 +34,10 @@ class EnvVarsTest extends TestCase public function provideExistingEnvVars(): iterable { - yield 'DB_NAME' => [EnvVars::DB_NAME(), true]; - yield 'BASE_PATH' => [EnvVars::BASE_PATH(), true]; - yield 'DB_DRIVER' => [EnvVars::DB_DRIVER(), false]; - yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT(), false]; + yield 'DB_NAME' => [EnvVars::DB_NAME, true]; + yield 'BASE_PATH' => [EnvVars::BASE_PATH, true]; + yield 'DB_DRIVER' => [EnvVars::DB_DRIVER, false]; + yield 'DEFAULT_REGULAR_404_REDIRECT' => [EnvVars::DEFAULT_REGULAR_404_REDIRECT, false]; } /** @@ -130,11 +51,11 @@ class EnvVarsTest extends TestCase public function provideEnvVarsValues(): iterable { - yield 'DB_NAME without default' => [EnvVars::DB_NAME(), 'shlink', null]; - yield 'DB_NAME with default' => [EnvVars::DB_NAME(), 'shlink', 'foobar']; - yield 'BASE_PATH without default' => [EnvVars::BASE_PATH(), 'the_base_path', null]; - yield 'BASE_PATH with default' => [EnvVars::BASE_PATH(), 'the_base_path', 'foobar']; - yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER(), null, null]; - yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER(), 'foobar', 'foobar']; + yield 'DB_NAME without default' => [EnvVars::DB_NAME, 'shlink', null]; + yield 'DB_NAME with default' => [EnvVars::DB_NAME, 'shlink', 'foobar']; + yield 'BASE_PATH without default' => [EnvVars::BASE_PATH, 'the_base_path', null]; + yield 'BASE_PATH with default' => [EnvVars::BASE_PATH, 'the_base_path', 'foobar']; + yield 'DB_DRIVER without default' => [EnvVars::DB_DRIVER, null, null]; + yield 'DB_DRIVER with default' => [EnvVars::DB_DRIVER, 'foobar', 'foobar']; } } diff --git a/module/Core/test/ConfigProviderTest.php b/module/Core/test/ConfigProviderTest.php index 4044446a..33714f88 100644 --- a/module/Core/test/ConfigProviderTest.php +++ b/module/Core/test/ConfigProviderTest.php @@ -22,8 +22,7 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(5, $config); - self::assertArrayHasKey('routes', $config); + self::assertCount(4, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('entity_manager', $config); self::assertArrayHasKey('events', $config); diff --git a/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php new file mode 100644 index 00000000..004dfd59 --- /dev/null +++ b/module/Core/test/EventDispatcher/Mercure/NotifyNewShortUrlToMercureTest.php @@ -0,0 +1,103 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + + $this->listener = new NotifyNewShortUrlToMercure( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + ); + } + + /** @test */ + public function messageIsLoggedWhenShortUrlIsNotFound(): void + { + $find = $this->em->find(ShortUrl::class, '123')->willReturn(null); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => '123', 'name' => 'Mercure'], + )->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->updatesGenerator->newShortUrlUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedNotificationIsPublished(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = Update::forTopicAndPayload('', []); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function messageIsPrintedIfPublishingFails(): void + { + $shortUrl = ShortUrl::withLongUrl(''); + $update = Update::forTopicAndPayload('', []); + $e = new Exception('Error'); + + $find = $this->em->find(ShortUrl::class, '123')->willReturn($shortUrl); + $newUpdate = $this->updatesGenerator->newShortUrlUpdate($shortUrl)->willReturn($update); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + ($this->listener)(new ShortUrlCreated('123')); + + $find->shouldHaveBeenCalledOnce(); + $newUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'Mercure'], + )->shouldHaveBeenCalledOnce(); + } +} diff --git a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php similarity index 79% rename from module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php rename to module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php index 0b863b69..1ce29d0d 100644 --- a/module/Core/test/EventDispatcher/NotifyVisitToMercureTest.php +++ b/module/Core/test/EventDispatcher/Mercure/NotifyVisitToMercureTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\EventDispatcher; +namespace ShlinkioTest\Shlink\Core\EventDispatcher\Mercure; use Doctrine\ORM\EntityManagerInterface; use PHPUnit\Framework\TestCase; @@ -11,34 +11,35 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Log\LoggerInterface; use RuntimeException; +use Shlinkio\Shlink\Common\UpdatePublishing\PublishingHelperInterface; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\EventDispatcher\Event\VisitLocated; -use Shlinkio\Shlink\Core\EventDispatcher\NotifyVisitToMercure; -use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGeneratorInterface; +use Shlinkio\Shlink\Core\EventDispatcher\Mercure\NotifyVisitToMercure; +use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGeneratorInterface; use Shlinkio\Shlink\Core\Model\Visitor; -use Symfony\Component\Mercure\HubInterface; -use Symfony\Component\Mercure\Update; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; class NotifyVisitToMercureTest extends TestCase { use ProphecyTrait; private NotifyVisitToMercure $listener; - private ObjectProphecy $hub; + private ObjectProphecy $helper; private ObjectProphecy $updatesGenerator; private ObjectProphecy $em; private ObjectProphecy $logger; public function setUp(): void { - $this->hub = $this->prophesize(HubInterface::class); - $this->updatesGenerator = $this->prophesize(MercureUpdatesGeneratorInterface::class); + $this->helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); $this->em = $this->prophesize(EntityManagerInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); $this->listener = new NotifyVisitToMercure( - $this->hub->reveal(), + $this->helper->reveal(), $this->updatesGenerator->reveal(), $this->em->reveal(), $this->logger->reveal(), @@ -51,8 +52,8 @@ class NotifyVisitToMercureTest extends TestCase $visitId = '123'; $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); $logWarning = $this->logger->warning( - 'Tried to notify mercure for visit with id "{visitId}", but it does not exist.', - ['visitId' => $visitId], + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => 'Mercure'], ); $logDebug = $this->logger->debug(Argument::cetera()); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate( @@ -60,7 +61,7 @@ class NotifyVisitToMercureTest extends TestCase ); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class)); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate(Argument::type(Visit::class)); - $publish = $this->hub->publish(Argument::type(Update::class)); + $publish = $this->helper->publishUpdate(Argument::type(Update::class)); ($this->listener)(new VisitLocated($visitId)); @@ -78,7 +79,7 @@ class NotifyVisitToMercureTest extends TestCase { $visitId = '123'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); @@ -86,7 +87,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update); + $publish = $this->helper->publishUpdate($update); ($this->listener)(new VisitLocated($visitId)); @@ -104,18 +105,19 @@ class NotifyVisitToMercureTest extends TestCase { $visitId = '123'; $visit = Visit::forValidShortUrl(ShortUrl::createEmpty(), Visitor::emptyInstance()); - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $e = new RuntimeException('Error'); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); - $logDebug = $this->logger->debug('Error while trying to notify mercure hub with new visit. {e}', [ + $logDebug = $this->logger->debug('Error while trying to notify {name} with new visit. {e}', [ 'e' => $e, + 'name' => 'Mercure', ]); $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update)->willThrow($e); + $publish = $this->helper->publishUpdate($update)->willThrow($e); ($this->listener)(new VisitLocated($visitId)); @@ -135,7 +137,7 @@ class NotifyVisitToMercureTest extends TestCase public function notificationsAreSentForOrphanVisits(Visit $visit): void { $visitId = '123'; - $update = new Update('', ''); + $update = Update::forTopicAndPayload('', []); $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); $logWarning = $this->logger->warning(Argument::cetera()); @@ -143,7 +145,7 @@ class NotifyVisitToMercureTest extends TestCase $buildNewShortUrlVisitUpdate = $this->updatesGenerator->newShortUrlVisitUpdate($visit)->willReturn($update); $buildNewOrphanVisitUpdate = $this->updatesGenerator->newOrphanVisitUpdate($visit)->willReturn($update); $buildNewVisitUpdate = $this->updatesGenerator->newVisitUpdate($visit)->willReturn($update); - $publish = $this->hub->publish($update); + $publish = $this->helper->publishUpdate($update); ($this->listener)(new VisitLocated($visitId)); @@ -160,8 +162,8 @@ class NotifyVisitToMercureTest extends TestCase { $visitor = Visitor::emptyInstance(); - yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; - yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; - yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; + yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; + yield VisitType::BASE_URL->value => [Visit::forBasePath($visitor)]; } } diff --git a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php deleted file mode 100644 index 778da889..00000000 --- a/module/Core/test/EventDispatcher/NotifyVisitToRabbitMqTest.php +++ /dev/null @@ -1,178 +0,0 @@ -channel = $this->prophesize(AMQPChannel::class); - - $this->connection = $this->prophesize(AMQPStreamConnection::class); - $this->connection->isConnected()->willReturn(false); - $this->connection->channel()->willReturn($this->channel->reveal()); - - $this->em = $this->prophesize(EntityManagerInterface::class); - $this->logger = $this->prophesize(LoggerInterface::class); - - $this->listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - new OrphanVisitDataTransformer(), - true, - ); - } - - /** @test */ - public function doesNothingWhenTheFeatureIsNotEnabled(): void - { - $listener = new NotifyVisitToRabbitMq( - $this->connection->reveal(), - $this->em->reveal(), - $this->logger->reveal(), - new OrphanVisitDataTransformer(), - false, - ); - - $listener(new VisitLocated('123')); - - $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); - } - - /** @test */ - public function notificationsAreNotSentWhenVisitCannotBeFound(): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); - $logWarning = $this->logger->warning( - 'Tried to notify RabbitMQ for visit with id "{visitId}", but it does not exist.', - ['visitId' => $visitId], - ); - - ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $logWarning->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - $this->connection->isConnected()->shouldNotHaveBeenCalled(); - $this->connection->close()->shouldNotHaveBeenCalled(); - } - - /** - * @test - * @dataProvider provideVisits - */ - public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); - $argumentWithExpectedChannel = Argument::that(fn (string $channel) => contains($expectedChannels, $channel)); - - ($this->listener)(new VisitLocated($visitId)); - - $findVisit->shouldHaveBeenCalledOnce(); - $this->channel->exchange_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_declare($argumentWithExpectedChannel, Argument::cetera())->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->queue_bind( - $argumentWithExpectedChannel, - $argumentWithExpectedChannel, - )->shouldHaveBeenCalledTimes(count($expectedChannels)); - $this->channel->basic_publish(Argument::any(), $argumentWithExpectedChannel)->shouldHaveBeenCalledTimes( - count($expectedChannels), - ); - $this->channel->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); - $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); - } - - public function provideVisits(): iterable - { - $visitor = Visitor::emptyInstance(); - - yield 'orphan visit' => [Visit::forBasePath($visitor), ['https://shlink.io/new-orphan-visit']]; - yield 'non-orphan visit' => [ - Visit::forValidShortUrl( - ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ - 'longUrl' => 'foo', - 'customSlug' => 'bar', - ])), - $visitor, - ), - ['https://shlink.io/new-visit', 'https://shlink.io/new-visit/bar'], - ]; - } - - /** - * @test - * @dataProvider provideExceptions - */ - public function printsDebugMessageInCaseOfError(Throwable $e): void - { - $visitId = '123'; - $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); - $channel = $this->connection->channel()->willThrow($e); - - ($this->listener)(new VisitLocated($visitId)); - - $this->logger->debug( - 'Error while trying to notify RabbitMQ with new visit. {e}', - ['e' => $e], - )->shouldHaveBeenCalledOnce(); - $this->connection->close()->shouldHaveBeenCalledOnce(); - $this->connection->reconnect()->shouldHaveBeenCalledOnce(); - $findVisit->shouldHaveBeenCalledOnce(); - $channel->shouldHaveBeenCalledOnce(); - $this->channel->close()->shouldNotHaveBeenCalled(); - } - - public function provideExceptions(): iterable - { - yield [new RuntimeException('RuntimeException Error')]; - yield [new Exception('Exception Error')]; - yield [new DomainException('DomainException Error')]; - } -} diff --git a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php similarity index 59% rename from module/Core/test/Mercure/MercureUpdatesGeneratorTest.php rename to module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php index 14378b4f..e4b616e8 100644 --- a/module/Core/test/Mercure/MercureUpdatesGeneratorTest.php +++ b/module/Core/test/EventDispatcher/PublishingUpdatesGeneratorTest.php @@ -2,27 +2,28 @@ declare(strict_types=1); -namespace ShlinkioTest\Shlink\Core\Mercure; +namespace ShlinkioTest\Shlink\Core\EventDispatcher; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Common\UpdatePublishing\Update; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Entity\Visit; -use Shlinkio\Shlink\Core\Mercure\MercureUpdatesGenerator; +use Shlinkio\Shlink\Core\EventDispatcher\PublishingUpdatesGenerator; +use Shlinkio\Shlink\Core\EventDispatcher\Topic; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Model\Visitor; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; -use function Shlinkio\Shlink\Common\json_decode; - -class MercureUpdatesGeneratorTest extends TestCase +class PublishingUpdatesGeneratorTest extends TestCase { - private MercureUpdatesGenerator $generator; + private PublishingUpdatesGenerator $generator; public function setUp(): void { - $this->generator = new MercureUpdatesGenerator( + $this->generator = new PublishingUpdatesGenerator( new ShortUrlDataTransformer(new ShortUrlStringifier([])), new OrphanVisitDataTransformer(), ); @@ -41,9 +42,10 @@ class MercureUpdatesGeneratorTest extends TestCase ])); $visit = Visit::forValidShortUrl($shortUrl, Visitor::emptyInstance()); + /** @var Update $update */ $update = $this->generator->{$method}($visit); - self::assertEquals([$expectedTopic], $update->getTopics()); + self::assertEquals($expectedTopic, $update->topic); self::assertEquals([ 'shortUrl' => [ 'shortCode' => $shortUrl->getShortCode(), @@ -69,7 +71,7 @@ class MercureUpdatesGeneratorTest extends TestCase 'date' => $visit->getDate()->toAtomString(), 'potentialBot' => false, ], - ], json_decode($update->getData())); + ], $update->payload); } public function provideMethod(): iterable @@ -86,7 +88,7 @@ class MercureUpdatesGeneratorTest extends TestCase { $update = $this->generator->newOrphanVisitUpdate($orphanVisit); - self::assertEquals(['https://shlink.io/new-orphan-visit'], $update->getTopics()); + self::assertEquals('https://shlink.io/new-orphan-visit', $update->topic); self::assertEquals([ 'visit' => [ 'referer' => '', @@ -95,17 +97,48 @@ class MercureUpdatesGeneratorTest extends TestCase 'date' => $orphanVisit->getDate()->toAtomString(), 'potentialBot' => false, 'visitedUrl' => $orphanVisit->visitedUrl(), - 'type' => $orphanVisit->type(), + 'type' => $orphanVisit->type()->value, ], - ], json_decode($update->getData())); + ], $update->payload); } public function provideOrphanVisits(): iterable { $visitor = Visitor::emptyInstance(); - yield Visit::TYPE_REGULAR_404 => [Visit::forRegularNotFound($visitor)]; - yield Visit::TYPE_INVALID_SHORT_URL => [Visit::forInvalidShortUrl($visitor)]; - yield Visit::TYPE_BASE_URL => [Visit::forBasePath($visitor)]; + yield VisitType::REGULAR_404->value => [Visit::forRegularNotFound($visitor)]; + yield VisitType::INVALID_SHORT_URL->value => [Visit::forInvalidShortUrl($visitor)]; + yield VisitType::BASE_URL->value => [Visit::forBasePath($visitor)]; + } + + /** @test */ + public function shortUrlIsProperlySerializedIntoUpdate(): void + { + $shortUrl = ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'customSlug' => 'foo', + 'longUrl' => '', + 'title' => 'The title', + ])); + + $update = $this->generator->newShortUrlUpdate($shortUrl); + + self::assertEquals(Topic::NEW_SHORT_URL->value, $update->topic); + self::assertEquals(['shortUrl' => [ + 'shortCode' => $shortUrl->getShortCode(), + 'shortUrl' => 'http:/' . $shortUrl->getShortCode(), + 'longUrl' => '', + 'dateCreated' => $shortUrl->getDateCreated()->toAtomString(), + 'visitsCount' => 0, + 'tags' => [], + 'meta' => [ + 'validSince' => null, + 'validUntil' => null, + 'maxVisits' => null, + ], + 'domain' => null, + 'title' => $shortUrl->title(), + 'crawlable' => false, + 'forwardQuery' => true, + ]], $update->payload); } } diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php new file mode 100644 index 00000000..9cf44977 --- /dev/null +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyNewShortUrlToRabbitMqTest.php @@ -0,0 +1,134 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->options = new RabbitMqOptions(['enabled' => true]); + + $this->listener = new NotifyNewShortUrlToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $this->options, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->options->enabled = false; + + ($this->listener)(new ShortUrlCreated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenShortUrlCannotBeFound(): void + { + $shortUrlId = '123'; + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify {name} for new short URL with id "{shortUrlId}", but it does not exist.', + ['shortUrlId' => $shortUrlId, 'name' => 'RabbitMQ'], + ); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function expectedChannelIsNotified(): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate($update)->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + ($this->listener)(new ShortUrlCreated($shortUrlId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'RabbitMQ'], + )->shouldHaveBeenCalledOnce(); + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } +} diff --git a/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php new file mode 100644 index 00000000..05ee7568 --- /dev/null +++ b/module/Core/test/EventDispatcher/RabbitMq/NotifyVisitToRabbitMqTest.php @@ -0,0 +1,250 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + $this->options = new RabbitMqOptions(['enabled' => true, 'legacy_visits_publishing' => false]); + + $this->listener = new NotifyVisitToRabbitMq( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + new OrphanVisitDataTransformer(), + $this->options, + ); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->options->enabled = false; + + ($this->listener)(new VisitLocated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** @test */ + public function notificationsAreNotSentWhenVisitCannotBeFound(): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(null); + $logWarning = $this->logger->warning( + 'Tried to notify {name} for visit with id "{visitId}", but it does not exist.', + ['visitId' => $visitId, 'name' => 'RabbitMQ'], + ); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $logWarning->shouldHaveBeenCalledOnce(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideVisits + */ + public function expectedChannelsAreNotifiedBasedOnTheVisitType(Visit $visit, array $expectedChannels): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + each($expectedChannels, function (string $method): void { + $this->updatesGenerator->{$method}(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + )->shouldBeCalledOnce(); + }); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $this->helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes( + count($expectedChannels), + ); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + public function provideVisits(): iterable + { + $visitor = Visitor::emptyInstance(); + + yield 'orphan visit' => [Visit::forBasePath($visitor), ['newOrphanVisitUpdate']]; + yield 'non-orphan visit' => [ + Visit::forValidShortUrl( + ShortUrl::fromMeta(ShortUrlMeta::fromRawData([ + 'longUrl' => 'foo', + 'customSlug' => 'bar', + ])), + $visitor, + ), + ['newShortUrlVisitUpdate', 'newVisitUpdate'], + ]; + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); + $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + ); + $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); + + ($this->listener)(new VisitLocated($visitId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => 'RabbitMQ'], + )->shouldHaveBeenCalledOnce(); + $findVisit->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + /** + * @test + * @dataProvider provideLegacyPayloads + */ + public function expectedPayloadIsPublishedDependingOnConfig( + bool $legacy, + Visit $visit, + callable $assert, + callable $setup, + ): void { + $this->options->legacyVisitsPublishing = $legacy; + + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn($visit); + $setup($this->updatesGenerator); + + ($this->listener)(new VisitLocated($visitId)); + + $findVisit->shouldHaveBeenCalledOnce(); + $assert($this->helper, $this->updatesGenerator); + } + + public function provideLegacyPayloads(): iterable + { + yield 'legacy non-orphan visit' => [ + true, + $visit = Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper) use ($visit): void { + $helper->publishUpdate(Argument::that(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; + })); + }, + noop(...), + ]; + yield 'legacy orphan visit' => [ + true, + Visit::forBasePath(Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::that(function (Update $update): bool { + $payload = $update->payload; + Assert::assertArrayHasKey('visitedUrl', $payload); + Assert::assertArrayHasKey('type', $payload); + + return true; + })); + }, + noop(...), + ]; + yield 'non-legacy non-orphan visit' => [ + false, + Visit::forValidShortUrl(ShortUrl::withLongUrl(''), Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledTimes(2); + }, + function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + $update = Update::forTopicAndPayload('', []); + $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + $updatesGenerator->newVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + }, + ]; + yield 'non-legacy orphan visit' => [ + false, + Visit::forBasePath(Visitor::emptyInstance()), + function (ObjectProphecy|PublishingHelperInterface $helper): void { + $helper->publishUpdate(Argument::type(Update::class))->shouldHaveBeenCalledOnce(); + }, + function (ObjectProphecy|PublishingUpdatesGeneratorInterface $updatesGenerator): void { + $update = Update::forTopicAndPayload('', []); + $updatesGenerator->newOrphanVisitUpdate(Argument::cetera())->willReturn($update) + ->shouldBeCalledOnce(); + $updatesGenerator->newVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + $updatesGenerator->newShortUrlVisitUpdate(Argument::cetera())->shouldNotBeCalled(); + }, + ]; + } +} diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php new file mode 100644 index 00000000..d5fa8b8c --- /dev/null +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyNewShortUrlToRedisTest.php @@ -0,0 +1,95 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->createListener(false)(new ShortUrlCreated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $shortUrlId = '123'; + $update = Update::forTopicAndPayload(Topic::NEW_SHORT_URL->value, []); + $find = $this->em->find(ShortUrl::class, $shortUrlId)->willReturn(ShortUrl::withLongUrl('')); + $generateUpdate = $this->updatesGenerator->newShortUrlUpdate(Argument::type(ShortUrl::class))->willReturn( + $update, + ); + $publish = $this->helper->publishUpdate($update)->willThrow($e); + + $this->createListener()(new ShortUrlCreated($shortUrlId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new short URL. {e}', + ['e' => $e, 'name' => 'Redis pub/sub'], + )->shouldHaveBeenCalledOnce(); + $find->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + private function createListener(bool $enabled = true): NotifyNewShortUrlToRedis + { + return new NotifyNewShortUrlToRedis( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $enabled, + ); + } +} diff --git a/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php new file mode 100644 index 00000000..3beaa838 --- /dev/null +++ b/module/Core/test/EventDispatcher/RedisPubSub/NotifyVisitToRedisTest.php @@ -0,0 +1,94 @@ +helper = $this->prophesize(PublishingHelperInterface::class); + $this->updatesGenerator = $this->prophesize(PublishingUpdatesGeneratorInterface::class); + $this->em = $this->prophesize(EntityManagerInterface::class); + $this->logger = $this->prophesize(LoggerInterface::class); + } + + /** @test */ + public function doesNothingWhenTheFeatureIsNotEnabled(): void + { + $this->createListener(false)(new VisitLocated('123')); + + $this->em->find(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->warning(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->logger->debug(Argument::cetera())->shouldNotHaveBeenCalled(); + $this->helper->publishUpdate(Argument::cetera())->shouldNotHaveBeenCalled(); + } + + /** + * @test + * @dataProvider provideExceptions + */ + public function printsDebugMessageInCaseOfError(Throwable $e): void + { + $visitId = '123'; + $findVisit = $this->em->find(Visit::class, $visitId)->willReturn(Visit::forBasePath(Visitor::emptyInstance())); + $generateUpdate = $this->updatesGenerator->newOrphanVisitUpdate(Argument::type(Visit::class))->willReturn( + Update::forTopicAndPayload('', []), + ); + $publish = $this->helper->publishUpdate(Argument::cetera())->willThrow($e); + + $this->createListener()(new VisitLocated($visitId)); + + $this->logger->debug( + 'Error while trying to notify {name} with new visit. {e}', + ['e' => $e, 'name' => 'Redis pub/sub'], + )->shouldHaveBeenCalledOnce(); + $findVisit->shouldHaveBeenCalledOnce(); + $generateUpdate->shouldHaveBeenCalledOnce(); + $publish->shouldHaveBeenCalledOnce(); + } + + public function provideExceptions(): iterable + { + yield [new RuntimeException('RuntimeException Error')]; + yield [new Exception('Exception Error')]; + yield [new DomainException('DomainException Error')]; + } + + private function createListener(bool $enabled = true): NotifyVisitToRedis + { + return new NotifyVisitToRedis( + $this->helper->reveal(), + $this->updatesGenerator->reveal(), + $this->em->reveal(), + $this->logger->reveal(), + $enabled, + ); + } +} diff --git a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php index ea4e606d..e86a63cb 100644 --- a/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php +++ b/module/Core/test/Exception/ShortUrlNotFoundExceptionTest.php @@ -24,7 +24,7 @@ class ShortUrlNotFoundExceptionTest extends TestCase $expectedAdditional['domain'] = $domain; } - $e = ShortUrlNotFoundException::fromNotFound(new ShortUrlIdentifier($shortCode, $domain)); + $e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain)); self::assertEquals($expectedMessage, $e->getMessage()); self::assertEquals($expectedMessage, $e->getDetail()); diff --git a/module/Core/test/Model/ShortUrlMetaTest.php b/module/Core/test/Model/ShortUrlMetaTest.php index 1933b3b6..975dc372 100644 --- a/module/Core/test/Model/ShortUrlMetaTest.php +++ b/module/Core/test/Model/ShortUrlMetaTest.php @@ -6,6 +6,7 @@ namespace ShlinkioTest\Shlink\Core\Model; use Cake\Chronos\Chronos; use PHPUnit\Framework\TestCase; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -74,12 +75,16 @@ class ShortUrlMetaTest extends TestCase * @test * @dataProvider provideCustomSlugs */ - public function properlyCreatedInstanceReturnsValues(string $customSlug, string $expectedSlug): void - { + public function properlyCreatedInstanceReturnsValues( + string $customSlug, + string $expectedSlug, + bool $multiSegmentEnabled = false, + ): void { $meta = ShortUrlMeta::fromRawData([ 'validSince' => Chronos::parse('2015-01-01')->toAtomString(), 'customSlug' => $customSlug, 'longUrl' => '', + EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value => $multiSegmentEnabled, ]); self::assertTrue($meta->hasValidSince()); @@ -103,7 +108,10 @@ class ShortUrlMetaTest extends TestCase yield ['foo bar', 'foo-bar']; yield ['foo bar baz', 'foo-bar-baz']; yield ['foo bar-baz', 'foo-bar-baz']; + yield ['foo/bar/baz', 'foo/bar/baz', true]; + yield ['/foo/bar/baz', 'foo/bar/baz', true]; yield ['foo/bar/baz', 'foo-bar-baz']; + yield ['/foo/bar/baz', '-foo-bar-baz']; yield ['wp-admin.php', 'wp-admin.php']; yield ['UPPER_lower', 'UPPER_lower']; yield ['more~url_special.chars', 'more~url_special.chars']; diff --git a/module/Core/test/Model/VisitorTest.php b/module/Core/test/Model/VisitorTest.php index 50c277c4..92a46a16 100644 --- a/module/Core/test/Model/VisitorTest.php +++ b/module/Core/test/Model/VisitorTest.php @@ -24,9 +24,9 @@ class VisitorTest extends TestCase $visitor = new Visitor(...$params); ['userAgent' => $userAgent, 'referer' => $referer, 'remoteAddress' => $remoteAddress] = $expected; - self::assertEquals($userAgent, $visitor->getUserAgent()); - self::assertEquals($referer, $visitor->getReferer()); - self::assertEquals($remoteAddress, $visitor->getRemoteAddress()); + self::assertEquals($userAgent, $visitor->userAgent); + self::assertEquals($referer, $visitor->referer); + self::assertEquals($remoteAddress, $visitor->remoteAddress); } public function provideParams(): iterable @@ -89,11 +89,11 @@ class VisitorTest extends TestCase ])); self::assertNotSame($visitor, $normalizedVisitor); - self::assertEmpty($normalizedVisitor->getUserAgent()); - self::assertNotEmpty($visitor->getUserAgent()); - self::assertEmpty($normalizedVisitor->getReferer()); - self::assertNotEmpty($visitor->getReferer()); - self::assertNull($normalizedVisitor->getRemoteAddress()); - self::assertNotNull($visitor->getRemoteAddress()); + self::assertEmpty($normalizedVisitor->userAgent); + self::assertNotEmpty($visitor->userAgent); + self::assertEmpty($normalizedVisitor->referer); + self::assertNotEmpty($visitor->referer); + self::assertNull($normalizedVisitor->remoteAddress); + self::assertNotNull($visitor->remoteAddress); } } diff --git a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php index 6c03d7b5..cd4d6193 100644 --- a/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrl/DeleteShortUrlServiceTest.php @@ -55,7 +55,7 @@ class DeleteShortUrlServiceTest extends TestCase $this->shortCode, )); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); } /** @test */ @@ -66,7 +66,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode), true); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode), true); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); @@ -80,7 +80,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); @@ -94,7 +94,7 @@ class DeleteShortUrlServiceTest extends TestCase $remove = $this->em->remove(Argument::type(ShortUrl::class))->willReturn(null); $flush = $this->em->flush()->willReturn(null); - $service->deleteByShortCode(new ShortUrlIdentifier($this->shortCode)); + $service->deleteByShortCode(ShortUrlIdentifier::fromShortCodeAndDomain($this->shortCode)); $remove->shouldHaveBeenCalledOnce(); $flush->shouldHaveBeenCalledOnce(); diff --git a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php index 70857e5e..bdccfa3f 100644 --- a/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php +++ b/module/Core/test/Service/ShortUrl/ShortUrlResolverTest.php @@ -91,7 +91,7 @@ class ShortUrlResolverTest extends TestCase )->willReturn($shortUrl); $getRepo = $this->em->getRepository(ShortUrl::class)->willReturn($repo->reveal()); - $result = $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode)); + $result = $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); self::assertSame($shortUrl, $result); $findOneByShortCode->shouldHaveBeenCalledOnce(); @@ -116,7 +116,7 @@ class ShortUrlResolverTest extends TestCase $findOneByShortCode->shouldBeCalledOnce(); $getRepo->shouldBeCalledOnce(); - $this->urlResolver->resolveEnabledShortUrl(new ShortUrlIdentifier($shortCode)); + $this->urlResolver->resolveEnabledShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode)); } public function provideDisabledShortUrls(): iterable diff --git a/module/Core/test/Service/ShortUrlServiceTest.php b/module/Core/test/Service/ShortUrlServiceTest.php index b07d4df9..90000423 100644 --- a/module/Core/test/Service/ShortUrlServiceTest.php +++ b/module/Core/test/Service/ShortUrlServiceTest.php @@ -88,7 +88,7 @@ class ShortUrlServiceTest extends TestCase $shortUrl = ShortUrl::withLongUrl($originalLongUrl); $findShortUrl = $this->urlResolver->resolveShortUrl( - new ShortUrlIdentifier('abc123'), + ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), $apiKey, )->willReturn($shortUrl); $flush = $this->em->flush()->willReturn(null); @@ -97,7 +97,11 @@ class ShortUrlServiceTest extends TestCase $shortUrlEdit, ); - $result = $this->service->updateShortUrl(new ShortUrlIdentifier('abc123'), $shortUrlEdit, $apiKey); + $result = $this->service->updateShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain('abc123'), + $shortUrlEdit, + $apiKey, + ); self::assertSame($shortUrl, $result); self::assertEquals($shortUrlEdit->validSince(), $shortUrl->getValidSince()); diff --git a/module/Core/test/Service/UrlShortenerTest.php b/module/Core/test/Service/UrlShortenerTest.php index bdd508b4..fbe9b1c4 100644 --- a/module/Core/test/Service/UrlShortenerTest.php +++ b/module/Core/test/Service/UrlShortenerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Psr\EventDispatcher\EventDispatcherInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; @@ -27,6 +28,7 @@ class UrlShortenerTest extends TestCase private ObjectProphecy $em; private ObjectProphecy $titleResolutionHelper; private ObjectProphecy $shortCodeHelper; + private ObjectProphecy $eventDispatcher; public function setUp(): void { @@ -39,7 +41,7 @@ class UrlShortenerTest extends TestCase [$shortUrl] = $arguments; $shortUrl->setId('10'); }); - $this->em->transactional(Argument::type('callable'))->will(function (array $args) { + $this->em->wrapInTransaction(Argument::type('callable'))->will(function (array $args) { /** @var callable $callback */ [$callback] = $args; @@ -51,11 +53,14 @@ class UrlShortenerTest extends TestCase $this->shortCodeHelper = $this->prophesize(ShortCodeUniquenessHelperInterface::class); $this->shortCodeHelper->ensureShortCodeUniqueness(Argument::cetera())->willReturn(true); + $this->eventDispatcher = $this->prophesize(EventDispatcherInterface::class); + $this->urlShortener = new UrlShortener( $this->titleResolutionHelper->reveal(), $this->em->reveal(), new SimpleShortUrlRelationResolver(), $this->shortCodeHelper->reveal(), + $this->eventDispatcher->reveal(), ); } diff --git a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php index d8997524..4099faea 100644 --- a/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php +++ b/module/Core/test/ShortUrl/Middleware/ExtraPathRedirectMiddlewareTest.php @@ -16,6 +16,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; +use Shlinkio\Shlink\Core\Action\RedirectAction; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\ErrorHandler\Model\NotFoundType; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; @@ -27,6 +28,8 @@ use Shlinkio\Shlink\Core\ShortUrl\Middleware\ExtraPathRedirectMiddleware; use Shlinkio\Shlink\Core\Util\RedirectResponseHelperInterface; use Shlinkio\Shlink\Core\Visit\RequestTrackerInterface; +use function str_starts_with; + class ExtraPathRedirectMiddlewareTest extends TestCase { use ProphecyTrait; @@ -65,12 +68,15 @@ class ExtraPathRedirectMiddlewareTest extends TestCase */ public function handlerIsCalledWhenConfigPreventsRedirectWithExtraPath( bool $appendExtraPath, + bool $multiSegmentEnabled, ServerRequestInterface $request, ): void { $this->options->appendExtraPath = $appendExtraPath; + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; $this->middleware->process($request, $this->handler->reveal()); + $this->handler->handle($request)->shouldHaveBeenCalledOnce(); $this->resolver->resolveEnabledShortUrl(Argument::cetera())->shouldNotHaveBeenCalled(); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); @@ -83,65 +89,109 @@ class ExtraPathRedirectMiddlewareTest extends TestCase $buildReq = static fn (?NotFoundType $type): ServerRequestInterface => $baseReq->withAttribute(NotFoundType::class, $type); - yield 'disabled option' => [false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; - yield 'base_url error' => [true, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; + yield 'disabled option' => [false, false, $buildReq(NotFoundType::fromRequest($baseReq, '/foo/bar'))]; + yield 'no error type' => [true, false, $buildReq(null)]; + yield 'base_url error' => [true, false, $buildReq(NotFoundType::fromRequest($baseReq, ''))]; yield 'invalid_short_url error' => [ true, - $buildReq(NotFoundType::fromRequest($baseReq, ''))->withAttribute( + false, + $buildReq(NotFoundType::fromRequest($baseReq->withUri(new Uri('/foo'))->withAttribute( RouteResult::class, RouteResult::fromRoute(new Route( - '', + '/foo', $this->prophesize(MiddlewareInterface::class)->reveal(), ['GET'], + RedirectAction::class, )), - ), + ), '')), + ]; + yield 'regular_404 error with multi-segment slugs' => [ + true, + true, + $buildReq(NotFoundType::fromRequest($baseReq->withUri(new Uri('/foo'))->withAttribute( + RouteResult::class, + RouteResult::fromRouteFailure(['GET']), + ), '')), ]; - yield 'no error type' => [true, $buildReq(null)]; } - /** @test */ - public function handlerIsCalledWhenNoShortUrlIsFound(): void - { + /** + * @test + * @dataProvider provideResolves + */ + public function handlerIsCalledWhenNoShortUrlIsFoundAfterExpectedAmountOfIterations( + bool $multiSegmentEnabled, + int $expectedResolveCalls, + ): void { + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); + $type->isInvalidShortUrl()->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) ->withUri(new Uri('/shortCode/bar/baz')); - $resolve = $this->resolver->resolveEnabledShortUrl(Argument::cetera())->willThrow( - ShortUrlNotFoundException::class, - ); + $resolve = $this->resolver->resolveEnabledShortUrl( + Argument::that(fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode')), + )->willThrow(ShortUrlNotFoundException::class); $this->middleware->process($request, $this->handler->reveal()); - $resolve->shouldHaveBeenCalledOnce(); + $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $this->requestTracker->trackIfApplicable(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectionBuilder->buildShortUrlRedirect(Argument::cetera())->shouldNotHaveBeenCalled(); $this->redirectResponseHelper->buildRedirectResponse(Argument::cetera())->shouldNotHaveBeenCalled(); } - /** @test */ - public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFound(): void - { + /** + * @test + * @dataProvider provideResolves + */ + public function visitIsTrackedAndRedirectIsReturnedWhenShortUrlIsFoundAfterExpectedAmountOfIterations( + bool $multiSegmentEnabled, + int $expectedResolveCalls, + ?string $expectedExtraPath, + ): void { + $this->options->multiSegmentSlugsEnabled = $multiSegmentEnabled; + $type = $this->prophesize(NotFoundType::class); $type->isRegularNotFound()->willReturn(true); + $type->isInvalidShortUrl()->willReturn(true); $request = ServerRequestFactory::fromGlobals()->withAttribute(NotFoundType::class, $type->reveal()) ->withUri(new Uri('https://doma.in/shortCode/bar/baz')); $shortUrl = ShortUrl::withLongUrl(''); - $identifier = ShortUrlIdentifier::fromShortCodeAndDomain('shortCode', 'doma.in'); - - $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->willReturn($shortUrl); - $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], '/bar/baz')->willReturn( - 'the_built_long_url', + $identifier = Argument::that( + fn (ShortUrlIdentifier $identifier) => str_starts_with($identifier->shortCode, 'shortCode'), ); + + $currentIteration = 1; + $resolve = $this->resolver->resolveEnabledShortUrl($identifier)->will( + function () use ($shortUrl, &$currentIteration, $expectedResolveCalls): ShortUrl { + if ($expectedResolveCalls === $currentIteration) { + return $shortUrl; + } + + $currentIteration++; + throw ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortUrl($shortUrl)); + }, + ); + $buildLongUrl = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, [], $expectedExtraPath) + ->willReturn('the_built_long_url'); $buildResp = $this->redirectResponseHelper->buildRedirectResponse('the_built_long_url')->willReturn( new RedirectResponse(''), ); $this->middleware->process($request, $this->handler->reveal()); - $resolve->shouldHaveBeenCalledOnce(); + $resolve->shouldHaveBeenCalledTimes($expectedResolveCalls); $buildLongUrl->shouldHaveBeenCalledOnce(); $buildResp->shouldHaveBeenCalledOnce(); $this->requestTracker->trackIfApplicable($shortUrl, $request)->shouldHaveBeenCalledOnce(); } + + public function provideResolves(): iterable + { + yield [false, 1, '/bar/baz']; + yield [true, 3, null]; + } } diff --git a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php index 336526b1..2675b04a 100644 --- a/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php +++ b/module/Core/test/ShortUrl/Paginator/Adapter/ShortUrlRepositoryAdapterTest.php @@ -10,6 +10,7 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\Repository\ShortUrlRepositoryInterface; +use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Paginator\Adapter\ShortUrlRepositoryAdapter; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsCountFiltering; use Shlinkio\Shlink\Core\ShortUrl\Persistence\ShortUrlsListFiltering; @@ -49,7 +50,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->findList( - new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange), + new ShortUrlsListFiltering(10, 5, $orderBy, $searchTerm, $tags, TagsMode::ANY, $dateRange), )->shouldBeCalledOnce(); $adapter->getSlice(5, 10); } @@ -75,7 +76,7 @@ class ShortUrlRepositoryAdapterTest extends TestCase $dateRange = $params->dateRange(); $this->repo->countList( - new ShortUrlsCountFiltering($searchTerm, $tags, ShortUrlsParams::TAGS_MODE_ANY, $dateRange, $apiKey), + new ShortUrlsCountFiltering($searchTerm, $tags, TagsMode::ANY, $dateRange, $apiKey), )->shouldBeCalledOnce(); $adapter->getNbResults(); } diff --git a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php index 4c4c00e5..ba1d2767 100644 --- a/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/NonOrphanVisitsPaginatorAdapterTest.php @@ -39,7 +39,7 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $repoCount = $this->repo->countNonOrphanVisits( - new VisitsCountFiltering($this->params->getDateRange(), $this->params->excludeBots(), $this->apiKey), + new VisitsCountFiltering($this->params->dateRange, $this->params->excludeBots, $this->apiKey), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -57,8 +57,8 @@ class NonOrphanVisitsPaginatorAdapterTest extends TestCase $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $repoFind = $this->repo->findNonOrphanVisits(new VisitsListFiltering( - $this->params->getDateRange(), - $this->params->excludeBots(), + $this->params->dateRange, + $this->params->excludeBots, $this->apiKey, $limit, $offset, diff --git a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php index 0ea91f29..6709c538 100644 --- a/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/OrphanVisitsPaginatorAdapterTest.php @@ -35,7 +35,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase { $expectedCount = 5; $repoCount = $this->repo->countOrphanVisits( - new VisitsCountFiltering($this->params->getDateRange()), + new VisitsCountFiltering($this->params->dateRange), )->willReturn($expectedCount); $result = $this->adapter->getNbResults(); @@ -53,7 +53,7 @@ class OrphanVisitsPaginatorAdapterTest extends TestCase $visitor = Visitor::emptyInstance(); $list = [Visit::forRegularNotFound($visitor), Visit::forInvalidShortUrl($visitor)]; $repoFind = $this->repo->findOrphanVisits( - new VisitsListFiltering($this->params->getDateRange(), $this->params->excludeBots(), null, $limit, $offset), + new VisitsListFiltering($this->params->dateRange, $this->params->excludeBots, null, $limit, $offset), )->willReturn($list); $result = $this->adapter->getSlice($offset, $limit); diff --git a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php index 04e17bc6..7d6d04a6 100644 --- a/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/ShortUrlVisitsPaginatorAdapterTest.php @@ -36,7 +36,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain(''), - new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset), + new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); for ($i = 0; $i < $count; $i++) { @@ -54,7 +54,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter($apiKey); $countVisits = $this->repo->countVisitsByShortCode( ShortUrlIdentifier::fromShortCodeAndDomain(''), - new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey), + new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); for ($i = 0; $i < $count; $i++) { @@ -68,7 +68,7 @@ class ShortUrlVisitsPaginatorAdapterTest extends TestCase { return new ShortUrlVisitsPaginatorAdapter( $this->repo->reveal(), - new ShortUrlIdentifier(''), + ShortUrlIdentifier::fromShortCodeAndDomain(''), VisitsParams::fromRawData([]), $apiKey, ); diff --git a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php index 442e7128..32e1bb85 100644 --- a/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php +++ b/module/Core/test/Visit/Paginator/Adapter/VisitsForTagPaginatorAdapterTest.php @@ -35,7 +35,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter(null); $findVisits = $this->repo->findVisitsByTag( 'foo', - new VisitsListFiltering(DateRange::emptyInstance(), false, null, $limit, $offset), + new VisitsListFiltering(DateRange::allTime(), false, null, $limit, $offset), )->willReturn([]); for ($i = 0; $i < $count; $i++) { @@ -53,7 +53,7 @@ class VisitsForTagPaginatorAdapterTest extends TestCase $adapter = $this->createAdapter($apiKey); $countVisits = $this->repo->countVisitsByTag( 'foo', - new VisitsCountFiltering(DateRange::emptyInstance(), false, $apiKey), + new VisitsCountFiltering(DateRange::allTime(), false, $apiKey), )->willReturn(3); for ($i = 0; $i < $count; $i++) { diff --git a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php index c836cd7c..2d2561bd 100644 --- a/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php +++ b/module/Core/test/Visit/Transformer/OrphanVisitDataTransformerTest.php @@ -10,6 +10,7 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Entity\Visit; use Shlinkio\Shlink\Core\Entity\VisitLocation; use Shlinkio\Shlink\Core\Model\Visitor; +use Shlinkio\Shlink\Core\Visit\Model\VisitType; use Shlinkio\Shlink\Core\Visit\Transformer\OrphanVisitDataTransformer; use Shlinkio\Shlink\IpGeolocation\Model\Location; @@ -44,7 +45,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => null, 'potentialBot' => false, 'visitedUrl' => '', - 'type' => Visit::TYPE_BASE_URL, + 'type' => VisitType::BASE_URL->value, ], ]; yield 'invalid short url visit' => [ @@ -60,7 +61,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => null, 'potentialBot' => false, 'visitedUrl' => 'https://example.com/foo', - 'type' => Visit::TYPE_INVALID_SHORT_URL, + 'type' => VisitType::INVALID_SHORT_URL->value, ], ]; yield 'regular 404 visit' => [ @@ -78,7 +79,7 @@ class OrphanVisitDataTransformerTest extends TestCase 'visitLocation' => $location, 'potentialBot' => false, 'visitedUrl' => 'https://doma.in/foo/bar', - 'type' => Visit::TYPE_REGULAR_404, + 'type' => VisitType::REGULAR_404->value, ], ]; } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 34be71f4..189180b0 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -61,10 +61,15 @@ return [ Action\HealthAction::class => ['em', Options\AppOptions::class], Action\MercureInfoAction::class => [LcobucciJwtProvider::class, 'config.mercure'], - Action\ShortUrl\CreateShortUrlAction::class => [Service\UrlShortener::class, ShortUrlDataTransformer::class], + Action\ShortUrl\CreateShortUrlAction::class => [ + Service\UrlShortener::class, + ShortUrlDataTransformer::class, + Options\UrlShortenerOptions::class, + ], Action\ShortUrl\SingleStepCreateShortUrlAction::class => [ Service\UrlShortener::class, ShortUrlDataTransformer::class, + Options\UrlShortenerOptions::class, ], Action\ShortUrl\EditShortUrlAction::class => [Service\ShortUrlService::class, ShortUrlDataTransformer::class], Action\ShortUrl\DeleteShortUrlAction::class => [Service\ShortUrl\DeleteShortUrlService::class], diff --git a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php index b7787b1a..8df324a4 100644 --- a/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php +++ b/module/Rest/config/entities-mappings/Shlinkio.Shlink.Rest.Entity.ApiKeyRole.php @@ -6,7 +6,9 @@ namespace Shlinkio\Shlink\Rest; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping\Builder\ClassMetadataBuilder; +use Doctrine\ORM\Mapping\Builder\FieldBuilder; use Doctrine\ORM\Mapping\ClassMetadata; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Shlinkio\Shlink\Rest\Entity\ApiKey; use function Shlinkio\Shlink\Core\determineTableName; @@ -22,11 +24,14 @@ return static function (ClassMetadata $metadata, array $emConfig): void { ->option('unsigned', true) ->build(); - $builder->createField('roleName', Types::STRING) - ->columnName('role_name') - ->length(255) - ->nullable(false) - ->build(); + (new FieldBuilder($builder, [ + 'fieldName' => 'roleName', + 'type' => Types::STRING, + 'enumType' => Role::class, + ]))->columnName('role_name') + ->length(255) + ->nullable(false) + ->build(); $builder->createField('meta', Types::JSON) ->columnName('meta') diff --git a/module/Rest/config/routes.config.php b/module/Rest/config/routes.config.php deleted file mode 100644 index f318664f..00000000 --- a/module/Rest/config/routes.config.php +++ /dev/null @@ -1,57 +0,0 @@ - [ - Action\HealthAction::getRouteDef(), - - // Short URLs - Action\ShortUrl\CreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $dropDomainMiddleware, - $overrideDomainMiddleware, - Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class, - ]), - Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([ - $contentNegotiationMiddleware, - $overrideDomainMiddleware, - ]), - Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\DeleteShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ResolveShortUrlAction::getRouteDef([$dropDomainMiddleware]), - Action\ShortUrl\ListShortUrlsAction::getRouteDef(), - - // Visits - Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]), - Action\Visit\TagVisitsAction::getRouteDef(), - Action\Visit\DomainVisitsAction::getRouteDef(), - Action\Visit\GlobalVisitsAction::getRouteDef(), - Action\Visit\OrphanVisitsAction::getRouteDef(), - Action\Visit\NonOrphanVisitsAction::getRouteDef(), - - // Tags - Action\Tag\ListTagsAction::getRouteDef(), - Action\Tag\TagsStatsAction::getRouteDef(), - Action\Tag\DeleteTagsAction::getRouteDef(), - Action\Tag\UpdateTagAction::getRouteDef(), - - // Domains - Action\Domain\ListDomainsAction::getRouteDef(), - Action\Domain\DomainRedirectsAction::getRouteDef(), - - Action\MercureInfoAction::getRouteDef([NotConfiguredMercureErrorHandler::class]), - ], - - ]; -})(); diff --git a/module/Rest/src/Action/AbstractRestAction.php b/module/Rest/src/Action/AbstractRestAction.php index da8b6d80..f330bab1 100644 --- a/module/Rest/src/Action/AbstractRestAction.php +++ b/module/Rest/src/Action/AbstractRestAction.php @@ -8,8 +8,6 @@ use Fig\Http\Message\RequestMethodInterface; use Fig\Http\Message\StatusCodeInterface; use Psr\Http\Server\RequestHandlerInterface; -use function array_merge; - abstract class AbstractRestAction implements RequestHandlerInterface, RequestMethodInterface, StatusCodeInterface { protected const ROUTE_PATH = ''; @@ -19,7 +17,7 @@ abstract class AbstractRestAction implements RequestHandlerInterface, RequestMet { return [ 'name' => static::class, - 'middleware' => array_merge($prevMiddleware, [static::class], $postMiddleware), + 'middleware' => [...$prevMiddleware, static::class, ...$postMiddleware], 'path' => static::ROUTE_PATH, 'allowed_methods' => static::ROUTE_ALLOWED_METHODS, ]; diff --git a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php index 90616dc5..f122601b 100644 --- a/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/AbstractCreateShortUrlAction.php @@ -10,14 +10,16 @@ use Psr\Http\Message\ServerRequestInterface as Request; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\AbstractRestAction; abstract class AbstractCreateShortUrlAction extends AbstractRestAction { public function __construct( - private UrlShortenerInterface $urlShortener, - private DataTransformerInterface $transformer, + private readonly UrlShortenerInterface $urlShortener, + private readonly DataTransformerInterface $transformer, + protected readonly UrlShortenerOptions $urlShortenerOptions, ) { } diff --git a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php index d8b873a6..376c6bec 100644 --- a/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/CreateShortUrlAction.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Action\ShortUrl; use Psr\Http\Message\ServerRequestInterface as Request; +use Shlinkio\Shlink\Core\Config\EnvVars; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; use Shlinkio\Shlink\Core\Validation\ShortUrlInputFilter; @@ -22,6 +23,7 @@ class CreateShortUrlAction extends AbstractCreateShortUrlAction { $payload = (array) $request->getParsedBody(); $payload[ShortUrlInputFilter::API_KEY] = AuthenticationMiddleware::apiKeyFromRequest($request); + $payload[EnvVars::MULTI_SEGMENT_SLUGS_ENABLED->value] = $this->urlShortenerOptions->multiSegmentSlugsEnabled(); return ShortUrlMeta::fromRawData($payload); } diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 87c21aec..71cf8bf3 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -17,7 +17,7 @@ use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware; class EditShortUrlAction extends AbstractRestAction { protected const ROUTE_PATH = '/short-urls/{shortCode}'; - protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH, self::METHOD_PUT]; + protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH]; public function __construct( private ShortUrlServiceInterface $shortUrlService, diff --git a/module/Rest/src/Action/Tag/ListTagsAction.php b/module/Rest/src/Action/Tag/ListTagsAction.php index ab81400c..d52436d2 100644 --- a/module/Rest/src/Action/Tag/ListTagsAction.php +++ b/module/Rest/src/Action/Tag/ListTagsAction.php @@ -32,7 +32,7 @@ class ListTagsAction extends AbstractRestAction $params = TagsParams::fromRawData($request->getQueryParams()); $apiKey = AuthenticationMiddleware::apiKeyFromRequest($request); - if (! $params->withStats()) { + if (! $params->withStats) { return new JsonResponse([ 'tags' => $this->serializePaginator($this->tagService->listTags($params, $apiKey)), ]); @@ -41,7 +41,7 @@ class ListTagsAction extends AbstractRestAction // This part is deprecated. To get tags with stats, the /tags/stats endpoint should be used instead $tagsInfo = $this->tagService->tagsInfo($params, $apiKey); $rawTags = $this->serializePaginator($tagsInfo, null, 'stats'); - $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag()); + $rawTags['data'] = map($tagsInfo, static fn (TagInfo $info) => $info->tag); return new JsonResponse(['tags' => $rawTags]); } diff --git a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php index 39b5dca1..430221a2 100644 --- a/module/Rest/src/ApiKey/Model/ApiKeyMeta.php +++ b/module/Rest/src/ApiKey/Model/ApiKeyMeta.php @@ -8,11 +8,13 @@ use Cake\Chronos\Chronos; final class ApiKeyMeta { + /** + * @param RoleDefinition[] $roleDefinitions + */ private function __construct( - private ?string $name, - private ?Chronos $expirationDate, - /** @var RoleDefinition[] */ - private array $roleDefinitions, + public readonly ?string $name, + public readonly ?Chronos $expirationDate, + public readonly array $roleDefinitions, ) { } @@ -35,22 +37,4 @@ final class ApiKeyMeta { return new self(null, null, $roleDefinitions); } - - public function name(): ?string - { - return $this->name; - } - - public function expirationDate(): ?Chronos - { - return $this->expirationDate; - } - - /** - * @return RoleDefinition[] - */ - public function roleDefinitions(): array - { - return $this->roleDefinitions; - } } diff --git a/module/Rest/src/ApiKey/Model/RoleDefinition.php b/module/Rest/src/ApiKey/Model/RoleDefinition.php index fdd4d5cb..63c9b72a 100644 --- a/module/Rest/src/ApiKey/Model/RoleDefinition.php +++ b/module/Rest/src/ApiKey/Model/RoleDefinition.php @@ -9,7 +9,7 @@ use Shlinkio\Shlink\Rest\ApiKey\Role; final class RoleDefinition { - private function __construct(private string $roleName, private array $meta) + private function __construct(public readonly Role $role, public readonly array $meta) { } @@ -25,14 +25,4 @@ final class RoleDefinition ['domain_id' => $domain->getId(), 'authority' => $domain->getAuthority()], ); } - - public function roleName(): string - { - return $this->roleName; - } - - public function meta(): array - { - return $this->meta; - } } diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 557abd00..64803969 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -2,6 +2,8 @@ declare(strict_types=1); +// phpcs:disable +// TODO Enable coding style checks again once code sniffer 3.7 is released https://github.com/squizlabs/PHP_CodeSniffer/issues/3474 namespace Shlinkio\Shlink\Rest\ApiKey; use Happyr\DoctrineSpecification\Spec; @@ -12,30 +14,24 @@ use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomain; use Shlinkio\Shlink\Core\ShortUrl\Spec\BelongsToDomainInlined; use Shlinkio\Shlink\Rest\Entity\ApiKeyRole; -class Role +enum Role: string { - public const AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; - public const DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; - private const ROLE_FRIENDLY_NAMES = [ - self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', - ]; + case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; + case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { - return match ($role->name()) { + 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(), }; } public static function toInlinedSpec(ApiKeyRole $role): Specification { - return match ($role->name()) { + 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(), }; } @@ -49,8 +45,11 @@ class Role return $meta['authority'] ?? ''; } - public static function toFriendlyName(string $roleName): string + public static function toFriendlyName(Role $role): string { - return self::ROLE_FRIENDLY_NAMES[$roleName] ?? ''; + return match ($role) { + self::AUTHORED_SHORT_URLS => 'Author only', + self::DOMAIN_SPECIFIC => 'Domain only', + }; } } diff --git a/module/Rest/src/ConfigProvider.php b/module/Rest/src/ConfigProvider.php index 130617d6..3304ce4d 100644 --- a/module/Rest/src/ConfigProvider.php +++ b/module/Rest/src/ConfigProvider.php @@ -4,12 +4,11 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest; -use Closure; - use function Functional\first; use function Functional\map; use function Shlinkio\Shlink\Config\loadConfigFromGlob; use function sprintf; +use function str_replace; class ConfigProvider { @@ -17,45 +16,35 @@ class ConfigProvider private const UNVERSIONED_ROUTES_PREFIX = '/rest'; public const UNVERSIONED_HEALTH_ENDPOINT_NAME = 'unversioned_health'; - private Closure $loadConfig; - - public function __construct(?callable $loadConfig = null) - { - $this->loadConfig = Closure::fromCallable($loadConfig ?? fn (string $glob) => loadConfigFromGlob($glob)); - } - public function __invoke(): array { - $config = ($this->loadConfig)(__DIR__ . '/../config/{,*.}config.php'); - return $this->applyRoutesPrefix($config); + return loadConfigFromGlob(__DIR__ . '/../config/{,*.}config.php'); } - private function applyRoutesPrefix(array $config): array + public static function applyRoutesPrefix(array $routes, bool $multiSegmentEnabled): array { - $routes = $config['routes'] ?? []; - $healthRoute = $this->buildUnversionedHealthRouteFromExistingRoutes($routes); - - $prefixRoute = static function (array $route) { + $healthRoute = self::buildUnversionedHealthRouteFromExistingRoutes($routes); + $prefixedRoutes = map($routes, static function (array $route) use ($multiSegmentEnabled) { ['path' => $path] = $route; + if ($multiSegmentEnabled) { + $path = str_replace('{shortCode}', '{shortCode:.+}', $path); + } $route['path'] = sprintf('%s%s', self::ROUTES_PREFIX, $path); return $route; - }; - $prefixedRoutes = map($routes, $prefixRoute); + }); - $config['routes'] = $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; - - return $config; + return $healthRoute !== null ? [...$prefixedRoutes, $healthRoute] : $prefixedRoutes; } - private function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array + private static function buildUnversionedHealthRouteFromExistingRoutes(array $routes): ?array { $healthRoute = first($routes, fn (array $route) => $route['path'] === '/health'); if ($healthRoute === null) { return null; } - $path = $healthRoute['path']; + ['path' => $path] = $healthRoute; $healthRoute['path'] = sprintf('%s%s', self::UNVERSIONED_ROUTES_PREFIX, $path); $healthRoute['name'] = self::UNVERSIONED_HEALTH_ENDPOINT_NAME; diff --git a/module/Rest/src/Entity/ApiKey.php b/module/Rest/src/Entity/ApiKey.php index 2940bc69..261baee4 100644 --- a/module/Rest/src/Entity/ApiKey.php +++ b/module/Rest/src/Entity/ApiKey.php @@ -44,8 +44,8 @@ class ApiKey extends AbstractEntity public static function fromMeta(ApiKeyMeta $meta): self { - $apiKey = new self($meta->name(), $meta->expirationDate()); - foreach ($meta->roleDefinitions() as $roleDefinition) { + $apiKey = new self($meta->name, $meta->expirationDate); + foreach ($meta->roleDefinitions as $roleDefinition) { $apiKey->registerRole($roleDefinition); } @@ -113,45 +113,40 @@ class ApiKey extends AbstractEntity return $this->roles->isEmpty(); } - public function hasRole(string $roleName): bool + public function hasRole(Role $role): bool { - return $this->roles->containsKey($roleName); + return $this->roles->containsKey($role->value); } - public function getRoleMeta(string $roleName): array + public function getRoleMeta(Role $role): array { - /** @var ApiKeyRole|null $role */ - $role = $this->roles->get($roleName); - return $role?->meta() ?? []; + /** @var ApiKeyRole|null $apiKeyRole */ + $apiKeyRole = $this->roles->get($role->value); + return $apiKeyRole?->meta() ?? []; } /** * @template T - * @param callable(string $roleName, array $meta): T $fun + * @param callable(Role $role, array $meta): T $fun * @return T[] */ public function mapRoles(callable $fun): array { - return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->name(), $role->meta()))->getValues(); + return $this->roles->map(fn (ApiKeyRole $role) => $fun($role->role(), $role->meta()))->getValues(); } public function registerRole(RoleDefinition $roleDefinition): void { - $roleName = $roleDefinition->roleName(); - $meta = $roleDefinition->meta(); + $role = $roleDefinition->role; + $meta = $roleDefinition->meta; - if ($this->hasRole($roleName)) { - /** @var ApiKeyRole $role */ - $role = $this->roles->get($roleName); - $role->updateMeta($meta); + if ($this->hasRole($role)) { + /** @var ApiKeyRole $apiKeyRole */ + $apiKeyRole = $this->roles->get($role); + $apiKeyRole->updateMeta($meta); } else { - $role = new ApiKeyRole($roleDefinition->roleName(), $roleDefinition->meta(), $this); - $this->roles[$roleName] = $role; + $apiKeyRole = new ApiKeyRole($roleDefinition->role, $roleDefinition->meta, $this); + $this->roles[$role->value] = $apiKeyRole; } } - - public function removeRole(string $roleName): void - { - $this->roles->remove($roleName); - } } diff --git a/module/Rest/src/Entity/ApiKeyRole.php b/module/Rest/src/Entity/ApiKeyRole.php index 1155c37b..8491cfce 100644 --- a/module/Rest/src/Entity/ApiKeyRole.php +++ b/module/Rest/src/Entity/ApiKeyRole.php @@ -5,14 +5,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\Rest\Entity; use Shlinkio\Shlink\Common\Entity\AbstractEntity; +use Shlinkio\Shlink\Rest\ApiKey\Role; class ApiKeyRole extends AbstractEntity { - public function __construct(private string $roleName, private array $meta, private ApiKey $apiKey) + public function __construct(private Role $roleName, private array $meta, private ApiKey $apiKey) { } - public function name(): string + public function role(): Role { return $this->roleName; } diff --git a/module/Rest/src/Middleware/AuthenticationMiddleware.php b/module/Rest/src/Middleware/AuthenticationMiddleware.php index 25f1fbe5..7b911817 100644 --- a/module/Rest/src/Middleware/AuthenticationMiddleware.php +++ b/module/Rest/src/Middleware/AuthenticationMiddleware.php @@ -49,7 +49,7 @@ class AuthenticationMiddleware implements MiddlewareInterface, StatusCodeInterfa throw VerifyAuthenticationException::forInvalidApiKey(); } - return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey())); + return $handler->handle($request->withAttribute(ApiKey::class, $result->apiKey)); } public static function apiKeyFromRequest(Request $request): ApiKey diff --git a/module/Rest/src/Middleware/CrossDomainMiddleware.php b/module/Rest/src/Middleware/CrossDomainMiddleware.php index b0d63dc7..d6a51a0c 100644 --- a/module/Rest/src/Middleware/CrossDomainMiddleware.php +++ b/module/Rest/src/Middleware/CrossDomainMiddleware.php @@ -11,7 +11,6 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; use Psr\Http\Server\RequestHandlerInterface; -use function array_merge; use function implode; class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterface @@ -45,7 +44,7 @@ class CrossDomainMiddleware implements MiddlewareInterface, RequestMethodInterfa ]; // Options requests should always be empty and have a 204 status code - return EmptyResponse::withHeaders(array_merge($response->getHeaders(), $corsHeaders)); + return EmptyResponse::withHeaders([...$response->getHeaders(), ...$corsHeaders]); } private function resolveCorsAllowedMethods(ResponseInterface $response): string diff --git a/module/Rest/src/Service/ApiKeyCheckResult.php b/module/Rest/src/Service/ApiKeyCheckResult.php index 2caee4e1..ff74fb79 100644 --- a/module/Rest/src/Service/ApiKeyCheckResult.php +++ b/module/Rest/src/Service/ApiKeyCheckResult.php @@ -8,7 +8,7 @@ use Shlinkio\Shlink\Rest\Entity\ApiKey; final class ApiKeyCheckResult { - public function __construct(private ?ApiKey $apiKey = null) + public function __construct(public readonly ?ApiKey $apiKey = null) { } @@ -16,9 +16,4 @@ final class ApiKeyCheckResult { return $this->apiKey !== null && $this->apiKey->isValid(); } - - public function apiKey(): ?ApiKey - { - return $this->apiKey; - } } diff --git a/module/Rest/test-api/Middleware/CorsTest.php b/module/Rest/test-api/Middleware/CorsTest.php index 3efbeacb..b09e2b3b 100644 --- a/module/Rest/test-api/Middleware/CorsTest.php +++ b/module/Rest/test-api/Middleware/CorsTest.php @@ -71,9 +71,9 @@ class CorsTest extends ApiTestCase public function providePreflightEndpoints(): iterable { - yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; + yield 'invalid route' => ['/foo/bar', 'GET,POST,PUT,PATCH,DELETE']; // TODO This won't work with multi-segment yield 'short URLs route' => ['/short-urls', 'GET,POST']; - yield 'tags route' => ['/tags', 'GET,PUT,DELETE']; + yield 'tags route' => ['/tags', 'GET,DELETE,PUT']; yield 'health route' => ['/health', 'GET']; } } diff --git a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php index 55828368..05212fe7 100644 --- a/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php +++ b/module/Rest/test/Action/Domain/Request/DomainRedirectsRequestTest.php @@ -44,9 +44,9 @@ class DomainRedirectsRequestTest extends TestCase $notFound = $request->toNotFoundRedirects($defaults); self::assertEquals($expectedAuthority, $request->authority()); - self::assertEquals($expectedBaseUrlRedirect, $notFound->baseUrlRedirect()); - self::assertEquals($expectedRegular404Redirect, $notFound->regular404Redirect()); - self::assertEquals($expectedInvalidShortUrlRedirect, $notFound->invalidShortUrlRedirect()); + self::assertEquals($expectedBaseUrlRedirect, $notFound->baseUrlRedirect); + self::assertEquals($expectedRegular404Redirect, $notFound->regular404Redirect); + self::assertEquals($expectedInvalidShortUrlRedirect, $notFound->invalidShortUrlRedirect); } public function provideValidData(): iterable diff --git a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php index ffcd6c62..206b016f 100644 --- a/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/CreateShortUrlActionTest.php @@ -16,6 +16,7 @@ use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Exception\ValidationException; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortener; use Shlinkio\Shlink\Rest\Action\ShortUrl\CreateShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -34,7 +35,11 @@ class CreateShortUrlActionTest extends TestCase $this->transformer = $this->prophesize(DataTransformerInterface::class); $this->transformer->transform(Argument::type(ShortUrl::class))->willReturn([]); - $this->action = new CreateShortUrlAction($this->urlShortener->reveal(), $this->transformer->reveal()); + $this->action = new CreateShortUrlAction( + $this->urlShortener->reveal(), + $this->transformer->reveal(), + new UrlShortenerOptions(), + ); } /** @test */ diff --git a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php index 04ffb107..19422d9d 100644 --- a/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/ResolveShortUrlActionTest.php @@ -36,9 +36,11 @@ class ResolveShortUrlActionTest extends TestCase { $shortCode = 'abc123'; $apiKey = ApiKey::create(); - $this->urlResolver->resolveShortUrl(new ShortUrlIdentifier($shortCode), $apiKey)->willReturn( - ShortUrl::withLongUrl('http://domain.com/foo/bar'), - )->shouldBeCalledOnce(); + $this->urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), + $apiKey, + )->willReturn(ShortUrl::withLongUrl('http://domain.com/foo/bar')) + ->shouldBeCalledOnce(); $request = (new ServerRequest())->withAttribute('shortCode', $shortCode)->withAttribute(ApiKey::class, $apiKey); $response = $this->action->handle($request); diff --git a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php index 8bb1482a..e3fd3e10 100644 --- a/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php +++ b/module/Rest/test/Action/ShortUrl/SingleStepCreateShortUrlActionTest.php @@ -12,6 +12,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Common\Rest\DataTransformerInterface; use Shlinkio\Shlink\Core\Entity\ShortUrl; use Shlinkio\Shlink\Core\Model\ShortUrlMeta; +use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Service\UrlShortenerInterface; use Shlinkio\Shlink\Rest\Action\ShortUrl\SingleStepCreateShortUrlAction; use Shlinkio\Shlink\Rest\Entity\ApiKey; @@ -33,6 +34,7 @@ class SingleStepCreateShortUrlActionTest extends TestCase $this->action = new SingleStepCreateShortUrlAction( $this->urlShortener->reveal(), $this->transformer->reveal(), + new UrlShortenerOptions(), ); } diff --git a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php index 6e982aec..299c42d1 100644 --- a/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php +++ b/module/Rest/test/Action/Visit/ShortUrlVisitsActionTest.php @@ -38,7 +38,7 @@ class ShortUrlVisitsActionTest extends TestCase { $shortCode = 'abc123'; $this->visitsHelper->visitsForShortUrl( - new ShortUrlIdentifier($shortCode), + ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), Argument::type(VisitsParams::class), Argument::type(ApiKey::class), )->willReturn(new Paginator(new ArrayAdapter([]))) @@ -52,8 +52,8 @@ class ShortUrlVisitsActionTest extends TestCase public function paramsAreReadFromQuery(): void { $shortCode = 'abc123'; - $this->visitsHelper->visitsForShortUrl(new ShortUrlIdentifier($shortCode), new VisitsParams( - DateRange::withEndDate(Chronos::parse('2016-01-01 00:00:00')), + $this->visitsHelper->visitsForShortUrl(ShortUrlIdentifier::fromShortCodeAndDomain($shortCode), new VisitsParams( + DateRange::until(Chronos::parse('2016-01-01 00:00:00')), 3, 10, ), Argument::type(ApiKey::class)) diff --git a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php index 8e6a58ad..ba27a02f 100644 --- a/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php +++ b/module/Rest/test/ApiKey/Model/RoleDefinitionTest.php @@ -16,8 +16,8 @@ class RoleDefinitionTest extends TestCase { $definition = RoleDefinition::forAuthoredShortUrls(); - self::assertEquals(Role::AUTHORED_SHORT_URLS, $definition->roleName()); - self::assertEquals([], $definition->meta()); + self::assertEquals(Role::AUTHORED_SHORT_URLS, $definition->role); + self::assertEquals([], $definition->meta); } /** @test */ @@ -26,7 +26,7 @@ class RoleDefinitionTest extends TestCase $domain = Domain::withAuthority('foo.com')->setId('123'); $definition = RoleDefinition::forDomain($domain); - self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->roleName()); - self::assertEquals(['domain_id' => '123', 'authority' => 'foo.com'], $definition->meta()); + self::assertEquals(Role::DOMAIN_SPECIFIC, $definition->role); + self::assertEquals(['domain_id' => '123', 'authority' => 'foo.com'], $definition->meta); } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index 7ee23076..f3cc64b2 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -30,7 +30,6 @@ class RoleTest extends TestCase { $apiKey = ApiKey::create(); - yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()]; yield 'author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), new BelongsToApiKey($apiKey), @@ -54,7 +53,6 @@ class RoleTest extends TestCase { $apiKey = ApiKey::create(); - yield 'invalid role' => [new ApiKeyRole('invalid', [], $apiKey), Spec::andX()]; yield 'author role' => [ new ApiKeyRole(Role::AUTHORED_SHORT_URLS, [], $apiKey), Spec::andX(new BelongsToApiKeyInlined($apiKey)), @@ -101,15 +99,14 @@ class RoleTest extends TestCase * @test * @dataProvider provideRoleNames */ - public function getsExpectedRoleFriendlyName(string $roleName, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $roleName, string $expectedFriendlyName): void { self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName)); } public function provideRoleNames(): iterable { - yield 'unknown' => ['unknown', '']; - yield Role::AUTHORED_SHORT_URLS => [Role::AUTHORED_SHORT_URLS, 'Author only']; - yield Role::DOMAIN_SPECIFIC => [Role::DOMAIN_SPECIFIC, 'Domain only']; + yield Role::AUTHORED_SHORT_URLS->value => [Role::AUTHORED_SHORT_URLS, 'Author only']; + yield Role::DOMAIN_SPECIFIC->value => [Role::DOMAIN_SPECIFIC, 'Domain only']; } } diff --git a/module/Rest/test/ConfigProviderTest.php b/module/Rest/test/ConfigProviderTest.php index 462947c9..07fa4e43 100644 --- a/module/Rest/test/ConfigProviderTest.php +++ b/module/Rest/test/ConfigProviderTest.php @@ -22,8 +22,7 @@ class ConfigProviderTest extends TestCase { $config = ($this->configProvider)(); - self::assertCount(5, $config); - self::assertArrayHasKey('routes', $config); + self::assertCount(4, $config); self::assertArrayHasKey('dependencies', $config); self::assertArrayHasKey('auth', $config); self::assertArrayHasKey('entity_manager', $config); @@ -34,13 +33,9 @@ class ConfigProviderTest extends TestCase * @test * @dataProvider provideRoutesConfig */ - public function routesAreProperlyPrefixed(array $routes, array $expected): void + public function routesAreProperlyPrefixed(array $routes, bool $multiSegmentEnabled, array $expected): void { - $configProvider = new ConfigProvider(fn () => ['routes' => $routes]); - - $config = $configProvider(); - - self::assertEquals($expected, $config['routes']); + self::assertEquals($expected, ConfigProvider::applyRoutesPrefix($routes, $multiSegmentEnabled)); } public function provideRoutesConfig(): iterable @@ -52,6 +47,7 @@ class ConfigProviderTest extends TestCase ['path' => '/baz/foo'], ['path' => '/health'], ], + false, [ ['path' => '/rest/v{version:1|2}/foo'], ['path' => '/rest/v{version:1|2}/bar'], @@ -66,11 +62,25 @@ class ConfigProviderTest extends TestCase ['path' => '/bar'], ['path' => '/baz/foo'], ], + false, [ ['path' => '/rest/v{version:1|2}/foo'], ['path' => '/rest/v{version:1|2}/bar'], ['path' => '/rest/v{version:1|2}/baz/foo'], ], ]; + yield 'multi-segment enabled' => [ + [ + ['path' => '/foo'], + ['path' => '/bar/{shortCode}'], + ['path' => '/baz/{shortCode}/foo'], + ], + true, + [ + ['path' => '/rest/v{version:1|2}/foo'], + ['path' => '/rest/v{version:1|2}/bar/{shortCode:.+}'], + ['path' => '/rest/v{version:1|2}/baz/{shortCode:.+}/foo'], + ], + ]; } } diff --git a/module/Rest/test/Service/ApiKeyServiceTest.php b/module/Rest/test/Service/ApiKeyServiceTest.php index de17d8bd..aba79036 100644 --- a/module/Rest/test/Service/ApiKeyServiceTest.php +++ b/module/Rest/test/Service/ApiKeyServiceTest.php @@ -46,7 +46,7 @@ class ApiKeyServiceTest extends TestCase self::assertEquals($date, $key->getExpirationDate()); self::assertEquals($name, $key->name()); foreach ($roles as $roleDefinition) { - self::assertTrue($key->hasRole($roleDefinition->roleName())); + self::assertTrue($key->hasRole($roleDefinition->role)); } } @@ -77,7 +77,7 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->check('12345'); self::assertFalse($result->isValid()); - self::assertSame($invalidKey, $result->apiKey()); + self::assertSame($invalidKey, $result->apiKey); } public function provideInvalidApiKeys(): iterable @@ -100,7 +100,7 @@ class ApiKeyServiceTest extends TestCase $result = $this->service->check('12345'); self::assertTrue($result->isValid()); - self::assertSame($apiKey, $result->apiKey()); + self::assertSame($apiKey, $result->apiKey); } /** @test */