Merge pull request #2548 from acelaya-forks/symfony-cli-improvements

Symfony console improvements
This commit is contained in:
Alejandro Celaya
2025-12-15 10:20:23 +01:00
committed by GitHub
10 changed files with 109 additions and 148 deletions

View File

@@ -4,53 +4,40 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Core\Exception;
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
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 sprintf;
#[AsCommand(name: DeleteShortUrlCommand::NAME, description: 'Deletes a short URL')]
class DeleteShortUrlCommand extends Command
{
public const string NAME = 'short-url:delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL to be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Deletes a short URL')
->addOption(
'ignore-threshold',
'i',
InputOption::VALUE_NONE,
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted',
);
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
$ignoreThreshold = $input->getOption('ignore-threshold');
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code for the short URL to be deleted')] string $shortCode,
#[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
string|null $domain = null,
#[Option(
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
. 'accidentally deleted',
shortcut: 'i',
)]
bool $ignoreThreshold = false,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$this->runDelete($io, $identifier, $ignoreThreshold);

View File

@@ -4,41 +4,44 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
#[AsCommand(DeleteShortUrlVisitsCommand::NAME, 'Deletes visits from a short URL')]
class DeleteShortUrlVisitsCommand extends Command
{
public const string NAME = 'short-url:visits-delete';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code for the short URL which visits will be deleted',
domainDesc: 'The domain if the short code does not belong to the default one',
}
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code for the short URL which visits will be deleted')] string $shortCode,
#[Option('The domain if the short code does not belong to the default one', shortcut: 'd')]
string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
return CommandUtils::executeWithWarning(
'You are about to delete all visits for a short URL. This operation cannot be undone',
$io,
fn () => $this->deleteVisits($io, $identifier),
);
}
protected function configure(): void
private function deleteVisits(SymfonyStyle $io, ShortUrlIdentifier $identifier): int
{
$this
->setName(self::NAME)
->setDescription('Deletes visits from a short URL');
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): int
{
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try {
$result = $this->deleter->deleteShortUrlVisits($identifier);
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
@@ -49,9 +52,4 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
return self::INVALID;
}
}
protected function getWarningMessage(): string
{
return 'You are about to delete all visits for a short URL. This operation cannot be undone.';
}
}

View File

