diff --git a/src/bundle/Resources/config/input_parsers.yml b/src/bundle/Resources/config/input_parsers.yml index d2bd4bbe1..011711041 100644 --- a/src/bundle/Resources/config/input_parsers.yml +++ b/src/bundle/Resources/config/input_parsers.yml @@ -874,3 +874,11 @@ services: $validator: '@validator' tags: - { name: ibexa.rest.input.parser, mediaType: application/vnd.ibexa.api.SwapLocationInput } + + Ibexa\Rest\Server\Input\Parser\RestoreTrashItemInput: + 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.RestoreTrashItemInput } diff --git a/src/bundle/Resources/config/routing.yml b/src/bundle/Resources/config/routing.yml index 9a871372d..26c79d905 100644 --- a/src/bundle/Resources/config/routing.yml +++ b/src/bundle/Resources/config/routing.yml @@ -775,6 +775,16 @@ ibexa.rest.unlink_content_type_from_group: # Trash +ibexa.rest.trash.restore_trash_item: + path: /content/trash/{trashItemId} + controller: Ibexa\Rest\Server\Controller\Trash::restoreItem + condition: 'ibexa_get_media_type(request) === "RestoreTrashItemInput"' + methods: [POST] + options: + options_route_suffix: 'RestoreTrashItemInput' + requirements: + trashItemId: \d+ + ibexa.rest.load_trash_items: path: /content/trash defaults: diff --git a/src/lib/Server/Controller/Trash.php b/src/lib/Server/Controller/Trash.php index a6223c668..b5dc3a723 100644 --- a/src/lib/Server/Controller/Trash.php +++ b/src/lib/Server/Controller/Trash.php @@ -11,6 +11,7 @@ use Ibexa\Contracts\Core\Repository\LocationService; use Ibexa\Contracts\Core\Repository\TrashService; use Ibexa\Contracts\Core\Repository\Values\Content\Query; +use Ibexa\Rest\Message; use Ibexa\Rest\Server\Controller as RestController; use Ibexa\Rest\Server\Exceptions\ForbiddenException; use Ibexa\Rest\Server\Values; @@ -176,4 +177,35 @@ public function restoreTrashItem($trashItemId, Request $request) ) ); } + + /** + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\NotFoundException + * @throws \Ibexa\Contracts\Core\Repository\Exceptions\UnauthorizedException + */ + public function restoreItem(int $trashItemId, Request $request): Values\ResourceCreated + { + $locationDestination = $this->inputDispatcher->parse( + new Message( + ['Content-Type' => $request->headers->get('Content-Type')], + $request->getContent(), + ), + ); + + $trashItem = $this->trashService->loadTrashItem($trashItemId); + + if ($locationDestination === null) { + $locationDestination = $this->locationService->loadLocation($trashItem->parentLocationId); + } + + $location = $this->trashService->recover($trashItem, $locationDestination); + + return new Values\ResourceCreated( + $this->router->generate( + 'ibexa.rest.load_location', + [ + 'locationPath' => trim($location->getPathString(), '/'), + ], + ) + ); + } } diff --git a/src/lib/Server/Input/Parser/RestoreTrashItemInput.php b/src/lib/Server/Input/Parser/RestoreTrashItemInput.php new file mode 100644 index 000000000..4cf75db34 --- /dev/null +++ b/src/lib/Server/Input/Parser/RestoreTrashItemInput.php @@ -0,0 +1,92 @@ +validateInputData($data); + + $location = $data[self::DESTINATION_KEY] ?? null; + + return $location === null ? null : $this->getLocationByPath($location); + } + + /** + * @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 = new RestoreTrashItemInputValidatorBuilder($this->validator); + $builder->validateInputArray($data); + $violations = $builder->build()->getViolations(); + + if ($violations->count() > 0) { + throw new ValidationFailedException( + 'RestoreTrashItemInput', + $violations, + ); + } + } +} diff --git a/src/lib/Server/Validation/Builder/Input/Parser/RestoreTrashItemInputValidatorBuilder.php b/src/lib/Server/Validation/Builder/Input/Parser/RestoreTrashItemInputValidatorBuilder.php new file mode 100644 index 000000000..e306420e8 --- /dev/null +++ b/src/lib/Server/Validation/Builder/Input/Parser/RestoreTrashItemInputValidatorBuilder.php @@ -0,0 +1,34 @@ + [ + 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 36ca2cd1c..1cc4dc619 100644 --- a/tests/bundle/Functional/HttpOptionsTest.php +++ b/tests/bundle/Functional/HttpOptionsTest.php @@ -101,6 +101,7 @@ public function providerForTestHttpOptions(): array ['/content/types/1/groups/1', ['DELETE']], ['/content/trash', ['GET', 'DELETE']], ['/content/trash/1', ['GET', 'DELETE', 'MOVE']], + ['/content/trash/1', ['POST'], 'RestoreTrashItemInput+json'], ['/content/urlwildcards', ['GET', 'POST']], ['/content/urlwildcards/1', ['GET', 'DELETE']], ['/user/policies', ['GET']], diff --git a/tests/bundle/Functional/TrashTest.php b/tests/bundle/Functional/TrashTest.php index 0c73e127a..396bfded8 100644 --- a/tests/bundle/Functional/TrashTest.php +++ b/tests/bundle/Functional/TrashTest.php @@ -169,4 +169,38 @@ private function sendLocationToTrash(string $contentHref): string return $trashHref; } + + public function testRestoreItemWithDestination(): void + { + $trashItemHref = $this->createTrashItem('testItemToRestore'); + + $request = $this->createHttpRequest( + 'POST', + $trashItemHref, + 'RestoreTrashItemInput+json', + '', + json_encode(['RestoreTrashItemInput' => ['destination' => '/1/2']], JSON_THROW_ON_ERROR), + ); + $response = $this->sendHttpRequest($request); + + self::assertHttpResponseCodeEquals($response, 201); + self::assertHttpResponseHasHeader($response, 'Location'); + } + + public function testRestoreTrashItemWithoutDestination(): void + { + $trashItemHref = $this->createTrashItem('testItemToRestore'); + + $request = $this->createHttpRequest( + 'POST', + $trashItemHref, + 'RestoreTrashItemInput+json', + '', + json_encode(['RestoreTrashItemInput' => []], JSON_THROW_ON_ERROR), + ); + $response = $this->sendHttpRequest($request); + + self::assertHttpResponseCodeEquals($response, 201); + self::assertHttpResponseHasHeader($response, 'Location'); + } } diff --git a/tests/lib/Server/Input/Parser/RestoreTrashItemInputTest.php b/tests/lib/Server/Input/Parser/RestoreTrashItemInputTest.php new file mode 100644 index 000000000..c7bf46641 --- /dev/null +++ b/tests/lib/Server/Input/Parser/RestoreTrashItemInputTest.php @@ -0,0 +1,102 @@ + $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()->getId(), + $result->id, + ); + + self::assertEquals( + $this->getMockedLocation()->getPathString(), + $result->getPathString(), + ); + } + + public function testParseWithMissingDestinationElement(): void + { + $inputArray = []; + + $sessionInput = $this->getParser(); + + $result = $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + + self::assertEmpty($result); + } + + public function testParseExceptionOnInvalidDestinationElement(): void + { + $inputArray = [ + 'destination' => 'test_destination', + ]; + + $sessionInput = $this->getParser(); + + $this->expectException(ValidationFailedException::class); + $this->expectExceptionMessage('Input data validation failed for RestoreTrashItemInput'); + + $sessionInput->parse($inputArray, $this->getParsingDispatcherMock()); + } + + protected function internalGetParser(): RestoreTrashItemInput + { + $locationService = $this->createMock(LocationService::class); + $this->locationService = $locationService; + $this->validator = Validation::createValidator(); + + return new RestoreTrashItemInput( + $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), + ], + ); + } +}