diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index d778f9a2..9cf96fd4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4035,11 +4035,6 @@ parameters: count: 1 path: tests/bundle/Functional/LocationTest.php - - - message: "#^Method Ibexa\\\\Tests\\\\Bundle\\\\Rest\\\\Functional\\\\LocationTest\\:\\:testMoveSubtree\\(\\) has no return type specified\\.$#" - count: 1 - path: tests/bundle/Functional/LocationTest.php - - message: "#^Method Ibexa\\\\Tests\\\\Bundle\\\\Rest\\\\Functional\\\\LocationTest\\:\\:testMoveSubtree\\(\\) has parameter \\$locationHref with no type specified\\.$#" count: 1 diff --git a/src/bundle/Resources/config/input_parsers.yml b/src/bundle/Resources/config/input_parsers.yml index 867e9222..35ae7e23 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -858,3 +858,11 @@ services: $validator: '@validator' tags: - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.internal.criterion.Image } + + Ibexa\Rest\Server\Input\Parser\MoveLocation: + 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.MoveLocationInput } diff --git a/src/bundle/Resources/config/routing.yml b/src/bundle/Resources/config/routing.yml index a5426133..38d056f2 100644 --- a/src/bundle/Resources/config/routing.yml +++ b/src/bundle/Resources/config/routing.yml @@ -404,6 +404,16 @@ ibexa.rest.languages.view: # Locations +ibexa.rest.move_location: + path: /content/locations/{locationPath} + controller: Ibexa\Rest\Server\Controller\Location::moveLocation + condition: 'ibexa_get_media_type(request) === "MoveLocationInput"' + methods: [POST] + options: + options_route_suffix: 'MoveLocationInput' + requirements: + locationPath: "[0-9/]+" + ibexa.rest.redirect_location: path: /content/locations defaults: diff --git a/src/lib/Server/Controller/Location.php b/src/lib/Server/Controller/Location.php index bb5a1264..3e12d51a 100644 --- a/src/lib/Server/Controller/Location.php +++ b/src/lib/Server/Controller/Location.php @@ -279,6 +279,39 @@ public function moveSubtree($locationPath, Request $request) } } + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\InvalidArgumentException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function moveLocation(Request $request, string $locationPath): Values\ResourceCreated + { + $destinationLocation = $this->inputDispatcher->parse( + new Message( + ['Content-Type' => $request->headers->get('Content-Type')], + $request->getContent(), + ), + ); + + $locationToMove = $this->locationService->loadLocation( + $this->extractLocationIdFromPath($locationPath), + ); + + $this->locationService->moveSubtree($locationToMove, $destinationLocation); + + // Reload the location to get a new subtree position + $locationToMove = $this->locationService->loadLocation($locationToMove->id); + + return new Values\ResourceCreated( + $this->router->generate( + 'ibexa.rest.load_location', + [ + 'locationPath' => trim($locationToMove->getPathString(), '/'), + ], + ), + ); + } + /** * Swaps a location with another one. * diff --git a/src/lib/Server/Input/Parser/MoveLocation.php b/src/lib/Server/Input/Parser/MoveLocation.php new file mode 100644 index 00000000..0e00fafb --- /dev/null +++ b/src/lib/Server/Input/Parser/MoveLocation.php @@ -0,0 +1,84 @@ +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 + { + $builder = new MoveLocationInputValidatorBuilder($this->validator); + $builder->validateInputArray($data); + $violations = $builder->build()->getViolations(); + if ($violations->count() > 0) { + throw new ValidationFailedException( + 'MoveLocation', + $violations, + ); + } + } +} diff --git a/src/lib/Server/Validation/Builder/Input/Parser/MoveLocationInputValidatorBuilder.php b/src/lib/Server/Validation/Builder/Input/Parser/MoveLocationInputValidatorBuilder.php new file mode 100644 index 00000000..13fba2c0 --- /dev/null +++ b/src/lib/Server/Validation/Builder/Input/Parser/MoveLocationInputValidatorBuilder.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 5f978872..5d29ac8b 100644 --- a/tests/bundle/Functional/HttpOptionsTest.php +++ b/tests/bundle/Functional/HttpOptionsTest.php @@ -77,6 +77,7 @@ public function providerForTestHttpOptions(): array ['/content/objectstategroups/1/objectstates/1', ['GET', 'PATCH', 'DELETE']], ['/content/objects/1/objectstates', ['GET', 'PATCH']], ['/content/locations', ['GET']], + ['/content/locations/1/2', ['POST'], 'MoveLocationInput+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 10c8c86e..aa83ec39 100644 --- a/tests/bundle/Functional/LocationTest.php +++ b/tests/bundle/Functional/LocationTest.php @@ -139,7 +139,7 @@ public function testCopySubtree($locationHref) * * @depends testCopySubtree */ - public function testMoveSubtree($locationHref) + public function testMoveSubtree($locationHref): string { $request = $this->createHttpRequest( 'MOVE', @@ -153,6 +153,8 @@ public function testMoveSubtree($locationHref) self::assertHttpResponseCodeEquals($response, 201); self::assertHttpResponseHasHeader($response, 'Location'); + + return $locationHref; } /** @@ -262,4 +264,23 @@ private function createUrlAlias(string $locationHref, string $urlAlias): string return $href; } + + /** + * @depends testMoveSubtree + */ + public function testMoveLocation(string $locationHref): void + { + $request = $this->createHttpRequest( + 'POST', + $locationHref, + 'MoveLocationInput+json', + '', + json_encode(['MoveLocationInput' => ['destination' => '/1/2']], JSON_THROW_ON_ERROR), + ); + + $response = $this->sendHttpRequest($request); + + self::assertHttpResponseCodeEquals($response, 201); + self::assertHttpResponseHasHeader($response, 'Location'); + } } diff --git a/tests/lib/Server/Input/Parser/MoveLocationTest.php b/tests/lib/Server/Input/Parser/MoveLocationTest.php new file mode 100644 index 00000000..6170c5f2 --- /dev/null +++ b/tests/lib/Server/Input/Parser/MoveLocationTest.php @@ -0,0 +1,105 @@ + $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(), + ); + } + + 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()); + } + + 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()); + } + + protected function internalGetParser(): MoveLocation + { + $locationService = $this->createMock(LocationService::class); + $this->locationService = $locationService; + $this->validator = Validation::createValidator(); + + return new MoveLocation( + $this->locationService, + $this->validator, + ); + } + + private function getMockedLocation(): Location + { + return new Location( + [ + 'id' => self::TESTED_LOCATION_ID, + 'pathString' => sprintf('/1/2/%d', self::TESTED_LOCATION_ID), + ], + ); + } +}