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

Symfony cli improvements
This commit is contained in:
Alejandro Celaya
2025-12-15 09:39:02 +01:00
committed by GitHub
16 changed files with 235 additions and 384 deletions

View File

@@ -50,9 +50,9 @@
"shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0", "shlinkio/shlink-json": "dev-main#7c096d6 as 1.3.0",
"spiral/roadrunner": "^2025.1", "spiral/roadrunner": "^2025.1",
"spiral/roadrunner-cli": "^2.7", "spiral/roadrunner-cli": "^2.7",
"spiral/roadrunner-http": "^3.5", "spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-jobs": "^4.6", "spiral/roadrunner-jobs": "^4.7",
"symfony/console": "^8.0 || ^7.4", "symfony/console": "^8.0",
"symfony/filesystem": "^8.0", "symfony/filesystem": "^8.0",
"symfony/lock": "^8.0", "symfony/lock": "^8.0",
"symfony/process": "^8.0", "symfony/process": "^8.0",

View File

@@ -9,9 +9,9 @@ use Shlinkio\Shlink\Core\Domain\DomainServiceInterface;
use Shlinkio\Shlink\Core\Domain\Model\DomainItem; use Shlinkio\Shlink\Core\Domain\Model\DomainItem;
use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function array_filter; use function array_filter;
@@ -32,7 +32,8 @@ class DomainRedirectsCommand extends Command
parent::__construct(); parent::__construct();
} }
protected function interact(InputInterface $input, OutputInterface $output): void #[Interact]
public function askDomain(InputInterface $input, SymfonyStyle $io): void
{ {
/** @var string|null $domain */ /** @var string|null $domain */
$domain = $input->getArgument('domain'); $domain = $input->getArgument('domain');
@@ -40,7 +41,6 @@ class DomainRedirectsCommand extends Command
return; return;
} }
$io = new SymfonyStyle($input, $output);
$askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects'); $askNewDomain = static fn () => $io->ask('Domain authority for which you want to set specific redirects');
/** @var string[] $availableDomains */ /** @var string[] $availableDomains */

View File