@@ -36,8 +36,8 @@ class EditShortUrlCommand extends Command
public function __invoke(
SymfonyStyle $io,
#[Argument('The short code to edit')] string $shortCode,
#[MapInput] ShortUrlDataInput $data,
#[Argument('The short code to edit')] string $shortCode,
#[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
#[Option('The long URL to set', shortcut: 'l')] string|null $longUrl = null,
): int {

View File

@@ -4,60 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
#[AsCommand(ResolveUrlCommand::NAME, 'Returns the long URL behind a short code')]
class ResolveUrlCommand extends Command
{
public const string NAME = 'short-url:parse';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public const string NAME = 'short-url:resolve';
public function __construct(private readonly ShortUrlResolverInterface $urlResolver)
{
parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to parse',
domainDesc: 'The domain to which the short URL is attached.',
);
}
protected function configure(): void
{
$this
->setName(self::NAME)
->setDescription('Returns the long URL behind a short code');
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
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 parse?');
if (! empty($shortCode)) {
$input->setArgument('shortCode', $shortCode);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
public function __invoke(
SymfonyStyle $io,
#[
Argument('The short code to resolve'),
Ask('A short code was not provided. Which short code do you want to resolve?'),
]
string $shortCode,
#[Option('The domain to which the short URL is attached', shortcut: 'd')] string|null $domain = null,
): int {
$identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
try {
$url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
$url = $this->urlResolver->resolveShortUrl($identifier);
$io->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
return self::SUCCESS;
} catch (ShortUrlNotFoundException $e) {
$io->error($e->getMessage());

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Util;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
class CommandUtils
{
/**
* Displays a warning and confirmation message before running a callback. If the response to the confirmation is
* positive, the callback is executed normally.
*
* @param callable(): int $callback
*/
public static function executeWithWarning(string $warning, SymfonyStyle $io, callable $callback): int
{
$io->warning($warning);
if (! $io->confirm('<comment>Do you want to proceed?</comment>', default: false)) {
$io->info('Operation aborted');
return Command::SUCCESS;
}
return $callback();
}
}

View File

@@ -1,34 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
abstract class AbstractDeleteVisitsCommand extends Command
{
final protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (! $this->confirm($io)) {
$io->info('Operation aborted');
return self::SUCCESS;
}
return $this->doExecute($input, $io);
}
private function confirm(SymfonyStyle $io): bool
{
$io->warning($this->getWarningMessage());
return $io->confirm('<comment>Continue deleting visits?</comment>', false);
}
abstract protected function doExecute(InputInterface $input, SymfonyStyle $io): int;
abstract protected function getWarningMessage(): string;
}

View File

@@ -4,13 +4,16 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Visit;
use Shlinkio\Shlink\CLI\Command\Util\CommandUtils;
use Shlinkio\Shlink\Core\Visit\VisitsDeleterInterface;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf;
class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
#[AsCommand(DeleteOrphanVisitsCommand::NAME, 'Deletes all orphan visits')]
class DeleteOrphanVisitsCommand extends Command
{
public const string NAME = 'visit:orphan-delete';
@@ -19,23 +22,20 @@ class DeleteOrphanVisitsCommand extends AbstractDeleteVisitsCommand
parent::__construct();
}
protected function configure(): void
public function __invoke(SymfonyStyle $io): int
{
$this
->setName(self::NAME)
->setDescription('Deletes all orphan visits');
return CommandUtils::executeWithWarning(
'You are about to delete all orphan visits. This operation cannot be undone',
$io,
fn () => $this->deleteVisits($io),
);
}
protected function doExecute(InputInterface $input, SymfonyStyle $io): int
private function deleteVisits(SymfonyStyle $io): int
{
$result = $this->deleter->deleteOrphanVisits();
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
return self::SUCCESS;
}
protected function getWarningMessage(): string
{
return 'You are about to delete all orphan visits. This operation cannot be undone.';
}
}

View File

@@ -39,7 +39,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->isFalse(),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(
@@ -58,7 +58,7 @@ class DeleteShortUrlCommandTest extends TestCase
$this->isFalse(),
)->willThrowException(Exception\ShortUrlNotFoundException::fromNotFound($identifier));
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
@@ -88,7 +88,7 @@ class DeleteShortUrlCommandTest extends TestCase
});
$this->commandTester->setInputs($retryAnswer);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(
@@ -118,7 +118,7 @@ class DeleteShortUrlCommandTest extends TestCase
));
$this->commandTester->setInputs(['no']);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf(

View File

@@ -36,7 +36,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$this->deleter->expects($this->never())->method('deleteShortUrlVisits');
$this->commandTester->setInputs($input);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);
@@ -67,8 +67,8 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
public static function provideErrorArgs(): iterable
{
yield 'domain' => [['shortCode' => 'foo'], 'Short URL not found for "foo"'];
yield 'no domain' => [['shortCode' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
yield 'domain' => [['short-code' => 'foo'], 'Short URL not found for "foo"'];
yield 'no domain' => [['short-code' => 'foo', '--domain' => 's.test'], 'Short URL not found for "s.test/foo"'];
}
#[Test]
@@ -77,7 +77,7 @@ class DeleteShortUrlVisitsCommandTest extends TestCase
$this->deleter->expects($this->once())->method('deleteShortUrlVisits')->willReturn(new BulkDeleteResult(5));
$this->commandTester->setInputs(['yes']);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
$exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode);

View File

@@ -40,7 +40,7 @@ class ResolveUrlCommandTest extends TestCase
ShortUrlIdentifier::fromShortCodeAndDomain($shortCode),
)->willReturn($shortUrl);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertEquals('Long URL: ' . $expectedUrl . PHP_EOL, $output);
}
@@ -68,7 +68,7 @@ class ResolveUrlCommandTest extends TestCase
ShortUrlNotFoundException::fromNotFound($identifier),
);
$this->commandTester->execute(['shortCode' => $shortCode]);
$this->commandTester->execute(['short-code' => $shortCode]);
$output = $this->commandTester->getDisplay();
self::assertStringContainsString(sprintf('No URL found with short code "%s"', $shortCode), $output);
}