diff --git a/src/Action/GetCollectionAction.php b/src/Action/GetCollectionAction.php index 142c8aefd80..57f93b8c0b7 100644 --- a/src/Action/GetCollectionAction.php +++ b/src/Action/GetCollectionAction.php @@ -11,6 +11,7 @@ namespace ApiPlatform\Core\Action; +use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\Exception\RuntimeException; @@ -23,8 +24,6 @@ */ final class GetCollectionAction { - use ActionUtilTrait; - private $collectionDataProvider; public function __construct(CollectionDataProviderInterface $collectionDataProvider) @@ -43,7 +42,7 @@ public function __construct(CollectionDataProviderInterface $collectionDataProvi */ public function __invoke(Request $request) { - list($resourceClass, $operationName) = $this->extractAttributes($request); + list($resourceClass, $operationName) = RequestAttributesExtractor::extractAttributes($request); return $this->collectionDataProvider->getCollection($resourceClass, $operationName); } diff --git a/src/Action/GetItemAction.php b/src/Action/GetItemAction.php index a34215348dc..f0a74c1f160 100644 --- a/src/Action/GetItemAction.php +++ b/src/Action/GetItemAction.php @@ -11,6 +11,7 @@ namespace ApiPlatform\Core\Action; +use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\RuntimeException; use Symfony\Component\HttpFoundation\Request; @@ -23,8 +24,6 @@ */ final class GetItemAction { - use ActionUtilTrait; - private $itemDataProvider; public function __construct(ItemDataProviderInterface $itemDataProvider) @@ -45,8 +44,13 @@ public function __construct(ItemDataProviderInterface $itemDataProvider) */ public function __invoke(Request $request, $id) { - list($resourceClass, , $operationName) = $this->extractAttributes($request); + list($resourceClass, , $operationName) = RequestAttributesExtractor::extractAttributes($request); + + $data = $this->itemDataProvider->getItem($resourceClass, $id, $operationName, true); + if (!$data) { + throw new NotFoundHttpException('Not Found'); + } - return $this->getItem($this->itemDataProvider, $resourceClass, $operationName, $id); + return $data; } } diff --git a/src/Action/NullAction.php b/src/Action/NullAction.php new file mode 100644 index 00000000000..2c8f0507706 --- /dev/null +++ b/src/Action/NullAction.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Action; + +/** + * Empty action. Useful to trigger the kernel.view event without doing anything specific in the action + * (e.g. the POST action). + * + * @author Kévin Dunglas + */ +final class NullAction +{ + public function __invoke() + { + } +} diff --git a/src/Action/PostCollectionAction.php b/src/Action/PostCollectionAction.php deleted file mode 100644 index 3d461546263..00000000000 --- a/src/Action/PostCollectionAction.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace ApiPlatform\Core\Action; - -use ApiPlatform\Core\Exception\RuntimeException; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Serializer\SerializerInterface; - -/** - * Add a new resource to a collection. - * - * @author Kévin Dunglas - */ -final class PostCollectionAction -{ - use ActionUtilTrait; - - private $serializer; - - public function __construct(SerializerInterface $serializer) - { - $this->serializer = $serializer; - } - - /** - * Hydrate an item to persist. - * - * @param Request $request - * - * @throws RuntimeException - * - * @return mixed - */ - public function __invoke(Request $request) - { - list($resourceClass, $operationName, , $format) = $this->extractAttributes($request); - $context = ['resource_class' => $resourceClass, 'collection_operation_name' => $operationName]; - - return $this->serializer->deserialize($request->getContent(), $resourceClass, $format, $context); - } -} diff --git a/src/Action/PutItemAction.php b/src/Action/PutItemAction.php deleted file mode 100644 index 45e3117c000..00000000000 --- a/src/Action/PutItemAction.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace ApiPlatform\Core\Action; - -use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; -use ApiPlatform\Core\Exception\RuntimeException; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Symfony\Component\Serializer\SerializerInterface; - -/** - * Updates a resource. - * - * @author Kévin Dunglas - */ -final class PutItemAction -{ - use ActionUtilTrait; - - private $itemDataProvider; - private $serializer; - - public function __construct(ItemDataProviderInterface $itemDataProvider, SerializerInterface $serializer) - { - $this->itemDataProvider = $itemDataProvider; - $this->serializer = $serializer; - } - - /** - * Create a new item. - * - * @param Request $request - * @param string|int $id - * - * @throws NotFoundHttpException - * @throws RuntimeException - * - * @return mixed - */ - public function __invoke(Request $request, $id) - { - list($resourceClass, , $operationName, $format) = $this->extractAttributes($request); - $data = $this->getItem($this->itemDataProvider, $resourceClass, $operationName, $id); - - $context = ['object_to_populate' => $data, 'resource_class' => $resourceClass, 'item_operation_name' => $operationName]; - - return $this->serializer->deserialize($request->getContent(), $resourceClass, $format, $context); - } -} diff --git a/src/Action/ActionUtilTrait.php b/src/Api/RequestAttributesExtractor.php similarity index 55% rename from src/Action/ActionUtilTrait.php rename to src/Api/RequestAttributesExtractor.php index 09bcae0a3d6..6027a2289a5 100644 --- a/src/Action/ActionUtilTrait.php +++ b/src/Api/RequestAttributesExtractor.php @@ -9,44 +9,20 @@ * file that was distributed with this source code. */ -namespace ApiPlatform\Core\Action; +namespace ApiPlatform\Core\Api; -use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\Exception\RuntimeException; use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** - * Checks if the request is properly configured. + * Extracts data used by the library form a Request instance. * * @author Kévin Dunglas */ -trait ActionUtilTrait +final class RequestAttributesExtractor { /** - * Gets an item using the data provider. Throws a 404 error if not found. - * - * @param ItemDataProviderInterface $itemDataProvider - * @param string $resourceClass - * @param string $operationName - * @param string|int $id - * - * @throws NotFoundHttpException - * - * @return object - */ - private function getItem(ItemDataProviderInterface $itemDataProvider, string $resourceClass, string $operationName, $id) - { - $data = $itemDataProvider->getItem($resourceClass, $id, $operationName, true); - if (!$data) { - throw new NotFoundHttpException('Not Found'); - } - - return $data; - } - - /** - * Extract resource class, operation name and format request attributes. Throws an exception if the request does not contain required + * Extracts resource class, operation name and format request attributes. Throws an exception if the request does not contain required * attributes. * * @param Request $request @@ -55,7 +31,7 @@ private function getItem(ItemDataProviderInterface $itemDataProvider, string $re * * @return array */ - private function extractAttributes(Request $request) + public static function extractAttributes(Request $request) { $resourceClass = $request->attributes->get('_resource_class'); diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index a588cdb8e57..86cd5eddd42 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -58,6 +58,12 @@ + + + + + + @@ -71,18 +77,13 @@ - - - + - - - - + diff --git a/src/EventListener/DeserializerViewListener.php b/src/EventListener/DeserializerViewListener.php new file mode 100644 index 00000000000..43dda05b79d --- /dev/null +++ b/src/EventListener/DeserializerViewListener.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\EventListener; + +use ApiPlatform\Core\Api\RequestAttributesExtractor; +use ApiPlatform\Core\Exception\RuntimeException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Updates the entity retrieved by the data provider with data contained in the request body. + * + * @author Kévin Dunglas + */ +final class DeserializerViewListener +{ + private $serializer; + + public function __construct(SerializerInterface $serializer) + { + $this->serializer = $serializer; + } + + public function onKernelView(GetResponseForControllerResultEvent $event) + { + $data = $event->getControllerResult(); + $request = $event->getRequest(); + + if ($data instanceof Response || !in_array($request->getMethod(), [Request::METHOD_POST, Request::METHOD_PUT], true)) { + return; + } + + try { + list($resourceClass, $collectionOperation, $itemOperation, $format) = RequestAttributesExtractor::extractAttributes($request); + } catch (RuntimeException $e) { + return; + } + + $context = ['resource_class' => $resourceClass]; + if ($collectionOperation) { + $context['collection_operation_name'] = $collectionOperation; + } else { + $context['item_operation_name'] = $itemOperation; + } + + if (null !== $data) { + $context['object_to_populate'] = $data; + } + + $event->setControllerResult($this->serializer->deserialize($request->getContent(), $resourceClass, $format, $context)); + } +} diff --git a/tests/Action/GetCollectionActionTest.php b/tests/Action/GetCollectionActionTest.php new file mode 100644 index 00000000000..241b0656674 --- /dev/null +++ b/tests/Action/GetCollectionActionTest.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Action; + +use ApiPlatform\Core\Action\GetCollectionAction; +use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Kévin Dunglas + */ +class GetCollectionActionTest extends \PHPUnit_Framework_TestCase +{ + public function testGetCollection() + { + $result = new \stdClass(); + + $dataProviderProphecy = $this->prophesize(CollectionDataProviderInterface::class); + $dataProviderProphecy->getCollection('Foo', 'get')->willReturn($result); + + $request = new Request([], [], ['_resource_class' => 'Foo', '_collection_operation_name' => 'get', '_api_format' => 'json']); + + $action = new GetCollectionAction($dataProviderProphecy->reveal()); + $this->assertSame($result, $action($request)); + } +} diff --git a/tests/Action/GetItemActionTest.php b/tests/Action/GetItemActionTest.php new file mode 100644 index 00000000000..d88c8482b01 --- /dev/null +++ b/tests/Action/GetItemActionTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Action; + +use ApiPlatform\Core\Action\GetItemAction; +use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Kévin Dunglas + */ +class GetItemActionTest extends \PHPUnit_Framework_TestCase +{ + public function testGetItem() + { + $result = new \stdClass(); + + $dataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + $dataProviderProphecy->getItem('Foo', 22, 'get', true)->willReturn($result); + + $request = new Request([], [], ['_resource_class' => 'Foo', '_item_operation_name' => 'get', '_api_format' => 'json']); + + $action = new GetItemAction($dataProviderProphecy->reveal()); + $this->assertSame($result, $action($request, 22)); + } + + /** + * @expectedException \Symfony\Component\HttpKernel\Exception\NotFoundHttpException + * @expectedExceptionMessage Not Found + */ + public function testNotFound() + { + $dataProviderProphecy = $this->prophesize(ItemDataProviderInterface::class); + $dataProviderProphecy->getItem('Foo', 1312, 'get', true)->willReturn(null); + + $request = new Request([], [], ['_resource_class' => 'Foo', '_item_operation_name' => 'get', '_api_format' => 'json']); + + $action = new GetItemAction($dataProviderProphecy->reveal()); + $action($request, 1312); + } +} diff --git a/tests/Action/NullActionTest.php b/tests/Action/NullActionTest.php new file mode 100644 index 00000000000..4e11a6540ac --- /dev/null +++ b/tests/Action/NullActionTest.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Action; + +use ApiPlatform\Core\Action\NullAction; + +/** + * @author Kévin Dunglas + */ +class NullActionTest extends \PHPUnit_Framework_TestCase +{ + public function testNullAction() + { + $action = new NullAction(); + $this->assertNull($action()); + } +} diff --git a/tests/Api/RequestAttributesExtractorTest.php b/tests/Api/RequestAttributesExtractorTest.php new file mode 100644 index 00000000000..cdf492b9897 --- /dev/null +++ b/tests/Api/RequestAttributesExtractorTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\Api; + +use ApiPlatform\Core\Api\RequestAttributesExtractor; +use Symfony\Component\HttpFoundation\Request; + +/** + * @author Kévin Dunglas + */ +class RequestAttributesExtractorTest extends \PHPUnit_Framework_TestCase +{ + public function testExtractCollectionAttributes() + { + $request = new Request([], [], ['_resource_class' => 'Foo', '_collection_operation_name' => 'post', '_api_format' => 'json']); + $extactor = new RequestAttributesExtractor(); + + $this->assertEquals(['Foo', 'post', null, 'json'], $extactor->extractAttributes($request)); + } + + public function testExtractItemAttributes() + { + $request = new Request([], [], ['_resource_class' => 'Foo', '_item_operation_name' => 'get', '_api_format' => 'json']); + $extactor = new RequestAttributesExtractor(); + + $this->assertEquals(['Foo', null, 'get', 'json'], $extactor->extractAttributes($request)); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\RuntimeException + * @expectedExceptionMessage The request attribute "_resource_class" must be defined. + */ + public function testResourceClassNotSet() + { + $request = new Request([], [], ['_item_operation_name' => 'get', '_api_format' => 'json']); + $extactor = new RequestAttributesExtractor(); + $extactor->extractAttributes($request); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\RuntimeException + * @expectedExceptionMessage One of the request attribute "_item_operation_name" or "_collection_operation_name" must be defined. + */ + public function testOperationNotSet() + { + $request = new Request([], [], ['_resource_class' => 'Foo', '_api_format' => 'json']); + $extactor = new RequestAttributesExtractor(); + $extactor->extractAttributes($request); + } + + /** + * @expectedException \ApiPlatform\Core\Exception\RuntimeException + * @expectedExceptionMessage The request attribute "_api_format" must be defined. + */ + public function testFormatNotSet() + { + $request = new Request([], [], ['_resource_class' => 'Foo', '_collection_operation_name' => 'op']); + $extactor = new RequestAttributesExtractor(); + $extactor->extractAttributes($request); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 07533451a10..5269694936a 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -194,6 +194,7 @@ private function getContainerBuilderProphecy() 'api_platform.metadata.resource.metadata_factory', 'api_platform.metadata.property.name_collection_factory', 'api_platform.metadata.property.metadata_factory', + 'api_platform.action.put_item', 'api_platform.action.delete_item', ]; foreach ($aliases as $alias) { @@ -259,11 +260,10 @@ private function getContainerBuilderProphecy() 'api_platform.routing.resource_path_generator.dash', 'api_platform.listener.request.format', 'api_platform.listener.view.validation', - 'api_platform.listener.request.format', + 'api_platform.listener.view.deserializer', 'api_platform.action.get_collection', 'api_platform.action.post_collection', 'api_platform.action.get_item', - 'api_platform.action.put_item', 'api_platform.doctrine.metadata_factory', 'api_platform.doctrine.orm.collection_data_provider', 'api_platform.doctrine.orm.item_data_provider', diff --git a/tests/EventListener/DeserializerViewListenerTest.php b/tests/EventListener/DeserializerViewListenerTest.php new file mode 100644 index 00000000000..03333f8a7d4 --- /dev/null +++ b/tests/EventListener/DeserializerViewListenerTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace ApiPlatform\Core\Tests\EventListener; + +use ApiPlatform\Core\EventListener\DeserializerViewListener; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\GetResponseForControllerResultEvent; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Kévin Dunglas + */ +class DeserializerViewListenerTest extends \PHPUnit_Framework_TestCase +{ + public function testDoNotCallWhenAResponse() + { + $eventProphecy = $this->prophesize(GetResponseForControllerResultEvent::class); + $eventProphecy->getControllerResult()->willReturn(new Response()); + $eventProphecy->setControllerResult()->shouldNotBeCalled(); + + $request = new Request([], [], [], [], [], [], '{}'); + $request->setMethod(Request::METHOD_POST); + $eventProphecy->getRequest()->willReturn($request); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->deserialize()->shouldNotBeCalled(); + + $listener = new DeserializerViewListener($serializerProphecy->reveal()); + $listener->onKernelView($eventProphecy->reveal()); + } + + public function testDoNotCallWhenRequestMethodIsSafe() + { + $eventProphecy = $this->prophesize(GetResponseForControllerResultEvent::class); + $eventProphecy->getControllerResult()->willReturn(new \stdClass()); + $eventProphecy->setControllerResult()->shouldNotBeCalled(); + + $request = new Request(); + $request->setMethod(Request::METHOD_GET); + $eventProphecy->getRequest()->willReturn($request); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->deserialize()->shouldNotBeCalled(); + + $listener = new DeserializerViewListener($serializerProphecy->reveal()); + $listener->onKernelView($eventProphecy->reveal()); + } + + public function testDoNotCallWhenRequestNotManaged() + { + $eventProphecy = $this->prophesize(GetResponseForControllerResultEvent::class); + $eventProphecy->getControllerResult()->willReturn(new \stdClass()); + $eventProphecy->setControllerResult()->shouldNotBeCalled(); + + $request = new Request([], [], [], [], [], [], '{}'); + $request->setMethod(Request::METHOD_POST); + $eventProphecy->getRequest()->willReturn($request); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->deserialize()->shouldNotBeCalled(); + + $listener = new DeserializerViewListener($serializerProphecy->reveal()); + $listener->onKernelView($eventProphecy->reveal()); + } + + /** + * @dataProvider methodProvider + */ + public function testDeserialize($method) + { + $result = new \stdClass(); + + $eventProphecy = $this->prophesize(GetResponseForControllerResultEvent::class); + $eventProphecy->getControllerResult()->willReturn(new \stdClass()); + $eventProphecy->setControllerResult($result)->shouldBeCalled(); + + $request = new Request([], [], ['_resource_class' => 'Foo', '_collection_operation_name' => 'post', '_api_format' => 'json'], [], [], [], '{}'); + $request->setMethod($method); + $eventProphecy->getRequest()->willReturn($request); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->deserialize('{}', 'Foo', 'json', Argument::type('array'))->willReturn($result); + + $listener = new DeserializerViewListener($serializerProphecy->reveal()); + $listener->onKernelView($eventProphecy->reveal()); + } + + public function methodProvider() + { + return [[Request::METHOD_POST], [Request::METHOD_PUT]]; + } +} diff --git a/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php b/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php index c621e8e3440..808f0a10613 100644 --- a/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php +++ b/tests/Fixtures/TestBundle/Controller/ConfigCustomController.php @@ -11,7 +11,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller; -use ApiPlatform\Core\Action\ActionUtilTrait; +use ApiPlatform\Core\Api\RequestAttributesExtractor; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use Symfony\Component\HttpFoundation\Request; @@ -22,8 +22,6 @@ */ class ConfigCustomController { - use ActionUtilTrait; - /** * @var DataProviderInterface */ @@ -36,7 +34,7 @@ public function __construct(ItemDataProviderInterface $dataProvider) public function __invoke(Request $request, $id) { - list($resourceType) = $this->extractAttributes($request); + list($resourceType) = RequestAttributesExtractor::extractAttributes($request); return $this->dataProvider->getItem($resourceType, $id); }