@@ -4,7 +4,6 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\Integration; namespace Shlinkio\Shlink\CLI\Command\Integration;
use Cake\Chronos\Chronos;
use Shlinkio\Shlink\Core\Matomo\MatomoOptions; use Shlinkio\Shlink\Core\Matomo\MatomoOptions;
use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface; use Shlinkio\Shlink\Core\Matomo\MatomoVisitSenderInterface;
use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface; use Shlinkio\Shlink\Core\Matomo\VisitSendingProgressTrackerInterface;
@@ -17,10 +16,12 @@ use Throwable;
use function Shlinkio\Shlink\Common\buildDateRange; use function Shlinkio\Shlink\Common\buildDateRange;
use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly; use function Shlinkio\Shlink\Core\dateRangeToHumanFriendly;
use function Shlinkio\Shlink\Core\normalizeOptionalDate;
use function sprintf; use function sprintf;
#[AsCommand( #[AsCommand(
name: MatomoSendVisitsCommand::NAME, name: MatomoSendVisitsCommand::NAME,
description: 'Send existing visits to the configured matomo instance',
help: <<<HELP help: <<<HELP
This command allows you to send existing visits from this Shlink instance to the configured Matomo server. This command allows you to send existing visits from this Shlink instance to the configured Matomo server.
@@ -56,14 +57,6 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
parent::__construct(); parent::__construct();
} }
protected function configure(): void
{
$this->setDescription(sprintf(
'%sSend existing visits to the configured matomo instance',
$this->matomoEnabled ? '' : '<comment>[MATOMO INTEGRATION DISABLED]</comment> ',
));
}
public function __invoke( public function __invoke(
SymfonyStyle $io, SymfonyStyle $io,
InputInterface $input, InputInterface $input,
@@ -81,8 +74,8 @@ class MatomoSendVisitsCommand extends Command implements VisitSendingProgressTra
// TODO Validate provided date formats // TODO Validate provided date formats
$dateRange = buildDateRange( $dateRange = buildDateRange(
startDate: $since !== null ? Chronos::parse($since) : null, startDate: normalizeOptionalDate($since),
endDate: $until !== null ? Chronos::parse($until) : null, endDate: normalizeOptionalDate($until),
); );
if ($input->isInteractive()) { if ($input->isInteractive()) {

View File

@@ -4,48 +4,41 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\RedirectRule; namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface; use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
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\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf; use function sprintf;
#[AsCommand(
name: ManageRedirectRulesCommand::NAME,
description: 'Set redirect rules for a short URL',
)]
class ManageRedirectRulesCommand extends Command class ManageRedirectRulesCommand extends Command
{ {
public const string NAME = 'short-url:manage-rules'; public const string NAME = 'short-url:manage-rules';
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct( public function __construct(
protected readonly ShortUrlResolverInterface $shortUrlResolver, protected readonly ShortUrlResolverInterface $shortUrlResolver,
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService, protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
protected readonly RedirectRuleHandlerInterface $ruleHandler, protected readonly RedirectRuleHandlerInterface $ruleHandler,
) { ) {
parent::__construct(); parent::__construct();
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code which rules we want to set.',
domainDesc: 'The domain for the short code.',
);
} }
protected function configure(): void public function __invoke(
{ SymfonyStyle $io,
$this #[Argument('The short code which rules we want to set')] string $shortCode,
->setName(self::NAME) #[Option('The domain of the short code', shortcut: 'd')] string|null $domain = null,
->setDescription('Set redirect rules for a short URL'); ): int {
} $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try { try {
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier); $shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);

View File

@@ -4,109 +4,42 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlCreationInput;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Command\Command; 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 Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf; use function sprintf;
#[AsCommand(
name: CreateShortUrlCommand::NAME,
description: 'Generates a short URL for provided long URL and returns it',
)]
class CreateShortUrlCommand extends Command class CreateShortUrlCommand extends Command
{ {
public const string NAME = 'short-url:create'; public const string NAME = 'short-url:create';
private SymfonyStyle $io;
private readonly ShortUrlDataInput $shortUrlDataInput;
public function __construct( public function __construct(
private readonly UrlShortenerInterface $urlShortener, private readonly UrlShortenerInterface $urlShortener,
private readonly ShortUrlStringifierInterface $stringifier, private readonly ShortUrlStringifierInterface $stringifier,
private readonly UrlShortenerOptions $options, private readonly UrlShortenerOptions $options,
) { ) {
parent::__construct(); parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this);
} }
protected function configure(): void public function __invoke(SymfonyStyle $io, #[MapInput] ShortUrlCreationInput $inputData): int
{ {
$this
->setName(self::NAME)
->setDescription('Generates a short URL for provided long URL and returns it')
->addOption(
'domain',
'd',
InputOption::VALUE_REQUIRED,
'The domain to which this short URL will be attached.',
)
->addOption(
'custom-slug',
'c',
InputOption::VALUE_REQUIRED,
'If provided, this slug will be used instead of generating a short code',
)
->addOption(
'short-code-length',
'l',
InputOption::VALUE_REQUIRED,
'The length for generated short code (it will be ignored if --custom-slug was provided).',
)
->addOption(
'path-prefix',
'p',
InputOption::VALUE_REQUIRED,
'Prefix to prepend before the generated short code or provided custom slug',
)
->addOption(
'find-if-exists',
'f',
InputOption::VALUE_NONE,
'This will force existing matching URL to be returned if found, instead of creating a new one.',
);
}
protected function interact(InputInterface $input, OutputInterface $output): void
{
$this->verifyLongUrlArgument($input, $output);
}
private function verifyLongUrlArgument(InputInterface $input, OutputInterface $output): void
{
$longUrl = $input->getArgument('longUrl');
if (! empty($longUrl)) {
return;
}
$io = $this->getIO($input, $output);
$longUrl = $io->ask('Which URL do you want to shorten?');
if (! empty($longUrl)) {
$input->setArgument('longUrl', $longUrl);
}
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = $this->getIO($input, $output);
try { try {
$result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation( $result = $this->urlShortener->shorten($inputData->toShortUrlCreation($this->options));
$input,
$this->options,
customSlugField: 'custom-slug',
shortCodeLengthField: 'short-code-length',
pathPrefixField: 'path-prefix',
findIfExistsField: 'find-if-exists',
domainField: 'domain',
));
$result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning( $result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning(
'Short URL properly created, but the real-time updates cannot be notified when generating the ' 'Short URL properly created, but the real-time updates cannot be notified when generating the '
. 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.', . 'short URL from the command line. Migrate to roadrunner in order to bypass this limitation.',
)); ));
$io->writeln([ $io->writeln([
@@ -119,9 +52,4 @@ class CreateShortUrlCommand extends Command
return self::FAILURE; return self::FAILURE;
} }
} }
private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle
{
return $this->io ??= new SymfonyStyle($input, $output);
}
} }

View File

@@ -4,55 +4,49 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl; namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; use Shlinkio\Shlink\CLI\Command\ShortUrl\Input\ShortUrlDataInput;
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Command\Command; 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 Symfony\Component\Console\Style\SymfonyStyle;
use function sprintf; use function sprintf;
#[AsCommand(
name: EditShortUrlCommand::NAME,
description: 'Edit an existing short URL',
)]
class EditShortUrlCommand extends Command class EditShortUrlCommand extends Command
{ {
public const string NAME = 'short-url:edit'; public const string NAME = 'short-url:edit';
private readonly ShortUrlDataInput $shortUrlDataInput;
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
public function __construct( public function __construct(
private readonly ShortUrlServiceInterface $shortUrlService, private readonly ShortUrlServiceInterface $shortUrlService,
private readonly ShortUrlStringifierInterface $stringifier, private readonly ShortUrlStringifierInterface $stringifier,
) { ) {
parent::__construct(); parent::__construct();
$this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true);
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
$this,
shortCodeDesc: 'The short code to edit',
domainDesc: 'The domain to which the short URL is attached.',
);
} }
protected function configure(): void public function __invoke(
{ SymfonyStyle $io,
$this #[Argument('The short code to edit')] string $shortCode,
->setName(self::NAME) #[MapInput] ShortUrlDataInput $data,
->setDescription('Edit an existing short URL'); #[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 {
protected function execute(InputInterface $input, OutputInterface $output): int $identifier = ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
{
$io = new SymfonyStyle($input, $output);
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
try { try {
$shortUrl = $this->shortUrlService->updateShortUrl( $shortUrl = $this->shortUrlService->updateShortUrl(
$identifier, $identifier,
$this->shortUrlDataInput->toShortUrlEdition($input), ShortUrlEdition::fromRawData($data->toArray()),
); );
$io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl))); $io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl)));

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Ask;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
/**
* Data used for short URL creation
*/
final class ShortUrlCreationInput
{
#[Argument('The long URL to set'), Ask('Which URL do you want to shorten?')]
public string $longUrl;
#[MapInput]
public ShortUrlDataInput $commonData;
#[Option('The domain to which this short URL will be attached', shortcut: 'd')]
public string|null $domain = null;
#[Option('If provided, this slug will be used instead of generating a short code', shortcut: 'c')]
public string|null $customSlug = null;
#[Option('The length for generated short code (it will be ignored if --custom-slug was provided)', shortcut: 'l')]
public int|null $shortCodeLength = null;
#[Option('Prefix to prepend before the generated short code or provided custom slug', shortcut: 'p')]
public string|null $pathPrefix = null;
#[Option(
'This will force existing matching URL to be returned if found, instead of creating a new one',
shortcut: 'f',
)]
public bool $findIfExists = false;
public function toShortUrlCreation(UrlShortenerOptions $options): ShortUrlCreation
{
$shortCodeLength = $this->shortCodeLength ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
ShortUrlInputFilter::LONG_URL => $this->longUrl,
ShortUrlInputFilter::DOMAIN => $this->domain,
ShortUrlInputFilter::CUSTOM_SLUG => $this->customSlug,
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $this->pathPrefix,
ShortUrlInputFilter::FIND_IF_EXISTS => $this->findIfExists,
...$this->commonData->toArray(),
], $options);
}
}

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\ShortUrl\Input;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Symfony\Component\Console\Attribute\Option;
use function array_unique;
/**
* Common input used for short URL creation and edition
*/
final class ShortUrlDataInput
{
/** @var string[]|null */
#[Option('Tags to apply to the short URL', name: 'tag', shortcut: 't')]
public array|null $tags = null;
#[Option(
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found',
shortcut: 's',
)]
public string|null $validSince = null;
#[Option(
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found',
shortcut: 'u',
)]
public string|null $validUntil = null;
#[Option('This will limit the number of visits for this short URL', shortcut: 'm')]
public int|null $maxVisits = null;
#[Option('A descriptive title for the short URL')]
public string|null $title = null;
#[Option('Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt', shortcut: 'r')]
public bool|null $crawlable = null;
#[Option(
'Disables the forwarding of the query string to the long URL, when the short URL is visited',
shortcut: 'w',
)]
public bool|null $noForwardQuery = null;
public function toArray(): array
{
$data = [];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if ($this->validSince !== null) {
$data[ShortUrlInputFilter::VALID_SINCE] = $this->validSince;
}
if ($this->validUntil !== null) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $this->validUntil;
}
if ($this->maxVisits !== null) {
$data[ShortUrlInputFilter::MAX_VISITS] = $this->maxVisits;
}
if ($this->tags !== null) {
$data[ShortUrlInputFilter::TAGS] = array_unique($this->tags);
}
if ($this->title !== null) {
$data[ShortUrlInputFilter::TITLE] = $this->title;
}
if ($this->crawlable !== null) {
$data[ShortUrlInputFilter::CRAWLABLE] = $this->crawlable;
}
if ($this->noForwardQuery !== null) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$this->noForwardQuery;
}
return $data;
}
}

