diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index be399f4f1bf..06bb84ffbf4 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -75,19 +75,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[SerializerContextBuilderInterface::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[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); + } + } + + 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) { diff --git a/src/State/SerializerContextBuilderInterface.php b/src/State/SerializerContextBuilderInterface.php index cdbbee16698..78f4cf990b8 100644 --- a/src/State/SerializerContextBuilderInterface.php +++ b/src/State/SerializerContextBuilderInterface.php @@ -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. * @@ -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; diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 46710587ac7..34620875df3 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -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; @@ -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'); @@ -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 = [SerializerContextBuilderInterface::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 = [SerializerContextBuilderInterface::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]); + } } diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index 7e9cc8cdfe9..8c691bf654d 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -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; @@ -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 = []; @@ -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 diff --git a/src/Symfony/EventListener/DeserializeListener.php b/src/Symfony/EventListener/DeserializeListener.php index 433f86149dc..403ebc647fe 100644 --- a/src/Symfony/EventListener/DeserializeListener.php +++ b/src/Symfony/EventListener/DeserializeListener.php @@ -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; @@ -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; }