mirror of
https://github.com/shlinkio/shlink.git
synced 2025-02-17 07:49:54 +03:00
Merge pull request #2038 from acelaya-forks/feature/redirect-rules-cli
Add command to manage the redirect rules for a short URLs
This commit is contained in:
commit
7ecfb24584
32 changed files with 978 additions and 93 deletions
|
@ -7,6 +7,7 @@ return [
|
|||
'rabbitmq' => [
|
||||
'enabled' => true,
|
||||
'host' => 'shlink_rabbitmq',
|
||||
'port' => '5673',
|
||||
'user' => 'rabbit',
|
||||
'password' => 'rabbit',
|
||||
],
|
||||
|
|
|
@ -52,7 +52,7 @@ $buildDbConnection = static function (): array {
|
|||
'postgres' => [
|
||||
'driver' => 'pdo_pgsql',
|
||||
'host' => $isCi ? '127.0.0.1' : 'shlink_db_postgres',
|
||||
'port' => $isCi ? '5433' : '5432',
|
||||
'port' => $isCi ? '5434' : '5432',
|
||||
'user' => 'postgres',
|
||||
'password' => 'root',
|
||||
'dbname' => 'shlink_test',
|
||||
|
|
|
@ -81,7 +81,7 @@ services:
|
|||
container_name: shlink_db_postgres
|
||||
image: postgres:12.2-alpine
|
||||
ports:
|
||||
- "5433:5432"
|
||||
- "5434:5432"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/database_pg:/var/lib/postgresql/data
|
||||
|
@ -153,8 +153,8 @@ services:
|
|||
container_name: shlink_rabbitmq
|
||||
image: rabbitmq:3.11-management-alpine
|
||||
ports:
|
||||
- "15672:15672"
|
||||
- "5672:5672"
|
||||
- "15673:15672"
|
||||
- "5673:5672"
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: "rabbit"
|
||||
RABBITMQ_DEFAULT_PASS: "rabbit"
|
||||
|
|
|
@ -37,6 +37,9 @@ return [
|
|||
|
||||
Command\Db\CreateDatabaseCommand::NAME => Command\Db\CreateDatabaseCommand::class,
|
||||
Command\Db\MigrateDatabaseCommand::NAME => Command\Db\MigrateDatabaseCommand::class,
|
||||
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::NAME =>
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
|
|
@ -11,6 +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\ShortUrlRedirectRuleService;
|
||||
use Shlinkio\Shlink\Core\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
|
@ -33,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,
|
||||
|
@ -66,6 +68,8 @@ return [
|
|||
Command\Domain\ListDomainsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Domain\DomainRedirectsCommand::class => ConfigAbstractFactory::class,
|
||||
Command\Domain\GetDomainVisitsCommand::class => ConfigAbstractFactory::class,
|
||||
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class => ConfigAbstractFactory::class,
|
||||
],
|
||||
],
|
||||
|
||||
|
@ -117,6 +121,12 @@ return [
|
|||
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
|
||||
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
|
||||
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class => [
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
ShortUrlRedirectRuleService::class,
|
||||
RedirectRule\RedirectRuleHandler::class,
|
||||
],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
Util\ProcessRunner::class,
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
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 ManageRedirectRulesCommand extends Command
|
||||
{
|
||||
public const NAME = 'short-url:manage-rules';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(
|
||||
protected readonly ShortUrlResolverInterface $shortUrlResolver,
|
||||
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
|
||||
protected readonly RedirectRuleHandlerInterface $ruleHandler,
|
||||
) {
|
||||
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
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set redirect rules for a short URL');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
|
||||
try {
|
||||
$shortUrl = $this->shortUrlResolver->resolveShortUrl($identifier);
|
||||
} catch (ShortUrlNotFoundException) {
|
||||
$io->error(sprintf('Short URL for %s not found', $identifier->__toString()));
|
||||
return ExitCode::EXIT_FAILURE;
|
||||
}
|
||||
|
||||
$rulesToSave = $this->ruleHandler->manageRules($io, $shortUrl, $this->ruleService->rulesForShortUrl($shortUrl));
|
||||
if ($rulesToSave !== null) {
|
||||
$this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave);
|
||||
$io->success('Rules properly saved');
|
||||
}
|
||||
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
}
|
||||
}
|
|
@ -4,12 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\DeleteShortUrlServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
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;
|
||||
|
@ -21,9 +21,16 @@ class DeleteShortUrlCommand extends Command
|
|||
{
|
||||
public const NAME = 'short-url:delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(private readonly DeleteShortUrlServiceInterface $deleteShortUrlService)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code for the short URL to be deleted',
|
||||
domainDesc: 'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
|
@ -31,26 +38,19 @@ class DeleteShortUrlCommand extends Command
|
|||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes a short URL')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code for the short URL to be deleted')
|
||||
->addOption(
|
||||
'ignore-threshold',
|
||||
'i',
|
||||
InputOption::VALUE_NONE,
|
||||
'Ignores the safety visits threshold check, which could make short URLs with many visits to be '
|
||||
. 'accidentally deleted',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
$ignoreThreshold = $input->getOption('ignore-threshold');
|
||||
|
||||
try {
|
||||
|
|
|
@ -5,13 +5,11 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractDeleteVisitsCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlVisitsDeleterInterface;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
use function sprintf;
|
||||
|
@ -20,32 +18,28 @@ class DeleteShortUrlVisitsCommand extends AbstractDeleteVisitsCommand
|
|||
{
|
||||
public const NAME = 'short-url:visits-delete';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(private readonly ShortUrlVisitsDeleterInterface $deleter)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code for the short URL which visits will be deleted',
|
||||
domainDesc: 'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Deletes visits from a short URL')
|
||||
->addArgument(
|
||||
'shortCode',
|
||||
InputArgument::REQUIRED,
|
||||
'The short code for the short URL which visits will be deleted',
|
||||
)
|
||||
->addOption(
|
||||
'domain',
|
||||
'd',
|
||||
InputOption::VALUE_REQUIRED,
|
||||
'The domain if the short code does not belong to the default one',
|
||||
);
|
||||
->setDescription('Deletes visits from a short URL');
|
||||
}
|
||||
|
||||
protected function doExecute(InputInterface $input, SymfonyStyle $io): int
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
try {
|
||||
$result = $this->deleter->deleteShortUrlVisits($identifier);
|
||||
$io->success(sprintf('Successfully deleted %s visits', $result->affectedItems));
|
||||
|
|
|
@ -5,14 +5,12 @@ declare(strict_types=1);
|
|||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Command\Visit\AbstractVisitsListCommand;
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\Common\Paginator\Paginator;
|
||||
use Shlinkio\Shlink\Common\Util\DateRange;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\Visit\Entity\Visit;
|
||||
use Shlinkio\Shlink\Core\Visit\Model\VisitsParams;
|
||||
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;
|
||||
|
||||
|
@ -20,18 +18,23 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
|||
{
|
||||
public const NAME = 'short-url:visits';
|
||||
|
||||
private ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the detailed visits information for provided short code')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which visits we want to get.')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
|
||||
->setDescription('Returns the detailed visits information for provided short code');
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code which visits we want to get.',
|
||||
domainDesc: 'The domain for the short code.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
|
||||
if (! empty($shortCode)) {
|
||||
return;
|
||||
}
|
||||
|
@ -45,7 +48,7 @@ class GetShortUrlVisitsCommand extends AbstractVisitsListCommand
|
|||
|
||||
protected function getVisitsPaginator(InputInterface $input, DateRange $dateRange): Paginator
|
||||
{
|
||||
$identifier = ShortUrlIdentifier::fromCli($input);
|
||||
$identifier = $this->shortUrlIdentifierInput->toShortUrlIdentifier($input);
|
||||
return $this->visitsHelper->visitsForShortUrl($identifier, new VisitsParams($dateRange));
|
||||
}
|
||||
|
||||
|
|
|
@ -4,14 +4,12 @@ declare(strict_types=1);
|
|||
|
||||
namespace Shlinkio\Shlink\CLI\Command\ShortUrl;
|
||||
|
||||
use Shlinkio\Shlink\CLI\Input\ShortUrlIdentifierInput;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
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;
|
||||
|
||||
|
@ -21,23 +19,28 @@ class ResolveUrlCommand extends Command
|
|||
{
|
||||
public const NAME = 'short-url:parse';
|
||||
|
||||
private readonly ShortUrlIdentifierInput $shortUrlIdentifierInput;
|
||||
|
||||
public function __construct(private readonly ShortUrlResolverInterface $urlResolver)
|
||||
{
|
||||
parent::__construct();
|
||||
$this->shortUrlIdentifierInput = new ShortUrlIdentifierInput(
|
||||
$this,
|
||||
shortCodeDesc: 'The short code to parse',
|
||||
domainDesc: 'The domain to which the short URL is attached.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Returns the long URL behind a short code')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code to parse')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain to which the short URL is attached.');
|
||||
->setDescription('Returns the long URL behind a short code');
|
||||
}
|
||||
|
||||
protected function interact(InputInterface $input, OutputInterface $output): void
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$shortCode = $this->shortUrlIdentifierInput->shortCode($input);
|
||||
if (! empty($shortCode)) {
|
||||
return;
|
||||
}
|
||||
|
@ -54,7 +57,7 @@ class ResolveUrlCommand extends Command
|
|||
$io = new SymfonyStyle($input, $output);
|
||||
|
||||
try {
|
||||
$url = $this->urlResolver->resolveShortUrl(ShortUrlIdentifier::fromCli($input));
|
||||
$url = $this->urlResolver->resolveShortUrl($this->shortUrlIdentifierInput->toShortUrlIdentifier($input));
|
||||
$output->writeln(sprintf('Long URL: <info>%s</info>', $url->getLongUrl()));
|
||||
return ExitCode::EXIT_SUCCESS;
|
||||
} catch (ShortUrlNotFoundException $e) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
class EndDateOption
|
||||
readonly final class EndDateOption
|
||||
{
|
||||
private readonly DateOption $dateOption;
|
||||
private DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
|
|
34
module/CLI/src/Input/ShortUrlIdentifierInput.php
Normal file
34
module/CLI/src/Input/ShortUrlIdentifierInput.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Input;
|
||||
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
|
||||
readonly final class ShortUrlIdentifierInput
|
||||
{
|
||||
public function __construct(Command $command, string $shortCodeDesc, string $domainDesc)
|
||||
{
|
||||
$command
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, $shortCodeDesc)
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, $domainDesc);
|
||||
}
|
||||
|
||||
public function shortCode(InputInterface $input): ?string
|
||||
{
|
||||
return $input->getArgument('shortCode');
|
||||
}
|
||||
|
||||
public function toShortUrlIdentifier(InputInterface $input): ShortUrlIdentifier
|
||||
{
|
||||
$shortCode = $input->getArgument('shortCode');
|
||||
$domain = $input->getOption('domain');
|
||||
|
||||
return ShortUrlIdentifier::fromShortCodeAndDomain($shortCode, $domain);
|
||||
}
|
||||
}
|
|
@ -11,9 +11,9 @@ use Symfony\Component\Console\Output\OutputInterface;
|
|||
|
||||
use function sprintf;
|
||||
|
||||
class StartDateOption
|
||||
readonly final class StartDateOption
|
||||
{
|
||||
private readonly DateOption $dateOption;
|
||||
private DateOption $dateOption;
|
||||
|
||||
public function __construct(Command $command, string $descriptionHint)
|
||||
{
|
||||
|
|
225
module/CLI/src/RedirectRule/RedirectRuleHandler.php
Normal file
225
module/CLI/src/RedirectRule/RedirectRuleHandler.php
Normal file
|
@ -0,0 +1,225 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\RedirectRule;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Shlinkio\Shlink\Core\Exception\InvalidArgumentException;
|
||||
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\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
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 RedirectRuleHandler implements RedirectRuleHandlerInterface
|
||||
{
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array
|
||||
{
|
||||
$amountOfRules = count($rules);
|
||||
|
||||
if ($amountOfRules === 0) {
|
||||
$io->newLine();
|
||||
$io->text('<comment>// No rules found.</comment>');
|
||||
} 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(
|
||||
'<comment>%s</comment>',
|
||||
$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 = RedirectRuleHandlerAction::from($io->choice(
|
||||
'What do you want to do next?',
|
||||
enumValues(RedirectRuleHandlerAction::class),
|
||||
RedirectRuleHandlerAction::SAVE->value,
|
||||
));
|
||||
|
||||
return match ($action) {
|
||||
RedirectRuleHandlerAction::ADD => $this->manageRules(
|
||||
$io,
|
||||
$shortUrl,
|
||||
$this->addRule($shortUrl, $io, $rules),
|
||||
),
|
||||
RedirectRuleHandlerAction::REMOVE => $this->manageRules($io, $shortUrl, $this->removeRule($io, $rules)),
|
||||
RedirectRuleHandlerAction::RE_ARRANGE => $this->manageRules(
|
||||
$io,
|
||||
$shortUrl,
|
||||
$this->reArrangeRule($io, $rules),
|
||||
),
|
||||
RedirectRuleHandlerAction::SAVE => $rules,
|
||||
RedirectRuleHandlerAction::DISCARD => 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) {
|
||||
$priority = $index + 1;
|
||||
$key = sprintf('%s - %s', $priority, $rule->longUrl);
|
||||
$choices[$key] = $priority;
|
||||
}
|
||||
|
||||
$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));
|
||||
}
|
||||
}
|
12
module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php
Normal file
12
module/CLI/src/RedirectRule/RedirectRuleHandlerAction.php
Normal file
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\RedirectRule;
|
||||
|
||||
enum RedirectRuleHandlerAction: string
|
||||
{
|
||||
case ADD = 'Add new rule';
|
||||
case REMOVE = 'Remove existing rule';
|
||||
case RE_ARRANGE = 'Re-arrange rule';
|
||||
case SAVE = 'Save and exit';
|
||||
case DISCARD = 'Discard changes';
|
||||
}
|
20
module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php
Normal file
20
module/CLI/src/RedirectRule/RedirectRuleHandlerInterface.php
Normal file
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\RedirectRule;
|
||||
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
interface RedirectRuleHandlerInterface
|
||||
{
|
||||
/**
|
||||
* Interactively manages provided list of rules and applies changes to it
|
||||
*
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
* @return ShortUrlRedirectRule[]|null - A new list of rules to save, or null if no changes should be saved
|
||||
*/
|
||||
public function manageRules(StyleInterface $io, ShortUrl $shortUrl, array $rules): ?array;
|
||||
}
|
33
module/CLI/test-cli/Command/ManageRedirectRulesTest.php
Normal file
33
module/CLI/test-cli/Command/ManageRedirectRulesTest.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioCliTest\Shlink\CLI\Command;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Shlinkio\Shlink\CLI\Command\RedirectRule\ManageRedirectRulesCommand;
|
||||
use Shlinkio\Shlink\TestUtils\CliTest\CliTestCase;
|
||||
|
||||
class ManageRedirectRulesTest extends CliTestCase
|
||||
{
|
||||
#[Test]
|
||||
public function printsErrorsWhenPassingInvalidValues(): void
|
||||
{
|
||||
[$output] = $this->exec([ManageRedirectRulesCommand::NAME, 'abc123'], [
|
||||
'0', // Add new rule
|
||||
'not-a-number', // Invalid priority
|
||||
'1', // Valid priority, to continue execution
|
||||
'invalid-long-url', // Invalid long URL
|
||||
'https://example.com', // Valid long URL, to continue execution
|
||||
'1', // Language condition type
|
||||
'', // Invalid required language
|
||||
'es-ES', // Valid language, to continue execution
|
||||
'no', // Do not add more conditions
|
||||
'4', // Discard changes
|
||||
]);
|
||||
|
||||
self::assertStringContainsString('The priority must be a numeric positive value', $output);
|
||||
self::assertStringContainsString('The input is not valid', $output);
|
||||
self::assertStringContainsString('The value is mandatory', $output);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\Command\RedirectRule;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\Command\RedirectRule\ManageRedirectRulesCommand;
|
||||
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerInterface;
|
||||
use Shlinkio\Shlink\CLI\Util\ExitCode;
|
||||
use Shlinkio\Shlink\Core\Exception\ShortUrlNotFoundException;
|
||||
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\ShortUrlResolverInterface;
|
||||
use ShlinkioTest\Shlink\CLI\Util\CliTestUtils;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
class ManageRedirectRulesCommandTest extends TestCase
|
||||
{
|
||||
private ShortUrlResolverInterface & MockObject $shortUrlResolver;
|
||||
private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService;
|
||||
private RedirectRuleHandlerInterface & MockObject $ruleHandler;
|
||||
private CommandTester $commandTester;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->shortUrlResolver = $this->createMock(ShortUrlResolverInterface::class);
|
||||
$this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class);
|
||||
$this->ruleHandler = $this->createMock(RedirectRuleHandlerInterface::class);
|
||||
|
||||
$this->commandTester = CliTestUtils::testerForCommand(new ManageRedirectRulesCommand(
|
||||
$this->shortUrlResolver,
|
||||
$this->ruleService,
|
||||
$this->ruleHandler,
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedIfShortUrlCannotBeFound(): void
|
||||
{
|
||||
$this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
|
||||
)->willThrowException(new ShortUrlNotFoundException(''));
|
||||
$this->ruleService->expects($this->never())->method('rulesForShortUrl');
|
||||
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
|
||||
$this->ruleHandler->expects($this->never())->method('manageRules');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_FAILURE, $exitCode);
|
||||
self::assertStringContainsString('Short URL for foo not found', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function savesNoRulesIfManageResultIsNull(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
|
||||
$this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
|
||||
)->willReturn($shortUrl);
|
||||
$this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]);
|
||||
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn(null);
|
||||
$this->ruleService->expects($this->never())->method('saveRulesForShortUrl');
|
||||
|
||||
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringNotContainsString('Rules properly saved', $output);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function savesRulesIfManageResultIsAnArray(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
|
||||
$this->shortUrlResolver->expects($this->once())->method('resolveShortUrl')->with(
|
||||
ShortUrlIdentifier::fromShortCodeAndDomain('foo'),
|
||||
)->willReturn($shortUrl);
|
||||
$this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn([]);
|
||||
$this->ruleHandler->expects($this->once())->method('manageRules')->willReturn([]);
|
||||
$this->ruleService->expects($this->once())->method('saveRulesForShortUrl')->with($shortUrl, []);
|
||||
|
||||
$exitCode = $this->commandTester->execute(['shortCode' => 'foo']);
|
||||
$output = $this->commandTester->getDisplay();
|
||||
|
||||
self::assertEquals(ExitCode::EXIT_SUCCESS, $exitCode);
|
||||
self::assertStringContainsString('Rules properly saved', $output);
|
||||
}
|
||||
}
|
252
module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php
Normal file
252
module/CLI/test/RedirectRule/RedirectRuleHandlerTest.php
Normal file
|
@ -0,0 +1,252 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\CLI\RedirectRule;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandler;
|
||||
use Shlinkio\Shlink\CLI\RedirectRule\RedirectRuleHandlerAction;
|
||||
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\ShortUrl\Entity\ShortUrl;
|
||||
use Symfony\Component\Console\Style\StyleInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class RedirectRuleHandlerTest extends TestCase
|
||||
{
|
||||
private RedirectRuleHandler $handler;
|
||||
private StyleInterface & MockObject $io;
|
||||
private ShortUrl $shortUrl;
|
||||
private RedirectCondition $cond1;
|
||||
private RedirectCondition $cond2;
|
||||
private RedirectCondition $cond3;
|
||||
/** @var ShortUrlRedirectRule[] */
|
||||
private array $rules;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->io = $this->createMock(StyleInterface::class);
|
||||
$this->shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
$this->cond1 = RedirectCondition::forLanguage('es-AR');
|
||||
$this->cond2 = RedirectCondition::forQueryParam('foo', 'bar');
|
||||
$this->cond3 = RedirectCondition::forDevice(DeviceType::ANDROID);
|
||||
$this->rules = [
|
||||
new ShortUrlRedirectRule($this->shortUrl, 3, 'https://example.com/one', new ArrayCollection(
|
||||
[$this->cond1],
|
||||
)),
|
||||
new ShortUrlRedirectRule($this->shortUrl, 8, 'https://example.com/two', new ArrayCollection(
|
||||
[$this->cond2, $this->cond3],
|
||||
)),
|
||||
new ShortUrlRedirectRule($this->shortUrl, 5, 'https://example.com/three', new ArrayCollection(
|
||||
[$this->cond1, $this->cond3],
|
||||
)),
|
||||
];
|
||||
|
||||
$this->handler = new RedirectRuleHandler();
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideExitActions')]
|
||||
public function commentIsDisplayedWhenRulesListIsEmpty(
|
||||
RedirectRuleHandlerAction $action,
|
||||
?array $expectedResult,
|
||||
): void {
|
||||
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
|
||||
$this->io->expects($this->once())->method('newLine');
|
||||
$this->io->expects($this->once())->method('text')->with('<comment>// No rules found.</comment>');
|
||||
$this->io->expects($this->never())->method('table');
|
||||
|
||||
$result = $this->handler->manageRules($this->io, $this->shortUrl, []);
|
||||
|
||||
self::assertEquals($expectedResult, $result);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideExitActions')]
|
||||
public function rulesAreDisplayedWhenRulesListIsEmpty(
|
||||
RedirectRuleHandlerAction $action,
|
||||
): void {
|
||||
$comment = fn (string $value) => sprintf('<comment>%s</comment>', $value);
|
||||
|
||||
$this->io->expects($this->once())->method('choice')->willReturn($action->value);
|
||||
$this->io->expects($this->never())->method('newLine');
|
||||
$this->io->expects($this->never())->method('text');
|
||||
$this->io->expects($this->once())->method('table')->with($this->isType('array'), [
|
||||
['1', $comment($this->cond1->toHumanFriendly()), 'https://example.com/one'],
|
||||
[
|
||||
'2',
|
||||
$comment($this->cond2->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()),
|
||||
'https://example.com/two',
|
||||
],
|
||||
[
|
||||
'3',
|
||||
$comment($this->cond1->toHumanFriendly()) . ' AND ' . $comment($this->cond3->toHumanFriendly()),
|
||||
'https://example.com/three',
|
||||
],
|
||||
]);
|
||||
|
||||
$this->handler->manageRules($this->io, $this->shortUrl, $this->rules);
|
||||
}
|
||||
|
||||
public static function provideExitActions(): iterable
|
||||
{
|
||||
yield 'discard' => [RedirectRuleHandlerAction::DISCARD, null];
|
||||
yield 'save' => [RedirectRuleHandlerAction::SAVE, []];
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideDeviceConditions')]
|
||||
/**
|
||||
* @param RedirectCondition[] $expectedConditions
|
||||
*/
|
||||
public function newRulesCanBeAdded(
|
||||
RedirectConditionType $type,
|
||||
array $expectedConditions,
|
||||
bool $continue = false,
|
||||
): void {
|
||||
$this->io->expects($this->any())->method('ask')->willReturnCallback(
|
||||
fn (string $message): string|int => match ($message) {
|
||||
'Rule priority (the lower the value, the higher the priority)' => 2, // Add in between existing rules
|
||||
'Long URL to redirect when the rule matches' => 'https://example.com/new-two',
|
||||
'Language to match?' => 'en-US',
|
||||
'Query param name?' => 'foo',
|
||||
'Query param value?' => 'bar',
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->any())->method('choice')->willReturnCallback(
|
||||
function (string $message) use (&$callIndex, $type): string {
|
||||
$callIndex++;
|
||||
|
||||
if ($message === 'Type of the condition?') {
|
||||
return $type->value;
|
||||
} elseif ($message === 'Device to match?') {
|
||||
return DeviceType::ANDROID->value;
|
||||
}
|
||||
|
||||
// First we select remove action to trigger code branch, then save to finish execution
|
||||
$action = $callIndex === 1 ? RedirectRuleHandlerAction::ADD : RedirectRuleHandlerAction::SAVE;
|
||||
return $action->value;
|
||||
},
|
||||
);
|
||||
|
||||
$continueCallCount = 0;
|
||||
$this->io->method('confirm')->willReturnCallback(function () use (&$continueCallCount, $continue) {
|
||||
$continueCallCount++;
|
||||
return $continueCallCount < 2 && $continue;
|
||||
});
|
||||
|
||||
$result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules);
|
||||
|
||||
self::assertEquals([
|
||||
$this->rules[0],
|
||||
new ShortUrlRedirectRule($this->shortUrl, 2, 'https://example.com/new-two', new ArrayCollection(
|
||||
$expectedConditions,
|
||||
)),
|
||||
$this->rules[1],
|
||||
$this->rules[2],
|
||||
], $result);
|
||||
}
|
||||
|
||||
public static function provideDeviceConditions(): iterable
|
||||
{
|
||||
yield 'device' => [RedirectConditionType::DEVICE, [RedirectCondition::forDevice(DeviceType::ANDROID)]];
|
||||
yield 'language' => [RedirectConditionType::LANGUAGE, [RedirectCondition::forLanguage('en-US')]];
|
||||
yield 'query param' => [RedirectConditionType::QUERY_PARAM, [RedirectCondition::forQueryParam('foo', 'bar')]];
|
||||
yield 'multiple query params' => [
|
||||
RedirectConditionType::QUERY_PARAM,
|
||||
[RedirectCondition::forQueryParam('foo', 'bar'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
true,
|
||||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existingRulesCanBeRemoved(): void
|
||||
{
|
||||
$callIndex = 0;
|
||||
$this->io->expects($this->exactly(3))->method('choice')->willReturnCallback(
|
||||
function (string $message) use (&$callIndex): string {
|
||||
$callIndex++;
|
||||
|
||||
if ($message === 'What rule do you want to delete?') {
|
||||
return '2 - https://example.com/two'; // Second rule to be removed
|
||||
}
|
||||
|
||||
// First we select remove action to trigger code branch, then save to finish execution
|
||||
$action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::SAVE;
|
||||
return $action->value;
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->never())->method('warning');
|
||||
|
||||
$result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules);
|
||||
|
||||
self::assertEquals([$this->rules[0], $this->rules[2]], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsPrintedWhenTryingToRemoveRuleFromEmptyList(): void
|
||||
{
|
||||
$callIndex = 0;
|
||||
$this->io->expects($this->exactly(2))->method('choice')->willReturnCallback(
|
||||
function () use (&$callIndex): string {
|
||||
$callIndex++;
|
||||
$action = $callIndex === 1 ? RedirectRuleHandlerAction::REMOVE : RedirectRuleHandlerAction::DISCARD;
|
||||
return $action->value;
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->once())->method('warning')->with('There are no rules to remove');
|
||||
|
||||
$this->handler->manageRules($this->io, $this->shortUrl, []);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function existingRulesCanBeReArranged(): void
|
||||
{
|
||||
$this->io->expects($this->any())->method('ask')->willReturnCallback(
|
||||
fn (string $message): string|int => match ($message) {
|
||||
'Rule priority (the lower the value, the higher the priority)' => 1,
|
||||
default => '',
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->exactly(3))->method('choice')->willReturnCallback(
|
||||
function (string $message) use (&$callIndex): string {
|
||||
$callIndex++;
|
||||
|
||||
if ($message === 'What rule do you want to re-arrange?') {
|
||||
return '2 - https://example.com/two'; // Second rule to be re-arrange
|
||||
}
|
||||
|
||||
// First we select remove action to trigger code branch, then save to finish execution
|
||||
$action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::SAVE;
|
||||
return $action->value;
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->never())->method('warning');
|
||||
|
||||
$result = $this->handler->manageRules($this->io, $this->shortUrl, $this->rules);
|
||||
|
||||
self::assertEquals([$this->rules[1], $this->rules[0], $this->rules[2]], $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function warningIsPrintedWhenTryingToReArrangeRuleFromEmptyList(): void
|
||||
{
|
||||
$callIndex = 0;
|
||||
$this->io->expects($this->exactly(2))->method('choice')->willReturnCallback(
|
||||
function () use (&$callIndex): string {
|
||||
$callIndex++;
|
||||
$action = $callIndex === 1 ? RedirectRuleHandlerAction::RE_ARRANGE : RedirectRuleHandlerAction::DISCARD;
|
||||
return $action->value;
|
||||
},
|
||||
);
|
||||
$this->io->expects($this->once())->method('warning')->with('There are no rules to re-arrange');
|
||||
|
||||
$this->handler->manageRules($this->io, $this->shortUrl, []);
|
||||
}
|
||||
}
|
|
@ -72,3 +72,20 @@ function select_keys(array $array, array $keys): array
|
|||
ARRAY_FILTER_USE_KEY,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @template T
|
||||
* @template R
|
||||
* @param iterable<T> $collection
|
||||
* @param callable(T $value, string|number $key): R $callback
|
||||
* @return R[]
|
||||
*/
|
||||
function map(iterable $collection, callable $callback): array
|
||||
{
|
||||
$aggregation = [];
|
||||
foreach ($collection as $key => $value) {
|
||||
$aggregation[$key] = $callback($value, $key);
|
||||
}
|
||||
|
||||
return $aggregation;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
|
|||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||
use function Shlinkio\Shlink\Core\splitLocale;
|
||||
use function sprintf;
|
||||
use function strtolower;
|
||||
use function trim;
|
||||
|
||||
|
@ -107,4 +108,17 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
'matchValue' => $this->matchValue,
|
||||
];
|
||||
}
|
||||
|
||||
public function toHumanFriendly(): string
|
||||
{
|
||||
return match ($this->type) {
|
||||
RedirectConditionType::DEVICE => sprintf('device is %s', $this->matchValue),
|
||||
RedirectConditionType::LANGUAGE => sprintf('%s language is accepted', $this->matchValue),
|
||||
RedirectConditionType::QUERY_PARAM => sprintf(
|
||||
'query string contains %s=%s',
|
||||
$this->matchKey,
|
||||
$this->matchValue,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,7 @@ use function Shlinkio\Shlink\Core\ArrayUtils\every;
|
|||
class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @param Collection<RedirectCondition> $conditions
|
||||
* @param Collection<int, RedirectCondition> $conditions
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
|
||||
|
@ -25,6 +25,16 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
|
|||
) {
|
||||
}
|
||||
|
||||
public function withPriority(int $newPriority): self
|
||||
{
|
||||
return new self(
|
||||
$this->shortUrl,
|
||||
$newPriority,
|
||||
$this->longUrl,
|
||||
$this->conditions,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if this condition matches provided request
|
||||
*/
|
||||
|
@ -41,6 +51,16 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
|
|||
$this->conditions->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* @template R
|
||||
* @param callable(RedirectCondition $condition): R $callback
|
||||
* @return R[]
|
||||
*/
|
||||
public function mapConditions(callable $callback): array
|
||||
{
|
||||
return $this->conditions->map($callback(...))->toArray();
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
|
|
@ -6,5 +6,5 @@ enum RedirectConditionType: string
|
|||
{
|
||||
case DEVICE = 'device';
|
||||
case LANGUAGE = 'language';
|
||||
case QUERY_PARAM = 'query';
|
||||
case QUERY_PARAM = 'query-param';
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
|||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
use function array_map;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\map;
|
||||
|
||||
readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface
|
||||
{
|
||||
|
@ -34,23 +35,6 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
|
|||
*/
|
||||
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array
|
||||
{
|
||||
return $this->em->wrapInTransaction(fn () => $this->doSetRulesForShortUrl($shortUrl, $data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrlRedirectRule[]
|
||||
*/
|
||||
private function doSetRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array
|
||||
{
|
||||
// First, delete existing rules for the short URL
|
||||
$oldRules = $this->rulesForShortUrl($shortUrl);
|
||||
foreach ($oldRules as $oldRule) {
|
||||
$oldRule->clearConditions(); // This will trigger the orphan removal of old conditions
|
||||
$this->em->remove($oldRule);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
// Then insert new rules
|
||||
$rules = [];
|
||||
foreach ($data->rules as $index => $rule) {
|
||||
$rule = new ShortUrlRedirectRule(
|
||||
|
@ -64,9 +48,47 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
|
|||
);
|
||||
|
||||
$rules[] = $rule;
|
||||
$this->em->persist($rule);
|
||||
}
|
||||
|
||||
$this->doSetRulesForShortUrl($shortUrl, $rules);
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
*/
|
||||
public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void
|
||||
{
|
||||
$normalizedAndDetachedRules = map($rules, function (ShortUrlRedirectRule $rule, int|string|float $priority) {
|
||||
// Make sure all rules and conditions are detached so that the EM considers them new.
|
||||
$rule->mapConditions(fn (RedirectCondition $cond) => $this->em->detach($cond));
|
||||
$this->em->detach($rule);
|
||||
|
||||
// Normalize priorities so that they are sequential
|
||||
return $rule->withPriority(((int) $priority) + 1);
|
||||
});
|
||||
|
||||
$this->doSetRulesForShortUrl($shortUrl, $normalizedAndDetachedRules);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
*/
|
||||
public function doSetRulesForShortUrl(ShortUrl $shortUrl, array $rules): void
|
||||
{
|
||||
$this->em->wrapInTransaction(function () use ($shortUrl, $rules): void {
|
||||
// First, delete existing rules for the short URL
|
||||
$oldRules = $this->rulesForShortUrl($shortUrl);
|
||||
foreach ($oldRules as $oldRule) {
|
||||
$oldRule->clearConditions(); // This will trigger the orphan removal of old conditions
|
||||
$this->em->remove($oldRule);
|
||||
}
|
||||
$this->em->flush();
|
||||
|
||||
// Then insert new rules
|
||||
foreach ($rules as $rule) {
|
||||
$this->em->persist($rule);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,4 +17,9 @@ interface ShortUrlRedirectRuleServiceInterface
|
|||
* @return ShortUrlRedirectRule[]
|
||||
*/
|
||||
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array;
|
||||
|
||||
/**
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
*/
|
||||
public function saveRulesForShortUrl(ShortUrl $shortUrl, array $rules): void;
|
||||
}
|
||||
|
|
|
@ -6,7 +6,6 @@ namespace Shlinkio\Shlink\Core\ShortUrl\Model;
|
|||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
|
@ -32,18 +31,6 @@ final readonly class ShortUrlIdentifier
|
|||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public static function fromCli(InputInterface $input): self
|
||||
{
|
||||
// Using getArguments and getOptions instead of getArgument(...) and getOption(...) because
|
||||
// the later throw an exception if requested options are not defined
|
||||
/** @var string $shortCode */
|
||||
$shortCode = $input->getArguments()['shortCode'] ?? '';
|
||||
/** @var string|null $domain */
|
||||
$domain = $input->getOptions()['domain'] ?? null;
|
||||
|
||||
return new self($shortCode, $domain);
|
||||
}
|
||||
|
||||
public static function fromShortUrl(ShortUrl $shortUrl): self
|
||||
{
|
||||
$domain = $shortUrl->getDomain();
|
||||
|
|
|
@ -124,6 +124,9 @@ class ShortUrlInputFilter extends InputFilter
|
|||
$this->add($apiKeyInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* @todo Extract to its own validator class
|
||||
*/
|
||||
public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
|
||||
{
|
||||
$emptyModifiers = [
|
||||
|
|
|
@ -9,6 +9,7 @@ use PHPUnit\Framework\Attributes\Test;
|
|||
use PHPUnit\Framework\TestCase;
|
||||
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\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
class ShortUrlRedirectRuleTest extends TestCase
|
||||
|
@ -51,6 +52,39 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||
self::assertEmpty($conditions);
|
||||
}
|
||||
|
||||
#[Test, DataProvider('provideConditionMappingCallbacks')]
|
||||
public function conditionsCanBeMapped(callable $callback, array $expectedResult): void
|
||||
{
|
||||
$conditions = new ArrayCollection(
|
||||
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
);
|
||||
$rule = $this->createRule($conditions);
|
||||
|
||||
$result = $rule->mapConditions($callback);
|
||||
|
||||
self::assertEquals($expectedResult, $result);
|
||||
}
|
||||
|
||||
public static function provideConditionMappingCallbacks(): iterable
|
||||
{
|
||||
yield 'json-serialized conditions' => [fn (RedirectCondition $cond) => $cond->jsonSerialize(), [
|
||||
[
|
||||
'type' => RedirectConditionType::LANGUAGE->value,
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'en-UK',
|
||||
],
|
||||
[
|
||||
'type' => RedirectConditionType::QUERY_PARAM->value,
|
||||
'matchKey' => 'foo',
|
||||
'matchValue' => 'bar',
|
||||
],
|
||||
]];
|
||||
yield 'human-friendly conditions' => [fn (RedirectCondition $cond) => $cond->toHumanFriendly(), [
|
||||
'en-UK language is accepted',
|
||||
'query string contains foo=bar',
|
||||
]];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ArrayCollection<RedirectCondition> $conditions
|
||||
*/
|
||||
|
|
|
@ -132,4 +132,40 @@ class ShortUrlRedirectRuleServiceTest extends TestCase
|
|||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function saveRulesForShortUrlDetachesAllEntitiesAndArrangesPriorities(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
$rules = [
|
||||
new ShortUrlRedirectRule($shortUrl, 8, 'https://example.com', new ArrayCollection([
|
||||
RedirectCondition::forLanguage('es-ES'),
|
||||
RedirectCondition::forDevice(DeviceType::ANDROID),
|
||||
])),
|
||||
new ShortUrlRedirectRule($shortUrl, 3, 'https://example.com', new ArrayCollection([
|
||||
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||
RedirectCondition::forQueryParam('bar', 'foo'),
|
||||
])),
|
||||
new ShortUrlRedirectRule($shortUrl, 15, 'https://example.com', new ArrayCollection([
|
||||
RedirectCondition::forDevice(DeviceType::IOS),
|
||||
])),
|
||||
];
|
||||
|
||||
// Detach will be called 8 times: 3 rules + 5 conditions
|
||||
$this->em->expects($this->exactly(8))->method('detach');
|
||||
$this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback(
|
||||
fn (callable $callback) => $callback(),
|
||||
);
|
||||
|
||||
// Persist will be called for each of the three rules. Their priorities should be consecutive starting at 1
|
||||
$cont = 0;
|
||||
$this->em->expects($this->exactly(3))->method('persist')->with($this->callback(
|
||||
function (ShortUrlRedirectRule $rule) use (&$cont): bool {
|
||||
$cont++;
|
||||
return $rule->jsonSerialize()['priority'] === $cont;
|
||||
},
|
||||
));
|
||||
|
||||
$this->ruleService->saveRulesForShortUrl($shortUrl, $rules);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ class ListRedirectRulesTest extends ApiTestCase
|
|||
'matchValue' => 'en',
|
||||
];
|
||||
private const QUERY_FOO_BAR_CONDITION = [
|
||||
'type' => 'query',
|
||||
'type' => 'query-param',
|
||||
'matchKey' => 'foo',
|
||||
'matchValue' => 'bar',
|
||||
];
|
||||
|
@ -53,7 +53,7 @@ class ListRedirectRulesTest extends ApiTestCase
|
|||
'priority' => 2,
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'query',
|
||||
'type' => 'query-param',
|
||||
'matchKey' => 'hello',
|
||||
'matchValue' => 'world',
|
||||
],
|
||||
|
|
|
@ -19,7 +19,7 @@ class SetRedirectRulesTest extends ApiTestCase
|
|||
'matchValue' => 'en',
|
||||
];
|
||||
private const QUERY_FOO_BAR_CONDITION = [
|
||||
'type' => 'query',
|
||||
'type' => 'query-param',
|
||||
'matchKey' => 'foo',
|
||||
'matchValue' => 'bar',
|
||||
];
|
||||
|
@ -75,7 +75,7 @@ class SetRedirectRulesTest extends ApiTestCase
|
|||
'priority' => 2,
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'query',
|
||||
'type' => 'query-param',
|
||||
'matchKey' => 'hello',
|
||||
'matchValue' => 'world',
|
||||
],
|
||||
|
|
Loading…
Add table
Reference in a new issue