View File

@@ -45,16 +45,16 @@ final class ShortUrlsParamsInput
)] )]
public string|null $domain = null; public string|null $domain = null;
/** @var string[] */ /** @var string[]|null */
#[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')] #[Option('A list of tags that short URLs need to include', name: 'tag', shortcut: 't')]
public array $tags = []; public array|null $tags = null;
#[Option('If --tag is provided, returns only short URLs including ALL of them')] #[Option('If --tag is provided, returns only short URLs including ALL of them')]
public bool $tagsAll = false; public bool $tagsAll = false;
/** @var string[] */ /** @var string[]|null */
#[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')] #[Option('A list of tags that short URLs should NOT include', name: 'exclude-tag', shortcut: 'et')]
public array $excludeTags = []; public array|null $excludeTags = null;
#[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')] #[Option('If --exclude-tag is provided, returns only short URLs not including ANY of them')]
public bool $excludeTagsAll = false; public bool $excludeTagsAll = false;
@@ -88,17 +88,10 @@ final class ShortUrlsParamsInput
public function toArray(OutputInterface $output): array 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 = [ $data = [
ShortUrlsParamsInputFilter::PAGE => $this->page, ShortUrlsParamsInputFilter::PAGE => $this->page,
ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm, ShortUrlsParamsInputFilter::SEARCH_TERM => $this->searchTerm,
ShortUrlsParamsInputFilter::DOMAIN => $this->domain, 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::ORDER_BY => $this->orderBy,
ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output), ShortUrlsParamsInputFilter::START_DATE => InputUtils::processDate('start-date', $this->startDate, $output),
ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output), ShortUrlsParamsInputFilter::END_DATE => InputUtils::processDate('end-date', $this->endDate, $output),
@@ -107,6 +100,18 @@ final class ShortUrlsParamsInput
ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName, ShortUrlsParamsInputFilter::API_KEY_NAME => $this->apiKeyName,
]; ];
if ($this->tags !== null) {
$tagsMode = $this->tagsAll ? TagsMode::ALL : TagsMode::ANY;
$data[ShortUrlsParamsInputFilter::TAGS_MODE] = $tagsMode->value;
$data[ShortUrlsParamsInputFilter::TAGS] = array_unique($this->tags);
}
if ($this->excludeTags !== null) {
$excludeTagsMode = $this->excludeTagsAll ? TagsMode::ALL : TagsMode::ANY;
$data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS_MODE] = $excludeTagsMode->value;
$data[ShortUrlsParamsInputFilter::EXCLUDE_TAGS] = array_unique($this->excludeTags);
}
if ($this->all) { if ($this->all) {
$data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS; $data[ShortUrlsParamsInputFilter::ITEMS_PER_PAGE] = Paginator::ALL_ITEMS;
} }

