Create endpoint to set redirect rules for a short URL

This commit is contained in:
Alejandro Celaya
2024-02-28 20:24:16 +01:00
parent a7cde9364a
commit d9286765e1
13 changed files with 397 additions and 9 deletions

View File

@@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
);
}
public function clearConditions(): void
{
$this->conditions->clear();
}
public function jsonSerialize(): array
{
return [

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\RedirectRule\Model;
use Shlinkio\Shlink\Core\Exception\ValidationException;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
readonly class RedirectRulesData
{
private function __construct(public array $rules)
{
}
public static function fromRawData(array $rawData): self
{
$inputFilter = RedirectRulesInputFilter::initialize($rawData);
if (! $inputFilter->isValid()) {
throw ValidationException::fromInputFilter($inputFilter);
}
return new self($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES));
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Core\RedirectRule\Model\Validation;
use Laminas\InputFilter\CollectionInputFilter;
use Laminas\InputFilter\InputFilter;
use Laminas\Validator\Callback;
use Laminas\Validator\InArray;
use Shlinkio\Shlink\Common\Validation\InputFactory;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
use Shlinkio\Shlink\Core\ShortUrl\Model\Validation\ShortUrlInputFilter;
use function Shlinkio\Shlink\Core\ArrayUtils\contains;
use function Shlinkio\Shlink\Core\enumValues;
class RedirectRulesInputFilter extends InputFilter
{
public const REDIRECT_RULES = 'redirectRules';
public const RULE_PRIORITY = 'priority';
public const RULE_LONG_URL = 'longUrl';
public const RULE_CONDITIONS = 'conditions';
public const CONDITION_TYPE = 'type';
public const CONDITION_MATCH_VALUE = 'matchValue';
public const CONDITION_MATCH_KEY = 'matchKey';
private function __construct()
{
}
public static function initialize(array $rawData): self
{
$redirectRulesInputFilter = new CollectionInputFilter();
$redirectRulesInputFilter->setInputFilter(self::createRedirectRuleInputFilter());
$instance = new self();
$instance->add($redirectRulesInputFilter, self::REDIRECT_RULES);
$instance->setData($rawData);
return $instance;
}
private static function createRedirectRuleInputFilter(): InputFilter
{
$redirectRuleInputFilter = new InputFilter();
$redirectRuleInputFilter->add(InputFactory::numeric(self::RULE_PRIORITY, required: true));
$longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true);
$longUrl->setValidatorChain(ShortUrlInputFilter::longUrlValidators());
$redirectRuleInputFilter->add($longUrl);
$conditionsInputFilter = new CollectionInputFilter();
$conditionsInputFilter->setInputFilter(self::createRedirectConditionInputFilter())
->setIsRequired(true);
$redirectRuleInputFilter->add($conditionsInputFilter, self::RULE_CONDITIONS);
return $redirectRuleInputFilter;
}
private static function createRedirectConditionInputFilter(): InputFilter
{
$redirectConditionInputFilter = new InputFilter();
$type = InputFactory::basic(self::CONDITION_TYPE, required: true);
$type->getValidatorChain()->attach(new InArray([
'haystack' => enumValues(RedirectConditionType::class),
'strict' => InArray::COMPARE_STRICT,
]));
$redirectConditionInputFilter->add($type);
$value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true);
$value->getValidatorChain()->attach(new Callback(function (string $value, array $context) {
if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) {
return contains($value, enumValues(DeviceType::class));
}
return true;
}));
$redirectConditionInputFilter->add($value);
$redirectConditionInputFilter->add(
InputFactory::basic(self::CONDITION_MATCH_KEY, required: true)->setAllowEmpty(true),
);
return $redirectConditionInputFilter;
}
}

View File

@@ -2,10 +2,18 @@
namespace Shlinkio\Shlink\Core\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
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\Model\RedirectRulesData;
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function array_map;
readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface
{
public function __construct(private EntityManagerInterface $em)
@@ -22,4 +30,52 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
orderBy: ['priority' => 'ASC'],
);
}
/**
* @return ShortUrlRedirectRule[]
*/
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array
{
return $this->em->wrapInTransaction(function () use ($shortUrl, $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 $rule) {
$rule = new ShortUrlRedirectRule(
shortUrl: $shortUrl,
priority: $rule[RedirectRulesInputFilter::RULE_PRIORITY],
longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL],
conditions: new ArrayCollection(array_map(
fn (array $conditionData) => $this->createCondition($conditionData),
$rule[RedirectRulesInputFilter::RULE_CONDITIONS],
)),
);
$rules[] = $rule;
$this->em->persist($rule);
}
return $rules;
});
}
private function createCondition(array $rawConditionData): RedirectCondition
{
$type = RedirectConditionType::from($rawConditionData[RedirectRulesInputFilter::CONDITION_TYPE]);
$value = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE];
$key = $rawConditionData[RedirectRulesInputFilter::CONDITION_MATCH_KEY];
return match ($type) {
RedirectConditionType::DEVICE => RedirectCondition::forDevice(DeviceType::from($value)),
RedirectConditionType::LANGUAGE => RedirectCondition::forLanguage($value),
RedirectConditionType::QUERY_PARAM => RedirectCondition::forQueryParam($key, $value),
};
}
}

View File

@@ -3,6 +3,7 @@
namespace Shlinkio\Shlink\Core\RedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
interface ShortUrlRedirectRuleServiceInterface
@@ -11,4 +12,9 @@ interface ShortUrlRedirectRuleServiceInterface
* @return ShortUrlRedirectRule[]
*/
public function rulesForShortUrl(ShortUrl $shortUrl): array;
/**
* @return ShortUrlRedirectRule[]
*/
public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array;
}

View File

@@ -93,7 +93,7 @@ class ShortUrlInputFilter extends InputFilter
private function initializeForEdition(bool $requireLongUrl = false): void
{
$longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators());
$longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl));
$this->add($longUrlInput);
$validSince = InputFactory::basic(self::VALID_SINCE);
@@ -124,7 +124,7 @@ class ShortUrlInputFilter extends InputFilter
$this->add($apiKeyInput);
}
private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
{
$emptyModifiers = [
Validator\NotEmpty::OBJECT,