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:
Alejandro Celaya 2024-02-26 19:11:13 +01:00 committed by GitHub
commit 89a987d03a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 468 additions and 47 deletions

View file

@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
## [Unreleased]
### 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.
* [#1979](https://github.com/shlinkio/shlink/issues/1979) Allow orphan visits lists to be filtered by type.

View file

@ -71,7 +71,7 @@
"phpunit/phpunit": "^10.4",
"roave/security-advisories": "dev-master",
"shlinkio/php-coding-standard": "~2.3.0",
"shlinkio/shlink-test-utils": "^4.0",
"shlinkio/shlink-test-utils": "^4.1",
"symfony/var-dumper": "^7.0",
"veewee/composer-run-parallel": "^1.3"
},

View file

@ -32,6 +32,8 @@ return [
Options\QrCodeOptions::class => [ValinorConfigFactory::class, 'config.qr_codes'],
Options\RabbitMqOptions::class => [ValinorConfigFactory::class, 'config.rabbitmq'],
RedirectRule\ShortUrlRedirectionResolver::class => ConfigAbstractFactory::class,
ShortUrl\UrlShortener::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlService::class => ConfigAbstractFactory::class,
ShortUrl\ShortUrlListService::class => ConfigAbstractFactory::class,
@ -156,6 +158,7 @@ return [
Util\RedirectResponseHelper::class => [Options\RedirectOptions::class],
Config\NotFoundRedirectResolver::class => [Util\RedirectResponseHelper::class, 'Logger_Shlink'],
RedirectRule\ShortUrlRedirectionResolver::class => ['em'],
Action\RedirectAction::class => [
ShortUrl\ShortUrlResolver::class,
@ -179,7 +182,10 @@ return [
],
ShortUrl\Helper\ShortUrlStringifier::class => ['config.url_shortener.domain', 'config.router.base_path'],
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\Middleware\ExtraPathRedirectMiddleware::class => [
ShortUrl\ShortUrlResolver::class,

View file

@ -26,7 +26,9 @@ use function print_r;
use function Shlinkio\Shlink\Common\buildDateRange;
use function sprintf;
use function str_repeat;
use function str_replace;
use function strtolower;
use function trim;
use function ucfirst;
function generateRandomShortCode(int $length, ShortUrlMode $mode = ShortUrlMode::STRICT): string
@ -74,6 +76,11 @@ function normalizeDate(string|DateTimeInterface|Chronos $date): Chronos
return normalizeOptionalDate($date);
}
function normalizeLocale(string $locale): string
{
return trim(strtolower(str_replace('_', '-', $locale)));
}
function getOptionalIntFromInputFilter(InputFilter $inputFilter, string $fieldName): ?int
{
$value = $inputFilter->getValue($fieldName);

View file

@ -2,16 +2,73 @@
namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
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
{
public function __construct(
private function __construct(
public readonly string $name,
public readonly RedirectConditionType $type,
public readonly string $matchValue,
public readonly ?string $matchKey = null,
private readonly RedirectConditionType $type,
private readonly string $matchValue,
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,
);
}
}

View file

@ -4,9 +4,12 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Entity;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Common\Entity\AbstractEntity;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use function Shlinkio\Shlink\Core\ArrayUtils\every;
class ShortUrlRedirectRule extends AbstractEntity
{
/**
@ -14,9 +17,20 @@ class ShortUrlRedirectRule extends AbstractEntity
*/
public function __construct(
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 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),
);
}
}

View file

@ -4,7 +4,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule\Model;
enum RedirectConditionType: string
{
case DEVICE = 'device';
// case LANGUAGE = 'language';
// case QUERY_PARAM = 'query';
// case DEVICE = 'device';
case LANGUAGE = 'language';
case QUERY_PARAM = 'query';
}

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

View file

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

View file

@ -8,16 +8,18 @@ use GuzzleHttp\Psr7\Query;
use Laminas\Diactoros\Uri;
use Laminas\Stdlib\ArrayUtils;
use Psr\Http\Message\ServerRequestInterface;
use Shlinkio\Shlink\Core\Model\DeviceType;
use Shlinkio\Shlink\Core\Options\TrackingOptions;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
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(
@ -25,9 +27,8 @@ class ShortUrlRedirectionBuilder implements ShortUrlRedirectionBuilderInterface
ServerRequestInterface $request,
?string $extraPath = null,
): string {
$uri = new Uri($this->redirectionResolver->resolveLongUrl($shortUrl, $request));
$currentQuery = $request->getQueryParams();
$device = DeviceType::matchFromUserAgent($request->getHeaderLine('User-Agent'));
$uri = new Uri($shortUrl->longUrlForDevice($device));
$shouldForwardQuery = $shortUrl->forwardQuery();
return $uri

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace ShlinkioApiTest\Shlink\Core\Action;
use GuzzleHttp\RequestOptions;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Shlinkio\Shlink\TestUtils\ApiTest\ApiTestCase;
@ -15,9 +16,9 @@ use const ShlinkioTest\Shlink\IOS_USER_AGENT;
class RedirectTest extends ApiTestCase
{
#[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($expectedRedirect, $response->getHeaderLine('Location'));
@ -25,15 +26,48 @@ class RedirectTest extends ApiTestCase
public static function provideUserAgents(): iterable
{
yield 'android' => [ANDROID_USER_AGENT, 'https://blog.alejandrocelaya.com/android'];
yield 'ios' => [IOS_USER_AGENT, 'https://blog.alejandrocelaya.com/ios'];
yield 'android' => [
[
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' => [
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/',
];
yield 'unknown' => [
null,
[],
'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',
];
}
}

View file

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

View file

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

View file

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

View file

@ -7,26 +7,26 @@ namespace ShlinkioTest\Shlink\Core\ShortUrl\Helper;
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\Options\TrackingOptions;
use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectionResolverInterface;
use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl;
use Shlinkio\Shlink\Core\ShortUrl\Helper\ShortUrlRedirectionBuilder;
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
{
private ShortUrlRedirectionBuilder $redirectionBuilder;
private ShortUrlRedirectionResolverInterface & MockObject $redirectionResolver;
protected function setUp(): void
{
$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')]
@ -39,11 +39,12 @@ class ShortUrlRedirectionBuilderTest extends TestCase
$shortUrl = ShortUrl::create(ShortUrlCreation::fromRawData([
'longUrl' => 'https://domain.com/foo/bar?some=thing',
'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);
self::assertEquals($expectedUrl, $result);
@ -72,7 +73,7 @@ class ShortUrlRedirectionBuilderTest extends TestCase
];
yield [
'https://domain.com/foo/bar?some=overwritten',
$request(['foobar' => 'notrack', 'some' => 'overwritten'])->withHeader('User-Agent', 'Unknown'),
$request(['foobar' => 'notrack', 'some' => 'overwritten']),
null,
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&hello=world',
$request(['hello' => 'world'])->withHeader('User-Agent', DESKTOP_USER_AGENT),
$request(['hello' => 'world']),
'/something/else-baz',
true,
];
@ -107,17 +108,5 @@ class ShortUrlRedirectionBuilderTest extends TestCase
'/something/else-baz',
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,
];
}
}

View file

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