View File

@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Shlinkio\Shlink\Core\Config\Options\UrlShortenerOptions;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlEdition;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
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 ShortUrlDataInput
{
private readonly TagsOption $tagsOption;
public function __construct(Command $command, private bool $longUrlAsOption = false)
{
if ($longUrlAsOption) {
$command->addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set');
} else {
$command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set');
}
$this->tagsOption = new TagsOption($command, 'Tags to apply to the short URL');
$command
->addOption(
ShortUrlDataOption::VALID_SINCE->value,
ShortUrlDataOption::VALID_SINCE->shortcut(),
InputOption::VALUE_REQUIRED,
'The date from which this short URL will be valid. '
. 'If someone tries to access it before this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::VALID_UNTIL->value,
ShortUrlDataOption::VALID_UNTIL->shortcut(),
InputOption::VALUE_REQUIRED,
'The date until which this short URL will be valid. '
. 'If someone tries to access it after this date, it will not be found.',
)
->addOption(
ShortUrlDataOption::MAX_VISITS->value,
ShortUrlDataOption::MAX_VISITS->shortcut(),
InputOption::VALUE_REQUIRED,
'This will limit the number of visits for this short URL.',
)
->addOption(
ShortUrlDataOption::TITLE->value,
ShortUrlDataOption::TITLE->shortcut(),
InputOption::VALUE_REQUIRED,
'A descriptive title for the short URL.',
)
->addOption(
ShortUrlDataOption::CRAWLABLE->value,
ShortUrlDataOption::CRAWLABLE->shortcut(),
InputOption::VALUE_NONE,
'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.',
)
->addOption(
ShortUrlDataOption::NO_FORWARD_QUERY->value,
ShortUrlDataOption::NO_FORWARD_QUERY->shortcut(),
InputOption::VALUE_NONE,
'Disables the forwarding of the query string to the long URL, when the short URL is visited.',
);
}
public function toShortUrlEdition(InputInterface $input): ShortUrlEdition
{
return ShortUrlEdition::fromRawData($this->getCommonData($input));
}
public function toShortUrlCreation(
InputInterface $input,
UrlShortenerOptions $options,
string $customSlugField,
string $shortCodeLengthField,
string $pathPrefixField,
string $findIfExistsField,
string $domainField,
): ShortUrlCreation {
$shortCodeLength = $input->getOption($shortCodeLengthField) ?? $options->defaultShortCodesLength;
return ShortUrlCreation::fromRawData([
...$this->getCommonData($input),
ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption($customSlugField),
ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength,
ShortUrlInputFilter::PATH_PREFIX => $input->getOption($pathPrefixField),
ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption($findIfExistsField),
ShortUrlInputFilter::DOMAIN => $input->getOption($domainField),
], $options);
}
private function getCommonData(InputInterface $input): array
{
$longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl');
$data = [ShortUrlInputFilter::LONG_URL => $longUrl];
// Avoid setting arguments that were not explicitly provided.
// This is important when editing short URLs and should not make a difference when creating.
if (ShortUrlDataOption::VALID_SINCE->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since');
}
if (ShortUrlDataOption::VALID_UNTIL->wasProvided($input)) {
$data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until');
}
if (ShortUrlDataOption::MAX_VISITS->wasProvided($input)) {
$maxVisits = $input->getOption('max-visits');
$data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null;
}
if ($this->tagsOption->exists($input)) {
$data[ShortUrlInputFilter::TAGS] = $this->tagsOption->get($input);
}
if (ShortUrlDataOption::TITLE->wasProvided($input)) {
$data[ShortUrlInputFilter::TITLE] = $input->getOption('title');
}
if (ShortUrlDataOption::CRAWLABLE->wasProvided($input)) {
$data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable');
}
if (ShortUrlDataOption::NO_FORWARD_QUERY->wasProvided($input)) {
$data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query');
}
return $data;
}
}

