Skip to content

Commit

Permalink
refactor #780 Init Deserialize provider (loic425)
Browse files Browse the repository at this point in the history
This PR was merged into the 1.11 branch.

Discussion
----------

| Q               | A
| --------------- | -----
| Bug fix?        | no
| New feature?    | yes
| BC breaks?      | no
| Deprecations?   | no
| Related tickets | 
| License         | MIT

Needs #781 first

Not used and no DI for now, cause we need the ReadProvider first.

Commits
-------

eff455c Init Deserialize provider
aa925da Fix PHpstan errors and fix dezerialize provider namespace
ed0300e Apply changes from code review
cf2823f Apply suggestions from code review

Co-authored-by: Dmitri Perunov <diimpp@gmail.com>
  • Loading branch information
lchrusciel and diimpp authored Nov 20, 2023
2 parents 82af9cd + cf2823f commit bb83cd0
Show file tree
Hide file tree
Showing 3 changed files with 309 additions and 0 deletions.
1 change: 1 addition & 0 deletions phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -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\<object\> 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./'
Expand Down
232 changes: 232 additions & 0 deletions src/Component/Tests/State/Provider/DeserializeProviderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace State\Provider;

use PHPUnit\Framework\TestCase;
use Prophecy\PhpUnit\ProphecyTrait;
use Prophecy\Prophecy\ObjectProphecy;
use Sylius\Resource\Context\Context;
use Sylius\Resource\Context\Option\RequestOption;
use Sylius\Resource\Metadata\Delete;
use Sylius\Resource\Metadata\HttpOperation;
use Sylius\Resource\Metadata\ResourceMetadata;
use Sylius\Resource\State\Provider\DeserializeProvider;
use Sylius\Resource\State\ProviderInterface;
use Symfony\Component\HttpFoundation\ParameterBag;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Serializer\SerializerInterface;

final class DeserializeProviderTest extends TestCase
{
use ProphecyTrait;

private ProviderInterface|ObjectProphecy $decorated;

private SerializerInterface|ObjectProphecy $serializer;

private DeserializeProvider $deserializableProvider;

protected function setUp(): void
{
$this->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);
}
}
76 changes: 76 additions & 0 deletions src/Component/src/State/Provider/DeserializeProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace Sylius\Resource\State\Provider;

use Sylius\Resource\Context\Context;
use Sylius\Resource\Context\Option\RequestOption;
use Sylius\Resource\Metadata\DeleteOperationInterface;
use Sylius\Resource\Metadata\HttpOperation;
use Sylius\Resource\Metadata\Operation;
use Sylius\Resource\State\ProviderInterface;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;

final class DeserializeProvider implements ProviderInterface
{
public function __construct(
private ProviderInterface $decorated,
private ?SerializerInterface $serializer,
) {
}

public function provide(Operation $operation, Context $context): object|array|null
{
$data = $this->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);
}
}

0 comments on commit bb83cd0

Please sign in to comment.