diff --git a/.github/actions/ci-setup/action.yml b/.github/actions/ci-setup/action.yml index 0a8a09d7..7ff7ad8a 100644 --- a/.github/actions/ci-setup/action.yml +++ b/.github/actions/ci-setup/action.yml @@ -41,7 +41,7 @@ runs: extensions: ${{ inputs.php-extensions }} coverage: pcov ini-values: pcov.directory=module - - run: echo "::set-output name=composerArgs::${{ inputs.php-version == '8.2' && '--ignore-platform-req=php' || '' }}" + - run: echo "::set-output name=composerArgs::${{ inputs.php-version == '8.2' && '--ignore-platform-req=php+' || '' }}" id: composer_args shell: bash - name: Install dependencies diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index 50060383..aa3a2bc3 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -28,7 +28,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1, pdo_sqlsrv-5.10.1 + php-extensions: openswoole-4.12.0, pdo_sqlsrv-5.10.1 extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} diff --git a/.github/workflows/ci-mutation-tests.yml b/.github/workflows/ci-mutation-tests.yml index c7b361d8..5eeb9ac3 100644 --- a/.github/workflows/ci-mutation-tests.yml +++ b/.github/workflows/ci-mutation-tests.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: mutation-tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - uses: actions/download-artifact@v3 with: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index e20428c6..66aa666b 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -26,7 +26,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ inputs.test-group }} - run: composer test:${{ inputs.test-group }}:ci - uses: actions/upload-artifact@v3 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 013226a6..b6986061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: tests-extensions-${{ matrix.php-version }}-${{ matrix.command }} - run: composer ${{ matrix.command }} @@ -52,7 +52,7 @@ jobs: with: php-version: ${{ matrix.php-version }} tools: composer - - run: echo "::set-output name=composerArgs::${{ matrix.php-version == '8.2' && '--ignore-platform-req=php' || '' }}" + - run: echo "::set-output name=composerArgs::${{ matrix.php-version == '8.2' && '--ignore-platform-req=php+' || '' }}" id: composer_args shell: bash - run: composer install --no-interaction --prefer-dist ${{ steps.composer_args.outputs.composerArgs }} diff --git a/.github/workflows/docker-image-build.yml b/.github/workflows/docker-image-build.yml index 96457033..9eb682d6 100644 --- a/.github/workflows/docker-image-build.yml +++ b/.github/workflows/docker-image-build.yml @@ -8,7 +8,7 @@ on: - 'v*' jobs: - build-openswool: + build-openswoole: uses: shlinkio/github-actions/.github/workflows/docker-build-and-publish.yml@main secrets: inherit with: diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index b4ed7bba..d9625125 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -17,7 +17,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} install-deps: 'no' - if: ${{ matrix.swoole == 'yes' }} diff --git a/.github/workflows/publish-swagger-spec.yml b/.github/workflows/publish-swagger-spec.yml index 9002353d..6e6cb925 100644 --- a/.github/workflows/publish-swagger-spec.yml +++ b/.github/workflows/publish-swagger-spec.yml @@ -20,7 +20,7 @@ jobs: - uses: './.github/actions/ci-setup' with: php-version: ${{ matrix.php-version }} - php-extensions: openswoole-4.11.1 + php-extensions: openswoole-4.12.0 extensions-cache-key: publish-swagger-spec-extensions-${{ matrix.php-version }} - run: composer swagger:inline - run: mkdir ${{ steps.determine_version.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d4838a2..816b5e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com), and this project adheres to [Semantic Versioning](https://semver.org). +## [Unreleased] +### Added +* *Nothing* + +### Changed +* [#1563](https://github.com/shlinkio/shlink/issues/1563) Moved logic to reuse command options to option classes instead of base abstract command classes. + +### Deprecated +* *Nothing* + +### Removed +* *Nothing* + +### Fixed +* *Nothing* + + ## [3.3.1] - 2022-09-30 ### Added * *Nothing* diff --git a/Dockerfile b/Dockerfile index 2835d75f..c498894e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ ARG SHLINK_VERSION=latest ENV SHLINK_VERSION ${SHLINK_VERSION} ARG SHLINK_RUNTIME=openswoole ENV SHLINK_RUNTIME ${SHLINK_RUNTIME} -ENV OPENSWOOLE_VERSION 4.11.1 +ENV OPENSWOOLE_VERSION 4.12.0 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 ENV LC_ALL "C" diff --git a/composer.json b/composer.json index 809ccfbb..181093f0 100644 --- a/composer.json +++ b/composer.json @@ -20,39 +20,39 @@ "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^2.3", "doctrine/migrations": "^3.5", - "doctrine/orm": "^2.12", - "endroid/qr-code": "^4.4", - "geoip2/geoip2": "^2.12", - "guzzlehttp/guzzle": "^7.4", + "doctrine/orm": "^2.13", + "endroid/qr-code": "^4.6", + "geoip2/geoip2": "^2.13", + "guzzlehttp/guzzle": "^7.5", "happyr/doctrine-specification": "^2.0", - "jaybizzle/crawler-detect": "^1.2.110", + "jaybizzle/crawler-detect": "^1.2.112", "laminas/laminas-config": "^3.7", - "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.7", + "laminas/laminas-config-aggregator": "^1.11", + "laminas/laminas-diactoros": "^2.19", + "laminas/laminas-inputfilter": "^2.22", + "laminas/laminas-servicemanager": "^3.19", + "laminas/laminas-stdlib": "^3.15", + "lcobucci/jwt": "^4.2", + "league/uri": "^6.8", "lstrojny/functional-php": "^1.17", - "mezzio/mezzio": "^3.11", - "mezzio/mezzio-fastroute": "^3.5", - "mezzio/mezzio-problem-details": "^1.6", - "mezzio/mezzio-swoole": "^4.3", + "mezzio/mezzio": "^3.13", + "mezzio/mezzio-fastroute": "^3.7", + "mezzio/mezzio-problem-details": "^1.7", + "mezzio/mezzio-swoole": "^4.5", "mlocati/ip-lib": "^1.18", "ocramius/proxy-manager": "^2.14", "pagerfanta/core": "^3.6", "php-middleware/request-id": "^4.1", - "pugx/shortid-php": "^1.0", - "ramsey/uuid": "^4.3", - "shlinkio/shlink-common": "^5.1", - "shlinkio/shlink-config": "^2.1", + "pugx/shortid-php": "^1.1", + "ramsey/uuid": "^4.5", + "shlinkio/shlink-common": "dev-main#7515008 as 5.2", + "shlinkio/shlink-config": "dev-main#db02e84 as 2.2", "shlinkio/shlink-event-dispatcher": "^2.6", "shlinkio/shlink-importer": "^4.0", "shlinkio/shlink-installer": "^8.2", - "shlinkio/shlink-ip-geolocation": "^3.1", + "shlinkio/shlink-ip-geolocation": "dev-main#e208963 as 3.2", "spiral/roadrunner": "^2.11", - "spiral/roadrunner-jobs": "^2.3", + "spiral/roadrunner-jobs": "^2.5", "symfony/console": "^6.1", "symfony/filesystem": "^6.1", "symfony/lock": "^6.1", @@ -64,7 +64,7 @@ "devster/ubench": "^2.1", "dms/phpunit-arraysubset-asserts": "^0.4.0", "infection/infection": "^0.26.15", - "openswoole/ide-helper": "~4.11.1", + "openswoole/ide-helper": "~4.11.5", "phpspec/prophecy-phpunit": "^2.0", "phpstan/phpstan": "^1.8", "phpstan/phpstan-doctrine": "^1.3", diff --git a/data/infra/swoole.Dockerfile b/data/infra/swoole.Dockerfile index 21a2fe5e..294ad71b 100644 --- a/data/infra/swoole.Dockerfile +++ b/data/infra/swoole.Dockerfile @@ -3,7 +3,7 @@ MAINTAINER Alejandro Celaya ENV APCU_VERSION 5.1.21 ENV INOTIFY_VERSION 3.0.0 -ENV OPENSWOOLE_VERSION 4.11.1 +ENV OPENSWOOLE_VERSION 4.12.0 ENV PDO_SQLSRV_VERSION 5.10.1 ENV MS_ODBC_SQL_VERSION 17.5.2.2 diff --git a/indocker b/indocker index 03061e2f..789386ac 100755 --- a/indocker +++ b/indocker @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Run docker containers if they are not up yet -if ! [[ $(docker ps | grep shlink) ]]; then +if ! [[ $(docker ps | grep shlink_swoole) ]]; then docker-compose up -d fi diff --git a/module/CLI/src/ApiKey/RoleResolver.php b/module/CLI/src/ApiKey/RoleResolver.php index 588a2fa2..c1ae8f05 100644 --- a/module/CLI/src/ApiKey/RoleResolver.php +++ b/module/CLI/src/ApiKey/RoleResolver.php @@ -7,6 +7,7 @@ namespace Shlinkio\Shlink\CLI\ApiKey; use Shlinkio\Shlink\CLI\Exception\InvalidRoleConfigException; use Shlinkio\Shlink\Core\Domain\DomainServiceInterface; use Shlinkio\Shlink\Rest\ApiKey\Model\RoleDefinition; +use Shlinkio\Shlink\Rest\ApiKey\Role; use Symfony\Component\Console\Input\InputInterface; use function is_string; @@ -19,8 +20,8 @@ class RoleResolver implements RoleResolverInterface public function determineRoles(InputInterface $input): array { - $domainAuthority = $input->getOption(self::DOMAIN_ONLY_PARAM); - $author = $input->getOption(self::AUTHOR_ONLY_PARAM); + $domainAuthority = $input->getOption(Role::DOMAIN_SPECIFIC->paramName()); + $author = $input->getOption(Role::AUTHORED_SHORT_URLS->paramName()); $roleDefinitions = []; if ($author) { diff --git a/module/CLI/src/ApiKey/RoleResolverInterface.php b/module/CLI/src/ApiKey/RoleResolverInterface.php index 98d50483..92a04594 100644 --- a/module/CLI/src/ApiKey/RoleResolverInterface.php +++ b/module/CLI/src/ApiKey/RoleResolverInterface.php @@ -9,9 +9,6 @@ use Symfony\Component\Console\Input\InputInterface; interface RoleResolverInterface { - public const AUTHOR_ONLY_PARAM = 'author-only'; - public const DOMAIN_ONLY_PARAM = 'domain-only'; - /** * @return RoleDefinition[] */ diff --git a/module/CLI/src/Command/Api/GenerateKeyCommand.php b/module/CLI/src/Command/Api/GenerateKeyCommand.php index b24619ef..12adcd57 100644 --- a/module/CLI/src/Command/Api/GenerateKeyCommand.php +++ b/module/CLI/src/Command/Api/GenerateKeyCommand.php @@ -32,8 +32,8 @@ class GenerateKeyCommand extends Command protected function configure(): void { - $authorOnly = RoleResolverInterface::AUTHOR_ONLY_PARAM; - $domainOnly = RoleResolverInterface::DOMAIN_ONLY_PARAM; + $authorOnly = Role::AUTHORED_SHORT_URLS->paramName(); + $domainOnly = Role::DOMAIN_SPECIFIC->paramName(); $help = <<%command.name% generates a new valid API key. diff --git a/module/CLI/src/Command/Api/ListKeysCommand.php b/module/CLI/src/Command/Api/ListKeysCommand.php index 0e98af31..59f5b534 100644 --- a/module/CLI/src/Command/Api/ListKeysCommand.php +++ b/module/CLI/src/Command/Api/ListKeysCommand.php @@ -62,8 +62,8 @@ class ListKeysCommand extends Command $rowData[] = $apiKey->isAdmin() ? 'Admin' : implode("\n", $apiKey->mapRoles( fn (Role $role, array $meta) => empty($meta) - ? Role::toFriendlyName($role) - : sprintf('%s: %s', Role::toFriendlyName($role), Role::domainAuthorityFromMeta($meta)), + ? $role->toFriendlyName() + : sprintf('%s: %s', $role->toFriendlyName(), Role::domainAuthorityFromMeta($meta)), )); return $rowData; diff --git a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php index 676a2141..8d2eb8c9 100644 --- a/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php +++ b/module/CLI/src/Command/Domain/GetDomainVisitsCommand.php @@ -25,7 +25,7 @@ class GetDomainVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php index 7f81e4da..a6a4f31d 100644 --- a/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php +++ b/module/CLI/src/Command/ShortUrl/GetShortUrlVisitsCommand.php @@ -20,7 +20,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'short-url:visits'; - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 0889bb03..11443abc 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,7 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; +use Shlinkio\Shlink\CLI\Option\EndDateOption; +use Shlinkio\Shlink\CLI\Option\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; @@ -15,6 +16,7 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -27,20 +29,25 @@ use function Functional\map; use function implode; use function sprintf; -class ListShortUrlsCommand extends AbstractWithDateRangeCommand +class ListShortUrlsCommand extends Command { use PagerfantaUtilsTrait; public const NAME = 'short-url:list'; + private readonly StartDateOption $startDateOption; + private readonly EndDateOption $endDateOption; + public function __construct( - private ShortUrlServiceInterface $shortUrlService, - private DataTransformerInterface $transformer, + private readonly ShortUrlServiceInterface $shortUrlService, + private readonly DataTransformerInterface $transformer, ) { parent::__construct(); + $this->startDateOption = new StartDateOption($this, 'short URLs'); + $this->endDateOption = new EndDateOption($this, 'short URLs'); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) @@ -104,16 +111,6 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand ); } - protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter short URLs, returning only those created after "%s".', $optionName); - } - - protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter short URLs, returning only those created before "%s".', $optionName); - } - protected function execute(InputInterface $input, OutputInterface $output): ?int { $io = new SymfonyStyle($input, $output); @@ -124,8 +121,8 @@ class ListShortUrlsCommand extends AbstractWithDateRangeCommand $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); - $endDate = $this->getEndDateOption($input, $output); + $startDate = $this->startDateOption->get($input, $output); + $endDate = $this->endDateOption->get($input, $output); $orderBy = $this->processOrderBy($input); $columnsMap = $this->resolveColumnsMap($input); diff --git a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php index 842c9b45..290a172a 100644 --- a/module/CLI/src/Command/Tag/GetTagVisitsCommand.php +++ b/module/CLI/src/Command/Tag/GetTagVisitsCommand.php @@ -25,7 +25,7 @@ class GetTagVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php b/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php deleted file mode 100644 index c3e3c407..00000000 --- a/module/CLI/src/Command/Util/AbstractWithDateRangeCommand.php +++ /dev/null @@ -1,69 +0,0 @@ -doConfigure(); - $this - ->addOption(self::START_DATE, 's', InputOption::VALUE_REQUIRED, $this->getStartDateDesc(self::START_DATE)) - ->addOption(self::END_DATE, 'e', InputOption::VALUE_REQUIRED, $this->getEndDateDesc(self::END_DATE)); - } - - protected function getStartDateOption(InputInterface $input, OutputInterface $output): ?Chronos - { - return $this->getDateOption($input, $output, self::START_DATE); - } - - protected function getEndDateOption(InputInterface $input, OutputInterface $output): ?Chronos - { - return $this->getDateOption($input, $output, self::END_DATE); - } - - private function getDateOption(InputInterface $input, OutputInterface $output, string $key): ?Chronos - { - $value = $input->getOption($key); - if (empty($value) || ! is_string($value)) { - return null; - } - - try { - return Chronos::parse($value); - } catch (Throwable $e) { - $output->writeln(sprintf( - '> Ignored provided "%s" since its value "%s" is not a valid date. <', - $key, - $value, - )); - - if ($output->isVeryVerbose()) { - $this->getApplication()?->renderThrowable($e, $output); - } - - return null; - } - } - - abstract protected function doConfigure(): void; - - abstract protected function getStartDateDesc(string $optionName): string; - - abstract protected function getEndDateDesc(string $optionName): string; -} diff --git a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php index 37a875c6..402d5ba4 100644 --- a/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php +++ b/module/CLI/src/Command/Visit/AbstractVisitsListCommand.php @@ -4,13 +4,15 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\Visit; -use Shlinkio\Shlink\CLI\Command\Util\AbstractWithDateRangeCommand; +use Shlinkio\Shlink\CLI\Option\EndDateOption; +use Shlinkio\Shlink\CLI\Option\StartDateOption; use Shlinkio\Shlink\CLI\Util\ExitCodes; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Util\DateRange; use Shlinkio\Shlink\Core\Visit\Entity\Visit; use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,29 +21,23 @@ use function Functional\map; use function Functional\select_keys; use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Core\camelCaseToHumanFriendly; -use function sprintf; -abstract class AbstractVisitsListCommand extends AbstractWithDateRangeCommand +abstract class AbstractVisitsListCommand extends Command { + private readonly StartDateOption $startDateOption; + private readonly EndDateOption $endDateOption; + public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper) { parent::__construct(); - } - - final protected function getStartDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those older than "%s".', $optionName); - } - - final protected function getEndDateDesc(string $optionName): string - { - return sprintf('Allows to filter visits, returning only those newer than "%s".', $optionName); + $this->startDateOption = new StartDateOption($this, 'visits'); + $this->endDateOption = new EndDateOption($this, 'visits'); } final protected function execute(InputInterface $input, OutputInterface $output): ?int { - $startDate = $this->getStartDateOption($input, $output); - $endDate = $this->getEndDateOption($input, $output); + $startDate = $this->startDateOption->get($input, $output); + $endDate = $this->endDateOption->get($input, $output); $paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate)); [$rows, $headers] = $this->resolveRowsAndHeaders($paginator); diff --git a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php index 0b4a4612..0dd32f3e 100644 --- a/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetNonOrphanVisitsCommand.php @@ -23,7 +23,7 @@ class GetNonOrphanVisitsCommand extends AbstractVisitsListCommand parent::__construct($visitsHelper); } - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php index c2d353af..618a35cd 100644 --- a/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php +++ b/module/CLI/src/Command/Visit/GetOrphanVisitsCommand.php @@ -14,7 +14,7 @@ class GetOrphanVisitsCommand extends AbstractVisitsListCommand { public const NAME = 'visit:orphan'; - protected function doConfigure(): void + protected function configure(): void { $this ->setName(self::NAME) diff --git a/module/CLI/src/Option/DateOption.php b/module/CLI/src/Option/DateOption.php new file mode 100644 index 00000000..a863696f --- /dev/null +++ b/module/CLI/src/Option/DateOption.php @@ -0,0 +1,51 @@ +addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + $value = $input->getOption($this->name); + if (empty($value) || ! is_string($value)) { + return null; + } + + try { + return Chronos::parse($value); + } catch (Throwable $e) { + $output->writeln(sprintf( + '> Ignored provided "%s" since its value "%s" is not a valid date. <', + $this->name, + $value, + )); + + if ($output->isVeryVerbose()) { + $this->command->getApplication()?->renderThrowable($e, $output); + } + + return null; + } + } +} diff --git a/module/CLI/src/Option/EndDateOption.php b/module/CLI/src/Option/EndDateOption.php new file mode 100644 index 00000000..72421981 --- /dev/null +++ b/module/CLI/src/Option/EndDateOption.php @@ -0,0 +1,30 @@ +dateOption = new DateOption($command, 'end-date', 'e', sprintf( + 'Allows to filter %s, returning only those newer than provided date.', + $descriptionHint, + )); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + return $this->dateOption->get($input, $output); + } +} diff --git a/module/CLI/src/Option/StartDateOption.php b/module/CLI/src/Option/StartDateOption.php new file mode 100644 index 00000000..2da5aaee --- /dev/null +++ b/module/CLI/src/Option/StartDateOption.php @@ -0,0 +1,30 @@ +dateOption = new DateOption($command, 'start-date', 's', sprintf( + 'Allows to filter %s, returning only those older than provided date.', + $descriptionHint, + )); + } + + public function get(InputInterface $input, OutputInterface $output): ?Chronos + { + return $this->dateOption->get($input, $output); + } +} diff --git a/module/CLI/test-cli/Command/ListShortUrlsTest.php b/module/CLI/test-cli/Command/ListShortUrlsTest.php new file mode 100644 index 00000000..faa47a2f --- /dev/null +++ b/module/CLI/test-cli/Command/ListShortUrlsTest.php @@ -0,0 +1,66 @@ +exec([ListShortUrlsCommand::NAME, ...$flags], ['no']); + self::assertStringContainsString($expectedOutput, $output); + } + + public function provideFlagsAndOutput(): iterable + { + // phpcs:disable Generic.Files.LineLength + yield 'no flags' => [[], << [['--start-date=2019-01'], << [['-e 2018-12-01'], << [['-s 2018-06-20', '--end-date=2019-01-01T00:00:20+00:00'], <<domainService = $this->prophesize(DomainServiceInterface::class); - $this->resolver = new RoleResolver($this->domainService->reveal(), 'default.com'); + $this->domainService = $this->createMock(DomainServiceInterface::class); + $this->resolver = new RoleResolver($this->domainService, 'default.com'); } /** @@ -36,61 +36,65 @@ class RoleResolverTest extends TestCase array $expectedRoles, int $expectedDomainCalls, ): void { - $getDomain = $this->domainService->getOrCreate('example.com')->willReturn( - Domain::withAuthority('example.com')->setId('1'), - ); + $this->domainService + ->expects($this->exactly($expectedDomainCalls)) + ->method('getOrCreate') + ->with($this->equalTo('example.com')) + ->willReturn(Domain::withAuthority('example.com')->setId('1')); $result = $this->resolver->determineRoles($input); self::assertEquals($expectedRoles, $result); - $getDomain->shouldHaveBeenCalledTimes($expectedDomainCalls); } public function provideRoles(): iterable { $domain = Domain::withAuthority('example.com')->setId('1'); $buildInput = function (array $definition): InputInterface { - $input = $this->prophesize(InputInterface::class); + $input = $this->createStub(InputInterface::class); + $input->method('getOption')->willReturnMap( + map($definition, static fn (mixed $returnValue, string $param) => [$param, $returnValue]), + ); - foreach ($definition as $name => $value) { - $input->getOption($name)->willReturn($value); - } - - return $input->reveal(); + return $input; }; yield 'no roles' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => false]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => false]), [], 0, ]; yield 'domain role only' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => false]), + $buildInput( + [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => false], + ), [RoleDefinition::forDomain($domain)], 1, ]; yield 'false domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => false]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => false]), [], 0, ]; yield 'true domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => true]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => true]), [], 0, ]; yield 'string array domain role' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => ['foo', 'bar']]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => ['foo', 'bar']]), [], 0, ]; yield 'author role only' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => null, RoleResolver::AUTHOR_ONLY_PARAM => true]), + $buildInput([Role::DOMAIN_SPECIFIC->paramName() => null, Role::AUTHORED_SHORT_URLS->paramName() => true]), [RoleDefinition::forAuthoredShortUrls()], 0, ]; yield 'both roles' => [ - $buildInput([RoleResolver::DOMAIN_ONLY_PARAM => 'example.com', RoleResolver::AUTHOR_ONLY_PARAM => true]), + $buildInput( + [Role::DOMAIN_SPECIFIC->paramName() => 'example.com', Role::AUTHORED_SHORT_URLS->paramName() => true], + ), [RoleDefinition::forAuthoredShortUrls(), RoleDefinition::forDomain($domain)], 1, ]; @@ -99,12 +103,16 @@ class RoleResolverTest extends TestCase /** @test */ public function exceptionIsThrownWhenTryingToAddDomainOnlyLinkedToDefaultDomain(): void { - $input = $this->prophesize(InputInterface::class); - $input->getOption(RoleResolver::DOMAIN_ONLY_PARAM)->willReturn('default.com'); - $input->getOption(RoleResolver::AUTHOR_ONLY_PARAM)->willReturn(null); + $input = $this->createStub(InputInterface::class); + $input + ->method('getOption') + ->willReturnMap([ + [Role::DOMAIN_SPECIFIC->paramName(), 'default.com'], + [Role::AUTHORED_SHORT_URLS->paramName(), null], + ]); $this->expectException(InvalidRoleConfigException::class); - $this->resolver->determineRoles($input->reveal()); + $this->resolver->determineRoles($input); } } diff --git a/module/Core/src/Action/RobotsAction.php b/module/Core/src/Action/RobotsAction.php index 12baa7b3..214dc7a0 100644 --- a/module/Core/src/Action/RobotsAction.php +++ b/module/Core/src/Action/RobotsAction.php @@ -17,7 +17,7 @@ use const PHP_EOL; class RobotsAction implements RequestHandlerInterface, StatusCodeInterface { - public function __construct(private CrawlingHelperInterface $crawlingHelper) + public function __construct(private readonly CrawlingHelperInterface $crawlingHelper) { } diff --git a/module/Core/src/Crawling/CrawlingHelperInterface.php b/module/Core/src/Crawling/CrawlingHelperInterface.php index 635a4fc9..3438b2ba 100644 --- a/module/Core/src/Crawling/CrawlingHelperInterface.php +++ b/module/Core/src/Crawling/CrawlingHelperInterface.php @@ -7,7 +7,7 @@ namespace Shlinkio\Shlink\Core\Crawling; interface CrawlingHelperInterface { /** - * @return string[]|iterable + * @return iterable */ public function listCrawlableShortCodes(): iterable; } diff --git a/module/Core/test/Action/RobotsActionTest.php b/module/Core/test/Action/RobotsActionTest.php index ad8a02d1..4f405506 100644 --- a/module/Core/test/Action/RobotsActionTest.php +++ b/module/Core/test/Action/RobotsActionTest.php @@ -5,23 +5,20 @@ declare(strict_types=1); namespace ShlinkioTest\Shlink\Core\Action; use Laminas\Diactoros\ServerRequestFactory; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; -use Prophecy\Prophecy\ObjectProphecy; use Shlinkio\Shlink\Core\Action\RobotsAction; use Shlinkio\Shlink\Core\Crawling\CrawlingHelperInterface; class RobotsActionTest extends TestCase { - use ProphecyTrait; - private RobotsAction $action; - private ObjectProphecy $helper; + private MockObject $helper; protected function setUp(): void { - $this->helper = $this->prophesize(CrawlingHelperInterface::class); - $this->action = new RobotsAction($this->helper->reveal()); + $this->helper = $this->createMock(CrawlingHelperInterface::class); + $this->action = new RobotsAction($this->helper); } /** @@ -30,14 +27,16 @@ class RobotsActionTest extends TestCase */ public function buildsRobotsLinesFromCrawlableShortCodes(array $shortCodes, string $expected): void { - $getShortCodes = $this->helper->listCrawlableShortCodes()->willReturn($shortCodes); + $this->helper + ->expects($this->once()) + ->method('listCrawlableShortCodes') + ->willReturn($shortCodes); $response = $this->action->handle(ServerRequestFactory::fromGlobals()); self::assertEquals(200, $response->getStatusCode()); self::assertEquals($expected, $response->getBody()->__toString()); self::assertEquals('text/plain', $response->getHeaderLine('Content-Type')); - $getShortCodes->shouldHaveBeenCalledOnce(); } public function provideShortCodes(): iterable diff --git a/module/Rest/src/ApiKey/Role.php b/module/Rest/src/ApiKey/Role.php index 64803969..5a4edb81 100644 --- a/module/Rest/src/ApiKey/Role.php +++ b/module/Rest/src/ApiKey/Role.php @@ -2,8 +2,6 @@ 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; @@ -19,6 +17,22 @@ enum Role: string case AUTHORED_SHORT_URLS = 'AUTHORED_SHORT_URLS'; case DOMAIN_SPECIFIC = 'DOMAIN_SPECIFIC'; + public function toFriendlyName(): string + { + return match ($this) { + self::AUTHORED_SHORT_URLS => 'Author only', + self::DOMAIN_SPECIFIC => 'Domain only', + }; + } + + public function paramName(): string + { + return match ($this) { + self::AUTHORED_SHORT_URLS => 'author-only', + self::DOMAIN_SPECIFIC => 'domain-only', + }; + } + public static function toSpec(ApiKeyRole $role, ?string $context = null): Specification { return match ($role->role()) { @@ -44,12 +58,4 @@ enum Role: string { return $meta['authority'] ?? ''; } - - public static function toFriendlyName(Role $role): string - { - return match ($role) { - self::AUTHORED_SHORT_URLS => 'Author only', - self::DOMAIN_SPECIFIC => 'Domain only', - }; - } } diff --git a/module/Rest/test/ApiKey/RoleTest.php b/module/Rest/test/ApiKey/RoleTest.php index f3cc64b2..715b89b8 100644 --- a/module/Rest/test/ApiKey/RoleTest.php +++ b/module/Rest/test/ApiKey/RoleTest.php @@ -99,9 +99,9 @@ class RoleTest extends TestCase * @test * @dataProvider provideRoleNames */ - public function getsExpectedRoleFriendlyName(Role $roleName, string $expectedFriendlyName): void + public function getsExpectedRoleFriendlyName(Role $role, string $expectedFriendlyName): void { - self::assertEquals($expectedFriendlyName, Role::toFriendlyName($roleName)); + self::assertEquals($expectedFriendlyName, $role->toFriendlyName()); } public function provideRoleNames(): iterable