mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 12:11:19 +03:00
Implement command to manage redirect rules for a short URL
This commit is contained in:
parent
c36e43e249
commit
d8ede3263f
15 changed files with 365 additions and 28 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;
|
||||
use Shlinkio\Shlink\Core\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlStringifier;
|
||||
use Shlinkio\Shlink\Core\Tag\TagService;
|
||||
|
@ -66,6 +67,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 +120,11 @@ return [
|
|||
Command\Domain\DomainRedirectsCommand::class => [DomainService::class],
|
||||
Command\Domain\GetDomainVisitsCommand::class => [Visit\VisitsStatsHelper::class, ShortUrlStringifier::class],
|
||||
|
||||
Command\RedirectRule\ManageRedirectRulesCommand::class => [
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
RedirectRule\ShortUrlRedirectRuleService::class,
|
||||
],
|
||||
|
||||
Command\Db\CreateDatabaseCommand::class => [
|
||||
LockFactory::class,
|
||||
Util\ProcessRunner::class,
|
||||
|
|
|
@ -0,0 +1,272 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\CLI\Command\RedirectRule;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
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;
|
||||
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_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
|
||||
{
|
||||
public const NAME = 'short-url:manage-rules';
|
||||
|
||||
public function __construct(
|
||||
protected readonly ShortUrlResolverInterface $shortUrlResolver,
|
||||
protected readonly ShortUrlRedirectRuleServiceInterface $ruleService,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
protected function configure(): void
|
||||
{
|
||||
$this
|
||||
->setName(self::NAME)
|
||||
->setDescription('Set redirect rules for a short URL')
|
||||
->addArgument('shortCode', InputArgument::REQUIRED, 'The short code which rules we want to set.')
|
||||
->addOption('domain', 'd', InputOption::VALUE_REQUIRED, 'The domain for the short code.');
|
||||
}
|
||||
|
||||
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||
{
|
||||
$io = new SymfonyStyle($input, $output);
|
||||
$identifier = ShortUrlIdentifier::fromCli($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->processRules($shortUrl, $io, $this->ruleService->rulesForShortUrl($shortUrl));
|
||||
if ($rulesToSave !== null) {
|
||||
$this->ruleService->saveRulesForShortUrl($shortUrl, $rulesToSave);
|
||||
}
|
||||
|
||||
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('<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 = $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));
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
@ -41,6 +41,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';
|
||||
}
|
||||
|
|
|
@ -34,23 +34,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 +47,30 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
|
|||
);
|
||||
|
||||
$rules[] = $rule;
|
||||
$this->em->persist($rule);
|
||||
}
|
||||
|
||||
$this->saveRulesForShortUrl($shortUrl, $rules);
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ShortUrlRedirectRule[] $rules
|
||||
*/
|
||||
public function saveRulesForShortUrl(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;
|
||||
}
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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