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,
) {
}