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 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/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..3853fd1d 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, 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 4b6a088d..0273da71 100644 --- a/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php +++ b/module/CLI/src/Command/ShortUrl/CreateShortUrlCommand.php @@ -4,24 +4,18 @@ 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; 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\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 +23,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 +31,7 @@ class CreateShortUrlCommand extends Command private readonly UrlShortenerOptions $options, ) { parent::__construct(); + $this->shortUrlDataInput = new ShortUrlDataInput($this); } protected function configure(): void @@ -43,26 +39,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 +51,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 +58,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,32 +93,17 @@ class CreateShortUrlCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { $io = $this->getIO($input, $output); - $longUrl = $input->getArgument('longUrl'); - 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::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'), - ], $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 ' @@ -169,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; @@ -181,6 +123,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..048b3934 --- /dev/null +++ b/module/CLI/src/Command/ShortUrl/EditShortUrlCommand.php @@ -0,0 +1,71 @@ +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); + $identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input); + + try { + $shortUrl = $this->shortUrlService->updateShortUrl( + $identifier, + $this->shortUrlDataInput->toShortUrlEdition($input), + ); + + $io->success(sprintf('Short URL "%s" properly edited', $this->stringifier->stringify($shortUrl))); + return ExitCode::EXIT_SUCCESS; + } 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 new file mode 100644 index 00000000..a77de92e --- /dev/null +++ b/module/CLI/src/Input/ShortUrlDataInput.php @@ -0,0 +1,135 @@ +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( + 'title', + mode: InputOption::VALUE_REQUIRED, + description: 'A descriptive title for the 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 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 ($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; + } +} 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); + } +} 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, ) { }