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

Merged
merged 1 commit into from
May 12, 2025
Merged
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
23 changes: 14 additions & 9 deletions src/State/Provider/DeserializeProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,19 +75,24 @@
throw new UnsupportedMediaTypeHttpException('Format not supported.');
}

$method = $operation->getMethod();

if (
null !== $data
&& (
'POST' === $method
if ($operation instanceof HttpOperation && null === ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? null)) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method

Check warning on line 80 in src/State/Provider/DeserializeProvider.php

View check run for this annotation

Codecov / codecov/patch

src/State/Provider/DeserializeProvider.php#L79-L80

Added lines #L79 - L80 were not covered by tests
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true))
)
) {
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

Check warning on line 82 in src/State/Provider/DeserializeProvider.php

View check run for this annotation

Codecov / codecov/patch

src/State/Provider/DeserializeProvider.php#L82

Added line #L82 was not covered by tests

if ($assignObjectToPopulate) {
$serializerContext[SerializerContextBuilderInterface::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.', SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE);

Check warning on line 86 in src/State/Provider/DeserializeProvider.php

View check run for this annotation

Codecov / codecov/patch

src/State/Provider/DeserializeProvider.php#L84-L86

Added lines #L84 - L86 were not covered by tests
}
}

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

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

try {
return $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext);
} catch (PartialDenormalizationException $e) {
Expand Down
4 changes: 4 additions & 0 deletions src/State/SerializerContextBuilderInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
*/
interface SerializerContextBuilderInterface
{
// @see ApiPlatform\Symfony\Controller\MainController and ApiPlatform\State\Provider\DeserializerProvider
public const ASSIGN_OBJECT_TO_POPULATE = 'api_assign_object_to_populate';

/**
* Creates a serialization context from a Request.
*
Expand Down Expand Up @@ -51,6 +54,7 @@ interface SerializerContextBuilderInterface
* api_included?: bool,
* attributes?: string[],
* deserializer_type?: string,
* api_assign_object_to_populate?: bool,
* }
*/
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array;
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]

Check warning on line 34 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L34

Added line #L34 was not covered by tests
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.');

Check warning on line 37 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L37

Added line #L37 was not covered by tests
$objectToPopulate = new \stdClass();
$serializerContext = [];
$operation = new Post(deserialize: true, class: 'Test');
Expand Down Expand Up @@ -128,4 +135,101 @@
$this->expectException(UnsupportedMediaTypeHttpException::class);
$provider->provide($operation, [], $context);
}

#[DataProvider('provideMethodsTriggeringDeprecation')]

Check warning on line 139 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L139

Added line #L139 was not covered by tests
#[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.');

Check warning on line 143 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L143

Added line #L143 was not covered by tests

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

Check warning on line 148 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L145-L148

Added lines #L145 - L148 were not covered by tests

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

Check warning on line 151 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L150-L151

Added lines #L150 - L151 were not covered by tests

$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());

Check warning on line 159 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L153-L159

Added lines #L153 - L159 were not covered by tests

$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]);

Check warning on line 165 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L161-L165

Added lines #L161 - L165 were not covered by tests
}

public static function provideMethodsTriggeringDeprecation(): iterable

Check warning on line 168 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L168

Added line #L168 was not covered by tests
{
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])];

Check warning on line 172 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L170-L172

Added lines #L170 - L172 were not covered by tests
}

public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void

Check warning on line 175 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L175

Added line #L175 was not covered by tests
{
$objectToPopulate = new \stdClass();
$serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => true];
$operation = new Post(deserialize: true, class: 'Test');
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);

Check warning on line 181 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L177-L181

Added lines #L177 - L181 were not covered by tests

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

Check warning on line 184 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L183-L184

Added lines #L183 - L184 were not covered by tests

$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]);

Check warning on line 193 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L186-L193

Added lines #L186 - L193 were not covered by tests

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

Check warning on line 197 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L195-L197

Added lines #L195 - L197 were not covered by tests

$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]);

Check warning on line 203 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L199-L203

Added lines #L199 - L203 were not covered by tests
}

public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void

Check warning on line 206 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L206

Added line #L206 was not covered by tests
{
$objectToPopulate = new \stdClass();
$serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => false];
$operation = new Post(deserialize: true, class: 'Test');
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);

Check warning on line 212 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L208-L212

Added lines #L208 - L212 were not covered by tests

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

Check warning on line 215 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L214-L215

Added lines #L214 - L215 were not covered by tests

$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);

Check warning on line 223 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L217-L223

Added lines #L217 - L223 were not covered by tests

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

Check warning on line 227 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L225-L227

Added lines #L225 - L227 were not covered by tests

$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]);

Check warning on line 233 in src/State/Tests/Provider/DeserializeProviderTest.php

View check run for this annotation

Codecov / codecov/patch

src/State/Tests/Provider/DeserializeProviderTest.php#L229-L233

Added lines #L229 - L233 were not covered by tests
}
}
19 changes: 15 additions & 4 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\SerializerContextBuilderInterface;
use ApiPlatform\State\UriVariablesResolverTrait;
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
use Psr\Log\LoggerInterface;
Expand Down Expand Up @@ -48,8 +49,8 @@ public function __invoke(Request $request): Response
{
$operation = $this->initializeOperation($request);

if (!$operation) {
throw new RuntimeException('Not an API operation.');
if (!$operation || !$operation instanceof HttpOperation) {
throw new RuntimeException('Not an HTTP API operation.');
}

$uriVariables = [];
Expand All @@ -72,14 +73,24 @@ public function __invoke(Request $request): Response
$operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE'));
}

if (null === $operation->canRead() && $operation instanceof HttpOperation) {
if (null === $operation->canRead()) {
$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[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE])) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

$operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::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 @@ -16,6 +16,7 @@
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use ApiPlatform\State\Util\OperationRequestInitiatorTrait;
use ApiPlatform\State\Util\RequestAttributesExtractor;
use Symfony\Component\HttpKernel\Event\RequestEvent;
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[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE])) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));

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

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