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:
Alejandro Celaya 2024-02-29 20:23:42 +01:00 committed by GitHub
commit e7796cc917
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 718 additions and 14 deletions

View file

@ -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",

View file

@ -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',
],

View file

@ -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([

View file

@ -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

View file

@ -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"],

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

@ -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
*/

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,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],
);
}
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

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,

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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'],

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,
]);
}
}

View 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']);
}
}

View file

@ -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);
}
}