Convert GetShortUrlVisitsCommand into invokable command

This commit is contained in:
Alejandro Celaya
2025-12-17 15:43:14 +01:00
parent e265e55917
commit c6b83a6437
8 changed files with 33 additions and 288 deletions

View File

@@ -4,61 +4,53 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Common\Paginator\Paginator;
use Shlinkio\Shlink\Common\Util\DateRange;
use Shlinkio\Shlink\CLI\Command\Visit\VisitsCommandUtils;
use Shlinkio\Shlink\CLI\Input\VisitsDateRangeInput;
use Shlinkio\Shlink\CLI\Util\ShlinkTable;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Shlinkio\Shlink\Core\Visit\VisitsStatsHelperInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
#[AsCommand(GetShortUrlVisitsCommand::NAME, 'Returns the detailed visits information for provided short code')]
class GetShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits';
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
protected function configure(): void
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
$this
->setName(self::NAME)
->setDescription('Returns the detailed visits information for provided short code');
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code which visits we want to get.',
domainDesc: 'The domain for the short code.',
);
parent::__construct();
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
if (! empty($shortCode)) {
return;
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code which visits we want to get'), Ask('Which short code do you want to use?')]
string $shortCode,
#[MapInput] VisitsDateRangeInput $dateRangeInput,
#[Option('The domain for the short code', shortcut: 'd')]
string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
$dateRange = $dateRangeInput->toDateRange();
$paginator = $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
$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);
}
}
ShlinkTable::default($io)->render($headers, $rows);
/**
* @return Paginator<Visit>
*/
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
return self::SUCCESS;
}
/**
* @return array<string, string>
*/
protected function mapExtraFields(Visit $visit): array
private function mapExtraFields(Visit $visit): array
{
return [];
}

View File

@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Input\EndDateOption;
use Shlinkio\Shlink\CLI\Input\StartDateOption;
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;
use function Shlinkio\Shlink\Common\buildDateRange;
abstract class AbstractVisitsListCommand extends Command
{
private readonly StartDateOption $startDateOption;
private readonly EndDateOption $endDateOption;
public function __construct(protected readonly VisitsStatsHelperInterface $visitsHelper)
{
parent::__construct();
$this->startDateOption = new StartDateOption($this, 'visits');
$this->endDateOption = new EndDateOption($this, 'visits');
}
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$startDate = $this->startDateOption->get($input, $output);
$endDate = $this->endDateOption->get($input, $output);
$paginator = $this->getVisitsPaginator($input, buildDateRange($startDate, $endDate));
[$rows, $headers] = VisitsCommandUtils::resolveRowsAndHeaders($paginator, $this->mapExtraFields(...));
ShlinkTable::default($output)->render($headers, $rows);
return self::SUCCESS;
}
/**
* @return Paginator<Visit>
*/
abstract protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator;
/**
* @return array<string, string>
*/
abstract protected function mapExtraFields(Visit $visit): array;
}

View File

@@ -1,48 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
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 Throwable;
use function is_string;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf;
readonly class DateOption
{
public function __construct(private Command $command, private string $name, string $shortcut, string $description)
{
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
public function get(InputInterface $input, OutputInterface $output): Chronos|null
{
$value = $input->getOption($this->name);
if (empty($value) || ! is_string($value)) {
return null;
}
try {
return normalizeOptionalDate($value);
} catch (Throwable $e) {
$output->writeln(sprintf(
'<comment>> Ignored provided "%s" since its value "%s" is not a valid date. <</comment>',
$this->name,
$value,
));
if ($output->isVeryVerbose()) {
$this->command->getApplication()?->renderThrowable($e, $output);
}
return null;
}
}
}

View File

@@ -1,29 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class DomainOption
{
private const string NAME = 'domain';
public function __construct(Command $command, string $description)
{
$command->addOption(
name: self::NAME,
shortcut: 'd',
mode: InputOption::VALUE_REQUIRED,
description: $description,
);
}
public function get(InputInterface $input): string|null
{
return $input->getOption(self::NAME);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
final readonly class EndDateOption
{
private DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->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|null
{
return $this->dateOption->get($input, $output);
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
final readonly class ShortUrlIdentifierInput
{
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
{
$command
->addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc)
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
}
public function shortCode(InputInterface $input): string|null
{
return $input->getArgument('shortCode');
}
public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier
{
$shortCode = $input->getArgument('shortCode');
$domain = $input->getOption('domain');
return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
}
}

View File

@@ -1,30 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Cake\Chronos\Chronos;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use function sprintf;
final readonly class StartDateOption
{
private DateOption $dateOption;
public function __construct(Command $command, string $descriptionHint)
{
$this->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|null
{
return $this->dateOption->get($input, $output);
}
}

View File

@@ -24,7 +24,6 @@ use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
use Symfony\Component\Console\Tester\CommandTester;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
class GetShortUrlVisitsCommandTest extends TestCase
{
@@ -47,7 +46,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
}
#[Test]
@@ -75,34 +74,12 @@ class GetShortUrlVisitsCommandTest extends TestCase
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
'short-code' => $shortCode,
'--start-date' => $startDate,
'--end-date' => $endDate,
]);
}
#[Test]
public function providingInvalidDatesPrintsWarning(): void
{
$shortCode = 'abc123';
$startDate = 'foo';
$this->visitsHelper->expects($this->once())->method('visitsForShortUrl')->with(
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
new VisitsParams(DateRange::allTime()),
)->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->execute([
'shortCode' => $shortCode,
'--start-date' => $startDate,
]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(
sprintf('Ignored provided "start-date" since its value "%s" is not a valid date', $startDate),
$output,
);
}
#[Test]
public function outputIsProperlyGenerated(): void
{
@@ -115,7 +92,7 @@ class GetShortUrlVisitsCommandTest extends TestCase
$this->anything(),
)->willReturn(new Paginator(new ArrayAdapter([$visit])));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertEquals(