Upstream api-platform/core state of the touched files before 5e95dd3773

This commit is contained in:
upstream committed 2026-10-10 11:43:47 +02:00
1 parent 167625fa31
commit b2a14e558d
5 files changed
+968

No files matched your search

+201
View File
@@ -0,0 +1,201 @@
<?php
/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
namespace ApiPlatform\State\Provider;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use ApiPlatform\State\SerializerContextBuilderInterface;
use ApiPlatform\State\StopwatchAwareInterface;
use ApiPlatform\State\StopwatchAwareTrait;
use ApiPlatform\Validator\Exception\ValidationException;
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints\Type;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Contracts\Translation\LocaleAwareInterface;
use Symfony\Contracts\Translation\TranslatorInterface;
use Symfony\Contracts\Translation\TranslatorTrait;
final class DeserializeProvider implements ProviderInterface, StopwatchAwareInterface
{
use StopwatchAwareTrait;
public function __construct(
private readonly ?ProviderInterface $decorated,
private readonly SerializerInterface $serializer,
private readonly SerializerContextBuilderInterface $serializerContextBuilder,
private ?TranslatorInterface $translator = null,
) {
if (null === $this->translator) {
$this->translator = new class implements TranslatorInterface, LocaleAwareInterface {
use TranslatorTrait;
};
$this->translator->setLocale('en');
}
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
// We need request content
if (!$operation instanceof HttpOperation || !($request = $context['request'] ?? null)) {
return $this->decorated?->provide($operation, $uriVariables, $context);
}
$data = $this->decorated ? $this->decorated->provide($operation, $uriVariables, $context) : $request->attributes->get('data');
if (!$operation->canDeserialize() || $context['request']->attributes->has('deserialized')) {
return $data;
}
$this->stopwatch?->start('api_platform.provider.deserialize');
$contentType = $request->headers->get('CONTENT_TYPE');
if (null === $contentType || '' === $contentType) {
throw new UnsupportedMediaTypeHttpException('The "Content-Type" header must exist.');
}
$serializerContext = $this->serializerContextBuilder->createFromRequest($request, false, [
'resource_class' => $operation->getClass(),
'operation' => $operation,
]);
$serializerContext['uri_variables'] = $uriVariables;
if (!$format = $request->attributes->get('input_format') ?? null) {
throw new UnsupportedMediaTypeHttpException('Format not supported.');
}
if (null === ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? null)) {
$method = $operation->getMethod();
$assignObjectToPopulate = 'POST' === $method
|| 'PATCH' === $method
|| ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true));
if ($assignObjectToPopulate) {
$serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] = true;
trigger_deprecation('api-platform/core', '4.4', '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 {
$data = $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext);
} catch (PartialDenormalizationException $e) {
if (!class_exists(ConstraintViolationList::class)) {
throw $e;
}
$violations = new ConstraintViolationList();
foreach ($e->getErrors() as $exception) {
if (!$exception instanceof NotNormalizableValueException) {
continue;
}
$violations->add($this->createViolationFromException($exception));
}
if (0 !== \count($violations)) {
throw new ValidationException($violations);
}
} catch (NotNormalizableValueException $e) {
// BackedEnum denormalization errors should surface as validation violations (422)
// rather than denormalization errors (400). See https://github.com/api-platform/core/issues/8183.
if (!class_exists(ConstraintViolationList::class) || !$this->isBackedEnumException($e)) {
throw $e;
}
$violations = new ConstraintViolationList();
$violations->add($this->createViolationFromException($e));
throw new ValidationException($violations);
}
$this->stopwatch?->stop('api_platform.provider.deserialize');
$request->attributes->set('data', $data);
return $data;
}
private function normalizeExpectedTypes(?array $expectedTypes = null): array
{
$normalizedTypes = [];
foreach ($expectedTypes ?? [] as $expectedType) {
$normalizedType = $expectedType;
if (class_exists($expectedType) || interface_exists($expectedType)) {
// A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the
// PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388).
if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) {
$normalizedType = (string) $backingType;
} else {
$normalizedType = (new \ReflectionClass($expectedType))->getShortName();
}
}
$normalizedTypes[] = $normalizedType;
}
return array_values(array_unique($normalizedTypes));
}
private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation
{
$expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes());
$parameters = [];
if ($exception->canUseMessageForUser()) {
$parameters['hint'] = $exception->getMessage();
}
if (!$expectedTypes && $exception->canUseMessageForUser()) {
$violationMessage = $exception->getMessage();
return new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR);
}
$message = (new Type($expectedTypes))->message;
return new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR);
}
private function isBackedEnumException(NotNormalizableValueException $exception): bool
{
foreach ($exception->getExpectedTypes() ?? [] as $expectedType) {
if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) {
return true;
}
}
for ($previous = $exception->getPrevious(); $previous instanceof \Throwable; $previous = $previous->getPrevious()) {
if (!$previous instanceof NotNormalizableValueException) {
continue;
}
foreach ($previous->getExpectedTypes() ?? [] as $expectedType) {
if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) {
return true;
}
}
}
return false;
}
}
@@ -0,0 +1,368 @@
<?php
/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
declare(strict_types=1);
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 ApiPlatform\Validator\Exception\ValidationException;
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;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
use Symfony\Component\Serializer\SerializerInterface;
use Symfony\Component\Validator\Constraints\Type;
class DeserializeProviderTest extends TestCase
{
#[IgnoreDeprecations]
public function testDeserialize(): void
{
$this->expectUserDeprecationMessage('Since api-platform/core 4.4: 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: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn($objectToPopulate);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('deserialize')->with('test', \stdClass::class, 'format', ['uri_variables' => ['id' => 1], AbstractNormalizer::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 function testDeserializeNoContentType(): void
{
$this->expectException(UnsupportedMediaTypeHttpException::class);
$operation = new Get(deserialize: true, class: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: 'test');
$request->attributes->set('input_format', 'format');
$provider->provide($operation, ['id' => 1], ['request' => $request]);
}
public function testDeserializeNoInput(): void
{
$this->expectException(UnsupportedMediaTypeHttpException::class);
$operation = new Get(deserialize: true, class: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializer = $this->createMock(SerializerInterface::class);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: 'test');
$request->headers->set('CONTENT_TYPE', 'ok');
$provider->provide($operation, ['id' => 1], ['request' => $request]);
}
public function testDeserializeWithContextClass(): void
{
$serializerContext = ['deserializer_type' => 'Test'];
$operation = new Get(deserialize: true);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->expects($this->once())->method('deserialize')->with('test', 'Test', 'format', ['uri_variables' => ['id' => 1]] + $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 function testRequestWithEmptyContentType(): void
{
$expectedResult = new \stdClass();
$decorated = $this->createMock(ProviderInterface::class);
$decorated->method('provide')->willReturn($expectedResult);
$serializer = $this->createStub(SerializerInterface::class);
$serializerContextBuilder = $this->createStub(SerializerContextBuilderInterface::class);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
// in Symfony (at least up to 7.0.2, 6.4.2, 6.3.11, 5.4.34), a request
// without a content-type and content-length header will result in the
// variables set to an empty string, not null
$request = new Request(
server: [
'REQUEST_METHOD' => 'POST',
'REQUEST_URI' => '/',
'CONTENT_TYPE' => '',
'CONTENT_LENGTH' => '',
],
content: ''
);
$operation = new Post(deserialize: true);
$context = ['request' => $request];
$this->expectException(UnsupportedMediaTypeHttpException::class);
$provider->provide($operation, [], $context);
}
#[DataProvider('provideMethodsTriggeringDeprecation')]
#[IgnoreDeprecations]
public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperation $operation): void
{
$this->expectUserDeprecationMessage('Since api-platform/core 4.4: 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',
\stdClass::class,
'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: \stdClass::class)];
yield 'PATCH method' => [new Patch(deserialize: true, class: \stdClass::class)];
yield 'PUT method (non-standard)' => [new Put(deserialize: true, class: \stdClass::class, extraProperties: ['standard_put' => false])];
}
public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void
{
$objectToPopulate = new \stdClass();
$serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => true];
$operation = new Post(deserialize: true, class: \stdClass::class);
$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',
\stdClass::class,
'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]);
}
#[IgnoreDeprecations]
public function testDeserializeKeepsTypeMessageWhenExpectedTypesAreSet(): void
{
$operation = new Post(deserialize: true, class: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$exception = NotNormalizableValueException::createForUnexpectedDataType(
'The data must belong to a backed enumeration of type Suit.',
'invalid',
['string'],
'status',
true,
);
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->method('deserialize')->willThrowException($partialException);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: '{"status":"invalid"}');
$request->headers->set('CONTENT_TYPE', 'application/json');
$request->attributes->set('input_format', 'json');
try {
$provider->provide($operation, [], ['request' => $request]);
$this->fail('Expected ValidationException');
} catch (ValidationException $e) {
$violations = $e->getConstraintViolationList();
$this->assertCount(1, $violations);
$this->assertSame('This value should be of type string.', $violations[0]->getMessage());
$this->assertSame('status', $violations[0]->getPropertyPath());
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
$this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getParameters()['hint'] ?? null);
}
}
/**
* Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574):
* when a value has the right type but is not a valid enum case, the exception
* is created with expectedTypes=null and a user-friendly message listing valid values.
*/
#[IgnoreDeprecations]
public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void
{
$operation = new Post(deserialize: true, class: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct');
if ($ctor->getNumberOfParameters() <= 3) {
$this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.');
}
$exception = new NotNormalizableValueException(
"The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'",
0,
null,
null,
null,
'suit',
true,
);
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->method('deserialize')->willThrowException($partialException);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: '{"suit":"invalid"}');
$request->headers->set('CONTENT_TYPE', 'application/json');
$request->attributes->set('input_format', 'json');
try {
$provider->provide($operation, [], ['request' => $request]);
$this->fail('Expected ValidationException');
} catch (ValidationException $e) {
$violations = $e->getConstraintViolationList();
$this->assertCount(1, $violations);
$this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage());
$this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate());
$this->assertSame('suit', $violations[0]->getPropertyPath());
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
}
}
#[IgnoreDeprecations]
public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void
{
$operation = new Post(deserialize: true, class: \stdClass::class);
$decorated = $this->createStub(ProviderInterface::class);
$decorated->method('provide')->willReturn(null);
$exception = NotNormalizableValueException::createForUnexpectedDataType(
'Internal error detail',
42,
['string'],
'name',
false,
);
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
$serializer = $this->createMock(SerializerInterface::class);
$serializer->method('deserialize')->willThrowException($partialException);
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
$request = new Request(content: '{"name":42}');
$request->headers->set('CONTENT_TYPE', 'application/json');
$request->attributes->set('input_format', 'json');
try {
$provider->provide($operation, [], ['request' => $request]);
$this->fail('Expected ValidationException');
} catch (ValidationException $e) {
$violations = $e->getConstraintViolationList();
$this->assertCount(1, $violations);
$this->assertStringContainsString('string', $violations[0]->getMessage());
$this->assertSame('name', $violations[0]->getPropertyPath());
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
$this->assertArrayNotHasKey('hint', $violations[0]->getParameters());
}
}
public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void
{
$objectToPopulate = new \stdClass();
$serializerContext = [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => false];
$operation = new Post(deserialize: true, class: \stdClass::class);
$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',
\stdClass::class,
'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]);
}
}