View File

@@ -1,39 +0,0 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Input;
use Symfony\Component\Console\Input\InputInterface;
use function sprintf;
enum ShortUrlDataOption: string
{
case VALID_SINCE = 'valid-since';
case VALID_UNTIL = 'valid-until';
case MAX_VISITS = 'max-visits';
case TITLE = 'title';
case CRAWLABLE = 'crawlable';
case NO_FORWARD_QUERY = 'no-forward-query';
public function shortcut(): string|null
{
return match ($this) {
self::VALID_SINCE => 's',
self::VALID_UNTIL => 'u',
self::MAX_VISITS => 'm',
self::TITLE => null,
self::CRAWLABLE => 'r',
self::NO_FORWARD_QUERY => 'w',
};
}
public function wasProvided(InputInterface $input): bool
{
$option = sprintf('--%s', $this->value);
$shortcut = $this->shortcut();
return $input->hasParameterOption($shortcut === null ? $option : [$option, sprintf('-%s', $shortcut)]);
}
}

View File

@@ -1,41 +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;
use function array_unique;
readonly class TagsOption
{
public function __construct(Command $command, string $description)
{
$command
->addOption(
'tag',
't',
InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY,
$description,
);
}
/**
* Whether tags have been set or not, via `--tag` or `-t`
*/
public function exists(InputInterface $input): bool
{
return $input->hasParameterOption(['--tag', '-t']);
}
/**
* @return string[]
*/
public function get(InputInterface $input): array
{
return array_unique($input->getOption('tag'));
}
}

