Merge pull request #2031 from acelaya-forks/feature/redirect-rules-api

Create endpoint to list redirect rules for a specific short URL
This commit is contained in:
Alejandro Celaya 2024-02-28 09:17:05 +01:00 committed by GitHub
commit 23c07c4e82
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 585 additions and 133 deletions

View file

@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2016-2023 Alejandro Celaya
Copyright (c) 2016-2024 Alejandro Celaya
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -17,7 +17,6 @@ use Shlinkio\Shlink\Rest\Middleware\Mercure\NotConfiguredMercureErrorHandler;
use function sprintf;
return (static function (): array {
$contentNegotiationMiddleware = Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class;
$dropDomainMiddleware = Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class;
$overrideDomainMiddleware = Middleware\ShortUrl\OverrideDomainMiddleware::class;
@ -32,9 +31,10 @@ return (static function (): array {
...ConfigProvider::applyRoutesPrefix([
Action\HealthAction::getRouteDef(),
// Visits and rules routes must go first, as they have a more specific path, otherwise, when
// multi-segment slugs are enabled, routes with a less-specific path might match first
// Visits.
// These routes must go first, as they have a more specific path, otherwise, when multi-segment slugs
// are enabled, routes with a less-specific path might match first
Action\Visit\ShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\ShortUrl\DeleteShortUrlVisitsAction::getRouteDef([$dropDomainMiddleware]),
Action\Visit\TagVisitsAction::getRouteDef(),
@ -44,15 +44,17 @@ return (static function (): array {
Action\Visit\DeleteOrphanVisitsAction::getRouteDef(),
Action\Visit\NonOrphanVisitsAction::getRouteDef(),
//Redirect rules
Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]),
// Short URLs
Action\ShortUrl\CreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
$dropDomainMiddleware,
$overrideDomainMiddleware,
Middleware\ShortUrl\DefaultShortCodesLengthMiddleware::class,
]),
Action\ShortUrl\SingleStepCreateShortUrlAction::getRouteDef([
$contentNegotiationMiddleware,
Middleware\ShortUrl\CreateShortUrlContentNegotiationMiddleware::class,
$overrideDomainMiddleware,
]),
Action\ShortUrl\EditShortUrlAction::getRouteDef([$dropDomainMiddleware]),

View file

@ -0,0 +1,39 @@
{
"type": "object",
"required": ["priority", "longUrl", "conditions"],
"properties": {
"longUrl": {
"description": "Long URL to redirect to when this condition matches",
"type": "string"
},
"priority": {
"description": "Order in which attempting to match the rule. Lower goes first",
"type": "number"
},
"conditions": {
"description": "List of conditions that need to match in order to consider this rule matches",
"type": "array",
"items": {
"type": "object",
"required": ["name", "type", "matchKey", "matchValue"],
"properties": {
"name": {
"type": "string",
"description": "Unique condition name"
},
"type": {
"type": "string",
"enum": ["device", "language", "query"],
"description": "The type of the condition, which will condition the logic used to match it"
},
"matchKey": {
"type": ["string", "null"]
},
"matchValue": {
"type": "string"
}
}
}
}
}
}

View file

@ -1,9 +0,0 @@
{
"value": {
"title": "Invalid data",
"type": "INVALID_ARGUMENT",
"detail": "Provided data is not valid",
"status": 400,
"invalidElements": ["maxVisits", "validSince"]
}
}

View file

@ -1,9 +0,0 @@
{
"value": {
"detail": "No URL found with short code \"abc123\"",
"title": "Short URL not found",
"type": "INVALID_SHORTCODE",
"status": 404,
"shortCode": "abc123"
}
}

View file

@ -1,9 +0,0 @@
{
"value": {
"detail": "Tag with name \"foo\" could not be found",
"title": "Tag not found",
"type": "TAG_NOT_FOUND",
"status": 404,
"tag": "foo"
}
}

View file

@ -383,10 +383,10 @@
]
},
"examples": {
"Invalid arguments with API v3 and newer": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args-v3.json"
},
"Non-unique slug with API v3 and newer": {
"Non-unique slug": {
"value": {
"title": "Invalid custom slug",
"type": "https://shlink.io/api/error/non-unique-slug",
@ -394,18 +394,6 @@
"status": 400,
"customSlug": "my-slug"
}
},
"Invalid arguments previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
},
"Non-unique slug previous to API v3": {
"value": {
"title": "Invalid custom slug",
"type": "INVALID_SLUG",
"detail": "Provided slug \"my-slug\" is already in use.",
"status": 400,
"customSlug": "my-slug"
}
}
}
}

