diff --git a/composer.json b/composer.json index 76292b5f7..5724674da 100644 --- a/composer.json +++ b/composer.json @@ -41,7 +41,8 @@ "symfony/routing": "^5.3", "symfony/security-bundle": "^5.3", "symfony/security-csrf": "^5.3", - "symfony/yaml": "^5.3" + "symfony/yaml": "^5.3", + "webmozart/assert": "^1.11" }, "require-dev": { "ibexa/ci-scripts": "^0.2@dev", diff --git a/src/bundle/Resources/config/input_parsers.yml b/src/bundle/Resources/config/input_parsers.yml index 35ae7e235..d2bd4bbe1 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -866,3 +866,11 @@ services: $validator: '@validator' tags: - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.MoveLocationInput } + + Ibexa\Rest\Server\Input\Parser\SwapLocationInput: + parent: Ibexa\Rest\Server\Common\Parser + arguments: + $locationService: '@ibexa.api.service.location' + $validator: '@validator' + tags: + - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.SwapLocationInput } diff --git a/src/bundle/Resources/config/routing.yml b/src/bundle/Resources/config/routing.yml index 9484be9d5..9a871372d 100644 --- a/src/bundle/Resources/config/routing.yml +++ b/src/bundle/Resources/config/routing.yml @@ -432,8 +432,17 @@ ibexa.rest.languages.view: # Locations +ibexa.rest.location.swap: + path: /content/locations/{locationPath} + controller: Ibexa\Rest\Server\Controller\Location::swap + condition: 'ibexa_get_media_type(request) === "SwapLocationInput"' + methods: [POST] + options: + options_route_suffix: 'SwapLocationInput' + requirements: + locationPath: "[0-9/]+" -ibexa.rest.move_location: +ibexa.rest.location.move: path: /content/locations/{locationPath} controller: Ibexa\Rest\Server\Controller\Location::moveLocation condition: 'ibexa_get_media_type(request) === "MoveLocationInput"' diff --git a/src/lib/Server/Controller/Location.php b/src/lib/Server/Controller/Location.php index 3e12d51aa..2f2926f4f 100644 --- a/src/lib/Server/Controller/Location.php +++ b/src/lib/Server/Controller/Location.php @@ -338,6 +338,27 @@ public function swapLocation($locationPath, Request $request) return new Values\NoContent(); } + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function swap(Request $request, string $locationPath): Values\NoContent + { + $locationId = $this->extractLocationIdFromPath($locationPath); + $location = $this->locationService->loadLocation($locationId); + + $destinationLocation = $this->inputDispatcher->parse( + new Message( + ['Content-Type' => $request->headers->get('Content-Type')], + $request->getContent(), + ), + ); + + $this->locationService->swapLocation($location, $destinationLocation); + + return new Values\NoContent(); + } + /** * Loads a location by remote ID. * diff --git a/src/lib/Server/Input/Parser/AbstractDestinationLocationParser.php b/src/lib/Server/Input/Parser/AbstractDestinationLocationParser.php new file mode 100644 index 000000000..2ea8e228d --- /dev/null +++ b/src/lib/Server/Input/Parser/AbstractDestinationLocationParser.php @@ -0,0 +1,91 @@ +validateInputData($data); + + return $this->getLocationByPath($data[self::DESTINATION_KEY]); + } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + */ + private function getLocationByPath(string $path): Location + { + return $this->locationService->loadLocation( + $this->extractLocationIdFromPath($path) + ); + } + + private function extractLocationIdFromPath(string $path): int + { + $pathParts = explode('/', $path); + $lastPart = array_pop($pathParts); + + Assert::integerish($lastPart); + + return (int)$lastPart; + } + + /** + * @phpstan-assert array{ + * 'destination': string, + * } $data + * + * @param array $data + * + * @throws \Ibexa\Rest\Server\Exceptions\ValidationFailedException + */ + private function validateInputData(array $data): void + { + $builder = $this->getValidatorBuilder(); + $builder->validateInputArray($data); + $violations = $builder->build()->getViolations(); + if ($violations->count() > 0) { + throw new ValidationFailedException( + static::PARSER, + $violations, + ); + } + } + + abstract protected function getValidatorBuilder(): BaseInputParserValidatorBuilder; +} diff --git a/src/lib/Server/Input/Parser/MoveLocation.php b/src/lib/Server/Input/Parser/MoveLocation.php index 0e00fafb3..79b113572 100644 --- a/src/lib/Server/Input/Parser/MoveLocation.php +++ b/src/lib/Server/Input/Parser/MoveLocation.php @@ -8,77 +8,15 @@ namespace Ibexa\Rest\Server\Input\Parser; -use Ibexa\Contracts\Core\Repository\LocationService; -use Ibexa\Contracts\Core\Repository\Values\Content\Location; -use Ibexa\Contracts\Rest\Input\ParsingDispatcher; -use Ibexa\Rest\Input\BaseParser; -use Ibexa\Rest\Server\Exceptions\ValidationFailedException; +use Ibexa\Rest\Server\Validation\Builder\Input\Parser\BaseInputParserValidatorBuilder; use Ibexa\Rest\Server\Validation\Builder\Input\Parser\MoveLocationInputValidatorBuilder; -use Symfony\Component\Validator\Validator\ValidatorInterface; -final class MoveLocation extends BaseParser +final class MoveLocation extends AbstractDestinationLocationParser { - public const string DESTINATION_KEY = 'destination'; + protected const string PARSER = 'MoveLocation'; - public function __construct( - private readonly LocationService $locationService, - private readonly ValidatorInterface $validator, - ) { - } - - /** - * @phpstan-param array{ - * 'destination': string, - * } $data - * - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - * @throws \Ibexa\Contracts\Rest\Exceptions\Parser - */ - public function parse(array $data, ParsingDispatcher $parsingDispatcher): Location - { - $this->validateInputData($data); - - return $this->getLocationByPath($data[self::DESTINATION_KEY]); - } - - /** - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException - * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException - */ - private function getLocationByPath(string $path): Location - { - return $this->locationService->loadLocation( - $this->extractLocationIdFromPath($path) - ); - } - - private function extractLocationIdFromPath(string $path): int - { - $pathParts = explode('/', $path); - - return (int)array_pop($pathParts); - } - - /** - * @phpstan-assert array{ - * 'destination': string, - * } $data - * - * @param array $data - * - * @throws \Ibexa\Rest\Server\Exceptions\ValidationFailedException - */ - private function validateInputData(array $data): void + protected function getValidatorBuilder(): BaseInputParserValidatorBuilder { - $builder = new MoveLocationInputValidatorBuilder($this->validator); - $builder->validateInputArray($data); - $violations = $builder->build()->getViolations(); - if ($violations->count() > 0) { - throw new ValidationFailedException( - 'MoveLocation', - $violations, - ); - } + return new MoveLocationInputValidatorBuilder($this->validator); } } diff --git a/src/lib/Server/Input/Parser/SwapLocationInput.php b/src/lib/Server/Input/Parser/SwapLocationInput.php new file mode 100644 index 000000000..88926f4c5 --- /dev/null +++ b/src/lib/Server/Input/Parser/SwapLocationInput.php @@ -0,0 +1,22 @@ +validator); + } +} diff --git a/src/lib/Server/Validation/Builder/Input/Parser/SwapLocationInputValidatorBuilder.php b/src/lib/Server/Validation/Builder/Input/Parser/SwapLocationInputValidatorBuilder.php new file mode 100644 index 000000000..b1225ba72 --- /dev/null +++ b/src/lib/Server/Validation/Builder/Input/Parser/SwapLocationInputValidatorBuilder.php @@ -0,0 +1,29 @@ + [ + new Assert\NotBlank(), + new Assert\Type('string'), + new Assert\Regex('/^(\/\d+)+$/'), + ], + ], + ); + } +} diff --git a/tests/bundle/Functional/HttpOptionsTest.php b/tests/bundle/Functional/HttpOptionsTest.php index 1ee56f2e8..36ca2cd1c 100644 --- a/tests/bundle/Functional/HttpOptionsTest.php +++ b/tests/bundle/Functional/HttpOptionsTest.php @@ -81,6 +81,7 @@ public function providerForTestHttpOptions(): array ['/content/objects/1/objectstates', ['GET', 'PATCH']], ['/content/locations', ['GET']], ['/content/locations/1/2', ['POST'], 'MoveLocationInput+json'], + ['/content/locations/1/2', ['POST'], 'SwapLocationInput+json'], ['/content/locations/1/2', ['GET', 'PATCH', 'DELETE', 'COPY', 'MOVE', 'SWAP']], ['/content/locations/1/2/children', ['GET']], ['/content/objects/1/locations', ['GET', 'POST']], diff --git a/tests/bundle/Functional/LocationTest.php b/tests/bundle/Functional/LocationTest.php index aa83ec392..97aa15d48 100644 --- a/tests/bundle/Functional/LocationTest.php +++ b/tests/bundle/Functional/LocationTest.php @@ -268,7 +268,7 @@ private function createUrlAlias(string $locationHref, string $urlAlias): string /** * @depends testMoveSubtree */ - public function testMoveLocation(string $locationHref): void + public function testMoveLocation(string $locationHref): string { $request = $this->createHttpRequest( 'POST', @@ -282,5 +282,55 @@ public function testMoveLocation(string $locationHref): void self::assertHttpResponseCodeEquals($response, 201); self::assertHttpResponseHasHeader($response, 'Location'); + + return $locationHref; + } + + /** + * @depends testMoveLocation + */ + public function testSwap(string $locationHref): void + { + $request = $this->createHttpRequest( + 'COPY', + $locationHref, + '', + '', + '', + ['Destination' => '/api/ibexa/v2/content/locations/1/43'] + ); + $response = $this->sendHttpRequest($request); + $newCopiedLocation = $response->getHeader('Location')[0]; + + $request = $this->createHttpRequest( + 'COPY', + $locationHref, + '', + '', + '', + ['Destination' => '/api/ibexa/v2/content/locations/1/43'] + ); + $response = $this->sendHttpRequest($request); + $secondCopiedLocation = $response->getHeader('Location')[0]; + + $request = $this->createHttpRequest( + 'POST', + $newCopiedLocation, + 'SwapLocationInput+json', + '', + json_encode([ + 'SwapLocationInput' => [ + 'destination' => str_replace( + '/api/ibexa/v2/content/locations', + '', + $secondCopiedLocation, + ), + ], + ], JSON_THROW_ON_ERROR), + ); + + $response = $this->sendHttpRequest($request); + + self::assertHttpResponseCodeEquals($response, 204); } } diff --git a/tests/bundle/Functional/SearchView/Criterion/IsContainerTest.php b/tests/bundle/Functional/SearchView/Criterion/IsContainerTest.php index c19b966e9..2a889e36d 100644 --- a/tests/bundle/Functional/SearchView/Criterion/IsContainerTest.php +++ b/tests/bundle/Functional/SearchView/Criterion/IsContainerTest.php @@ -28,7 +28,7 @@ public function getCriteriaPayloads(): iterable 'is container' => [ 'json', $this->buildJsonCriterionQuery('"IsContainerCriterion": true'), - 10, + 12, ], 'is not container' => [ 'json', diff --git a/tests/bundle/Functional/SearchView/Criterion/IsUserBasedTest.php b/tests/bundle/Functional/SearchView/Criterion/IsUserBasedTest.php index 3e1d647a8..4ab5b4d4f 100644 --- a/tests/bundle/Functional/SearchView/Criterion/IsUserBasedTest.php +++ b/tests/bundle/Functional/SearchView/Criterion/IsUserBasedTest.php @@ -23,7 +23,7 @@ public function getCriteriaPayloads(): iterable 'is not user based' => [ 'json', $this->buildJsonCriterionQuery('"IsUserBasedCriterion": false'), - 10, + 12, ], ]; } diff --git a/tests/bundle/Functional/SearchView/Criterion/ObjectStateIdentifierTest.php b/tests/bundle/Functional/SearchView/Criterion/ObjectStateIdentifierTest.php index 999c617de..a019afae7 100644 --- a/tests/bundle/Functional/SearchView/Criterion/ObjectStateIdentifierTest.php +++ b/tests/bundle/Functional/SearchView/Criterion/ObjectStateIdentifierTest.php @@ -18,12 +18,12 @@ public function getCriteriaPayloads(): iterable 'identifier with target group' => [ 'json', $this->buildJsonCriterionQuery('"ObjectStateIdentifierCriterion": {"value": "not_locked", "target": "ez_lock"}'), - 12, + 14, ], 'identifier without target group' => [ 'json', $this->buildJsonCriterionQuery('"ObjectStateIdentifierCriterion": {"value": "not_locked", "target": null}'), - 12, + 14, ], ]; } diff --git a/tests/lib/Server/Input/Parser/AbstractDestinationLocationInputTest.php b/tests/lib/Server/Input/Parser/AbstractDestinationLocationInputTest.php new file mode 100644 index 000000000..b86b3eb67 --- /dev/null +++ b/tests/lib/Server/Input/Parser/AbstractDestinationLocationInputTest.php @@ -0,0 +1,98 @@ + $destinationPath, + ]; + + $moveLocationParser = $this->getParser(); + + $this->locationService + ->expects(self::once()) + ->method('loadLocation') + ->with(self::TESTED_LOCATION_ID) + ->willReturn($this->getMockedLocation()); + + $result = $moveLocationParser->parse($inputArray, $this->getParsingDispatcherMock()); + + self::assertEquals( + $this->getMockedLocation()->id, + $result->id, + ); + + self::assertEquals( + $this->getMockedLocation()->getPathString(), + $result->getPathString(), + ); + } + + protected function parseExceptionOnMissingDestinationElement(string $parser): void + { + $this->expectException(ValidationFailedException::class); + $this->expectExceptionMessage( + sprintf('Input data validation failed for %s', $parser), + ); + + $inputArray = [ + 'new_destination' => '/1/2/3', + ]; + + $sessionInput = $this->getParser(); + + $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + } + + protected function parseExceptionOnInvalidDestinationElement(string $parser): void + { + $inputArray = [ + 'destination' => 'test_destination', + ]; + + $sessionInput = $this->getParser(); + + $this->expectException(ValidationFailedException::class); + $this->expectExceptionMessage( + sprintf('Input data validation failed for %s', $parser), + ); + + $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + } + + private function getMockedLocation(): Location + { + return new Location( + [ + 'id' => self::TESTED_LOCATION_ID, + 'pathString' => sprintf('/1/2/%d', self::TESTED_LOCATION_ID), + ], + ); + } + + abstract protected function internalGetParser(): AbstractDestinationLocationParser; +} diff --git a/tests/lib/Server/Input/Parser/MoveLocationTest.php b/tests/lib/Server/Input/Parser/MoveLocationTest.php index 6170c5f2b..6818d691f 100644 --- a/tests/lib/Server/Input/Parser/MoveLocationTest.php +++ b/tests/lib/Server/Input/Parser/MoveLocationTest.php @@ -9,76 +9,26 @@ namespace Ibexa\Tests\Rest\Server\Input\Parser; use Ibexa\Contracts\Core\Repository\LocationService; -use Ibexa\Core\Repository\Values\Content\Location; -use Ibexa\Rest\Server\Exceptions\ValidationFailedException; use Ibexa\Rest\Server\Input\Parser\MoveLocation; -use PHPUnit\Framework\MockObject\MockObject; use Symfony\Component\Validator\Validation; -use Symfony\Component\Validator\Validator\ValidatorInterface; -final class MoveLocationTest extends BaseTest +final class MoveLocationTest extends AbstractDestinationLocationInputTest { - private const int TESTED_LOCATION_ID = 22; - - private MockObject&LocationService $locationService; - - private ValidatorInterface $validator; + private const string PARSER = 'MoveLocation'; public function testParse(): void { - $destinationPath = sprintf('/1/2/%d', self::TESTED_LOCATION_ID); - - $inputArray = [ - 'destination' => $destinationPath, - ]; - - $moveLocationParser = $this->getParser(); - - $this->locationService - ->expects(self::once()) - ->method('loadLocation') - ->with(self::TESTED_LOCATION_ID) - ->willReturn($this->getMockedLocation()); - - $result = $moveLocationParser->parse($inputArray, $this->getParsingDispatcherMock()); - - self::assertEquals( - $this->getMockedLocation()->id, - $result->id, - ); - - self::assertEquals( - $this->getMockedLocation()->getPathString(), - $result->getPathString(), - ); + $this->parse(); } public function testParseExceptionOnMissingDestinationElement(): void { - $this->expectException(ValidationFailedException::class); - $this->expectExceptionMessage('Input data validation failed for MoveLocation'); - - $inputArray = [ - 'new_destination' => '/1/2/3', - ]; - - $sessionInput = $this->getParser(); - - $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + $this->parseExceptionOnMissingDestinationElement(self::PARSER); } public function testParseExceptionOnInvalidDestinationElement(): void { - $inputArray = [ - 'destination' => 'test_destination', - ]; - - $sessionInput = $this->getParser(); - - $this->expectException(ValidationFailedException::class); - $this->expectExceptionMessage('Input data validation failed for MoveLocation'); - - $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + $this->parseExceptionOnInvalidDestinationElement(self::PARSER); } protected function internalGetParser(): MoveLocation @@ -92,14 +42,4 @@ protected function internalGetParser(): MoveLocation $this->validator, ); } - - private function getMockedLocation(): Location - { - return new Location( - [ - 'id' => self::TESTED_LOCATION_ID, - 'pathString' => sprintf('/1/2/%d', self::TESTED_LOCATION_ID), - ], - ); - } } diff --git a/tests/lib/Server/Input/Parser/SwapLocationInputTest.php b/tests/lib/Server/Input/Parser/SwapLocationInputTest.php new file mode 100644 index 000000000..8b8a3bbec --- /dev/null +++ b/tests/lib/Server/Input/Parser/SwapLocationInputTest.php @@ -0,0 +1,45 @@ +parse(); + } + + public function testParseExceptionOnMissingDestinationElement(): void + { + $this->parseExceptionOnMissingDestinationElement(self::PARSER); + } + + public function testParseExceptionOnInvalidDestinationElement(): void + { + $this->parseExceptionOnInvalidDestinationElement(self::PARSER); + } + + protected function internalGetParser(): SwapLocationInput + { + $locationService = $this->createMock(LocationService::class); + $this->locationService = $locationService; + $this->validator = Validation::createValidator(); + + return new SwapLocationInput( + $this->locationService, + $this->validator, + ); + } +}