From a75ee138e177c917df47d202ce017fc839bbbf8d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 13 Dec 2025 11:05:20 +0100 Subject: [PATCH] Migrate ListShortUrlsCommand to symfony/console attributes --- .../ShortUrl/Input/ShortUrlsParamsInput.php | 116 +++++++++++++ .../Command/ShortUrl/ListShortUrlsCommand.php | 159 ++---------------- module/CLI/src/Input/DateOption.php | 3 +- module/CLI/src/Input/InputUtils.php | 37 ++++ .../ShortUrl/ListShortUrlsCommandTest.php | 2 - module/CLI/test/Input/InputUtilsTest.php | 47 ++++++ 6 files changed, 214 insertions(+), 150 deletions(-) create mode 100644 module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php create mode 100644 module/CLI/src/Input/InputUtils.php create mode 100644 module/CLI/test/Input/InputUtilsTest.php diff --git a/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php new file mode 100644 index 00000000..29c185d0 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/Input/ShortUrlsParamsInput.php @@ -0,0 +1,116 @@ +tagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; + $excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL->value : TagsMode::ANY->value; + + $data = [ + ShortUrlsParamsInputFilter::PAGE => $this->page, + ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm, + ShortUrlsParamsInputFilter::DOMAIN => $this->domain, + ShortUrlsParamsInputFilter::TAGS => array_unique($this->tags), + ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, + ShortUrlsParamsInputFilter::EXCLUDE_TAGS => array_unique($this->excludeTags), + ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, + ShortUrlsParamsInputFilter::ORDER_BY => $this->orderBy, + ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output), + ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output), + ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $this->excludeMaxVisitsReached, + ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $this->excludePastValidUntil, + ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName, + ]; + + if ($this->all) { + $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; + } + + return $data; + } +} diff --git a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php index 99c445f0..2882c839 100644 --- a/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php +++ b/module/CLI/src/Command/ShortUrl/ListShortUrlsCommand.php @@ -4,9 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; -use Shlinkio\Shlink\CLI\Input\EndDateOption; -use Shlinkio\Shlink\CLI\Input\StartDateOption; -use Shlinkio\Shlink\CLI\Input\TagsOption; +use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlsParamsInput; use Shlinkio\Shlink\CLI\Util\ShlinkTable; use Shlinkio\Shlink\Common\Paginator\Paginator; use Shlinkio\Shlink\Common\Paginator\Util\PagerfantaUtils; @@ -14,165 +12,43 @@ use Shlinkio\Shlink\Core\Domain\Entity\Domain; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlsParams; use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlWithDeps; -use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode; -use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlListServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformerInterface; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Attribute\MapInput; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; use function array_keys; -use function array_pad; -use function explode; use function implode; use function Shlinkio\Shlink\Core\ArrayUtils\map; use function sprintf; +#[AsCommand(name: ListShortUrlsCommand::NAME, description: 'List all short URLs')] class ListShortUrlsCommand extends Command { public const string NAME = 'short-url:list'; - private readonly StartDateOption $startDateOption; - private readonly EndDateOption $endDateOption; - private readonly TagsOption $tagsOption; - public function __construct( private readonly ShortUrlListServiceInterface $shortUrlService, private readonly ShortUrlDataTransformerInterface $transformer, ) { parent::__construct(); - $this->startDateOption = new StartDateOption($this, 'short URLs'); - $this->endDateOption = new EndDateOption($this, 'short URLs'); - $this->tagsOption = new TagsOption($this, 'A list of tags that short URLs need to include.'); } - protected function configure(): void - { - $this - ->setName(self::NAME) - ->setDescription('List all short URLs') - ->addOption( - 'page', - 'p', - InputOption::VALUE_REQUIRED, - 'The first page to list (10 items per page unless "--all" is provided).', - '1', - ) - ->addOption( - 'search-term', - 'st', - InputOption::VALUE_REQUIRED, - 'A query used to filter results by searching for it on the longUrl and shortCode fields.', - ) - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'Used to filter results by domain. Use DEFAULT keyword to filter by default domain', - ) - ->addOption( - 'tags-all', - mode: InputOption::VALUE_NONE, - description: 'If --tag is provided, returns only short URLs including ALL of them', - ) - ->addOption( - 'exclude-tag', - 'et', - InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, - 'A list of tags that short URLs should not have.', - ) - ->addOption( - 'exclude-tags-all', - mode: InputOption::VALUE_NONE, - description: 'If --exclude-tag is provided, returns only short URLs not including ANY of them', - ) - ->addOption( - 'exclude-max-visits-reached', - null, - InputOption::VALUE_NONE, - 'Excludes short URLs which reached their max amount of visits.', - ) - ->addOption( - 'exclude-past-valid-until', - null, - InputOption::VALUE_NONE, - 'Excludes short URLs which have a "validUntil" date in the past.', - ) - ->addOption( - 'order-by', - 'o', - InputOption::VALUE_REQUIRED, - 'The field from which you want to order by. ' - . 'Define ordering dir by passing ASC or DESC after "-" or ",".', - ) - ->addOption( - 'api-key-name', - 'kn', - InputOption::VALUE_REQUIRED, - 'List only short URLs created by the API key matching provided name.', - ) - ->addOption( - 'show-tags', - null, - InputOption::VALUE_NONE, - 'Whether to display the tags or not.', - ) - ->addOption( - 'show-domain', - null, - InputOption::VALUE_NONE, - 'Whether to display the domain or not. Those belonging to default domain will have value "DEFAULT".', - ) - ->addOption( - 'show-api-key', - 'k', - InputOption::VALUE_NONE, - 'Whether to display the API key name from which the URL was generated or not.', - ) - ->addOption( - 'all', - 'a', - InputOption::VALUE_NONE, - 'Disables pagination and just displays all existing URLs. Caution! If the amount of short URLs is big,' - . ' this may end up failing due to memory usage.', - ); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $io = new SymfonyStyle($input, $output); - - $page = (int) $input->getOption('page'); - $tagsMode = $input->getOption('tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - $excludeTagsMode = $input->getOption('exclude-tags-all') === true ? TagsMode::ALL->value : TagsMode::ANY->value; - - $data = [ - ShortUrlsParamsInputFilter::SEARCH_TERM => $input->getOption('search-term'), - ShortUrlsParamsInputFilter::DOMAIN => $input->getOption('domain'), - ShortUrlsParamsInputFilter::TAGS => $this->tagsOption->get($input), - ShortUrlsParamsInputFilter::TAGS_MODE => $tagsMode, - ShortUrlsParamsInputFilter::EXCLUDE_TAGS => $input->getOption('exclude-tag'), - ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE => $excludeTagsMode, - ShortUrlsParamsInputFilter::ORDER_BY => $this->processOrderBy($input), - ShortUrlsParamsInputFilter::START_DATE => $this->startDateOption->get($input, $output)?->toAtomString(), - ShortUrlsParamsInputFilter::END_DATE => $this->endDateOption->get($input, $output)?->toAtomString(), - ShortUrlsParamsInputFilter::EXCLUDE_MAX_VISITS_REACHED => $input->getOption('exclude-max-visits-reached'), - ShortUrlsParamsInputFilter::EXCLUDE_PAST_VALID_UNTIL => $input->getOption('exclude-past-valid-until'), - ShortUrlsParamsInputFilter::API_KEY_NAME => $input->getOption('api-key-name'), - ]; - - $all = $input->getOption('all'); - if ($all) { - $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; - } + public function __invoke( + SymfonyStyle $io, + InputInterface $input, + #[MapInput] ShortUrlsParamsInput $paramsInput, + ): int { + $page = $paramsInput->page; + $data = $paramsInput->toArray($io); $columnsMap = $this->resolveColumnsMap($input); do { - $data[ShortUrlsParamsInputFilter::PAGE] = $page; - $result = $this->renderPage($output, $columnsMap, ShortUrlsParams::fromRawData($data), $all); + $result = $this->renderPage($io, $columnsMap, ShortUrlsParams::fromRawData($data), $paramsInput->all); $page++; $continue = $result->hasNextPage() && $io->confirm( @@ -213,17 +89,6 @@ class ListShortUrlsCommand extends Command return $shortUrls; } - private function processOrderBy(InputInterface $input): string|null - { - $orderBy = $input->getOption('order-by'); - if (empty($orderBy)) { - return null; - } - - [$field, $dir] = array_pad(explode(',', $orderBy), 2, null); - return $dir === null ? $field : sprintf('%s-%s', $field, $dir); - } - /** * @return array */ diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php index 74acc162..05c9de94 100644 --- a/module/CLI/src/Input/DateOption.php +++ b/module/CLI/src/Input/DateOption.php @@ -12,6 +12,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Throwable; use function is_string; +use function Shlinkio\Shlink\Core\normalizeOptionalDate; use function sprintf; readonly class DateOption @@ -29,7 +30,7 @@ readonly class DateOption } try { - return Chronos::parse($value); + return normalizeOptionalDate($value); } catch (Throwable $e) { $output->writeln(sprintf( '> Ignored provided "%s" since its value "%s" is not a valid date. <', diff --git a/module/CLI/src/Input/InputUtils.php b/module/CLI/src/Input/InputUtils.php new file mode 100644 index 00000000..8a719bde --- /dev/null +++ b/module/CLI/src/Input/InputUtils.php @@ -0,0 +1,37 @@ +toAtomString(); + } catch (Throwable) { + $output->writeln(sprintf( + '> Ignored provided "%s" since its value "%s" is not a valid date. <', + $name, + $value, + )); + + return null; + } + } +} diff --git a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php index 76d1882f..69190f7c 100644 --- a/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php +++ b/module/CLI/test/Command/ShortUrl/ListShortUrlsCommandTest.php @@ -306,8 +306,6 @@ class ListShortUrlsCommandTest extends TestCase { yield [[], null]; yield [['--order-by' => 'visits'], 'visits']; - yield [['--order-by' => 'longUrl,ASC'], 'longUrl-ASC']; - yield [['--order-by' => 'shortCode,DESC'], 'shortCode-DESC']; yield [['--order-by' => 'title-DESC'], 'title-DESC']; } diff --git a/module/CLI/test/Input/InputUtilsTest.php b/module/CLI/test/Input/InputUtilsTest.php new file mode 100644 index 00000000..beb882ca --- /dev/null +++ b/module/CLI/test/Input/InputUtilsTest.php @@ -0,0 +1,47 @@ +input = $this->createMock(OutputInterface::class); + } + + #[Test] + #[TestWith([null], 'null')] + #[TestWith([''], 'empty string')] + public function processDateReturnsNullForEmptyDates(string|null $date): void + { + self::assertNull(InputUtils::processDate('name', $date, $this->input)); + } + + #[Test] + public function processDateReturnsAtomFormatedForValidDates(): void + { + $date = '2025-01-20'; + self::assertEquals(Chronos::parse($date)->toAtomString(), InputUtils::processDate('name', $date, $this->input)); + } + + #[Test] + public function warningIsPrintedWhenDateIsInvalid(): void + { + $this->input->expects($this->once())->method('writeln')->with( + '> Ignored provided "name" since its value "invalid" is not a valid date. <', + ); + self::assertNull(InputUtils::processDate('name', 'invalid', $this->input)); + } +}