View File

@@ -48,7 +48,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); $this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
$this->ruleHandler->expects($this->never())->method('manageRules'); $this->ruleHandler->expects($this->never())->method('manageRules');
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']); $exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertEquals(Command::FAILURE, $exitCode); self::assertEquals(Command::FAILURE, $exitCode);
@@ -67,7 +67,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null); $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null);
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl'); $this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']); $exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode); self::assertEquals(Command::SUCCESS, $exitCode);
@@ -86,7 +86,7 @@ class ManageRedirectRulesCommandTest extends TestCase
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]); $this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]);
$this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []); $this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []);
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']); $exitCode = $this->commandTester->execute(['short-code' => 'foo']);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertEquals(Command::SUCCESS, $exitCode); self::assertEquals(Command::SUCCESS, $exitCode);

View File

@@ -54,7 +54,7 @@ class CreateShortUrlCommandTest extends TestCase
); );
$this->commandTester->execute([ $this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar', 'long-url' => 'http://domain.com/foo/bar',
'--max-visits' => '3', '--max-visits' => '3',
], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]); ], ['verbosity' => OutputInterface::VERBOSITY_VERBOSE]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
@@ -87,7 +87,7 @@ class CreateShortUrlCommandTest extends TestCase
); );
$this->stringifier->method('stringify')->willReturn(''); $this->stringifier->method('stringify')->willReturn('');
$this->commandTester->execute(['longUrl' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']); $this->commandTester->execute(['long-url' => 'http://domain.com/invalid', '--custom-slug' => 'my-slug']);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode()); self::assertEquals(Command::FAILURE, $this->commandTester->getStatusCode());
@@ -109,7 +109,7 @@ class CreateShortUrlCommandTest extends TestCase
); );
$this->commandTester->execute([ $this->commandTester->execute([
'longUrl' => 'http://domain.com/foo/bar', 'long-url' => 'http://domain.com/foo/bar',
'--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'], '--tag' => ['foo', 'bar', 'baz', 'boo', 'zar', 'baz'],
]); ]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
@@ -129,7 +129,7 @@ class CreateShortUrlCommandTest extends TestCase
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake())); )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching(ShortUrl::createFake()));
$this->stringifier->method('stringify')->willReturn(''); $this->stringifier->method('stringify')->willReturn('');
$input['longUrl'] = 'http://domain.com/foo/bar'; $input['long-url'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($input); $this->commandTester->execute($input);
self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode()); self::assertEquals(Command::SUCCESS, $this->commandTester->getStatusCode());
@@ -156,7 +156,7 @@ class CreateShortUrlCommandTest extends TestCase
)->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl)); )->willReturn(UrlShorteningResult::withoutErrorOnEventDispatching($shortUrl));
$this->stringifier->method('stringify')->willReturn(''); $this->stringifier->method('stringify')->willReturn('');
$options['longUrl'] = 'http://domain.com/foo/bar'; $options['long-url'] = 'http://domain.com/foo/bar';
$this->commandTester->execute($options); $this->commandTester->execute($options);
} }
@@ -178,7 +178,7 @@ class CreateShortUrlCommandTest extends TestCase
); );
$this->stringifier->method('stringify')->willReturn('stringified_short_url'); $this->stringifier->method('stringify')->willReturn('stringified_short_url');
$this->commandTester->execute(['longUrl' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]); $this->commandTester->execute(['long-url' => 'http://domain.com/foo/bar'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$assert($output); $assert($output);

View File

@@ -40,7 +40,7 @@ class EditShortUrlCommandTest extends TestCase
); );
$this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo'); $this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo');
$this->commandTester->execute(['shortCode' => 'foobar']); $this->commandTester->execute(['short-code' => 'foobar']);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode(); $exitCode = $this->commandTester->getStatusCode();
@@ -59,7 +59,7 @@ class EditShortUrlCommandTest extends TestCase
$this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e); $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e);
$this->stringifier->expects($this->never())->method('stringify'); $this->stringifier->expects($this->never())->method('stringify');
$this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]); $this->commandTester->execute(['short-code' => 'foo'], ['verbosity' => $verbosity]);
$output = $this->commandTester->getDisplay(); $output = $this->commandTester->getDisplay();
$exitCode = $this->commandTester->getStatusCode(); $exitCode = $this->commandTester->getStatusCode();

View File

@@ -203,25 +203,34 @@ class ListShortUrlsCommandTest extends TestCase
array $commandArgs, array $commandArgs,
int|null $page, int|null $page,
string|null $searchTerm, string|null $searchTerm,
array $tags, array|null $tags,
string $tagsMode, string $tagsMode,
string|null $startDate = null, string|null $startDate = null,
string|null $endDate = null, string|null $endDate = null,
array $excludeTags = [], array|null $excludeTags = null,
string $excludeTagsMode = TagsMode::ANY->value, string $excludeTagsMode = TagsMode::ANY->value,
string|null $apiKeyName = null, string|null $apiKeyName = null,
): void { ): void {
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData([ $expectedData = [
'page' => $page, 'page' => $page,
'searchTerm' => $searchTerm, 'searchTerm' => $searchTerm,
'tags' => $tags,
'tagsMode' => $tagsMode, 'tagsMode' => $tagsMode,
'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null, 'startDate' => $startDate !== null ? Chronos::parse($startDate)->toAtomString() : null,
'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null, 'endDate' => $endDate !== null ? Chronos::parse($endDate)->toAtomString() : null,
'excludeTags' => $excludeTags,
'excludeTagsMode' => $excludeTagsMode, 'excludeTagsMode' => $excludeTagsMode,
'apiKeyName' => $apiKeyName, 'apiKeyName' => $apiKeyName,
]))->willReturn(new Paginator(new ArrayAdapter([]))); ];
if ($tags !== null) {
$expectedData['tags'] = $tags;
}
if ($excludeTags !== null) {
$expectedData['excludeTags'] = $excludeTags;
}
$this->shortUrlService->expects($this->once())->method('listShortUrls')->with(ShortUrlsParams::fromRawData(
$expectedData,
))->willReturn(new Paginator(new ArrayAdapter([])));
$this->commandTester->setInputs(['n']); $this->commandTester->setInputs(['n']);
$this->commandTester->execute($commandArgs); $this->commandTester->execute($commandArgs);
@@ -231,7 +240,7 @@ class ListShortUrlsCommandTest extends TestCase
{ {
yield [[], 1, null, [], TagsMode::ANY->value]; yield [[], 1, null, [], TagsMode::ANY->value];
yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value]; yield [['--page' => $page = 3], $page, null, [], TagsMode::ANY->value];
yield [['--tags-all' => true], 1, null, [], TagsMode::ALL->value]; yield [['--tags-all' => true, '--tag' => ['foo']], 1, null, ['foo'], TagsMode::ALL->value];
yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value]; yield [['--search-term' => $searchTerm = 'search this'], 1, $searchTerm, [], TagsMode::ANY->value];
yield [ yield [
['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']], ['--page' => $page = 3, '--search-term' => $searchTerm = 'search this', '--tag' => $tags = ['foo', 'bar']],
@@ -270,7 +279,7 @@ class ListShortUrlsCommandTest extends TestCase
['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true], ['--exclude-tag' => ['foo', 'bar'], '--exclude-tags-all' => true],
1, 1,
null, null,
[], null,
TagsMode::ANY->value, TagsMode::ANY->value,
null, null,
null, null,