mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-25 06:11:11 +03:00
Create endpoint to set redirect rules for a short URL
This commit is contained in:
parent
a7cde9364a
commit
d9286765e1
13 changed files with 397 additions and 9 deletions
|
@ -5,7 +5,7 @@ declare(strict_types=1);
|
|||
return [
|
||||
|
||||
'mercure' => [
|
||||
'public_hub_url' => 'http://localhost:8001',
|
||||
'public_hub_url' => 'http://localhost:8002',
|
||||
'internal_hub_url' => 'http://shlink_mercure_proxy',
|
||||
'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error',
|
||||
],
|
||||
|
|
|
@ -46,6 +46,7 @@ return (static function (): array {
|
|||
|
||||
//Redirect rules
|
||||
Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
|
||||
Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
|
||||
|
||||
// Short URLs
|
||||
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
|
||||
|
|
|
@ -131,7 +131,7 @@ services:
|
|||
container_name: shlink_mercure_proxy
|
||||
image: nginx:1.25-alpine
|
||||
ports:
|
||||
- "8001:80"
|
||||
- "8002:80"
|
||||
volumes:
|
||||
- ./:/home/shlink/www
|
||||
- ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf
|
||||
|
|
|
@ -15,12 +15,8 @@
|
|||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name", "type", "matchKey", "matchValue"],
|
||||
"required": ["type", "matchKey", "matchValue"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Unique condition name"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["device", "language", "query"],
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
|
|||
);
|
||||
}
|
||||
|
||||
public function clearConditions(): void
|
||||
{
|
||||
$this->conditions->clear();
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
|
25
module/Core/src/RedirectRule/Model/RedirectRulesData.php
Normal file
25
module/Core/src/RedirectRule/Model/RedirectRulesData.php
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -48,6 +48,7 @@ return [
|
|||
Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class,
|
||||
Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class,
|
||||
Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class,
|
||||
Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class,
|
||||
|
||||
ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class,
|
||||
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
|
||||
|
@ -109,6 +110,10 @@ return [
|
|||
ShortUrl\ShortUrlResolver::class,
|
||||
RedirectRule\ShortUrlRedirectRuleService::class,
|
||||
],
|
||||
Action\RedirectRule\SetRedirectRulesAction::class => [
|
||||
ShortUrl\ShortUrlResolver::class,
|
||||
RedirectRule\ShortUrlRedirectRuleService::class,
|
||||
],
|
||||
|
||||
Middleware\CrossDomainMiddleware::class => ['config.cors'],
|
||||
Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'],
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue