diff --git a/module/CLI/config/dependencies.config.php b/module/CLI/config/dependencies.config.php
index c2b61e19..0c709788 100644
--- a/module/CLI/config/dependencies.config.php
+++ b/module/CLI/config/dependencies.config.php
@@ -11,7 +11,7 @@ use Shlinkio\Shlink\Common\Doctrine\NoDbNameConnectionFactory;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\Options\UrlShortenerOptions;
-use Shlinkio\Shlink\Core\RedirectRule;
+use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService;
use Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
use Shlinkio\Shlink\Core\Tag\TagService;
@@ -34,6 +34,7 @@ return [
PhpExecutableFinder::class => InvokableFactory::class,
GeoLite\GeolocationDbUpdater::class => ConfigAbstractFactory::class,
+ RedirectRule\RedirectRuleHandler::class => InvokableFactory::class,
Util\ProcessRunner::class => ConfigAbstractFactory::class,
ApiKey\RoleResolver::class => ConfigAbstractFactory::class,
@@ -122,7 +123,8 @@ return [
Command\RedirectRule\ManageRedirectRulesCommand::class => [
ShortUrl\ShortUrlResolver::class,
- RedirectRule\ShortUrlRedirectRuleService::class,
+ ShortUrlRedirectRuleService::class,
+ RedirectRule\RedirectRuleHandler::class,
],
Command\Db\CreateDatabaseCommand::class => [
diff --git a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
index ee8dc328..e36fcf59 100644
--- a/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
+++ b/module/CLI/src/Command/RedirectRule/ManageRedirectRulesCommand.php
@@ -4,18 +4,11 @@ declare(strict_types=1);
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
-use Doctrine\Common\Collections\ArrayCollection;
+use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
use Shlinkio\Shlink\CLI\Util\ExitCode;
-use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
-use Shlinkio\Shlink\Core\Model\DeviceType;
-use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
-use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
-use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
-use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
-use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
@@ -24,22 +17,7 @@ use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
-use function array_flip;
-use function array_slice;
-use function array_values;
-use function count;
-use function implode;
-use function is_numeric;
-use function max;
-use function min;
-use function Shlinkio\Shlink\Core\ArrayUtils\map;
-use function Shlinkio\Shlink\Core\enumValues;
use function sprintf;
-use function str_pad;
-use function strlen;
-use function trim;
-
-use const STR_PAD_LEFT;
class ManageRedirectRulesCommand extends Command
{
@@ -48,6 +26,7 @@ class ManageRedirectRulesCommand extends Command
public function __construct(
protected readonly ShortUrlResolverInterface $shortUrlResolver,
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
+ protected readonly RedirectRuleHandlerInterface $ruleHandler,
) {
parent::__construct();
}
@@ -73,7 +52,7 @@ class ManageRedirectRulesCommand extends Command
return ExitCode::EXIT_FAILURE;
}
- $rulesToSave = $this->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl));
+ $rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl));
if ($rulesToSave !== null) {
$this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave);
$io->success('Rules properly saved');
@@ -81,193 +60,4 @@ class ManageRedirectRulesCommand extends Command
return ExitCode::EXIT_SUCCESS;
}
-
- /**
- * @param ShortUrlRedirectRule[] $rules
- * @return ShortUrlRedirectRule[]|null
- */
- private function processRules(ShortUrl $shortUrl, SymfonyStyle $io, array $rules): ?array
- {
- $amountOfRules = count($rules);
-
- if ($amountOfRules === 0) {
- $io->comment('No rules found.');
- } else {
- $listing = map(
- $rules,
- function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array {
- $priority = ((int) $index) + 1;
- $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf(
- '%s',
- $condition->toHumanFriendly(),
- ));
-
- return [
- str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT),
- implode(' AND ', $conditions),
- $rule->longUrl,
- ];
- },
- );
- $io->table(['Priority', 'Conditions', 'Redirect to'], $listing);
- }
-
- $action = $io->choice(
- 'What do you want to do next?',
- [
- 'Add new rule',
- 'Remove existing rule',
- 'Re-arrange rule',
- 'Discard changes',
- 'Save and exit',
- ],
- 'Save and exit',
- );
-
- return match ($action) {
- 'Add new rule' => $this->processRules($shortUrl, $io, $this->addRule($shortUrl, $io, $rules)),
- 'Remove existing rule' => $this->processRules($shortUrl, $io, $this->removeRule($io, $rules)),
- 'Re-arrange rule' => $this->processRules($shortUrl, $io, $this->reArrangeRule($io, $rules)),
- 'Save and exit' => $rules,
- default => null,
- };
- }
-
- /**
- * @param ShortUrlRedirectRule[] $currentRules
- */
- private function addRule(ShortUrl $shortUrl, SymfonyStyle $io, array $currentRules): array
- {
- $higherPriority = count($currentRules);
- $priority = $this->askPriority($io, $higherPriority + 1);
- $longUrl = $this->askLongUrl($io);
- $conditions = [];
-
- do {
- $type = RedirectConditionType::from(
- $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)),
- );
- $conditions[] = match ($type) {
- RedirectConditionType::DEVICE => RedirectCondition::forDevice(
- DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))),
- ),
- RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage(
- $this->askMandatory('Language to match?', $io),
- ),
- RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam(
- $this->askMandatory('Query param name?', $io),
- $this->askOptional('Query param value?', $io),
- ),
- };
-
- $continue = $io->confirm('Do you want to add another condition?');
- } while ($continue);
-
- $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions));
- $rulesBefore = array_slice($currentRules, 0, $priority - 1);
- $rulesAfter = array_slice($currentRules, $priority - 1);
-
- return [...$rulesBefore, $newRule, ...$rulesAfter];
- }
-
- /**
- * @param ShortUrlRedirectRule[] $currentRules
- */
- private function removeRule(SymfonyStyle $io, array $currentRules): array
- {
- if (empty($currentRules)) {
- $io->warning('There are no rules to remove');
- return $currentRules;
- }
-
- $index = $this->askRule('What rule do you want to delete?', $io, $currentRules);
- unset($currentRules[$index]);
- return array_values($currentRules);
- }
-
- /**
- * @param ShortUrlRedirectRule[] $currentRules
- */
- private function reArrangeRule(SymfonyStyle $io, array $currentRules): array
- {
- if (empty($currentRules)) {
- $io->warning('There are no rules to re-arrange');
- return $currentRules;
- }
-
- $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules);
- $newIndex = $this->askPriority($io, count($currentRules)) - 1;
-
- // Temporarily get rule from array and unset it
- $rule = $currentRules[$oldIndex];
- unset($currentRules[$oldIndex]);
-
- // Reindex remaining rules
- $currentRules = array_values($currentRules);
-
- $rulesBefore = array_slice($currentRules, 0, $newIndex);
- $rulesAfter = array_slice($currentRules, $newIndex);
-
- return [...$rulesBefore, $rule, ...$rulesAfter];
- }
-
- /**
- * @param ShortUrlRedirectRule[] $currentRules
- */
- private function askRule(string $message, SymfonyStyle $io, array $currentRules): int
- {
- $choices = [];
- foreach ($currentRules as $index => $rule) {
- $choices[$rule->longUrl] = $index + 1;
- }
-
- $resp = $io->choice($message, array_flip($choices));
- return $choices[$resp] - 1;
- }
-
- private function askPriority(SymfonyStyle $io, int $max): int
- {
- return $io->ask(
- 'Rule priority (the lower the value, the higher the priority)',
- (string) $max,
- function (string $answer) use ($max): int {
- if (! is_numeric($answer)) {
- throw new InvalidArgumentException('The priority must be a numeric positive value');
- }
-
- $priority = (int) $answer;
- return max(1, min($max, $priority));
- },
- );
- }
-
- private function askLongUrl(SymfonyStyle $io): string
- {
- return $io->ask(
- 'Long URL to redirect when the rule matches',
- validator: function (string $answer): string {
- $validator = ShortUrlInputFilter::longUrlValidators();
- if (! $validator->isValid($answer)) {
- throw new InvalidArgumentException(implode(', ', $validator->getMessages()));
- }
-
- return $answer;
- },
- );
- }
-
- private function askMandatory(string $message, SymfonyStyle $io): string
- {
- return $io->ask($message, validator: function (?string $answer): string {
- if ($answer === null) {
- throw new InvalidArgumentException('The value is mandatory');
- }
- return trim($answer);
- });
- }
-
- private function askOptional(string $message, SymfonyStyle $io): string
- {
- return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer));
- }
}
diff --git a/module/CLI/src/Input/DateOption.php b/module/CLI/src/Input/DateOption.php
index 41407d23..6183a6c5 100644
--- a/module/CLI/src/Input/DateOption.php
+++ b/module/CLI/src/Input/DateOption.php
@@ -14,14 +14,10 @@ use Throwable;
use function is_string;
use function sprintf;
-class DateOption
+readonly class DateOption
{
- public function __construct(
- private readonly Command $command,
- private readonly string $name,
- string $shortcut,
- string $description,
- ) {
+ public function __construct(private Command $command, private string $name, string $shortcut, string $description)
+ {
$command->addOption($name, $shortcut, InputOption::VALUE_REQUIRED, $description);
}
diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandler.php b/module/CLI/src/RedirectRule/RedirectRuleHandler.php
new file mode 100644
index 00000000..8b1592b7
--- /dev/null
+++ b/module/CLI/src/RedirectRule/RedirectRuleHandler.php
@@ -0,0 +1,221 @@
+newLine();
+ $io->text('// No rules found.');
+ } else {
+ $listing = map(
+ $rules,
+ function (ShortUrlRedirectRule $rule, string|int|float $index) use ($amountOfRules): array {
+ $priority = ((int) $index) + 1;
+ $conditions = $rule->mapConditions(static fn (RedirectCondition $condition): string => sprintf(
+ '%s',
+ $condition->toHumanFriendly(),
+ ));
+
+ return [
+ str_pad((string) $priority, strlen((string) $amountOfRules), '0', STR_PAD_LEFT),
+ implode(' AND ', $conditions),
+ $rule->longUrl,
+ ];
+ },
+ );
+ $io->table(['Priority', 'Conditions', 'Redirect to'], $listing);
+ }
+
+ $action = $io->choice(
+ 'What do you want to do next?',
+ [
+ 'Add new rule',
+ 'Remove existing rule',
+ 'Re-arrange rule',
+ 'Discard changes',
+ 'Save and exit',
+ ],
+ 'Save and exit',
+ );
+
+ return match ($action) {
+ 'Add new rule' => $this->manageRules($io, $shortUrl, $this->addRule($shortUrl, $io, $rules)),
+ 'Remove existing rule' => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)),
+ 'Re-arrange rule' => $this->manageRules($io, $shortUrl, $this->reArrangeRule($io, $rules)),
+ 'Save and exit' => $rules,
+ default => null,
+ };
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function addRule(ShortUrl $shortUrl, StyleInterface $io, array $currentRules): array
+ {
+ $higherPriority = count($currentRules);
+ $priority = $this->askPriority($io, $higherPriority + 1);
+ $longUrl = $this->askLongUrl($io);
+ $conditions = [];
+
+ do {
+ $type = RedirectConditionType::from(
+ $io->choice('Type of the condition?', enumValues(RedirectConditionType::class)),
+ );
+ $conditions[] = match ($type) {
+ RedirectConditionType::DEVICE => RedirectCondition::forDevice(
+ DeviceType::from($io->choice('Device to match?', enumValues(DeviceType::class))),
+ ),
+ RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage(
+ $this->askMandatory('Language to match?', $io),
+ ),
+ RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam(
+ $this->askMandatory('Query param name?', $io),
+ $this->askOptional('Query param value?', $io),
+ ),
+ };
+
+ $continue = $io->confirm('Do you want to add another condition?');
+ } while ($continue);
+
+ $newRule = new ShortUrlRedirectRule($shortUrl, $priority, $longUrl, new ArrayCollection($conditions));
+ $rulesBefore = array_slice($currentRules, 0, $priority - 1);
+ $rulesAfter = array_slice($currentRules, $priority - 1);
+
+ return [...$rulesBefore, $newRule, ...$rulesAfter];
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function removeRule(StyleInterface $io, array $currentRules): array
+ {
+ if (empty($currentRules)) {
+ $io->warning('There are no rules to remove');
+ return $currentRules;
+ }
+
+ $index = $this->askRule('What rule do you want to delete?', $io, $currentRules);
+ unset($currentRules[$index]);
+ return array_values($currentRules);
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function reArrangeRule(StyleInterface $io, array $currentRules): array
+ {
+ if (empty($currentRules)) {
+ $io->warning('There are no rules to re-arrange');
+ return $currentRules;
+ }
+
+ $oldIndex = $this->askRule('What rule do you want to re-arrange?', $io, $currentRules);
+ $newIndex = $this->askPriority($io, count($currentRules)) - 1;
+
+ // Temporarily get rule from array and unset it
+ $rule = $currentRules[$oldIndex];
+ unset($currentRules[$oldIndex]);
+
+ // Reindex remaining rules
+ $currentRules = array_values($currentRules);
+
+ $rulesBefore = array_slice($currentRules, 0, $newIndex);
+ $rulesAfter = array_slice($currentRules, $newIndex);
+
+ return [...$rulesBefore, $rule, ...$rulesAfter];
+ }
+
+ /**
+ * @param ShortUrlRedirectRule[] $currentRules
+ */
+ private function askRule(string $message, StyleInterface $io, array $currentRules): int
+ {
+ $choices = [];
+ foreach ($currentRules as $index => $rule) {
+ $choices[$rule->longUrl] = $index + 1;
+ }
+
+ $resp = $io->choice($message, array_flip($choices));
+ return $choices[$resp] - 1;
+ }
+
+ private function askPriority(StyleInterface $io, int $max): int
+ {
+ return $io->ask(
+ 'Rule priority (the lower the value, the higher the priority)',
+ (string) $max,
+ function (string $answer) use ($max): int {
+ if (! is_numeric($answer)) {
+ throw new InvalidArgumentException('The priority must be a numeric positive value');
+ }
+
+ $priority = (int) $answer;
+ return max(1, min($max, $priority));
+ },
+ );
+ }
+
+ private function askLongUrl(StyleInterface $io): string
+ {
+ return $io->ask(
+ 'Long URL to redirect when the rule matches',
+ validator: function (string $answer): string {
+ $validator = ShortUrlInputFilter::longUrlValidators();
+ if (! $validator->isValid($answer)) {
+ throw new InvalidArgumentException(implode(', ', $validator->getMessages()));
+ }
+
+ return $answer;
+ },
+ );
+ }
+
+ private function askMandatory(string $message, StyleInterface $io): string
+ {
+ return $io->ask($message, validator: function (?string $answer): string {
+ if ($answer === null) {
+ throw new InvalidArgumentException('The value is mandatory');
+ }
+ return trim($answer);
+ });
+ }
+
+ private function askOptional(string $message, StyleInterface $io): string
+ {
+ return $io->ask($message, validator: fn (?string $answer) => $answer === null ? '' : trim($answer));
+ }
+}
diff --git a/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php
new file mode 100644
index 00000000..16022768
--- /dev/null
+++ b/module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php
@@ -0,0 +1,20 @@
+