Migrate ListShortUrlsCommand to symfony/console attributes

This commit is contained in:
Alejandro Celaya 2025-12-13 11:05:20 +01:00
parent 89419e278c
commit a75ee138e1
6 changed files with 214 additions and 150 deletions

View File

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\CLI\Command\ShortUrl\ListShortUrlsCommand;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Core\Domain\Entity\Domain;
use Shlinkio\Shlink\Core\ShortUrl\Model\TagsMode;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlsParamsInputFilter;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Output\OutputInterface;
use function array_unique;
/**
* Input arguments and options for short-url:list command
* @see ListShortUrlsCommand
*/
final class ShortUrlsParamsInput
{
#[Option('The first page to list (10 items per page unless "--all" is provided).', shortcut: 'p')]
public int $page = 1;
#[Option(
'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.',
)]
public bool $all = false;
#[Option('Only return short URLs older than this date', shortcut: 's')]
public string|null $startDate = null;
#[Option('Only return short URLs newer than this date', shortcut: 'e')]
public string|null $endDate = null;
#[Option('A query used to filter results by searching for it on the longUrl and shortCode fields', shortcut: 'st')]
public string|null $searchTerm = null;
#[Option(
'Used to filter results by domain. Use ' . Domain::DEFAULT_AUTHORITY . ' keyword to filter by default domain',
shortcut: 'd',
)]
public string|null $domain = null;
/** @var string[] */
#[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')]
public array $tags = [];
#[Option('If --tag is provided, returns only short URLs including ALL of them')]
public bool $tagsAll = false;
/** @var string[] */
#[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')]
public array $excludeTags = [];
#[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')]
public bool $excludeTagsAll = false;
#[Option('Excludes short URLs which reached their max amount of visits')]
public bool $excludeMaxVisitsReached = false;
#[Option('Excludes short URLs which have a "validUntil" date in the past')]
public bool $excludePastValidUntil = false;
#[Option(
'Field name to order the list by. Set the dir by optionally passing ASC or DESC after "-": --orderBy=tags-ASC',
shortcut: 'o',
)]
public string|null $orderBy = null;
#[Option('List only short URLs created by the API key matching provided name', shortcut: 'kn')]
public string|null $apiKeyName = null;
#[Option('Whether to display the tags or not')]
public bool $showTags = false;
#[Option(
'Whether to display the domain or not. Those belonging to default domain will have value '
. '"' . Domain::DEFAULT_AUTHORITY . '"',
)]
public bool $showDomain = false;
#[Option('Whether to display the API key name from which the URL was generated or not', shortcut: 'k')]
public bool $showApiKey = false;
public function toArray(OutputInterface $output): array
{
$tagsMode = $this->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;
}
}

View File

@ -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<string, callable(array $serializedShortUrl, ShortUrl $shortUrl): ?string>
*/

View File

@ -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(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
final class InputUtils
{
/**
* Process a date provided via input params, and format it as ATOM.
* A warning is printed if the date cannot be parsed, returning `null` in that case.
*/
public static function processDate(string $name, string|null $value, OutputInterface $output): string|null
{
if ($value === null || $value === '') {
return null;
}
try {
return normalizeOptionalDate($value)->toAtomString();
} catch (Throwable) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$name,
$value,
));
return null;
}
}
}

View File

@ -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'];
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace ShlinkioTest\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
use Shlinkio\Shlink\CLI\Input\InputUtils;
use Symfony\Component\Console\Output\OutputInterface;
class InputUtilsTest extends TestCase
{
private MockObject & OutputInterface $input;
protected function setUp(): void
{
$this->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(
'<comment>> Ignored provided "name" since its value "invalid" is not a valid date. <</comment>',
);
self::assertNull(InputUtils::processDate('name', 'invalid', $this->input));
}
}