Create endpoint to list redirect rules for a specific short URL

This commit is contained in:
Alejandro Celaya 2024-02-27 21:00:53 +01:00
parent 721e3d9ef9
commit 33729289c7
17 changed files with 382 additions and 34 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

@ -0,0 +1,144 @@
{
"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",
"properties": {
"redirectRules": {
"type": "array",
"items": {
"$ref": "../definitions/ShortUrlRedirectRule.json"
}
}
}
},
"example": {
"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": {
"API v3 and newer": {
"$ref": "../examples/short-url-not-found-v3.json"
},
"Previous to API v3": {
"$ref": "../examples/short-url-not-found-v2.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,35 @@
<?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\ShortUrlRedirectRuleService;
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 ShortUrlRedirectRuleService $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(['redirectRules' => $rules]);
}
}