Skip to content

feat(serializer): set the object-to-populate in deserializer context as soon as data !== null #7124

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions src/State/Provider/DeserializeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@

final class DeserializeProvider implements ProviderInterface
{
// @see ApiPlatform\Symfony\Controller\MainController
public const ASSIGN_OBJECT_TO_POPULATE = '_api_assign_object_to_populate';

public function __construct(
private readonly ?ProviderInterface $decorated,
private readonly SerializerInterface $serializer,
Expand Down Expand Up @@ -75,19 +78,24 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
throw new UnsupportedMediaTypeHttpException('Format not supported.');
}

$method = $operation->getMethod();

if (
null !== $data
&& (
'POST' === $method
if ($operation instanceof HttpOperation && null === ($serializerContext[self::ASSIGN_OBJECT_TO_POPULATE] ?? null)) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true))
)
) {
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

if ($assignObjectToPopulate) {
$serializerContext[self::ASSIGN_OBJECT_TO_POPULATE] = true;
trigger_deprecation('api-platform/core', '5.0', 'To assign an object to populate you should set "%s" in your denormalizationContext, not defining it is deprecated.', self::ASSIGN_OBJECT_TO_POPULATE);
}
}

if (null !== $data && ($serializerContext[self::ASSIGN_OBJECT_TO_POPULATE] ?? false)) {
$serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data;
}

unset($serializerContext[self::ASSIGN_OBJECT_TO_POPULATE]);

try {
return $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext);
} catch (PartialDenormalizationException $e) {
Expand Down
104 changes: 104 additions & 0 deletions src/State/Tests/Provider/DeserializeProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@
namespace ApiPlatform\State\Tests\Provider;

use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\State\Provider\DeserializeProvider;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
Expand All @@ -26,8 +31,10 @@

class DeserializeProviderTest extends TestCase
{
#[IgnoreDeprecations]
public function testDeserialize(): void
{
$this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "_api_assign_object_to_populate" in your denormalizationContext not defining it is deprecated.');
$objectToPopulate = new \stdClass();
$serializerContext = [];
$operation = new Post(deserialize: true, class: 'Test');
Expand Down Expand Up @@ -128,4 +135,101 @@ public function testRequestWithEmptyContentType(): void
$this->expectException(UnsupportedMediaTypeHttpException::class);
$provider->provide($operation, [], $context);
}

#[DataProvider('provideMethodsTriggeringDeprecation')]
#[IgnoreDeprecations]
public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperation $operation): void
{
$this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "_api_assign_object_to_populate" in your denormalizationContext not defining it is deprecated.');

$objectToPopulate = new \stdClass();
$serializerContext = [];
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);

$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn($serializerContext);

$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('deserialize')->with(
'test',
'Test',
'format',
['uri_variables' => ['id' => 1], 'object_to_populate' => $objectToPopulate] + $serializerContext
)->willReturn(new \stdClass());

$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: 'test');
$request->headers->set('CONTENT_TYPE', 'ok');
$request->attributes->set('input_format', 'format');
$provider->provide($operation, ['id' => 1], ['request' => $request]);
}

public static function provideMethodsTriggeringDeprecation(): iterable
{
yield 'POST method' => [new Post(deserialize: true, class: 'Test')];
yield 'PATCH method' => [new Patch(deserialize: true, class: 'Test')];
yield 'PUT method (non-standard)' => [new Put(deserialize: true, class: 'Test', extraProperties: ['standard_put' => false])];
}

public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void
{
$objectToPopulate = new \stdClass();
$serializerContext = [DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE => true];
$operation = new Post(deserialize: true, class: 'Test');
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);

$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn($serializerContext);

$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('deserialize')->with(
'test',
'Test',
'format',
$this->callback(function (array $context) use ($objectToPopulate) {
$this->assertArrayHasKey(AbstractNormalizer::OBJECT_TO_POPULATE, $context);
$this->assertSame($objectToPopulate, $context[AbstractNormalizer::OBJECT_TO_POPULATE]);

return true;
})
)->willReturn(new \stdClass());

$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: 'test');
$request->headers->set('CONTENT_TYPE', 'ok');
$request->attributes->set('input_format', 'format');
$provider->provide($operation, ['id' => 1], ['request' => $request]);
}

public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void
{
$objectToPopulate = new \stdClass();
$serializerContext = [DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE => false];
$operation = new Post(deserialize: true, class: 'Test');
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);

$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn($serializerContext);

$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('deserialize')->with(
'test',
'Test',
'format',
$this->callback(function (array $context) {
$this->assertArrayNotHasKey(AbstractNormalizer::OBJECT_TO_POPULATE, $context);

return true;
})
)->willReturn(new \stdClass());

$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: 'test');
$request->headers->set('CONTENT_TYPE', 'ok');
$request->attributes->set('input_format', 'format');
$provider->provide($operation, ['id' => 1], ['request' => $request]);
}
}
17 changes: 15 additions & 2 deletions src/Symfony/Controller/MainController.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use ApiPlatform\Metadata\UriVariablesConverterInterface;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Provider\DeserializeProvider;
use ApiPlatform\State\UriVariablesResolverTrait;
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -72,14 +73,26 @@ public function __invoke(Request $request): Response
$operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE'));
}

if (null === $operation->canRead() && $operation instanceof HttpOperation) {
if ($operation instanceof HttpOperation) {

if (null === $operation->canRead()) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CS seems broken here

$operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe());
}

if (null === $operation->canDeserialize() && $operation instanceof HttpOperation) {
if (null === $operation->canDeserialize()) {
$operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true));
}

$denormalizationContext = $operation->getDenormalizationContext() ?? [];
if ($operation->canDeserialize() && !isset($denormalizationContext[DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE])) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

$operation = $operation->withDenormalizationContext($denormalizationContext + [DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]);
}

$body = $this->provider->provide($operation, $uriVariables, $context);

// The provider can change the Operation, extract it again from the Request attributes
Expand Down
11 changes: 11 additions & 0 deletions src/Symfony/EventListener/DeserializeListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\Provider\DeserializeProvider;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
use ApiPlatform\State\Util\RequestAttributesExtractor;
Expand Down Expand Up @@ -65,6 +66,16 @@ public function onKernelRequest(RequestEvent $event): void
$operation = $operation->withDeserialize(\in_array($method, ['POST', 'PUT', 'PATCH'], true));
}

$denormalizationContext = $operation->getDenormalizationContext() ?? [];
if ($operation->canDeserialize() && !isset($denormalizationContext[DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE])) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

$operation = $operation->withDenormalizationContext($denormalizationContext + [DeserializeProvider::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]);
}

if (!$operation->canDeserialize()) {
return;
}
Expand Down
Loading