diff --git a/phpstan.neon b/phpstan.neon index a4681765d..f5ecf6d17 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -78,6 +78,7 @@ parameters: - '/Method Sylius\\Component\\Resource\\Model\\TimestampableInterface\:\:setCreatedAt\(\) has no return type specified\./' - '/Method Sylius\\Component\\Resource\\Model\\TimestampableInterface\:\:setUpdatedAt\(\) has no return type specified\./' - '/Method Sylius\\Component\\Resource\\Repository\\InMemoryRepository\:\:findAll\(\) should return array\ but returns array\./' + - '/Method Sylius\\Resource\\State\\Provider\\DeserializeProvider\:\:provide\(\) should return array\|object\|null but returns mixed./' - '/Method Sylius\\Resource\\Symfony\\EventDispatcher\\GenericEvent\:\:stop\(\) has no return type specified./' - '/Method Sylius\\Bundle\\ResourceBundle\\Form\\Extension\\HttpFoundation\\HttpFoundationRequestHandler::handleRequest\(\) has no return type specified./' - '/Method Sylius\\Bundle\\ResourceBundle\\Grid\\Controller\\ResourcesResolver::getResources\(\) has no return type specified./' diff --git a/src/Component/Tests/State/Provider/DeserializeProviderTest.php b/src/Component/Tests/State/Provider/DeserializeProviderTest.php new file mode 100644 index 000000000..2a2810c86 --- /dev/null +++ b/src/Component/Tests/State/Provider/DeserializeProviderTest.php @@ -0,0 +1,232 @@ +decorated = $this->prophesize(ProviderInterface::class); + $this->serializer = $this->prophesize(SerializerInterface::class); + + $this->deserializableProvider = new DeserializeProvider( + $this->decorated->reveal(), + $this->serializer->reveal(), + ); + } + + /** @test */ + public function it_deserializes_data(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->getDenormalizationContext()->willReturn(null)->shouldBeCalled(); + + $request->isMethodSafe()->willReturn(false)->shouldBeCalled(); + $request->getRequestFormat()->willReturn('json')->shouldBeCalled(); + $request->getContent()->willReturn(['food' => 'fighters'])->shouldBeCalled(); + $request->getMethod()->willReturn('POST')->shouldBeCalled(); + + $operation->canDeserialize()->willReturn(null)->shouldBeCalled(); + $operation->getDenormalizationContext()->willReturn([])->shouldBeCalled(); + + $this->decorated->provide($operation->reveal(), $context)->willReturn($data)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', ['object_to_populate' => $data])->willReturn($data)->shouldBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_deserializes_data_with_denormalization_context(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $request->isMethodSafe()->willReturn(false)->shouldBeCalled(); + $request->getRequestFormat()->willReturn('json')->shouldBeCalled(); + $request->getContent()->willReturn(['food' => 'fighters'])->shouldBeCalled(); + $request->getMethod()->willReturn('POST')->shouldBeCalled(); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->canDeserialize()->willReturn(null)->shouldBeCalled(); + $operation->getDenormalizationContext()->willReturn(['groups' => ['dummy:write']]); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', ['groups' => ['dummy:write']])->willReturn($data)->shouldBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_does_nothing_if_operation_cannot_be_deserialized(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->canDeserialize()->willReturn(false)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_does_nothing_if_operation_has_no_resource(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $operation->getResource()->willReturn(null); + $operation->canDeserialize()->willReturn(true)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_does_nothing_if_request_format_is_html(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $request->getRequestFormat()->willReturn('html')->shouldBeCalled(); + $request->getContent()->willReturn(['food' => 'fighters']); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->canDeserialize()->willReturn(true)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_does_nothing_if_request_method_is_safe(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $request->isMethodSafe()->willReturn(true)->shouldBeCalled(); + $request->getRequestFormat()->willReturn('json')->shouldBeCalled(); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->canDeserialize()->willReturn(true)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->deserializableProvider->provide($operation->reveal(), $context); + } + + /** @test */ + public function it_does_nothing_if_operation_is_a_delete_one(): void + { + $request = $this->prophesize(Request::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $request->isMethodSafe()->willReturn(false)->shouldBeCalled(); + $request->getRequestFormat()->willReturn('json')->shouldBeCalled(); + + $operation = (new Delete())->withResource(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->deserializableProvider->provide($operation, $context); + } + + /** @test */ + public function it_throws_an_exception_when_serializer_is_not_available(): void + { + $request = $this->prophesize(Request::class); + $operation = $this->prophesize(HttpOperation::class); + $data = $this->prophesize(\stdClass::class); + + $context = new Context(new RequestOption($request->reveal())); + + $request->attributes = new ParameterBag(); + + $request->isMethodSafe()->willReturn(false)->shouldBeCalled(); + $request->getRequestFormat()->willReturn('json')->shouldBeCalled(); + + $operation->getResource()->willReturn(new ResourceMetadata(alias: 'app.dummy', class: 'App\Resource')); + $operation->canDeserialize()->willReturn(true)->shouldBeCalled(); + + $this->serializer->deserialize(['food' => 'fighters'], 'App\Resource', 'json', [])->willReturn($data)->shouldNotBeCalled(); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('You can not use the "json" format if the Serializer is not available. Try running "composer require symfony/serializer".'); + + $this->deserializableProvider = new DeserializeProvider($this->decorated->reveal(), null); + $this->deserializableProvider->provide($operation->reveal(), $context); + } +} diff --git a/src/Component/src/State/Provider/DeserializeProvider.php b/src/Component/src/State/Provider/DeserializeProvider.php new file mode 100644 index 000000000..df42cea0b --- /dev/null +++ b/src/Component/src/State/Provider/DeserializeProvider.php @@ -0,0 +1,76 @@ +decorated->provide($operation, $context); + + if (!$operation instanceof HttpOperation) { + return $data; + } + + $request = $context->get(RequestOption::class)?->request() ?? null; + if (!$request) { + return $data; + } + + if (!($operation->canDeserialize() ?? true)) { + return $data; + } + + $resourceClass = $operation->getResource()?->getClass(); + /** @var string $format */ + $format = $request->getRequestFormat(); + + if ( + null === $resourceClass || + 'html' === $format || + $request->isMethodSafe() || + $operation instanceof DeleteOperationInterface + ) { + return $data; + } + + if (null === $this->serializer) { + throw new \LogicException(sprintf('You can not use the "%s" format if the Serializer is not available. Try running "composer require symfony/serializer".', $format)); + } + + $denormalizationContext = $operation->getDenormalizationContext() ?? []; + $method = $request->getMethod(); + + if (null !== $data && in_array($method, ['POST', 'PATCH', 'PUT'])) { + $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; + } + + return $this->serializer->deserialize($request->getContent(), $resourceClass, $format, $denormalizationContext); + } +}