mirror of
https://github.com/shlinkio/shlink.git
synced 2024-11-27 16:26:37 +03:00
Merge pull request #2024 from acelaya-forks/feature/rule-based-redirects
Logic to resolve the long URL to redirect to for a short URL
This commit is contained in:
commit
89a987d03a
16 changed files with 468 additions and 47 deletions
|
@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
* [#1902](https://github.com/shlinkio/shlink/issues/1902) Add dynamic redirects based on query parameters.
|
||||||
|
|
||||||
|
This is implemented on top of the new [rule-based redirects](https://github.com/shlinkio/shlink/discussions/1912).
|
||||||
|
|
||||||
* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image.
|
* [#1868](https://github.com/shlinkio/shlink/issues/1868) Add support for [docker compose secrets](https://docs.docker.com/compose/use-secrets/) to the docker image.
|
||||||
* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type.
|
* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type.
|
||||||
|
|
||||||
|
|
|
@ -71,7 +71,7 @@
|
||||||
"phpunit/phpunit": "^10.4",
|
"phpunit/phpunit": "^10.4",
|
||||||
"roave/security-advisories": "dev-master",
|
"roave/security-advisories": "dev-master",
|
||||||
"shlinkio/php-coding-standard": "~2.3.0",
|
"shlinkio/php-coding-standard": "~2.3.0",
|
||||||
"shlinkio/shlink-test-utils": "^4.0",
|
"shlinkio/shlink-test-utils": "^4.1",
|
||||||
"symfony/var-dumper": "^7.0",
|
"symfony/var-dumper": "^7.0",
|
||||||
"veewee/composer-run-parallel": "^1.3"
|
"veewee/composer-run-parallel": "^1.3"
|
||||||
},
|
},
|
||||||
|
|
|
@ -32,6 +32,8 @@ return [
|
||||||
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
|
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
|
||||||
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
|
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
|
||||||
|
|
||||||
|
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
|
||||||
|
|
||||||
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
|
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
|
||||||
ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class,
|
ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class,
|
||||||
ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class,
|
ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class,
|
||||||
|
@ -156,6 +158,7 @@ return [
|
||||||
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
|
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
|
||||||
|
|
||||||
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
|
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
|
||||||
|
RedirectRule\ShortUrlRedirectionResolver::class => ['em'],
|
||||||
|
|
||||||
Action\RedirectAction::class => [
|
Action\RedirectAction::class => [
|
||||||
ShortUrl\ShortUrlResolver::class,
|
ShortUrl\ShortUrlResolver::class,
|
||||||
|
@ -179,7 +182,10 @@ return [
|
||||||
],
|
],
|
||||||
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
|
||||||
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class],
|
ShortUrl\Helper\ShortUrlTitleResolutionHelper::class => ['httpClient', Options\UrlShortenerOptions::class],
|
||||||
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [Options\TrackingOptions::class],
|
ShortUrl\Helper\ShortUrlRedirectionBuilder::class => [
|
||||||
|
Options\TrackingOptions::class,
|
||||||
|
RedirectRule\ShortUrlRedirectionResolver::class,
|
||||||
|
],
|
||||||
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
ShortUrl\Transformer\ShortUrlDataTransformer::class => [ShortUrl\Helper\ShortUrlStringifier::class],
|
||||||
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
|
ShortUrl\Middleware\ExtraPathRedirectMiddleware::class => [
|
||||||
ShortUrl\ShortUrlResolver::class,
|
ShortUrl\ShortUrlResolver::class,
|
||||||
|
|
|
@ -26,7 +26,9 @@ use function print_r;
|
||||||
use function Shlinkio\Shlink\Common\buildDateRange;
|
use function Shlinkio\Shlink\Common\buildDateRange;
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
use function str_repeat;
|
use function str_repeat;
|
||||||
|
use function str_replace;
|
||||||
use function strtolower;
|
use function strtolower;
|
||||||
|
use function trim;
|
||||||
use function ucfirst;
|
use function ucfirst;
|
||||||
|
|
||||||
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
|
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
|
||||||
|
@ -74,6 +76,11 @@ function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos
|
||||||
return normalizeOptionalDate($date);
|
return normalizeOptionalDate($date);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeLocale(string $locale): string
|
||||||
|
{
|
||||||
|
return trim(strtolower(str_replace('_', '-', $locale)));
|
||||||
|
}
|
||||||
|
|
||||||
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
|
||||||
{
|
{
|
||||||
$value = $inputFilter->getValue($fieldName);
|
$value = $inputFilter->getValue($fieldName);
|
||||||
|
|
|
@ -2,16 +2,73 @@
|
||||||
|
|
||||||
namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
|
namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType;
|
||||||
|
|
||||||
|
use function explode;
|
||||||
|
use function Shlinkio\Shlink\Core\ArrayUtils\some;
|
||||||
|
use function Shlinkio\Shlink\Core\normalizeLocale;
|
||||||
|
use function sprintf;
|
||||||
|
|
||||||
class RedirectCondition extends AbstractEntity
|
class RedirectCondition extends AbstractEntity
|
||||||
{
|
{
|
||||||
public function __construct(
|
private function __construct(
|
||||||
public readonly string $name,
|
public readonly string $name,
|
||||||
public readonly RedirectConditionType $type,
|
private readonly RedirectConditionType $type,
|
||||||
public readonly string $matchValue,
|
private readonly string $matchValue,
|
||||||
public readonly ?string $matchKey = null,
|
private readonly ?string $matchKey = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function forQueryParam(string $param, string $value): self
|
||||||
|
{
|
||||||
|
$type = RedirectConditionType::QUERY_PARAM;
|
||||||
|
$name = sprintf('%s-%s-%s', $type->value, $param, $value);
|
||||||
|
|
||||||
|
return new self($name, $type, $value, $param);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function forLanguage(string $language): self
|
||||||
|
{
|
||||||
|
$type = RedirectConditionType::LANGUAGE;
|
||||||
|
$name = sprintf('%s-%s', $type->value, $language);
|
||||||
|
|
||||||
|
return new self($name, $type, $language);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if this condition matches provided request
|
||||||
|
*/
|
||||||
|
public function matchesRequest(ServerRequestInterface $request): bool
|
||||||
|
{
|
||||||
|
return match ($this->type) {
|
||||||
|
RedirectConditionType::QUERY_PARAM => $this->matchesQueryParam($request),
|
||||||
|
RedirectConditionType::LANGUAGE => $this->matchesLanguage($request),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesQueryParam(ServerRequestInterface $request): bool
|
||||||
|
{
|
||||||
|
$query = $request->getQueryParams();
|
||||||
|
$queryValue = $query[$this->matchKey] ?? null;
|
||||||
|
|
||||||
|
return $queryValue === $this->matchValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function matchesLanguage(ServerRequestInterface $request): bool
|
||||||
|
{
|
||||||
|
$acceptLanguage = $request->getHeaderLine('Accept-Language');
|
||||||
|
if ($acceptLanguage === '' || $acceptLanguage === '*') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$acceptedLanguages = explode(',', $acceptLanguage);
|
||||||
|
$normalizedLanguage = normalizeLocale($this->matchValue);
|
||||||
|
|
||||||
|
return some(
|
||||||
|
$acceptedLanguages,
|
||||||
|
static fn (string $lang) => normalizeLocale($lang) === $normalizedLanguage,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,9 +4,12 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
|
||||||
|
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
use function Shlinkio\Shlink\Core\ArrayUtils\every;
|
||||||
|
|
||||||
class ShortUrlRedirectRule extends AbstractEntity
|
class ShortUrlRedirectRule extends AbstractEntity
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
|
@ -14,9 +17,20 @@ class ShortUrlRedirectRule extends AbstractEntity
|
||||||
*/
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
|
private readonly ShortUrl $shortUrl, // No need to read this field. It's used by doctrine
|
||||||
public readonly int $priority,
|
private readonly int $priority,
|
||||||
public readonly string $longUrl,
|
public readonly string $longUrl,
|
||||||
public readonly Collection $conditions = new ArrayCollection(),
|
private Collection $conditions = new ArrayCollection(),
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tells if this condition matches provided request
|
||||||
|
*/
|
||||||
|
public function matchesRequest(ServerRequestInterface $request): bool
|
||||||
|
{
|
||||||
|
return $this->conditions->count() > 0 && every(
|
||||||
|
$this->conditions,
|
||||||
|
static fn (RedirectCondition $condition) => $condition->matchesRequest($request),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model;
|
||||||
|
|
||||||
enum RedirectConditionType: string
|
enum RedirectConditionType: string
|
||||||
{
|
{
|
||||||
case DEVICE = 'device';
|
// case DEVICE = 'device';
|
||||||
// case LANGUAGE = 'language';
|
case LANGUAGE = 'language';
|
||||||
// case QUERY_PARAM = 'query';
|
case QUERY_PARAM = 'query';
|
||||||
}
|
}
|
||||||
|
|
33
module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php
Normal file
33
module/Core/src/RedirectRule/ShortUrlRedirectionResolver.php
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\RedirectRule;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Shlinkio\Shlink\Core\Model\DeviceType;
|
||||||
|
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 resolveLongUrl(ShortUrl $shortUrl, ServerRequestInterface $request): string
|
||||||
|
{
|
||||||
|
$rules = $this->em->getRepository(ShortUrlRedirectRule::class)->findBy(
|
||||||
|
criteria: ['shortUrl' => $shortUrl],
|
||||||
|
orderBy: ['priority' => 'ASC'],
|
||||||
|
);
|
||||||
|
foreach ($rules as $rule) {
|
||||||
|
// Return the long URL for the first rule found that matches
|
||||||
|
if ($rule->matchesRequest($request)) {
|
||||||
|
return $rule->longUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
|
||||||
|
return $shortUrl->longUrlForDevice($device);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,11 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Shlinkio\Shlink\Core\RedirectRule;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
interface ShortUrlRedirectionResolverInterface
|
||||||
|
{
|
||||||
|
public function resolveLongUrl(ShortUrl $shortUrl, ServerRequestInterface $request): string;
|
||||||
|
}
|
|
@ -8,16 +8,18 @@ use GuzzleHttp\Psr7\Query;
|
||||||
use Laminas\Diactoros\Uri;
|
use Laminas\Diactoros\Uri;
|
||||||
use Laminas\Stdlib\ArrayUtils;
|
use Laminas\Stdlib\ArrayUtils;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
use function sprintf;
|
use function sprintf;
|
||||||
|
|
||||||
class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
|
readonly class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
|
||||||
{
|
{
|
||||||
public function __construct(private readonly TrackingOptions $trackingOptions)
|
public function __construct(
|
||||||
{
|
private TrackingOptions $trackingOptions,
|
||||||
|
private ShortUrlRedirectionResolverInterface $redirectionResolver,
|
||||||
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buildShortUrlRedirect(
|
public function buildShortUrlRedirect(
|
||||||
|
@ -25,9 +27,8 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
|
||||||
ServerRequestInterface $request,
|
ServerRequestInterface $request,
|
||||||
?string $extraPath = null,
|
?string $extraPath = null,
|
||||||
): string {
|
): string {
|
||||||
|
$uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request));
|
||||||
$currentQuery = $request->getQueryParams();
|
$currentQuery = $request->getQueryParams();
|
||||||
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
|
|
||||||
$uri = new Uri($shortUrl->longUrlForDevice($device));
|
|
||||||
$shouldForwardQuery = $shortUrl->forwardQuery();
|
$shouldForwardQuery = $shortUrl->forwardQuery();
|
||||||
|
|
||||||
return $uri
|
return $uri
|
||||||
|
|
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||||
|
|
||||||
namespace ShlinkioApiTest\Shlink\Core\Action;
|
namespace ShlinkioApiTest\Shlink\Core\Action;
|
||||||
|
|
||||||
|
use GuzzleHttp\RequestOptions;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
|
||||||
|
@ -15,9 +16,9 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
||||||
class RedirectTest extends ApiTestCase
|
class RedirectTest extends ApiTestCase
|
||||||
{
|
{
|
||||||
#[Test, DataProvider('provideUserAgents')]
|
#[Test, DataProvider('provideUserAgents')]
|
||||||
public function properRedirectHappensBasedOnUserAgent(?string $userAgent, string $expectedRedirect): void
|
public function properRedirectHappensBasedOnUserAgent(array $options, string $expectedRedirect): void
|
||||||
{
|
{
|
||||||
$response = $this->callShortUrl('def456', $userAgent);
|
$response = $this->callShortUrl('def456', $options);
|
||||||
|
|
||||||
self::assertEquals(302, $response->getStatusCode());
|
self::assertEquals(302, $response->getStatusCode());
|
||||||
self::assertEquals($expectedRedirect, $response->getHeaderLine('Location'));
|
self::assertEquals($expectedRedirect, $response->getHeaderLine('Location'));
|
||||||
|
@ -25,15 +26,48 @@ class RedirectTest extends ApiTestCase
|
||||||
|
|
||||||
public static function provideUserAgents(): iterable
|
public static function provideUserAgents(): iterable
|
||||||
{
|
{
|
||||||
yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android'];
|
yield 'android' => [
|
||||||
yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios'];
|
[
|
||||||
|
RequestOptions::HEADERS => ['User-Agent' => ANDROID_USER_AGENT],
|
||||||
|
],
|
||||||
|
'https://blog.alejandrocelaya.com/android',
|
||||||
|
];
|
||||||
|
yield 'ios' => [
|
||||||
|
[
|
||||||
|
RequestOptions::HEADERS => ['User-Agent' => IOS_USER_AGENT],
|
||||||
|
],
|
||||||
|
'https://blog.alejandrocelaya.com/ios',
|
||||||
|
];
|
||||||
yield 'desktop' => [
|
yield 'desktop' => [
|
||||||
DESKTOP_USER_AGENT,
|
[
|
||||||
|
RequestOptions::HEADERS => ['User-Agent' => DESKTOP_USER_AGENT],
|
||||||
|
],
|
||||||
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||||
];
|
];
|
||||||
yield 'unknown' => [
|
yield 'unknown' => [
|
||||||
null,
|
[],
|
||||||
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
'https://blog.alejandrocelaya.com/2017/12/09/acmailer-7-0-the-most-important-release-in-a-long-time/',
|
||||||
];
|
];
|
||||||
|
yield 'rule: english and foo' => [
|
||||||
|
[
|
||||||
|
RequestOptions::HEADERS => [
|
||||||
|
'Accept-Language' => 'en-UK',
|
||||||
|
],
|
||||||
|
RequestOptions::QUERY => ['foo' => 'bar'],
|
||||||
|
],
|
||||||
|
'https://example.com/english-and-foo-query?foo=bar',
|
||||||
|
];
|
||||||
|
yield 'rule: multiple query params' => [
|
||||||
|
[
|
||||||
|
RequestOptions::QUERY => ['foo' => 'bar', 'hello' => 'world'],
|
||||||
|
],
|
||||||
|
'https://example.com/multiple-query-params?foo=bar&hello=world',
|
||||||
|
];
|
||||||
|
yield 'rule: english' => [
|
||||||
|
[
|
||||||
|
RequestOptions::HEADERS => ['Accept-Language' => 'en-UK'],
|
||||||
|
],
|
||||||
|
'https://example.com/only-english',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace RedirectRule\Entity;
|
||||||
|
|
||||||
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\Attributes\TestWith;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
|
|
||||||
|
class RedirectConditionTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test]
|
||||||
|
#[TestWith(['nop', '', false])] // param not present
|
||||||
|
#[TestWith(['foo', 'not-bar', false])] // param present with wrong value
|
||||||
|
#[TestWith(['foo', 'bar', true])] // param present with correct value
|
||||||
|
public function matchesQueryParams(string $param, string $value, bool $expectedResult): void
|
||||||
|
{
|
||||||
|
$request = ServerRequestFactory::fromGlobals()->withQueryParams(['foo' => 'bar']);
|
||||||
|
$result = RedirectCondition::forQueryParam($param, $value)->matchesRequest($request);
|
||||||
|
|
||||||
|
self::assertEquals($expectedResult, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test]
|
||||||
|
#[TestWith([null, '', false])] // no accept language
|
||||||
|
#[TestWith(['', '', false])] // empty accept language
|
||||||
|
#[TestWith(['*', '', false])] // wildcard accept language
|
||||||
|
#[TestWith(['en', 'en', true])] // single language match
|
||||||
|
#[TestWith(['es, en,fr', 'en', true])] // multiple languages match
|
||||||
|
#[TestWith(['es_ES', 'es-ES', true])] // single locale match
|
||||||
|
#[TestWith(['en-UK', 'en-uk', true])] // different casing match
|
||||||
|
public function matchesLanguage(?string $acceptLanguage, string $value, bool $expected): void
|
||||||
|
{
|
||||||
|
$request = ServerRequestFactory::fromGlobals();
|
||||||
|
if ($acceptLanguage !== null) {
|
||||||
|
$request = $request->withHeader('Accept-Language', $acceptLanguage);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = RedirectCondition::forLanguage($value)->matchesRequest($request);
|
||||||
|
|
||||||
|
self::assertEquals($expected, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test, DataProvider('provideNames')]
|
||||||
|
public function generatesExpectedName(RedirectCondition $condition, string $expectedName): void
|
||||||
|
{
|
||||||
|
self::assertEquals($expectedName, $condition->name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideNames(): iterable
|
||||||
|
{
|
||||||
|
yield [RedirectCondition::forLanguage('es-ES'), 'language-es-ES'];
|
||||||
|
yield [RedirectCondition::forLanguage('en_UK'), 'language-en_UK'];
|
||||||
|
yield [RedirectCondition::forQueryParam('foo', 'bar'), 'query-foo-bar'];
|
||||||
|
yield [RedirectCondition::forQueryParam('baz', 'foo'), 'query-baz-foo'];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace RedirectRule\Entity;
|
||||||
|
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
class ShortUrlRedirectRuleTest extends TestCase
|
||||||
|
{
|
||||||
|
#[Test, DataProvider('provideConditions')]
|
||||||
|
public function matchesRequestIfAllConditionsMatch(array $conditions, bool $expectedResult): void
|
||||||
|
{
|
||||||
|
$request = ServerRequestFactory::fromGlobals()
|
||||||
|
->withHeader('Accept-Language', 'en-UK')
|
||||||
|
->withQueryParams(['foo' => 'bar']);
|
||||||
|
|
||||||
|
$result = $this->createRule($conditions)->matchesRequest($request);
|
||||||
|
|
||||||
|
self::assertEquals($expectedResult, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideConditions(): iterable
|
||||||
|
{
|
||||||
|
yield 'no conditions' => [[], false];
|
||||||
|
yield 'not all conditions match' => [
|
||||||
|
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'foo')],
|
||||||
|
false,
|
||||||
|
];
|
||||||
|
yield 'all conditions match' => [
|
||||||
|
[RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')],
|
||||||
|
true,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param RedirectCondition[] $conditions
|
||||||
|
*/
|
||||||
|
private function createRule(array $conditions): ShortUrlRedirectRule
|
||||||
|
{
|
||||||
|
$shortUrl = ShortUrl::withLongUrl('https://s.test');
|
||||||
|
return new ShortUrlRedirectRule($shortUrl, 1, '', new ArrayCollection($conditions));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace 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;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
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\ShortUrl\Entity\ShortUrl;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||||
|
|
||||||
|
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
|
||||||
|
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
|
||||||
|
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
||||||
|
|
||||||
|
class ShortUrlRedirectionResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
private ShortUrlRedirectionResolver $resolver;
|
||||||
|
private EntityManagerInterface & MockObject $em;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->em = $this->createMock(EntityManagerInterface::class);
|
||||||
|
$this->resolver = new ShortUrlRedirectionResolver($this->em);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Test, DataProvider('provideData')]
|
||||||
|
public function resolveLongUrlReturnsExpectedValue(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
?RedirectCondition $condition,
|
||||||
|
string $expectedUrl,
|
||||||
|
): void {
|
||||||
|
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||||
|
'longUrl' => 'https://example.com/foo/bar',
|
||||||
|
'deviceLongUrls' => [
|
||||||
|
DeviceType::ANDROID->value => 'https://example.com/android',
|
||||||
|
DeviceType::IOS->value => 'https://example.com/ios',
|
||||||
|
],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$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,
|
||||||
|
);
|
||||||
|
|
||||||
|
$result = $this->resolver->resolveLongUrl($shortUrl, $request);
|
||||||
|
|
||||||
|
self::assertEquals($expectedUrl, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideData(): iterable
|
||||||
|
{
|
||||||
|
$request = static fn (string $userAgent = '') => ServerRequestFactory::fromGlobals()->withHeader(
|
||||||
|
'User-Agent',
|
||||||
|
$userAgent,
|
||||||
|
);
|
||||||
|
|
||||||
|
yield 'unknown user agent' => [
|
||||||
|
$request('Unknown'), // This user agent won't match any device
|
||||||
|
RedirectCondition::forLanguage('es-ES'), // This condition won't match
|
||||||
|
'https://example.com/foo/bar',
|
||||||
|
];
|
||||||
|
yield 'desktop user agent' => [$request(DESKTOP_USER_AGENT), null, 'https://example.com/foo/bar'];
|
||||||
|
yield 'android user agent' => [
|
||||||
|
$request(ANDROID_USER_AGENT),
|
||||||
|
RedirectCondition::forQueryParam('foo', 'bar'), // This condition won't match
|
||||||
|
'https://example.com/android',
|
||||||
|
];
|
||||||
|
yield 'ios user agent' => [$request(IOS_USER_AGENT), null, 'https://example.com/ios'];
|
||||||
|
yield 'matching language' => [
|
||||||
|
$request()->withHeader('Accept-Language', 'es-ES'),
|
||||||
|
RedirectCondition::forLanguage('es-ES'),
|
||||||
|
'https://example.com/from-rule',
|
||||||
|
];
|
||||||
|
yield 'matching query params' => [
|
||||||
|
$request()->withQueryParams(['foo' => 'bar']),
|
||||||
|
RedirectCondition::forQueryParam('foo', 'bar'),
|
||||||
|
'https://example.com/from-rule',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,26 +7,26 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
|
||||||
use Laminas\Diactoros\ServerRequestFactory;
|
use Laminas\Diactoros\ServerRequestFactory;
|
||||||
use PHPUnit\Framework\Attributes\DataProvider;
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
use PHPUnit\Framework\Attributes\Test;
|
use PHPUnit\Framework\Attributes\Test;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Psr\Http\Message\ServerRequestInterface;
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
use Shlinkio\Shlink\Core\Model\DeviceType;
|
|
||||||
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
use Shlinkio\Shlink\Core\Options\TrackingOptions;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder;
|
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder;
|
||||||
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
use Shlinkio\Shlink\Core\ShortUrl\Model\ShortUrlCreation;
|
||||||
|
|
||||||
use const ShlinkioTest\Shlink\ANDROID_USER_AGENT;
|
|
||||||
use const ShlinkioTest\Shlink\DESKTOP_USER_AGENT;
|
|
||||||
use const ShlinkioTest\Shlink\IOS_USER_AGENT;
|
|
||||||
|
|
||||||
class ShortUrlRedirectionBuilderTest extends TestCase
|
class ShortUrlRedirectionBuilderTest extends TestCase
|
||||||
{
|
{
|
||||||
private ShortUrlRedirectionBuilder $redirectionBuilder;
|
private ShortUrlRedirectionBuilder $redirectionBuilder;
|
||||||
|
private ShortUrlRedirectionResolverInterface & MockObject $redirectionResolver;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
$trackingOptions = new TrackingOptions(disableTrackParam: 'foobar');
|
$trackingOptions = new TrackingOptions(disableTrackParam: 'foobar');
|
||||||
$this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions);
|
$this->redirectionResolver = $this->createMock(ShortUrlRedirectionResolverInterface::class);
|
||||||
|
|
||||||
|
$this->redirectionBuilder = new ShortUrlRedirectionBuilder($trackingOptions, $this->redirectionResolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[Test, DataProvider('provideData')]
|
#[Test, DataProvider('provideData')]
|
||||||
|
@ -39,11 +39,12 @@ class ShortUrlRedirectionBuilderTest extends TestCase
|
||||||
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
|
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
|
||||||
'longUrl' => 'https://domain.com/foo/bar?some=thing',
|
'longUrl' => 'https://domain.com/foo/bar?some=thing',
|
||||||
'forwardQuery' => $forwardQuery,
|
'forwardQuery' => $forwardQuery,
|
||||||
'deviceLongUrls' => [
|
|
||||||
DeviceType::ANDROID->value => 'https://domain.com/android',
|
|
||||||
DeviceType::IOS->value => 'https://domain.com/ios',
|
|
||||||
],
|
|
||||||
]));
|
]));
|
||||||
|
$this->redirectionResolver->expects($this->once())->method('resolveLongUrl')->with(
|
||||||
|
$shortUrl,
|
||||||
|
$request,
|
||||||
|
)->willReturn($shortUrl->getLongUrl());
|
||||||
|
|
||||||
$result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
|
$result = $this->redirectionBuilder->buildShortUrlRedirect($shortUrl, $request, $extraPath);
|
||||||
|
|
||||||
self::assertEquals($expectedUrl, $result);
|
self::assertEquals($expectedUrl, $result);
|
||||||
|
@ -72,7 +73,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase
|
||||||
];
|
];
|
||||||
yield [
|
yield [
|
||||||
'https://domain.com/foo/bar?some=overwritten',
|
'https://domain.com/foo/bar?some=overwritten',
|
||||||
$request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'),
|
$request(['foobar' => 'notrack', 'some' => 'overwritten']),
|
||||||
null,
|
null,
|
||||||
true,
|
true,
|
||||||
];
|
];
|
||||||
|
@ -91,7 +92,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase
|
||||||
yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true];
|
yield ['https://domain.com/foo/bar/something/else-baz?some=thing', $request(), '/something/else-baz', true];
|
||||||
yield [
|
yield [
|
||||||
'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world',
|
'https://domain.com/foo/bar/something/else-baz?some=thing&hello=world',
|
||||||
$request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT),
|
$request(['hello' => 'world']),
|
||||||
'/something/else-baz',
|
'/something/else-baz',
|
||||||
true,
|
true,
|
||||||
];
|
];
|
||||||
|
@ -107,17 +108,5 @@ class ShortUrlRedirectionBuilderTest extends TestCase
|
||||||
'/something/else-baz',
|
'/something/else-baz',
|
||||||
false,
|
false,
|
||||||
];
|
];
|
||||||
yield [
|
|
||||||
'https://domain.com/android/something',
|
|
||||||
$request(['foo' => 'bar'])->withHeader('User-Agent', ANDROID_USER_AGENT),
|
|
||||||
'/something',
|
|
||||||
false,
|
|
||||||
];
|
|
||||||
yield [
|
|
||||||
'https://domain.com/ios?foo=bar',
|
|
||||||
$request(['foo' => 'bar'])->withHeader('User-Agent', IOS_USER_AGENT),
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,62 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace ShlinkioApiTest\Shlink\Rest\Fixtures;
|
||||||
|
|
||||||
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
|
use Doctrine\Common\DataFixtures\AbstractFixture;
|
||||||
|
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
|
||||||
|
use Doctrine\Persistence\ObjectManager;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition;
|
||||||
|
use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule;
|
||||||
|
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
|
||||||
|
|
||||||
|
class ShortUrlRedirectRulesFixture extends AbstractFixture implements DependentFixtureInterface
|
||||||
|
{
|
||||||
|
public function getDependencies(): array
|
||||||
|
{
|
||||||
|
return [ShortUrlsFixture::class];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function load(ObjectManager $manager): void
|
||||||
|
{
|
||||||
|
/** @var ShortUrl $defShortUrl */
|
||||||
|
$defShortUrl = $this->getReference('def456_short_url');
|
||||||
|
|
||||||
|
$englishCondition = RedirectCondition::forLanguage('en-UK');
|
||||||
|
$manager->persist($englishCondition);
|
||||||
|
|
||||||
|
$fooQueryCondition = RedirectCondition::forQueryParam('foo', 'bar');
|
||||||
|
$manager->persist($fooQueryCondition);
|
||||||
|
|
||||||
|
$helloQueryCondition = RedirectCondition::forQueryParam('hello', 'world');
|
||||||
|
$manager->persist($helloQueryCondition);
|
||||||
|
|
||||||
|
$englishAndFooQueryRule = new ShortUrlRedirectRule(
|
||||||
|
$defShortUrl,
|
||||||
|
1,
|
||||||
|
'https://example.com/english-and-foo-query',
|
||||||
|
new ArrayCollection([$englishCondition, $fooQueryCondition]),
|
||||||
|
);
|
||||||
|
$manager->persist($englishAndFooQueryRule);
|
||||||
|
|
||||||
|
$multipleQueryParamsRule = new ShortUrlRedirectRule(
|
||||||
|
$defShortUrl,
|
||||||
|
2,
|
||||||
|
'https://example.com/multiple-query-params',
|
||||||
|
new ArrayCollection([$helloQueryCondition, $fooQueryCondition]),
|
||||||
|
);
|
||||||
|
$manager->persist($multipleQueryParamsRule);
|
||||||
|
|
||||||
|
$onlyEnglishRule = new ShortUrlRedirectRule(
|
||||||
|
$defShortUrl,
|
||||||
|
3,
|
||||||
|
'https://example.com/only-english',
|
||||||
|
new ArrayCollection([$englishCondition]),
|
||||||
|
);
|
||||||
|
$manager->persist($onlyEnglishRule);
|
||||||
|
|
||||||
|
$manager->flush();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue