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

@ -5,7 +5,7 @@ declare(strict_types=1);
return [ return [
'mercure' => [ 'mercure' => [
'public_hub_url' => 'http://localhost:8001', 'public_hub_url' => 'http://localhost:8002',
'internal_hub_url' => 'http://shlink_mercure_proxy', 'internal_hub_url' => 'http://shlink_mercure_proxy',
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
], ],

View file

@ -46,6 +46,7 @@ return (static function (): array {
//Redirect rules //Redirect rules
Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
// Short URLs // Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([ Action\ShortUrl\CreateShortUrlAction::getRouteDef([

View file

@ -131,7 +131,7 @@ services:
container_name: shlink_mercure_proxy container_name: shlink_mercure_proxy
image: nginx:1.25-alpine image: nginx:1.25-alpine
ports: ports:
- "8001:80" - "8002:80"
volumes: volumes:
- ./:/home/shlink/www - ./:/home/shlink/www
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf

View file

@ -15,12 +15,8 @@
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "type": "object",
"required": ["name", "type", "matchKey", "matchValue"], "required": ["type", "matchKey", "matchValue"],
"properties": { "properties": {
"name": {
"type": "string",
"description": "Unique condition name"
},
"type": { "type": {
"type": "string", "type": "string",
"enum": ["device", "language", "query"], "enum": ["device", "language", "query"],

View file

@ -137,5 +137,164 @@
} }
} }
} }
},
"post": {
"operationId": "setShortUrlRedirectRules",
"tags": [
"Redirect rules"
],
"summary": "Set short URL redirect rules",
"description": "Overwrites redirect rules for a short URL with the ones provided here.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [
{
"ApiKey": []
}
],
"requestBody": {
"description": "Request body.",
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrlRedirectRule.json"
}
}
}
}
}
}
},
"responses": {
"200": {
"description": "The list of rules",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": ["defaultLongUrl", "redirectRules"],
"properties": {
"defaultLongUrl": {
"type": "string"
},
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrlRedirectRule.json"
}
}
}
},
"example": {
"defaultLongUrl": "https://example.com",
"redirectRules": [
{
"longUrl": "https://example.com/android-en-us",
"priority": 1,
"conditions": [
{
"type": "device",
"matchValue": "android",
"matchKey": null
},
{
"type": "language",
"matchValue": "en-US",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/fr",
"priority": 2,
"conditions": [
{
"type": "language",
"matchValue": "fr",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/query-foo-bar-hello-world",
"priority": 3,
"conditions": [
{
"type": "query",
"matchKey": "foo",
"matchValue": "bar"
},
{
"type": "query",
"matchKey": "hello",
"matchValue": "world"
}
]
}
]
}
}
}
},
"404": {
"description": "No URL was found for provided short code.",
"content": {
"application/problem+json": {
"schema": {
"allOf": [
{
"$ref": "../definitions/Error.json"
},
{
"type": "object",
"required": ["shortCode"],
"properties": {
"shortCode": {
"type": "string",
"description": "The short code with which we tried to find the short URL"
},
"domain": {
"type": "string",
"description": "The domain with which we tried to find the short URL"
}
}
}
]
},
"examples": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
}
}
}
}
},
"default": {
"description": "Unexpected error.",
"content": {
"application/problem+json": {
"schema": {
"$ref": "../definitions/Error.json"
}
}
}
}
}
} }
} }

View file

@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
); );
} }
public function clearConditions(): void
{
$this->conditions->clear();
}
public function jsonSerialize(): array public function jsonSerialize(): array
{ {
return [ 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; namespace Shlinkio\Shlink\Core\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface; 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\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 Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function array_map;
readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface
{ {
public function __construct(private EntityManagerInterface $em) public function __construct(private EntityManagerInterface $em)
@ -22,4 +30,52 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
orderBy: ['priority' => 'ASC'], 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; namespace Shlinkio\Shlink\Core\RedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
interface ShortUrlRedirectRuleServiceInterface interface ShortUrlRedirectRuleServiceInterface
@ -11,4 +12,9 @@ interface ShortUrlRedirectRuleServiceInterface
* @return ShortUrlRedirectRule[] * @return ShortUrlRedirectRule[]
*/ */
public function rulesForShortUrl(ShortUrl $shortUrl): array; 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 private function initializeForEdition(bool $requireLongUrl = false): void
{ {
$longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl);
$longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); $longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl));
$this->add($longUrlInput); $this->add($longUrlInput);
$validSince = InputFactory::basic(self::VALID_SINCE); $validSince = InputFactory::basic(self::VALID_SINCE);
@ -124,7 +124,7 @@ class ShortUrlInputFilter extends InputFilter
$this->add($apiKeyInput); $this->add($apiKeyInput);
} }
private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain
{ {
$emptyModifiers = [ $emptyModifiers = [
Validator\NotEmpty::OBJECT, Validator\NotEmpty::OBJECT,

View file

@ -48,6 +48,7 @@ return [
Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class,
Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class,
Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class,
Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class,
ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class,
Middleware\BodyParserMiddleware::class => InvokableFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class,
@ -109,6 +110,10 @@ return [
ShortUrl\ShortUrlResolver::class, ShortUrl\ShortUrlResolver::class,
RedirectRule\ShortUrlRedirectRuleService::class, RedirectRule\ShortUrlRedirectRuleService::class,
], ],
Action\RedirectRule\SetRedirectRulesAction::class => [
ShortUrl\ShortUrlResolver::class,
RedirectRule\ShortUrlRedirectRuleService::class,
],
Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\CrossDomainMiddleware::class => ['config.cors'],
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink\Rest\Action\RedirectRule;
use Laminas\Diactoros\Response\JsonResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
use Shlinkio\Shlink\Rest\Action\AbstractRestAction;
use Shlinkio\Shlink\Rest\Middleware\AuthenticationMiddleware;
class SetRedirectRulesAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-urls/{shortCode}/redirect-rules';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_POST, self::METHOD_PATCH];
public function __construct(
private readonly ShortUrlResolverInterface $urlResolver,
private readonly ShortUrlRedirectRuleServiceInterface $ruleService,
) {
}
public function handle(ServerRequestInterface $request): ResponseInterface
{
$shortUrl = $this->urlResolver->resolveShortUrl(
ShortUrlIdentifier::fromApiRequest($request),
AuthenticationMiddleware::apiKeyFromRequest($request),
);
$data = RedirectRulesData::fromRawData((array) $request->getParsedBody());
$result = $this->ruleService->setRulesForShortUrl($shortUrl, $data);
return new JsonResponse([
'defaultLongUrl' => $shortUrl->getLongUrl(),
'redirectRules' => $result,
]);
}
}