diff --git a/composer.json b/composer.json index a09eafa0..65605957 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "ext-pdo": "*", "akrabat/ip-address-middleware": "^2.1", "cakephp/chronos": "^3.0.2", + "doctrine/dbal": "^4.0", "doctrine/migrations": "^3.6", "doctrine/orm": "^3.0", "endroid/qr-code": "^5.0", diff --git a/config/autoload/mercure.local.php.dist b/config/autoload/mercure.local.php.dist index e818404b..13a74022 100644 --- a/config/autoload/mercure.local.php.dist +++ b/config/autoload/mercure.local.php.dist @@ -5,7 +5,7 @@ declare(strict_types=1); return [ 'mercure' => [ - 'public_hub_url' => 'http://localhost:8001', + 'public_hub_url' => 'http://localhost:8002', 'internal_hub_url' => 'http://shlink_mercure_proxy', 'jwt_secret' => 'mercure_jwt_key_long_enough_to_avoid_error', ], diff --git a/config/autoload/routes.config.php b/config/autoload/routes.config.php index 785c8341..6d072228 100644 --- a/config/autoload/routes.config.php +++ b/config/autoload/routes.config.php @@ -46,6 +46,7 @@ return (static function (): array { //Redirect rules Action\RedirectRule\ListRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), + Action\RedirectRule\SetRedirectRulesAction::getRouteDef([$dropDomainMiddleware]), // Short URLs Action\ShortUrl\CreateShortUrlAction::getRouteDef([ diff --git a/docker-compose.yml b/docker-compose.yml index 5416136d..3f65e4bb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -131,7 +131,7 @@ services: container_name: shlink_mercure_proxy image: nginx:1.25-alpine ports: - - "8001:80" + - "8002:80" volumes: - ./:/home/shlink/www - ./data/infra/mercure_proxy_vhost.conf:/etc/nginx/conf.d/default.conf diff --git a/docs/swagger/definitions/ShortUrlRedirectRule.json b/docs/swagger/definitions/ShortUrlRedirectRule.json index 74cdd216..8fde6e90 100644 --- a/docs/swagger/definitions/ShortUrlRedirectRule.json +++ b/docs/swagger/definitions/ShortUrlRedirectRule.json @@ -15,12 +15,8 @@ "type": "array", "items": { "type": "object", - "required": ["name", "type", "matchKey", "matchValue"], + "required": ["type", "matchKey", "matchValue"], "properties": { - "name": { - "type": "string", - "description": "Unique condition name" - }, "type": { "type": "string", "enum": ["device", "language", "query"], diff --git a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json index 44cd2d86..cd2904d4 100644 --- a/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json +++ b/docs/swagger/paths/v3_short-urls_{shortCode}_redirect-rules.json @@ -137,5 +137,164 @@ } } } + }, + + "post": { + "operationId": "setShortUrlRedirectRules", + "tags": [ + "Redirect rules" + ], + "summary": "Set short URL redirect rules", + "description": "Overwrites redirect rules for a short URL with the ones provided here.", + "parameters": [ + { + "$ref": "../parameters/version.json" + }, + { + "$ref": "../parameters/shortCode.json" + }, + { + "$ref": "../parameters/domain.json" + } + ], + "security": [ + { + "ApiKey": [] + } + ], + "requestBody": { + "description": "Request body.", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The list of rules", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["defaultLongUrl", "redirectRules"], + "properties": { + "defaultLongUrl": { + "type": "string" + }, + "redirectRules": { + "type": "array", + "items": { + "$ref": "../definitions/ShortUrlRedirectRule.json" + } + } + } + }, + "example": { + "defaultLongUrl": "https://example.com", + "redirectRules": [ + { + "longUrl": "https://example.com/android-en-us", + "priority": 1, + "conditions": [ + { + "type": "device", + "matchValue": "android", + "matchKey": null + }, + { + "type": "language", + "matchValue": "en-US", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/fr", + "priority": 2, + "conditions": [ + { + "type": "language", + "matchValue": "fr", + "matchKey": null + } + ] + }, + { + "longUrl": "https://example.com/query-foo-bar-hello-world", + "priority": 3, + "conditions": [ + { + "type": "query", + "matchKey": "foo", + "matchValue": "bar" + }, + { + "type": "query", + "matchKey": "hello", + "matchValue": "world" + } + ] + } + ] + } + } + } + }, + "404": { + "description": "No URL was found for provided short code.", + "content": { + "application/problem+json": { + "schema": { + "allOf": [ + { + "$ref": "../definitions/Error.json" + }, + { + "type": "object", + "required": ["shortCode"], + "properties": { + "shortCode": { + "type": "string", + "description": "The short code with which we tried to find the short URL" + }, + "domain": { + "type": "string", + "description": "The domain with which we tried to find the short URL" + } + } + } + ] + }, + "examples": { + "Short URL not found": { + "$ref": "../examples/short-url-not-found-v3.json" + } + } + } + } + }, + "default": { + "description": "Unexpected error.", + "content": { + "application/problem+json": { + "schema": { + "$ref": "../definitions/Error.json" + } + } + } + } + } } } diff --git a/module/Core/src/RedirectRule/Entity/RedirectCondition.php b/module/Core/src/RedirectRule/Entity/RedirectCondition.php index e5f0bae7..72cfdf49 100644 --- a/module/Core/src/RedirectRule/Entity/RedirectCondition.php +++ b/module/Core/src/RedirectRule/Entity/RedirectCondition.php @@ -7,6 +7,7 @@ use Psr\Http\Message\ServerRequestInterface; use Shlinkio\Shlink\Common\Entity\AbstractEntity; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use function Shlinkio\Shlink\Core\acceptLanguageToLocales; use function Shlinkio\Shlink\Core\ArrayUtils\some; @@ -39,6 +40,15 @@ class RedirectCondition extends AbstractEntity implements JsonSerializable return new self(RedirectConditionType::DEVICE, $device->value); } + public static function fromRawData(array $rawData): self + { + $type = RedirectConditionType::from($rawData[RedirectRulesInputFilter::CONDITION_TYPE]); + $value = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_VALUE]; + $key = $rawData[RedirectRulesInputFilter::CONDITION_MATCH_KEY] ?? null; + + return new self($type, $value, $key); + } + /** * Tells if this condition matches provided request */ diff --git a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php index 72bcfa99..4469a620 100644 --- a/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php +++ b/module/Core/src/RedirectRule/Entity/ShortUrlRedirectRule.php @@ -36,6 +36,11 @@ class ShortUrlRedirectRule extends AbstractEntity implements JsonSerializable ); } + public function clearConditions(): void + { + $this->conditions->clear(); + } + public function jsonSerialize(): array { return [ diff --git a/module/Core/src/RedirectRule/Model/RedirectRulesData.php b/module/Core/src/RedirectRule/Model/RedirectRulesData.php new file mode 100644 index 00000000..d9a9db18 --- /dev/null +++ b/module/Core/src/RedirectRule/Model/RedirectRulesData.php @@ -0,0 +1,34 @@ +isValid()) { + throw ValidationException::fromInputFilter($inputFilter); + } + + return new self(array_values($inputFilter->getValue(RedirectRulesInputFilter::REDIRECT_RULES))); + } catch (InvalidArgumentException) { + throw ValidationException::fromArray( + [RedirectRulesInputFilter::REDIRECT_RULES => RedirectRulesInputFilter::REDIRECT_RULES], + ); + } + } +} diff --git a/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php new file mode 100644 index 00000000..5decaf4c --- /dev/null +++ b/module/Core/src/RedirectRule/Model/Validation/RedirectRulesInputFilter.php @@ -0,0 +1,89 @@ +setInputFilter(self::createRedirectRuleInputFilter()); + + $instance = new self(); + $instance->add($redirectRulesInputFilter, self::REDIRECT_RULES); + + $instance->setData($rawData); + return $instance; + } + + private static function createRedirectRuleInputFilter(): InputFilter + { + $redirectRuleInputFilter = new InputFilter(); + + $longUrl = InputFactory::basic(self::RULE_LONG_URL, required: true); + $longUrl->getValidatorChain()->merge(ShortUrlInputFilter::longUrlValidators()); + $redirectRuleInputFilter->add($longUrl); + + $conditionsInputFilter = new CollectionInputFilter(); + $conditionsInputFilter->setInputFilter(self::createRedirectConditionInputFilter()) + ->setIsRequired(true); + $redirectRuleInputFilter->add($conditionsInputFilter, self::RULE_CONDITIONS); + + return $redirectRuleInputFilter; + } + + private static function createRedirectConditionInputFilter(): InputFilter + { + $redirectConditionInputFilter = new InputFilter(); + + $type = InputFactory::basic(self::CONDITION_TYPE, required: true); + $type->getValidatorChain()->attach(new InArray([ + 'haystack' => enumValues(RedirectConditionType::class), + 'strict' => InArray::COMPARE_STRICT, + ])); + $redirectConditionInputFilter->add($type); + + $value = InputFactory::basic(self::CONDITION_MATCH_VALUE, required: true); + $value->getValidatorChain()->attach(new Callback(function (string $value, array $context) { + if ($context[self::CONDITION_TYPE] === RedirectConditionType::DEVICE->value) { + return contains($value, enumValues(DeviceType::class)); + } + + return true; + })); + $redirectConditionInputFilter->add($value); + + $redirectConditionInputFilter->add( + InputFactory::basic(self::CONDITION_MATCH_KEY, required: true)->setAllowEmpty(true), + ); + + return $redirectConditionInputFilter; + } +} diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php index 03d40095..1a770ae9 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleService.php @@ -2,10 +2,16 @@ namespace Shlinkio\Shlink\Core\RedirectRule; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\EntityManagerInterface; +use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; +use function array_map; + readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServiceInterface { public function __construct(private EntityManagerInterface $em) @@ -22,4 +28,45 @@ readonly class ShortUrlRedirectRuleService implements ShortUrlRedirectRuleServic orderBy: ['priority' => 'ASC'], ); } + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array + { + return $this->em->wrapInTransaction(fn () => $this->doSetRulesForShortUrl($shortUrl, $data)); + } + + /** + * @return ShortUrlRedirectRule[] + */ + private function doSetRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array + { + // First, delete existing rules for the short URL + $oldRules = $this->rulesForShortUrl($shortUrl); + foreach ($oldRules as $oldRule) { + $oldRule->clearConditions(); // This will trigger the orphan removal of old conditions + $this->em->remove($oldRule); + } + $this->em->flush(); + + // Then insert new rules + $rules = []; + foreach ($data->rules as $index => $rule) { + $rule = new ShortUrlRedirectRule( + shortUrl: $shortUrl, + priority: $index + 1, + longUrl: $rule[RedirectRulesInputFilter::RULE_LONG_URL], + conditions: new ArrayCollection(array_map( + RedirectCondition::fromRawData(...), + $rule[RedirectRulesInputFilter::RULE_CONDITIONS], + )), + ); + + $rules[] = $rule; + $this->em->persist($rule); + } + + return $rules; + } } diff --git a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php index cda82910..7fc34a1b 100644 --- a/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php +++ b/module/Core/src/RedirectRule/ShortUrlRedirectRuleServiceInterface.php @@ -3,6 +3,7 @@ namespace Shlinkio\Shlink\Core\RedirectRule; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; interface ShortUrlRedirectRuleServiceInterface @@ -11,4 +12,9 @@ interface ShortUrlRedirectRuleServiceInterface * @return ShortUrlRedirectRule[] */ public function rulesForShortUrl(ShortUrl $shortUrl): array; + + /** + * @return ShortUrlRedirectRule[] + */ + public function setRulesForShortUrl(ShortUrl $shortUrl, RedirectRulesData $data): array; } diff --git a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php index 8818e0f6..22000e2c 100644 --- a/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php +++ b/module/Core/src/ShortUrl/Model/Validation/ShortUrlInputFilter.php @@ -93,7 +93,7 @@ class ShortUrlInputFilter extends InputFilter private function initializeForEdition(bool $requireLongUrl = false): void { $longUrlInput = InputFactory::basic(self::LONG_URL, required: $requireLongUrl); - $longUrlInput->getValidatorChain()->merge($this->longUrlValidators()); + $longUrlInput->getValidatorChain()->merge(self::longUrlValidators(allowNull: ! $requireLongUrl)); $this->add($longUrlInput); $validSince = InputFactory::basic(self::VALID_SINCE); @@ -124,7 +124,7 @@ class ShortUrlInputFilter extends InputFilter $this->add($apiKeyInput); } - private function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain + public static function longUrlValidators(bool $allowNull = false): Validator\ValidatorChain { $emptyModifiers = [ Validator\NotEmpty::OBJECT, diff --git a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php index 244e4f2f..19e431db 100644 --- a/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php +++ b/module/Core/test/RedirectRule/Entity/ShortUrlRedirectRuleTest.php @@ -20,7 +20,7 @@ class ShortUrlRedirectRuleTest extends TestCase ->withHeader('Accept-Language', 'en-UK') ->withQueryParams(['foo' => 'bar']); - $result = $this->createRule($conditions)->matchesRequest($request); + $result = $this->createRule(new ArrayCollection($conditions))->matchesRequest($request); self::assertEquals($expectedResult, $result); } @@ -38,12 +38,25 @@ class ShortUrlRedirectRuleTest extends TestCase ]; } + #[Test] + public function conditionsCanBeCleared(): void + { + $conditions = new ArrayCollection( + [RedirectCondition::forLanguage('en-UK'), RedirectCondition::forQueryParam('foo', 'bar')], + ); + $rule = $this->createRule($conditions); + + self::assertNotEmpty($conditions); + $rule->clearConditions(); + self::assertEmpty($conditions); + } + /** - * @param RedirectCondition[] $conditions + * @param ArrayCollection $conditions */ - private function createRule(array $conditions): ShortUrlRedirectRule + private function createRule(ArrayCollection $conditions): ShortUrlRedirectRule { $shortUrl = ShortUrl::withLongUrl('https://s.test'); - return new ShortUrlRedirectRule($shortUrl, 1, '', new ArrayCollection($conditions)); + return new ShortUrlRedirectRule($shortUrl, 1, '', $conditions); } } diff --git a/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php new file mode 100644 index 00000000..f0ded32b --- /dev/null +++ b/module/Core/test/RedirectRule/Model/RedirectRulesDataTest.php @@ -0,0 +1,59 @@ + ['foo']]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 34, + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'invalid', + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'device', + 'matchValue' => 'invalid-device', + 'matchKey' => null, + ], + ], + ], + ]]])] + #[TestWith([['redirectRules' => [ + [ + 'longUrl' => 'https://example.com', + 'conditions' => [ + [ + 'type' => 'language', + ], + ], + ], + ]]])] + public function throwsWhenProvidedDataIsInvalid(array $invalidData): void + { + $this->expectException(ValidationException::class); + RedirectRulesData::fromRawData($invalidData); + } +} diff --git a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php index 016c5453..b0b6d4f2 100644 --- a/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php +++ b/module/Core/test/RedirectRule/ShortUrlRedirectRuleServiceTest.php @@ -11,6 +11,9 @@ use PHPUnit\Framework\TestCase; use Shlinkio\Shlink\Core\Model\DeviceType; use Shlinkio\Shlink\Core\RedirectRule\Entity\RedirectCondition; use Shlinkio\Shlink\Core\RedirectRule\Entity\ShortUrlRedirectRule; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectConditionType; +use Shlinkio\Shlink\Core\RedirectRule\Model\RedirectRulesData; +use Shlinkio\Shlink\Core\RedirectRule\Model\Validation\RedirectRulesInputFilter; use Shlinkio\Shlink\Core\RedirectRule\ShortUrlRedirectRuleService; use Shlinkio\Shlink\Core\ShortUrl\Entity\ShortUrl; @@ -26,7 +29,7 @@ class ShortUrlRedirectRuleServiceTest extends TestCase } #[Test] - public function delegatesToRepository(): void + public function rulesForShortUrlDelegatesToRepository(): void { $shortUrl = ShortUrl::withLongUrl('https://shlink.io'); $rules = [ @@ -52,4 +55,81 @@ class ShortUrlRedirectRuleServiceTest extends TestCase self::assertSame($rules, $result); } + + #[Test] + public function setRulesForShortUrlParsesProvidedData(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [ + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/first', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::ANDROID->value, + ], + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::QUERY_PARAM->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => 'foo', + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => 'bar', + ], + ], + ], + [ + RedirectRulesInputFilter::RULE_LONG_URL => 'https://example.com/second', + RedirectRulesInputFilter::RULE_CONDITIONS => [ + [ + RedirectRulesInputFilter::CONDITION_TYPE => RedirectConditionType::DEVICE->value, + RedirectRulesInputFilter::CONDITION_MATCH_KEY => null, + RedirectRulesInputFilter::CONDITION_MATCH_VALUE => DeviceType::IOS->value, + ], + ], + ], + ], + ]); + + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->exactly(2))->method('persist'); + $this->em->expects($this->never())->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(2, $result); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[0]); + self::assertInstanceOf(ShortUrlRedirectRule::class, $result[1]); + } + + #[Test] + public function setRulesForShortUrlRemovesOldRules(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $data = RedirectRulesData::fromRawData([ + RedirectRulesInputFilter::REDIRECT_RULES => [], + ]); + + $repo = $this->createMock(EntityRepository::class); + $repo->expects($this->once())->method('findBy')->with( + ['shortUrl' => $shortUrl], + ['priority' => 'ASC'], + )->willReturn([ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com'), + new ShortUrlRedirectRule($shortUrl, 2, 'https://example.com'), + ]); + $this->em->expects($this->once())->method('getRepository')->with(ShortUrlRedirectRule::class)->willReturn( + $repo, + ); + $this->em->expects($this->once())->method('wrapInTransaction')->willReturnCallback( + fn (callable $callback) => $callback(), + ); + $this->em->expects($this->never())->method('persist'); + $this->em->expects($this->exactly(2))->method('remove'); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + self::assertCount(0, $result); + } } diff --git a/module/Rest/config/dependencies.config.php b/module/Rest/config/dependencies.config.php index 4eabfec9..9396dd38 100644 --- a/module/Rest/config/dependencies.config.php +++ b/module/Rest/config/dependencies.config.php @@ -48,6 +48,7 @@ return [ Action\Domain\ListDomainsAction::class => ConfigAbstractFactory::class, Action\Domain\DomainRedirectsAction::class => ConfigAbstractFactory::class, Action\RedirectRule\ListRedirectRulesAction::class => ConfigAbstractFactory::class, + Action\RedirectRule\SetRedirectRulesAction::class => ConfigAbstractFactory::class, ImplicitOptionsMiddleware::class => Middleware\EmptyResponseImplicitOptionsMiddlewareFactory::class, Middleware\BodyParserMiddleware::class => InvokableFactory::class, @@ -109,6 +110,10 @@ return [ ShortUrl\ShortUrlResolver::class, RedirectRule\ShortUrlRedirectRuleService::class, ], + Action\RedirectRule\SetRedirectRulesAction::class => [ + ShortUrl\ShortUrlResolver::class, + RedirectRule\ShortUrlRedirectRuleService::class, + ], Middleware\CrossDomainMiddleware::class => ['config.cors'], Middleware\ShortUrl\DropDefaultDomainFromRequestMiddleware::class => ['config.url_shortener.domain.hostname'], diff --git a/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php new file mode 100644 index 00000000..913a833d --- /dev/null +++ b/module/Rest/src/Action/RedirectRule/SetRedirectRulesAction.php @@ -0,0 +1,43 @@ +urlResolver->resolveShortUrl( + ShortUrlIdentifier::fromApiRequest($request), + AuthenticationMiddleware::apiKeyFromRequest($request), + ); + $data = RedirectRulesData::fromRawData((array) $request->getParsedBody()); + + $result = $this->ruleService->setRulesForShortUrl($shortUrl, $data); + + return new JsonResponse([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $result, + ]); + } +} diff --git a/module/Rest/test-api/Action/SetRedirectRulesTest.php b/module/Rest/test-api/Action/SetRedirectRulesTest.php new file mode 100644 index 00000000..c70fd0ea --- /dev/null +++ b/module/Rest/test-api/Action/SetRedirectRulesTest.php @@ -0,0 +1,98 @@ + 'language', + 'matchKey' => null, + 'matchValue' => 'en', + ]; + private const QUERY_FOO_BAR_CONDITION = [ + 'type' => 'query', + 'matchKey' => 'foo', + 'matchValue' => 'bar', + ]; + + #[Test] + public function errorIsReturnedWhenInvalidUrlProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/invalid/redirect-rules'); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(404, $response->getStatusCode()); + self::assertEquals(404, $payload['status']); + self::assertEquals('invalid', $payload['shortCode']); + self::assertEquals('No URL found with short code "invalid"', $payload['detail']); + self::assertEquals('Short URL not found', $payload['title']); + self::assertEquals('https://shlink.io/api/error/short-url-not-found', $payload['type']); + } + + #[Test] + public function errorIsReturnedWhenInvalidDataProvided(): void + { + $response = $this->callApiWithKey(self::METHOD_POST, '/short-urls/abc123/redirect-rules', [ + RequestOptions::JSON => [ + 'redirectRules' => [ + [ + 'longUrl' => 'invalid', + ], + ], + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(400, $response->getStatusCode()); + self::assertEquals(400, $payload['status']); + self::assertEquals('Provided data is not valid', $payload['detail']); + self::assertEquals('Invalid data', $payload['title']); + self::assertEquals('https://shlink.io/api/error/invalid-data', $payload['type']); + } + + #[Test] + #[TestWith(['def456', []])] + #[TestWith(['abc123', [ + [ + 'longUrl' => 'https://example.com/english-and-foo-query', + 'priority' => 1, + 'conditions' => [ + self::LANGUAGE_EN_CONDITION, + self::QUERY_FOO_BAR_CONDITION, + ], + ], + [ + 'longUrl' => 'https://example.com/multiple-query-params', + 'priority' => 2, + 'conditions' => [ + [ + 'type' => 'query', + 'matchKey' => 'hello', + 'matchValue' => 'world', + ], + self::QUERY_FOO_BAR_CONDITION, + ], + ], + ]])] + public function setsListOfRulesForShortUrl(string $shortCode, array $expectedRules): void + { + $response = $this->callApiWithKey(self::METHOD_POST, sprintf('/short-urls/%s/redirect-rules', $shortCode), [ + RequestOptions::JSON => [ + 'redirectRules' => $expectedRules, + ], + ]); + $payload = $this->getJsonResponsePayload($response); + + self::assertEquals(200, $response->getStatusCode()); + self::assertEquals($expectedRules, $payload['redirectRules']); + } +} diff --git a/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php new file mode 100644 index 00000000..e330839c --- /dev/null +++ b/module/Rest/test/Action/RedirectRule/SetRedirectRulesActionTest.php @@ -0,0 +1,58 @@ +urlResolver = $this->createMock(ShortUrlResolverInterface::class); + $this->ruleService = $this->createMock(ShortUrlRedirectRuleServiceInterface::class); + + $this->action = new SetRedirectRulesAction($this->urlResolver, $this->ruleService); + } + + #[Test] + public function requestIsHandledAndRulesAreReturned(): void + { + $shortUrl = ShortUrl::withLongUrl('https://example.com'); + $request = ServerRequestFactory::fromGlobals()->withAttribute(ApiKey::class, ApiKey::create()); + $conditions = [RedirectCondition::forDevice(DeviceType::ANDROID), RedirectCondition::forLanguage('en-US')]; + $redirectRules = [ + new ShortUrlRedirectRule($shortUrl, 1, 'https://example.com/rule', new ArrayCollection($conditions)), + ]; + + $this->urlResolver->expects($this->once())->method('resolveShortUrl')->willReturn($shortUrl); + $this->ruleService->expects($this->once())->method('setRulesForShortUrl')->willReturn($redirectRules); + + /** @var JsonResponse $response */ + $response = $this->action->handle($request); + $payload = $response->getPayload(); + + self::assertEquals([ + 'defaultLongUrl' => $shortUrl->getLongUrl(), + 'redirectRules' => $redirectRules, + ], $payload); + } +}