mirror of
https://github.com/shlinkio/shlink.git
synced 2025-03-14 04:00:57 +03:00
Merge pull request #2033 from acelaya-forks/feature/redirect-rule-creation
Create endpoint to set redirect rules for a short URL
This commit is contained in:
commit
e7796cc917
20 changed files with 718 additions and 14 deletions
|
@ -19,6 +19,7 @@
|
|||
"ext-pdo": "*",
|
||||
"akrabat/ip-address-middleware": "^2.1",
|
||||
"cakephp/chronos": "^3.0.2",
|
||||
"doctrine/dbal": "^4.0",
|
||||
"doctrine/migrations": "^3.6",
|
||||
"doctrine/orm": "^3.0",
|
||||
"endroid/qr-code": "^5.0",
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ use Psr\Http\Message\ServerRequestInterface;
|
|||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
||||
|
||||
use function Shlinkio\Shlink\Core\acceptLanguageToLocales;
|
||||
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||
|
@ -39,6 +40,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable
|
|||
return new self(RedirectConditionType::DEVICE, $device->value);
|
||||
}
|
||||
|
||||
public static function fromRawData(array $rawData): self
|
||||
{
|
||||
$type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]);
|
||||
$value = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE];
|
||||
$key = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_KEY] ?? null;
|
||||
|
||||
return new self($type, $value, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells if this condition matches provided request
|
||||
*/
|
||||
|
|
|
@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
|
|||
);
|
||||
}
|
||||
|
||||
public function clearConditions(): void
|
||||
{
|
||||
$this->conditions->clear();
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return [
|
||||
|
|
34
module/Core/src/RedirectRule/Model/RedirectRulesData.php
Normal file
34
module/Core/src/RedirectRule/Model/RedirectRulesData.php
Normal file
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Shlinkio\Shlink\Core\RedirectRule\Model;
|
||||
|
||||
use Laminas\InputFilter\Exception\InvalidArgumentException;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter;
|
||||
|
||||
use function array_values;
|
||||
|
||||
readonly class RedirectRulesData
|
||||
{
|
||||
private function __construct(public array $rules)
|
||||
{
|
||||
}
|
||||
|
||||
public static function fromRawData(array $rawData): self
|
||||
{
|
||||
try {
|
||||
$inputFilter = RedirectRulesInputFilter::initialize($rawData);
|
||||
if (! $inputFilter->isValid()) {
|
||||
throw ValidationException::fromInputFilter($inputFilter);
|
||||
}
|
||||
|
||||
return new self(array_values($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES)));
|
||||
} catch (InvalidArgumentException) {
|
||||
throw ValidationException::fromArray(
|
||||
[RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,89 @@
|
|||
<?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_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();
|
||||
|
||||
$longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true);
|
||||
$longUrl->getValidatorChain()->merge(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,16 @@
|
|||
|
||||
namespace Shlinkio\Shlink\Core\RedirectRule;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||
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 +28,45 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic
|
|||
orderBy: ['priority' => 'ASC'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ShortUrlRedirectRule[]
|
||||
*/
|
||||
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(
|
||||
shortUrl: $shortUrl,
|
||||
priority: $index + 1,
|
||||
longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL],
|
||||
conditions: new ArrayCollection(array_map(
|
||||
RedirectCondition::fromRawData(...),
|
||||
$rule[RedirectRulesInputFilter::RULE_CONDITIONS],
|
||||
)),
|
||||
);
|
||||
|
||||
$rules[] = $rule;
|
||||
$this->em->persist($rule);
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -20,7 +20,7 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||
->withHeader('Accept-Language', 'en-UK')
|
||||
->withQueryParams(['foo' => 'bar']);
|
||||
|
||||
$result = $this->createRule($conditions)->matchesRequest($request);
|
||||
$result = $this->createRule(new ArrayCollection($conditions))->matchesRequest($request);
|
||||
|
||||
self::assertEquals($expectedResult, $result);
|
||||
}
|
||||
|
@ -38,12 +38,25 @@ class ShortUrlRedirectRuleTest extends TestCase
|
|||
];
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function conditionsCanBeCleared(): void
|
||||
{
|
||||
$conditions = new ArrayCollection(
|
||||
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||
);
|
||||
$rule = $this->createRule($conditions);
|
||||
|
||||
self::assertNotEmpty($conditions);
|
||||
$rule->clearConditions();
|
||||
self::assertEmpty($conditions);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param RedirectCondition[] $conditions
|
||||
* @param ArrayCollection<RedirectCondition> $conditions
|
||||
*/
|
||||
private function createRule(array $conditions): ShortUrlRedirectRule
|
||||
private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://s.test');
|
||||
return new ShortUrlRedirectRule($shortUrl, 1, '', new ArrayCollection($conditions));
|
||||
return new ShortUrlRedirectRule($shortUrl, 1, '', $conditions);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Core\RedirectRule\Model;
|
||||
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Shlinkio\Shlink\Core\Exception\ValidationException;
|
||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData;
|
||||
|
||||
class RedirectRulesDataTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
#[TestWith([['redirectRules' => ['foo']]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 34,
|
||||
],
|
||||
]]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'invalid',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'device',
|
||||
'matchValue' => 'invalid-device',
|
||||
'matchKey' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
#[TestWith([['redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'https://example.com',
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'language',
|
||||
],
|
||||
],
|
||||
],
|
||||
]]])]
|
||||
public function throwsWhenProvidedDataIsInvalid(array $invalidData): void
|
||||
{
|
||||
$this->expectException(ValidationException::class);
|
||||
RedirectRulesData::fromRawData($invalidData);
|
||||
}
|
||||
}
|
|
@ -11,6 +11,9 @@ use PHPUnit\Framework\TestCase;
|
|||
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\RedirectRule\ShortUrlRedirectRuleService;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
|
||||
|
@ -26,7 +29,7 @@ class ShortUrlRedirectRuleServiceTest extends TestCase
|
|||
}
|
||||
|
||||
#[Test]
|
||||
public function delegatesToRepository(): void
|
||||
public function rulesForShortUrlDelegatesToRepository(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://shlink.io');
|
||||
$rules = [
|
||||
|
@ -52,4 +55,81 @@ class ShortUrlRedirectRuleServiceTest extends TestCase
|
|||
|
||||
self::assertSame($rules, $result);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function setRulesForShortUrlParsesProvidedData(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
$data = RedirectRulesData::fromRawData([
|
||||
RedirectRulesInputFilter::REDIRECT_RULES => [
|
||||
[
|
||||
RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/first',
|
||||
RedirectRulesInputFilter::RULE_CONDITIONS => [
|
||||
[
|
||||
RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value,
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_KEY => null,
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::ANDROID->value,
|
||||
],
|
||||
[
|
||||
RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::QUERY_PARAM->value,
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_KEY => 'foo',
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_VALUE => 'bar',
|
||||
],
|
||||
],
|
||||
],
|
||||
[
|
||||
RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/second',
|
||||
RedirectRulesInputFilter::RULE_CONDITIONS => [
|
||||
[
|
||||
RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value,
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_KEY => null,
|
||||
RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::IOS->value,
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback(
|
||||
fn (callable $callback) => $callback(),
|
||||
);
|
||||
$this->em->expects($this->exactly(2))->method('persist');
|
||||
$this->em->expects($this->never())->method('remove');
|
||||
|
||||
$result = $this->ruleService->setRulesForShortUrl($shortUrl, $data);
|
||||
|
||||
self::assertCount(2, $result);
|
||||
self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]);
|
||||
self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function setRulesForShortUrlRemovesOldRules(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
$data = RedirectRulesData::fromRawData([
|
||||
RedirectRulesInputFilter::REDIRECT_RULES => [],
|
||||
]);
|
||||
|
||||
$repo = $this->createMock(EntityRepository::class);
|
||||
$repo->expects($this->once())->method('findBy')->with(
|
||||
['shortUrl' => $shortUrl],
|
||||
['priority' => 'ASC'],
|
||||
)->willReturn([
|
||||
new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com'),
|
||||
new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com'),
|
||||
]);
|
||||
$this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn(
|
||||
$repo,
|
||||
);
|
||||
$this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback(
|
||||
fn (callable $callback) => $callback(),
|
||||
);
|
||||
$this->em->expects($this->never())->method('persist');
|
||||
$this->em->expects($this->exactly(2))->method('remove');
|
||||
|
||||
$result = $this->ruleService->setRulesForShortUrl($shortUrl, $data);
|
||||
|
||||
self::assertCount(0, $result);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
98
module/Rest/test-api/Action/SetRedirectRulesTest.php
Normal file
98
module/Rest/test-api/Action/SetRedirectRulesTest.php
Normal file
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioApiTest\Shlink\Rest\Action;
|
||||
|
||||
use GuzzleHttp\RequestOptions;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\Attributes\TestWith;
|
||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||
|
||||
use function sprintf;
|
||||
|
||||
class SetRedirectRulesTest extends ApiTestCase
|
||||
{
|
||||
private const LANGUAGE_EN_CONDITION = [
|
||||
'type' => 'language',
|
||||
'matchKey' => null,
|
||||
'matchValue' => 'en',
|
||||
];
|
||||
private const QUERY_FOO_BAR_CONDITION = [
|
||||
'type' => 'query',
|
||||
'matchKey' => 'foo',
|
||||
'matchValue' => 'bar',
|
||||
];
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedWhenInvalidUrlProvided(): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules');
|
||||
$payload = $this->getJsonResponsePayload($response);
|
||||
|
||||
self::assertEquals(404, $response->getStatusCode());
|
||||
self::assertEquals(404, $payload['status']);
|
||||
self::assertEquals('invalid', $payload['shortCode']);
|
||||
self::assertEquals('No URL found with short code "invalid"', $payload['detail']);
|
||||
self::assertEquals('Short URL not found', $payload['title']);
|
||||
self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function errorIsReturnedWhenInvalidDataProvided(): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [
|
||||
RequestOptions::JSON => [
|
||||
'redirectRules' => [
|
||||
[
|
||||
'longUrl' => 'invalid',
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
$payload = $this->getJsonResponsePayload($response);
|
||||
|
||||
self::assertEquals(400, $response->getStatusCode());
|
||||
self::assertEquals(400, $payload['status']);
|
||||
self::assertEquals('Provided data is not valid', $payload['detail']);
|
||||
self::assertEquals('Invalid data', $payload['title']);
|
||||
self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
#[TestWith(['def456', []])]
|
||||
#[TestWith(['abc123', [
|
||||
[
|
||||
'longUrl' => 'https://example.com/english-and-foo-query',
|
||||
'priority' => 1,
|
||||
'conditions' => [
|
||||
self::LANGUAGE_EN_CONDITION,
|
||||
self::QUERY_FOO_BAR_CONDITION,
|
||||
],
|
||||
],
|
||||
[
|
||||
'longUrl' => 'https://example.com/multiple-query-params',
|
||||
'priority' => 2,
|
||||
'conditions' => [
|
||||
[
|
||||
'type' => 'query',
|
||||
'matchKey' => 'hello',
|
||||
'matchValue' => 'world',
|
||||
],
|
||||
self::QUERY_FOO_BAR_CONDITION,
|
||||
],
|
||||
],
|
||||
]])]
|
||||
public function setsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void
|
||||
{
|
||||
$response = $this->callApiWithKey(self::METHOD_POST, sprintf('/short-urls/%s/redirect-rules', $shortCode), [
|
||||
RequestOptions::JSON => [
|
||||
'redirectRules' => $expectedRules,
|
||||
],
|
||||
]);
|
||||
$payload = $this->getJsonResponsePayload($response);
|
||||
|
||||
self::assertEquals(200, $response->getStatusCode());
|
||||
self::assertEquals($expectedRules, $payload['redirectRules']);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ShlinkioTest\Shlink\Rest\Action\RedirectRule;
|
||||
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Laminas\Diactoros\Response\JsonResponse;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
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\ShortUrlRedirectRuleServiceInterface;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||
use Shlinkio\Shlink\Core\ShortUrl\ShortUrlResolverInterface;
|
||||
use Shlinkio\Shlink\Rest\Action\RedirectRule\SetRedirectRulesAction;
|
||||
use Shlinkio\Shlink\Rest\Entity\ApiKey;
|
||||
|
||||
class SetRedirectRulesActionTest extends TestCase
|
||||
{
|
||||
private ShortUrlResolverInterface & MockObject $urlResolver;
|
||||
private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService;
|
||||
private SetRedirectRulesAction $action;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
|
||||
$this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class);
|
||||
|
||||
$this->action = new SetRedirectRulesAction($this->urlResolver, $this->ruleService);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function requestIsHandledAndRulesAreReturned(): void
|
||||
{
|
||||
$shortUrl = ShortUrl::withLongUrl('https://example.com');
|
||||
$request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create());
|
||||
$conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')];
|
||||
$redirectRules = [
|
||||
new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)),
|
||||
];
|
||||
|
||||
$this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl);
|
||||
$this->ruleService->expects($this->once())->method('setRulesForShortUrl')->willReturn($redirectRules);
|
||||
|
||||
/** @var JsonResponse $response */
|
||||
$response = $this->action->handle($request);
|
||||
$payload = $response->getPayload();
|
||||
|
||||
self::assertEquals([
|
||||
'defaultLongUrl' => $shortUrl->getLongUrl(),
|
||||
'redirectRules' => $redirectRules,
|
||||
], $payload);
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue