From fabc7523984dc6ddc9b378badad7284c636e40eb Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Thu, 25 Jul 2024 23:44:06 +0200 Subject: [PATCH 1/6] Extract reading and parsing of arguments for short URLs data in commands --- .../ShortUrl/CreateShortUrlCommand.php | 84 ++++---------- module/CLI/src/Input/ShortUrlDataInput.php | 106 ++++++++++++++++++ .../Action/ShortUrl/EditShortUrlAction.php | 4 +- 3 files changed, 128 insertions(+), 66 deletions(-) create mode 100644 module/CLI/src/Input/ShortUrlDataInput.php diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index 4b6a088d..fa7a2bea 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Command\ShortUrl; +use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; @@ -12,16 +13,11 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Input\InputArgument; 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_map; -use function array_unique; -use function explode; -use function Shlinkio\Shlink\Core\ArrayUtils\flatten; use function sprintf; class CreateShortUrlCommand extends Command @@ -29,6 +25,7 @@ class CreateShortUrlCommand extends Command public const NAME = 'short-url:create'; private ?SymfonyStyle $io; + private readonly ShortUrlDataInput $shortUrlDataInput; public function __construct( private readonly UrlShortenerInterface $urlShortener, @@ -36,6 +33,7 @@ class CreateShortUrlCommand extends Command private readonly UrlShortenerOptions $options, ) { parent::__construct(); + $this->shortUrlDataInput = new ShortUrlDataInput($this); } protected function configure(): void @@ -43,26 +41,11 @@ class CreateShortUrlCommand extends Command $this ->setName(self::NAME) ->setDescription('Generates a short URL for provided long URL and returns it') - ->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to parse') ->addOption( - 'tags', - 't', - InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'Tags to apply to the new short URL', - ) - ->addOption( - 'valid-since', - 's', + 'domain', + 'd', 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( - 'valid-until', - 'u', - 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.', + 'The domain to which this short URL will be attached.', ) ->addOption( 'custom-slug', @@ -70,30 +53,6 @@ class CreateShortUrlCommand extends Command InputOption::VALUE_REQUIRED, 'If provided, this slug will be used instead of generating a short code', ) - ->addOption( - 'path-prefix', - 'p', - InputOption::VALUE_REQUIRED, - 'Prefix to prepend before the generated short code or provided custom slug', - ) - ->addOption( - 'max-visits', - 'm', - InputOption::VALUE_REQUIRED, - 'This will limit the number of visits for this short URL.', - ) - ->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.', - ) - ->addOption( - 'domain', - 'd', - InputOption::VALUE_REQUIRED, - 'The domain to which this short URL will be attached.', - ) ->addOption( 'short-code-length', 'l', @@ -101,16 +60,16 @@ class CreateShortUrlCommand extends Command 'The length for generated short code (it will be ignored if --custom-slug was provided).', ) ->addOption( - 'crawlable', - 'r', - InputOption::VALUE_NONE, - 'Tells if this URL will be included as "Allow" in Shlink\'s robots.txt.', + 'path-prefix', + 'p', + InputOption::VALUE_REQUIRED, + 'Prefix to prepend before the generated short code or provided custom slug', ) ->addOption( - 'no-forward-query', - 'w', + 'find-if-exists', + 'f', InputOption::VALUE_NONE, - 'Disables the forwarding of the query string to the long URL, when the new short URL is visited.', + 'This will force existing matching URL to be returned if found, instead of creating a new one.', ); } @@ -136,31 +95,28 @@ class CreateShortUrlCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $io = $this->getIO($input, $output); - $longUrl = $input->getArgument('longUrl'); + $longUrl = $this->shortUrlDataInput->longUrl($input); if (empty($longUrl)) { $io->error('A URL was not provided!'); return ExitCode::EXIT_FAILURE; } - $explodeWithComma = static fn (string $tag) => explode(',', $tag); - $tags = array_unique(flatten(array_map($explodeWithComma, $input->getOption('tags')))); - $maxVisits = $input->getOption('max-visits'); $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; try { $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ ShortUrlInputFilter::LONG_URL => $longUrl, - ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), - ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), - ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, + ShortUrlInputFilter::VALID_SINCE => $this->shortUrlDataInput->validSince($input), + ShortUrlInputFilter::VALID_UNTIL => $this->shortUrlDataInput->validUntil($input), + ShortUrlInputFilter::MAX_VISITS => $this->shortUrlDataInput->maxVisits($input), ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'), ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'), ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlInputFilter::TAGS => $tags, - ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), - ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), + ShortUrlInputFilter::TAGS => $this->shortUrlDataInput->tags($input), + ShortUrlInputFilter::CRAWLABLE => $this->shortUrlDataInput->crawlable($input), + ShortUrlInputFilter::FORWARD_QUERY => !$this->shortUrlDataInput->noForwardQuery($input), ], $this->options)); $result->onEventDispatchingError(static fn () => $io->isVerbose() && $io->warning( diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php new file mode 100644 index 00000000..5ba3126f --- /dev/null +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -0,0 +1,106 @@ +addOption('long-url', 'l', InputOption::VALUE_REQUIRED, 'The long URL to set'); + } else { + $command->addArgument('longUrl', InputArgument::REQUIRED, 'The long URL to set'); + } + + $command + ->addOption( + 'tags', + 't', + InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, + 'Tags to apply to the short URL', + ) + ->addOption( + 'valid-since', + 's', + 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( + 'valid-until', + 'u', + 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( + 'max-visits', + 'm', + InputOption::VALUE_REQUIRED, + 'This will limit the number of visits for this short URL.', + ) + ->addOption( + 'crawlable', + 'r', + InputOption::VALUE_NONE, + 'Tells if this short URL will be included as "Allow" in Shlink\'s robots.txt.', + ) + ->addOption( + 'no-forward-query', + 'w', + InputOption::VALUE_NONE, + 'Disables the forwarding of the query string to the long URL, when the short URL is visited.', + ); + } + + public function longUrl(InputInterface $input): ?string + { + return $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl'); + } + + /** + * @return string[] + */ + public function tags(InputInterface $input): array + { + return array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags')))); + } + + public function validSince(InputInterface $input): ?string + { + return $input->getOption('valid-since'); + } + + public function validUntil(InputInterface $input): ?string + { + return $input->getOption('valid-until'); + } + + public function maxVisits(InputInterface $input): ?int + { + $maxVisits = $input->getOption('max-visits'); + return $maxVisits !== null ? (int) $maxVisits : null; + } + + public function crawlable(InputInterface $input): bool + { + return $input->getOption('crawlable'); + } + + public function noForwardQuery(InputInterface $input): bool + { + return $input->getOption('no-forward-query'); + } +} diff --git a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php index 61c1a70c..f0f8c068 100644 --- a/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php +++ b/module/Rest/src/Action/ShortUrl/EditShortUrlAction.php @@ -20,8 +20,8 @@ class EditShortUrlAction extends AbstractRestAction protected const ROUTE_ALLOWED_METHODS = [self::METHOD_PATCH]; public function __construct( - private ShortUrlServiceInterface $shortUrlService, - private DataTransformerInterface $transformer, + private readonly ShortUrlServiceInterface $shortUrlService, + private readonly DataTransformerInterface $transformer, ) { } From 8917ed5c2e732def6e9f20bba801f762d04bb71f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Jul 2024 00:01:40 +0200 Subject: [PATCH 2/6] Create command to edit existing short URLs --- module/CLI/config/cli.config.php | 1 + module/CLI/config/dependencies.config.php | 2 + .../ShortUrl/CreateShortUrlCommand.php | 2 +- .../Command/ShortUrl/EditShortUrlCommand.php | 60 +++++++++++++++++++ module/CLI/src/Input/ShortUrlDataInput.php | 19 ++++++ 5 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php diff --git a/module/CLI/config/cli.config.php b/module/CLI/config/cli.config.php index 2ee33a1d..e60bb2e1 100644 --- a/module/CLI/config/cli.config.php +++ b/module/CLI/config/cli.config.php @@ -9,6 +9,7 @@ return [ 'cli' => [ 'commands' => [ Command\ShortUrl\CreateShortUrlCommand::NAME => Command\ShortUrl\CreateShortUrlCommand::class, + Command\ShortUrl\EditShortUrlCommand::NAME => Command\ShortUrl\EditShortUrlCommand::class, Command\ShortUrl\ResolveUrlCommand::NAME => Command\ShortUrl\ResolveUrlCommand::class, Command\ShortUrl\ListShortUrlsCommand::NAME => Command\ShortUrl\ListShortUrlsCommand::class, Command\ShortUrl\GetShortUrlVisitsCommand::NAME => Command\ShortUrl\GetShortUrlVisitsCommand::class, diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index f9b90dac..f9bb9654 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -41,6 +41,7 @@ return [ ApiKey\RoleResolver::class => ConfigAbstractFactory::class, Command\ShortUrl\CreateShortUrlCommand::class => ConfigAbstractFactory::class, + Command\ShortUrl\EditShortUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ResolveUrlCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\ListShortUrlsCommand::class => ConfigAbstractFactory::class, Command\ShortUrl\GetShortUrlVisitsCommand::class => ConfigAbstractFactory::class, @@ -92,6 +93,7 @@ return [ ShortUrlStringifier::class, UrlShortenerOptions::class, ], + Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class], Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ ShortUrl\ShortUrlListService::class, diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index fa7a2bea..d47c30b9 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -137,6 +137,6 @@ class CreateShortUrlCommand extends Command private function getIO(InputInterface $input, OutputInterface $output): SymfonyStyle { - return $this->io ?? ($this->io = new SymfonyStyle($input, $output)); + return $this->io ??= new SymfonyStyle($input, $output); } } diff --git a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php new file mode 100644 index 00000000..b3fd0bd4 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php @@ -0,0 +1,60 @@ +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 + { + $this + ->setName(self::NAME) + ->setDescription('Edit an existing short URL'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + try { + $shortUrl = $this->shortUrlService->updateShortUrl( + $this->shortUrlIdentifierInput->toShortUrlIdentifier($input), + $this->shortUrlDataInput->toShortUrlEdition($input), + ); + + // TODO Print success + return ExitCode::EXIT_SUCCESS; + } catch (ShortUrlNotFoundException) { + // TODO Print error + return ExitCode::EXIT_FAILURE; + } + } +} diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 5ba3126f..46c5b1ce 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Input; +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; @@ -103,4 +105,21 @@ readonly final class ShortUrlDataInput { return $input->getOption('no-forward-query'); } + + public function toShortUrlEdition(InputInterface $input): ShortUrlEdition + { + return ShortUrlEdition::fromRawData([ + ShortUrlInputFilter::LONG_URL => $this->longUrl($input), + ShortUrlInputFilter::VALID_SINCE => $this->validSince($input), + ShortUrlInputFilter::VALID_UNTIL => $this->validUntil($input), + ShortUrlInputFilter::MAX_VISITS => $this->maxVisits($input), + ShortUrlInputFilter::TAGS => $this->tags($input), + ShortUrlInputFilter::CRAWLABLE => $this->crawlable($input), + ShortUrlInputFilter::FORWARD_QUERY => !$this->noForwardQuery($input), +// ShortUrlInputFilter::TITLE => TODO, + ]); + } + + // TODO + // public function toShortUrlCreation(InputInterface $input) } From 5bccdded8ab40f2a5a65a5e352b8ab076e33bd40 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Jul 2024 09:20:03 +0200 Subject: [PATCH 3/6] Create command to edit existing short URLs --- module/CLI/config/dependencies.config.php | 2 +- .../ShortUrl/CreateShortUrlCommand.php | 34 ++----- .../Command/ShortUrl/EditShortUrlCommand.php | 23 +++-- module/CLI/src/Input/ShortUrlDataInput.php | 96 +++++++++---------- 4 files changed, 73 insertions(+), 82 deletions(-) diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php index f9bb9654..3853fd1d 100644 --- a/module/CLI/config/dependencies.config.php +++ b/module/CLI/config/dependencies.config.php @@ -93,7 +93,7 @@ return [ ShortUrlStringifier::class, UrlShortenerOptions::class, ], - Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class], + Command\ShortUrl\EditShortUrlCommand::class => [ShortUrl\ShortUrlService::class, ShortUrlStringifier::class], Command\ShortUrl\ResolveUrlCommand::class => [ShortUrl\ShortUrlResolver::class], Command\ShortUrl\ListShortUrlsCommand::class => [ ShortUrl\ShortUrlListService::class, diff --git a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php index d47c30b9..0273da71 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -9,8 +9,6 @@ use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\NonUniqueSlugException; use Shlinkio\Shlink\Core\Options\UrlShortenerOptions; use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; -use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation; -use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter; use Shlinkio\Shlink\Core\ShortUrl\UrlShortenerInterface; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -95,29 +93,17 @@ class CreateShortUrlCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $io = $this->getIO($input, $output); - $longUrl = $this->shortUrlDataInput->longUrl($input); - if (empty($longUrl)) { - $io->error('A URL was not provided!'); - return ExitCode::EXIT_FAILURE; - } - - $shortCodeLength = $input->getOption('short-code-length') ?? $this->options->defaultShortCodesLength; try { - $result = $this->urlShortener->shorten(ShortUrlCreation::fromRawData([ - ShortUrlInputFilter::LONG_URL => $longUrl, - ShortUrlInputFilter::VALID_SINCE => $this->shortUrlDataInput->validSince($input), - ShortUrlInputFilter::VALID_UNTIL => $this->shortUrlDataInput->validUntil($input), - ShortUrlInputFilter::MAX_VISITS => $this->shortUrlDataInput->maxVisits($input), - ShortUrlInputFilter::CUSTOM_SLUG => $input->getOption('custom-slug'), - ShortUrlInputFilter::PATH_PREFIX => $input->getOption('path-prefix'), - ShortUrlInputFilter::FIND_IF_EXISTS => $input->getOption('find-if-exists'), - ShortUrlInputFilter::DOMAIN => $input->getOption('domain'), - ShortUrlInputFilter::SHORT_CODE_LENGTH => $shortCodeLength, - ShortUrlInputFilter::TAGS => $this->shortUrlDataInput->tags($input), - ShortUrlInputFilter::CRAWLABLE => $this->shortUrlDataInput->crawlable($input), - ShortUrlInputFilter::FORWARD_QUERY => !$this->shortUrlDataInput->noForwardQuery($input), - ], $this->options)); + $result = $this->urlShortener->shorten($this->shortUrlDataInput->toShortUrlCreation( + $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( 'Short URL properly created, but the real-time updates cannot be notified when generating the ' @@ -125,7 +111,7 @@ class CreateShortUrlCommand extends Command )); $io->writeln([ - sprintf('Processed long URL: %s', $longUrl), + sprintf('Processed long URL: %s', $result->shortUrl->getLongUrl()), sprintf('Generated short URL: %s', $this->stringifier->stringify($result->shortUrl)), ]); return ExitCode::EXIT_SUCCESS; diff --git a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php index b3fd0bd4..048b3934 100644 --- a/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php @@ -8,12 +8,15 @@ use Shlinkio\Shlink\CLI\Input\ShortUrlDataInput; use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput; use Shlinkio\Shlink\CLI\Util\ExitCode; use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException; +use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifierInterface; use Shlinkio\Shlink\Core\ShortUrl\ShortUrlServiceInterface; 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; + class EditShortUrlCommand extends Command { public const NAME = 'short-url:edit'; @@ -21,8 +24,10 @@ class EditShortUrlCommand extends Command private readonly ShortUrlDataInput $shortUrlDataInput; private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput; - public function __construct(private readonly ShortUrlServiceInterface $shortUrlService) - { + public function __construct( + private readonly ShortUrlServiceInterface $shortUrlService, + private readonly ShortUrlStringifierInterface $stringifier, + ) { parent::__construct(); $this->shortUrlDataInput = new ShortUrlDataInput($this, longUrlAsOption: true); @@ -43,17 +48,23 @@ class EditShortUrlCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $io = new SymfonyStyle($input, $output); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); try { $shortUrl = $this->shortUrlService->updateShortUrl( - $this->shortUrlIdentifierInput->toShortUrlIdentifier($input), + $identifier, $this->shortUrlDataInput->toShortUrlEdition($input), ); - // TODO Print success + $io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl))); return ExitCode::EXIT_SUCCESS; - } catch (ShortUrlNotFoundException) { - // TODO Print error + } catch (ShortUrlNotFoundException $e) { + $io->error(sprintf('Short URL not found for "%s"', $identifier->__toString())); + + if ($io->isVerbose()) { + $this->getApplication()?->renderThrowable($e, $io); + } + return ExitCode::EXIT_FAILURE; } } diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 46c5b1ce..30121547 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Shlinkio\Shlink\CLI\Input; +use Shlinkio\Shlink\Core\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; @@ -53,6 +55,11 @@ readonly final class ShortUrlDataInput InputOption::VALUE_REQUIRED, 'This will limit the number of visits for this short URL.', ) + ->addOption( + 'title', + mode: InputOption::VALUE_REQUIRED, + description: 'A descriptive title for the short URL.', + ) ->addOption( 'crawlable', 'r', @@ -67,59 +74,46 @@ readonly final class ShortUrlDataInput ); } - public function longUrl(InputInterface $input): ?string - { - return $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl'); - } - - /** - * @return string[] - */ - public function tags(InputInterface $input): array - { - return array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags')))); - } - - public function validSince(InputInterface $input): ?string - { - return $input->getOption('valid-since'); - } - - public function validUntil(InputInterface $input): ?string - { - return $input->getOption('valid-until'); - } - - public function maxVisits(InputInterface $input): ?int - { - $maxVisits = $input->getOption('max-visits'); - return $maxVisits !== null ? (int) $maxVisits : null; - } - - public function crawlable(InputInterface $input): bool - { - return $input->getOption('crawlable'); - } - - public function noForwardQuery(InputInterface $input): bool - { - return $input->getOption('no-forward-query'); - } - public function toShortUrlEdition(InputInterface $input): ShortUrlEdition { - return ShortUrlEdition::fromRawData([ - ShortUrlInputFilter::LONG_URL => $this->longUrl($input), - ShortUrlInputFilter::VALID_SINCE => $this->validSince($input), - ShortUrlInputFilter::VALID_UNTIL => $this->validUntil($input), - ShortUrlInputFilter::MAX_VISITS => $this->maxVisits($input), - ShortUrlInputFilter::TAGS => $this->tags($input), - ShortUrlInputFilter::CRAWLABLE => $this->crawlable($input), - ShortUrlInputFilter::FORWARD_QUERY => !$this->noForwardQuery($input), -// ShortUrlInputFilter::TITLE => TODO, - ]); + return ShortUrlEdition::fromRawData($this->getCommonData($input)); } - // TODO - // public function toShortUrlCreation(InputInterface $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'); + $tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags')))); + $maxVisits = $input->getOption('max-visits'); + + return [ + ShortUrlInputFilter::LONG_URL => $longUrl, + ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), + ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), + ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, + ShortUrlInputFilter::TAGS => $tags, + ShortUrlInputFilter::TITLE => $input->getOption('title'), + ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), + ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), + ]; + } } From 65ea1e00a6bc4c0b7a13528f920ec3015150ba52 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Jul 2024 19:26:48 +0200 Subject: [PATCH 4/6] Prevent resetting of non-providen params in EditShortUrlCommand --- module/CLI/src/Input/ShortUrlDataInput.php | 40 +++++++++++++++------- 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/module/CLI/src/Input/ShortUrlDataInput.php b/module/CLI/src/Input/ShortUrlDataInput.php index 30121547..a77de92e 100644 --- a/module/CLI/src/Input/ShortUrlDataInput.php +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -102,18 +102,34 @@ readonly final class ShortUrlDataInput private function getCommonData(InputInterface $input): array { $longUrl = $this->longUrlAsOption ? $input->getOption('long-url') : $input->getArgument('longUrl'); - $tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags')))); - $maxVisits = $input->getOption('max-visits'); + $data = [ShortUrlInputFilter::LONG_URL => $longUrl]; - return [ - ShortUrlInputFilter::LONG_URL => $longUrl, - ShortUrlInputFilter::VALID_SINCE => $input->getOption('valid-since'), - ShortUrlInputFilter::VALID_UNTIL => $input->getOption('valid-until'), - ShortUrlInputFilter::MAX_VISITS => $maxVisits !== null ? (int) $maxVisits : null, - ShortUrlInputFilter::TAGS => $tags, - ShortUrlInputFilter::TITLE => $input->getOption('title'), - ShortUrlInputFilter::CRAWLABLE => $input->getOption('crawlable'), - ShortUrlInputFilter::FORWARD_QUERY => !$input->getOption('no-forward-query'), - ]; + // Avoid setting arguments that were not explicitly provided. + // This is important when editing short URLs and should not make a difference when creating. + if ($input->hasParameterOption(['--valid-since', '-s'])) { + $data[ShortUrlInputFilter::VALID_SINCE] = $input->getOption('valid-since'); + } + if ($input->hasParameterOption(['--valid-until', '-v'])) { + $data[ShortUrlInputFilter::VALID_UNTIL] = $input->getOption('valid-until'); + } + if ($input->hasParameterOption(['--max-visits', '-m'])) { + $maxVisits = $input->getOption('max-visits'); + $data[ShortUrlInputFilter::MAX_VISITS] = $maxVisits !== null ? (int) $maxVisits : null; + } + if ($input->hasParameterOption(['--tags', '-t'])) { + $tags = array_unique(flatten(array_map(splitByComma(...), $input->getOption('tags')))); + $data[ShortUrlInputFilter::TAGS] = $tags; + } + if ($input->hasParameterOption('--title')) { + $data[ShortUrlInputFilter::TITLE] = $input->getOption('title'); + } + if ($input->hasParameterOption(['--crawlable', '-r'])) { + $data[ShortUrlInputFilter::CRAWLABLE] = $input->getOption('crawlable'); + } + if ($input->hasParameterOption(['--no-forward-query', '-w'])) { + $data[ShortUrlInputFilter::FORWARD_QUERY] = !$input->getOption('no-forward-query'); + } + + return $data; } } From df94c68e2ebf15f1b5c91cf5852d9cc8569db034 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Jul 2024 19:52:09 +0200 Subject: [PATCH 5/6] Add unit test for EditShortUrlCommand --- CHANGELOG.md | 3 + .../ShortUrl/EditShortUrlCommandTest.php | 74 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index ec85e5cc..307b510d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this * [#2018](https://github.com/shlinkio/shlink/issues/2018) Add option to allow all short URLs to be unconditionally crawlable in robots.txt, via `ROBOTS_ALLOW_ALL_SHORT_URLS=true` env var, or config option. * [#2109](https://github.com/shlinkio/shlink/issues/2109) Add option to customize user agents robots.txt, via `ROBOTS_USER_AGENTS=foo,bar,baz` env var, or config option. +* [#2163](https://github.com/shlinkio/shlink/issues/2163) Add `short-urls:edit` command to edit existing short URLs. + + This brings CLI and API interfaces capabilities closer, and solves an overlook since the feature was implemented years ago. ### Changed * [#2096](https://github.com/shlinkio/shlink/issues/2096) Update to RoadRunner 2024. diff --git a/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php b/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php new file mode 100644 index 00000000..f540b5dc --- /dev/null +++ b/module/CLI/test/Command/ShortUrl/EditShortUrlCommandTest.php @@ -0,0 +1,74 @@ +shortUrlService = $this->createMock(ShortUrlServiceInterface::class); + $this->stringifier = $this->createMock(ShortUrlStringifierInterface::class); + + $command = new EditShortUrlCommand($this->shortUrlService, $this->stringifier); + $this->commandTester = CliTestUtils::testerForCommand($command); + } + + #[Test] + public function successMessageIsPrintedIfNoErrorOccurs(): void + { + $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willReturn( + ShortUrl::createFake(), + ); + $this->stringifier->expects($this->once())->method('stringify')->willReturn('https://s.test/foo'); + + $this->commandTester->execute(['shortCode' => 'foobar']); + $output = $this->commandTester->getDisplay(); + $exitCode = $this->commandTester->getStatusCode(); + + self::assertStringContainsString('Short URL "https://s.test/foo" properly edited', $output); + self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode); + } + + #[Test] + #[TestWith([OutputInterface::VERBOSITY_NORMAL])] + #[TestWith([OutputInterface::VERBOSITY_VERBOSE])] + #[TestWith([OutputInterface::VERBOSITY_VERY_VERBOSE])] + #[TestWith([OutputInterface::VERBOSITY_DEBUG])] + public function errorIsPrintedInCaseOfFailure(int $verbosity): void + { + $e = ShortUrlNotFoundException::fromNotFound(ShortUrlIdentifier::fromShortCodeAndDomain('foo')); + $this->shortUrlService->expects($this->once())->method('updateShortUrl')->willThrowException($e); + $this->stringifier->expects($this->never())->method('stringify'); + + $this->commandTester->execute(['shortCode' => 'foo'], ['verbosity' => $verbosity]); + $output = $this->commandTester->getDisplay(); + $exitCode = $this->commandTester->getStatusCode(); + + self::assertStringContainsString('Short URL not found for "foo"', $output); + if ($verbosity >= OutputInterface::VERBOSITY_VERBOSE) { + self::assertStringContainsString('Exception trace:', $output); + } else { + self::assertStringNotContainsString('Exception trace:', $output); + } + self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode); + } +} From a1afc90150e718b65bccaf332b66daf5b124a1aa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Fri, 26 Jul 2024 20:04:13 +0200 Subject: [PATCH 6/6] Fix sqlcmd path --- .github/workflows/ci-db-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci-db-tests.yml b/.github/workflows/ci-db-tests.yml index ba45e9f4..33bf8f88 100644 --- a/.github/workflows/ci-db-tests.yml +++ b/.github/workflows/ci-db-tests.yml @@ -32,7 +32,7 @@ jobs: extensions-cache-key: db-tests-extensions-${{ matrix.php-version }}-${{ inputs.platform }} - name: Create test database if: ${{ inputs.platform == 'ms' }} - run: docker compose exec -T shlink_db_ms /opt/mssql-tools/bin/sqlcmd -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" + run: docker compose exec -T shlink_db_ms /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'Passw0rd!' -Q "CREATE DATABASE shlink_test;" - name: Run tests run: composer test:db:${{ inputs.platform }} - name: Upload code coverage