View file

@ -81,11 +81,8 @@
]
},
"examples": {
"API v3 and newer": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
@ -202,11 +199,8 @@
]
},
"examples": {
"API v3 and newer": {
"Invalid arguments": {
"$ref": "../examples/short-url-invalid-args-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-invalid-args-v2.json"
}
}
}
@ -238,11 +232,8 @@
]
},
"examples": {
"API v3 and newer": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
@ -368,11 +359,8 @@
]
},
"examples": {
"API v3 and newer": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}

View file

@ -145,11 +145,8 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"Short URL not found with API v3 and newer": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}
@ -219,11 +216,8 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"Short URL not found with API v3 and newer": {
"Short URL not found": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Short URL not found previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.json"
}
}
}

View file

@ -228,9 +228,6 @@
"examples": {
"API v3 and newer": {
"$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
}
}
}

View file

@ -148,12 +148,8 @@
"$ref": "../definitions/Error.json"
},
"examples": {
"API v3 and newer": {
"Tag not found": {
"$ref": "../examples/tag-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/tag-not-found-v2.json"
}
}
}

View file

@ -0,0 +1,146 @@
{
"get": {
"operationId": "listShortUrlRedirectRules",
"tags": [
"Redirect rules"
],
"summary": "List short URL redirect rules",
"description": "Returns the list of redirect rules for a short URL.",
"parameters": [
{
"$ref": "../parameters/version.json"
},
{
"$ref": "../parameters/shortCode.json"
},
{
"$ref": "../parameters/domain.json"
}
],
"security": [
{
"ApiKey": []
}
],
"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": [
{
"name": "device-android",
"type": "device",
"matchValue": "android",
"matchKey": null
},
{
"name": "language-en-US",
"type": "language",
"matchValue": "en-US",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/fr",
"priority": 2,
"conditions": [
{
"name": "language-fr",
"type": "language",
"matchValue": "fr",
"matchKey": null
}
]
},
{
"longUrl": "https://example.com/query-foo-bar-hello-world",
"priority": 3,
"conditions": [
{
"name": "query-foo-bar",
"type": "query",
"matchKey": "foo",
"matchValue": "bar"
},
{
"name": "query-hello-world",
"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

@ -42,6 +42,10 @@
"name": "Short URLs",
"description": "Operations that can be performed on short URLs"
},
{
"name": "Redirect rules",
"description": "Handle dynamic rule-based redirects"
},
{
"name": "Tags",
"description": "Let you handle the list of available tags"
@ -79,6 +83,10 @@
"$ref": "paths/v1_short-urls_{shortCode}.json"
},
"/rest/v{version}/short-urls/{shortCode}/redirect-rules": {
"$ref": "paths/v3_short-urls_{shortCode}_redirect-rules.json"
},
"/rest/v{version}/tags": {
"$ref": "paths/v1_tags.json"
},

View file

@ -32,6 +32,7 @@ return [
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
RedirectRule\ShortUrlRedirectRuleService::class => ConfigAbstractFactory::class,
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
@ -158,7 +159,9 @@ return [
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
RedirectRule\ShortUrlRedirectionResolver::class => ['em'],
RedirectRule\ShortUrlRedirectRuleService::class => ['em'],
RedirectRule\ShortUrlRedirectionResolver::class => [RedirectRule\ShortUrlRedirectRuleService::class],
Action\RedirectAction::class => [
ShortUrl\ShortUrlResolver::class,

View file

@ -2,6 +2,7 @@
namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
use JsonSerializable;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\Model\DeviceType;
@ -15,7 +16,7 @@ use function sprintf;
use function strtolower;
use function trim;
class RedirectCondition extends AbstractEntity
class RedirectCondition extends AbstractEntity implements JsonSerializable
{
private function __construct(
public readonly string $name,
@ -98,4 +99,14 @@ class RedirectCondition extends AbstractEntity
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
return $device !== null && $device->value === strtolower($this->matchValue);
}
public function jsonSerialize(): array
{
return [
'name' => $this->name,
'type' => $this->type->value,
'matchKey' => $this->matchKey,
'matchValue' => $this->matchValue,
];
}
}

View file

@ -4,13 +4,15 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use JsonSerializable;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function array_values;
use function Shlinkio\Shlink\Core\ArrayUtils\every;
class ShortUrlRedirectRule extends AbstractEntity
class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable
{
/**
* @param Collection<RedirectCondition> $conditions
@ -33,4 +35,13 @@ class ShortUrlRedirectRule extends AbstractEntity
static fn (RedirectCondition $condition) => $condition->matchesRequest($request),
);
}
public function jsonSerialize(): array
{
return [
'longUrl' => $this->longUrl,
'priority' => $this->priority,
'conditions' => array_values($this->conditions->toArray()),
];
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace Shlinkio\Shlink\Core\RedirectRule;
use Doctrine\ORM\EntityManagerInterface;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface
{
public function __construct(private EntityManagerInterface $em)
{
}
/**
* @return ShortUrlRedirectRule[]
*/
public function rulesForShortUrl(ShortUrl $shortUrl): array
{
return $this->em->getRepository(ShortUrlRedirectRule::class)->findBy(
criteria: ['shortUrl' => $shortUrl],
orderBy: ['priority' => 'ASC'],
);
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace Shlinkio\Shlink\Core\RedirectRule;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
interface ShortUrlRedirectRuleServiceInterface
{
/**
* @return ShortUrlRedirectRule[]
*/
public function rulesForShortUrl(ShortUrl $shortUrl): array;
}

View file

@ -2,23 +2,18 @@
namespace Shlinkio\Shlink\Core\RedirectRule;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
readonly class ShortUrlRedirectionResolver implements ShortUrlRedirectionResolverInterface
{
public function __construct(private EntityManagerInterface $em)
public function __construct(private ShortUrlRedirectRuleServiceInterface $ruleService)
{
}
public function resolveLongUrl(ShortUrl $shortUrl, ServerRequestInterface $request): string
{
$rules = $this->em->getRepository(ShortUrlRedirectRule::class)->findBy(
criteria: ['shortUrl' => $shortUrl],
orderBy: ['priority' => 'ASC'],
);
$rules = $this->ruleService->rulesForShortUrl($shortUrl);
foreach ($rules as $rule) {
// Return the long URL for the first rule found that matches
if ($rule->matchesRequest($request)) {

View file

@ -48,6 +48,9 @@ readonly class ShortUrlResolver implements ShortUrlResolverInterface
return $shortUrl;
}
/**
* @throws ShortUrlNotFoundException
*/
public function resolvePublicShortUrl(ShortUrlIdentifier $identifier): ShortUrl
{
/** @var ShortUrlRepository $shortUrlRepo */

View file

@ -13,13 +13,13 @@ use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlIdentifier;
use Shlinkio\Shlink\Core\ShortUrl\Resolver\ShortUrlRelationResolverInterface;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ShortUrlService implements ShortUrlServiceInterface
readonly class ShortUrlService implements ShortUrlServiceInterface
{
public function __construct(
private readonly ORM\EntityManagerInterface $em,
private readonly ShortUrlResolverInterface $urlResolver,
private readonly ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private readonly ShortUrlRelationResolverInterface $relationResolver,
private ORM\EntityManagerInterface $em,
private ShortUrlResolverInterface $urlResolver,
private ShortUrlTitleResolutionHelperInterface $titleResolutionHelper,
private ShortUrlRelationResolverInterface $relationResolver,
) {
}

View file

@ -0,0 +1,55 @@
<?php
namespace ShlinkioTest\Shlink\Core\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
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\ShortUrlRedirectRuleService;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
class ShortUrlRedirectRuleServiceTest extends TestCase
{
private EntityManagerInterface & MockObject $em;
private ShortUrlRedirectRuleService $ruleService;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->ruleService = new ShortUrlRedirectRuleService($this->em);
}
#[Test]
public function delegatesToRepository(): void
{
$shortUrl = ShortUrl::withLongUrl('https://shlink.io');
$rules = [
new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([
RedirectCondition::forLanguage('en-US'),
])),
new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com/from-rule-2', new ArrayCollection([
RedirectCondition::forQueryParam('foo', 'bar'),
RedirectCondition::forDevice(DeviceType::ANDROID),
])),
];
$repo = $this->createMock(EntityRepository::class);
$repo->expects($this->once())->method('findBy')->with(
['shortUrl' => $shortUrl],
['priority' => 'ASC'],
)->willReturn($rules);
$this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn(
$repo,
);
$result = $this->ruleService->rulesForShortUrl($shortUrl);
self::assertSame($rules, $result);
}
}

View file

@ -3,8 +3,6 @@
namespace ShlinkioTest\Shlink\Core\RedirectRule;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\EntityRepository;
use Laminas\Diactoros\ServerRequestFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
@ -15,6 +13,7 @@ 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\ShortUrlRedirectionResolver;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleServiceInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
@ -25,12 +24,12 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT;
class ShortUrlRedirectionResolverTest extends TestCase
{
private ShortUrlRedirectionResolver $resolver;
private EntityManagerInterface & MockObject $em;
private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService;
protected function setUp(): void
{
$this->em = $this->createMock(EntityManagerInterface::class);
$this->resolver = new ShortUrlRedirectionResolver($this->em);
$this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class);
$this->resolver = new ShortUrlRedirectionResolver($this->ruleService);
}
#[Test, DataProvider('provideData')]
@ -43,14 +42,12 @@ class ShortUrlRedirectionResolverTest extends TestCase
'longUrl' => 'https://example.com/foo/bar',
]));
$repo = $this->createMock(EntityRepository::class);
$repo->expects($this->once())->method('findBy')->willReturn($condition !== null ? [
new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([
$condition,
])),
] : []);
$this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn(
$repo,
$this->ruleService->expects($this->once())->method('rulesForShortUrl')->with($shortUrl)->willReturn(
$condition !== null ? [
new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/from-rule', new ArrayCollection([
$condition,
])),
] : [],
);
$result = $this->resolver->resolveLongUrl($shortUrl, $request);

View file

@ -12,6 +12,7 @@ use Psr\Log\LoggerInterface;
use Shlinkio\Shlink\Common\Mercure\LcobucciJwtProvider;
use Shlinkio\Shlink\Core\Domain\DomainService;
use Shlinkio\Shlink\Core\Options;
use Shlinkio\Shlink\Core\RedirectRule;
use Shlinkio\Shlink\Core\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Transformer\ShortUrlDataTransformer;
use Shlinkio\Shlink\Core\Tag\TagService;
@ -46,6 +47,7 @@ return [
Action\Tag\UpdateTagAction::class => ConfigAbstractFactory::class,
Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class,
Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class,
Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class,
ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class,
Middleware\BodyParserMiddleware::class => InvokableFactory::class,
@ -103,6 +105,10 @@ return [
Action\Tag\UpdateTagAction::class => [TagService::class],
Action\Domain\ListDomainsAction::class => [DomainService::class, Options\NotFoundRedirectOptions::class],
Action\Domain\DomainRedirectsAction::class => [DomainService::class],
Action\RedirectRule\ListRedirectRulesAction::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,38 @@
<?php
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\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 ListRedirectRulesAction extends AbstractRestAction
{
protected const ROUTE_PATH = '/short-urls/{shortCode}/redirect-rules';
protected const ROUTE_ALLOWED_METHODS = [self::METHOD_GET];
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),
);
$rules = $this->ruleService->rulesForShortUrl($shortUrl);
return new JsonResponse([
'defaultLongUrl' => $shortUrl->getLongUrl(),
'redirectRules' => $rules,
]);
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Rest\Action;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestWith;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
use function sprintf;
class ListRedirectRulesTest extends ApiTestCase
{
private const LANGUAGE_EN_CONDITION = [
'name' => 'language-en',
'type' => 'language',
'matchKey' => null,
'matchValue' => 'en',
];
private const QUERY_FOO_BAR_CONDITION = [
'name' => 'query-foo-bar',
'type' => 'query',
'matchKey' => 'foo',
'matchValue' => 'bar',
];
#[Test]
public function errorIsReturnedWhenInvalidUrlIsFetched(): void
{
$response = $this->callApiWithKey(self::METHOD_GET, '/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]
#[TestWith(['abc123', []])]
#[TestWith(['def456', [
[
'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' => [
self::QUERY_FOO_BAR_CONDITION,
[
'name' => 'query-hello-world',
'type' => 'query',
'matchKey' => 'hello',
'matchValue' => 'world',
],
],
],
[
'longUrl' => 'https://example.com/only-english',
'priority' => 3,
'conditions' => [self::LANGUAGE_EN_CONDITION],
],
[
'longUrl' => 'https://blog.alejandrocelaya.com/android',
'priority' => 4,
'conditions' => [
[
'name' => 'device-android',
'type' => 'device',
'matchKey' => null,
'matchValue' => 'android',
],
],
],
[
'longUrl' => 'https://blog.alejandrocelaya.com/ios',
'priority' => 5,
'conditions' => [
[
'name' => 'device-ios',
'type' => 'device',
'matchKey' => null,
'matchValue' => 'ios',
],
],
],
]])]
public function returnsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void
{
$response = $this->callApiWithKey(self::METHOD_GET, sprintf('/short-urls/%s/redirect-rules', $shortCode));
$payload = $this->getJsonResponsePayload($response);
self::assertEquals(200, $response->getStatusCode());
self::assertEquals($expectedRules, $payload['redirectRules']);
}
}

View file

@ -40,43 +40,44 @@ class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentF
$iosCondition = RedirectCondition::forDevice(DeviceType::IOS);
$manager->persist($iosCondition);
$englishAndFooQueryRule = new ShortUrlRedirectRule(
$defShortUrl,
1,
'https://example.com/english-and-foo-query',
new ArrayCollection([$englishCondition, $fooQueryCondition]),
);
$manager->persist($englishAndFooQueryRule);
// Create rules disordered to make sure the order by priority works
$multipleQueryParamsRule = new ShortUrlRedirectRule(
$defShortUrl,
2,
'https://example.com/multiple-query-params',
new ArrayCollection([$helloQueryCondition, $fooQueryCondition]),
shortUrl: $defShortUrl,
priority: 2,
longUrl: 'https://example.com/multiple-query-params',
conditions: new ArrayCollection([$helloQueryCondition, $fooQueryCondition]),
);
$manager->persist($multipleQueryParamsRule);
$onlyEnglishRule = new ShortUrlRedirectRule(
$defShortUrl,
3,
'https://example.com/only-english',
new ArrayCollection([$englishCondition]),
$englishAndFooQueryRule = new ShortUrlRedirectRule(
shortUrl: $defShortUrl,
priority: 1,
longUrl: 'https://example.com/english-and-foo-query',
conditions: new ArrayCollection([$englishCondition, $fooQueryCondition]),
);
$manager->persist($onlyEnglishRule);
$manager->persist($englishAndFooQueryRule);
$androidRule = new ShortUrlRedirectRule(
$defShortUrl,
4,
'https://blog.alejandrocelaya.com/android',
new ArrayCollection([$androidCondition]),
shortUrl: $defShortUrl,
priority: 4,
longUrl: 'https://blog.alejandrocelaya.com/android',
conditions: new ArrayCollection([$androidCondition]),
);
$manager->persist($androidRule);
$onlyEnglishRule = new ShortUrlRedirectRule(
shortUrl: $defShortUrl,
priority: 3,
longUrl: 'https://example.com/only-english',
conditions: new ArrayCollection([$englishCondition]),
);
$manager->persist($onlyEnglishRule);
$iosRule = new ShortUrlRedirectRule(
$defShortUrl,
5,
'https://blog.alejandrocelaya.com/ios',
new ArrayCollection([$iosCondition]),
shortUrl: $defShortUrl,
priority: 5,
longUrl: 'https://blog.alejandrocelaya.com/ios',
conditions: new ArrayCollection([$iosCondition]),
);
$manager->persist($iosRule);

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\ListRedirectRulesAction;
use Shlinkio\Shlink\Rest\Entity\ApiKey;
class ListRedirectRulesActionTest extends TestCase
{
private ShortUrlResolverInterface & MockObject $urlResolver;
private ShortUrlRedirectRuleServiceInterface & MockObject $ruleService;
private ListRedirectRulesAction $action;
protected function setUp(): void
{
$this->urlResolver = $this->createMock(ShortUrlResolverInterface::class);
$this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class);
$this->action = new ListRedirectRulesAction($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('rulesForShortUrl')->willReturn($redirectRules);
/** @var JsonResponse $response */
$response = $this->action->handle($request);
$payload = $response->getPayload();
self::assertEquals([
'defaultLongUrl' => $shortUrl->getLongUrl(),
'redirectRules' => $redirectRules,
], $payload);
}
}