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

+204
View File
@@ -0,0 +1,204 @@
parameters:
level: 6
paths:
- src
- tests
- tests/Fixtures/app/console
inferPrivatePropertyTypeFromConstructor: true
treatPhpDocTypesAsCertain: false
symfony:
containerXmlPath: tests/Fixtures/app/var/cache/test/AppKernelTestDebugContainer.xml
constantHassers: false
doctrine:
objectManagerLoader: tests/Fixtures/app/object-manager.php
bootstrapFiles:
# We're aliasing classes for phpunit in this file, it needs to be added here see phpstan/#2194
- src/Symfony/Bundle/Test/Constraint/ArraySubset.php
- tests/Fixtures/app/AppKernel.php
excludePaths:
# uses larastan
- src/Laravel
# Symfony config
- tests/Fixtures/app/config/config_swagger.php
# Symfony cache
- tests/Fixtures/app/var/
- tests/Fixtures/Symfony/Maker
# The Symfony Configuration API isn't good enough to be analysed
- src/Symfony/Bundle/DependencyInjection/Configuration.php
# Templates for Maker
- src/Symfony/Maker/Resources/skeleton
# subtree split
- src/Doctrine/Orm/Tests/var/*
- src/Doctrine/Odm/Tests/var/*
- src/Doctrine/*/vendor/*
- src/*/vendor/*
# Symfony 6 support
- src/Symfony/Bundle/ArgumentResolver/CompatibleValueResolverInterface.php
- tests/Fixtures/app/config/reference.php
earlyTerminatingMethodCalls:
PHPUnit\Framework\Constraint\Constraint:
- fail
ApiPlatform\Metadata\Resource\ResourceMetadataCollection:
- handleNotFound
ignoreErrors:
# ignore type todo remove in 5.0
-
message: '#Symfony\\Component\\PropertyInfo\\Type#'
identifier: class.notFound
# False positives
- message: '#Call to an undefined method Negotiation\\AcceptHeader::getType\(\).#'
-
identifier: doctrine.columnType
paths:
- tests/Fixtures/TestBundle/Entity/
-
identifier: doctrine.associationType
paths:
- tests/Fixtures/TestBundle/Entity/
- '#Cannot call method add\(\) on iterable.#'
-
message: '#is never read, only written.#'
paths:
- tests/Fixtures/TestBundle/Document/
- tests/Fixtures/TestBundle/Entity/
- src/OpenApi/Factory/OpenApiFactory.php
- src/JsonLd/Serializer/ObjectNormalizer.php
-
message: '#is never assigned .* so it can be removed from the property type.#'
paths:
- src/Doctrine/Common/Tests/Fixtures/
- src/Doctrine/Orm/Tests/Fixtures/
- src/Doctrine/Odm/Tests/Fixtures/
- src/Elasticsearch/Tests/Fixtures/
- src/GraphQl/Tests/Fixtures/
- src/JsonSchema/Tests/Fixtures/
- src/Metadata/Tests/Fixtures/
- src/Serializer/Tests/Fixtures/
- tests/Fixtures/
-
message: '#is never written, only read.#'
paths:
- tests/Fixtures/TestBundle/Document/
- src/Metadata/Tests/Fixtures/ApiResource/
- '#Access to an undefined property Prophecy\\Prophecy\\ObjectProphecy<(\\?[a-zA-Z0-9_]+)+>::\$[a-zA-Z0-9_]+#'
# ORM 2 and 3 disagree on the shape of ClassMetadata::$associationMappings
# (raw arrays vs mapping objects). The extension reads them through
# ArrayAccess, so array fixtures are valid for both; composer still
# supports doctrine/orm ^2.17.
-
identifier: assign.propertyType
path: src/Doctrine/Orm/Tests/Extension/EagerLoadingExtensionTest.php
# https://github.com/phpstan/phpstan-symfony/issues/76
-
message: '#Service "test" is not registered in the container\.#'
path: src/GraphQl/Tests/Type/TypesContainerTest.php
# Expected, due to backward compatibility
- '#Access to an undefined property GraphQL\\Type\\Definition\\NamedType&GraphQL\\Type\\Definition\\Type::\$name\.#'
- "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\PropertyInfo\\\\\\\\PropertyInfoExtractor' and 'getType' will always evaluate to true\\.#"
- "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\HttpFoundation\\\\\\\\Request' and 'getContentTypeFormat' will always evaluate to true\\.#"
- '#Call to an undefined method Symfony\\Component\\HttpFoundation\\Request::getContentType\(\)\.#'
- "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#"
- "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#"
- "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#"
# See https://github.com/phpstan/phpstan-symfony/issues/27
-
message: '#^Service "[^"]+" is private.$#'
path: src
# Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130
- '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'
# Level 6
-
identifier: missingType.iterableValue
paths:
- src/Doctrine/Common/Tests
- src/Doctrine/Odm/Tests
- src/Doctrine/Orm/Tests
- src/Elasticsearch/Tests
- src/GraphQl/Tests
- src/HttpCache/Tests
- src/Hydra/Tests
- src/JsonApi/Tests
- src/JsonSchema/Tests
- src/Metadata/Tests
- src/OpenApi/Tests
- src/Serializer/Tests
- src/State/Tests
- src/Symfony/Tests
- tests
- src # TODO
-
identifier: missingType.generics
-
identifier: missingType.property
paths:
- src/Doctrine/Common/Tests
- src/Doctrine/Odm/Tests
- src/Doctrine/Orm/Tests
- src/Elasticsearch/Tests
- src/Hydra/Tests
- src/JsonApi/Tests
- src/JsonSchema/Tests
- src/Metadata/Tests
- src/Serializer/Tests
- src/Symfony/Tests
- tests
-
identifier: missingType.parameter
paths:
- src/Doctrine/Common/Tests
- src/Doctrine/Odm/Tests
- src/Doctrine/Orm/Tests
- src/Elasticsearch/Tests
- src/GraphQl/Tests
- src/Hydra/Tests
- src/JsonApi/Tests
- src/JsonSchema/Tests
- src/Metadata/Tests
- src/OpenApi/Tests
- src/Serializer/Tests
- src/Symfony/Bundle/Test
- src/Symfony/Tests
- tests
-
identifier: missingType.return
paths:
- src/Doctrine/Common/Tests
- src/Doctrine/Odm/Tests
- src/Doctrine/Orm/Tests
- src/Elasticsearch/Tests
- src/GraphQl/Tests
- src/Hydra/Tests
- src/JsonApi/Tests
- src/JsonSchema/Tests
- src/Metadata/Tests
- src/OpenApi/Tests
- src/Serializer/Tests
- src/Symfony/Tests
- tests
-
identifier: argument.templateType
paths:
- src/Symfony/Bundle/Test
- tests
- src # TODO
-
identifier: method.notFound
paths:
- tests/Functional/JsonStreamerTest.php
# Symfony Console 7.4 added Application::addCommand(); 8.1 removed add(). One branch is unreachable depending on installed version.
-
message: '#Call to an undefined method Symfony\\Component\\Console\\Application::(add|addCommand)\(\)\.#'
path: tests/Symfony/Bundle/Command/DebugResourceCommandTest.php
reportUnmatched: false
-
identifier: function.alreadyNarrowedType
path: tests/Symfony/Bundle/Command/DebugResourceCommandTest.php
reportUnmatched: false
+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]);
}
}
@@ -0,0 +1,125 @@
<?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\Tests\Functional;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource;
use ApiPlatform\Tests\SetupClassResourcesTrait;
use Composer\InstalledVersions;
use Composer\Semver\VersionParser;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
/**
* @see https://github.com/api-platform/core/issues/8183
*/
final class EnumDenormalizationValidationTest extends ApiTestCase
{
use SetupClassResourcesTrait;
protected static ?bool $alwaysBootKernel = false;
/**
* @return class-string[]
*/
public static function getResources(): array
{
return [EnumValidationResource::class];
}
protected function setUp(): void
{
// On symfony/property-info < 7.1, nullable backed enums resolve to a
// single legacy Type instead of a UnionType. AbstractItemNormalizer
// then does not re-wrap the BackedEnumNormalizer exception with the
// enum FQCN, and DeserializeProvider cannot detect it as a BackedEnum
// failure. The 400 response is expected on those versions.
if (version_compare(ltrim(InstalledVersions::getPrettyVersion('symfony/property-info') ?? '0', 'v'), '7.1.0', '<')) {
$this->markTestSkipped('Requires symfony/property-info >= 7.1 for BackedEnum type detection.');
}
}
public function testInvalidBackedEnumValueProducesValidationViolation(): void
{
$response = static::createClient()->request('POST', '/enum_validation_resources', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['gender' => 'unknown'],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertResponseHeaderSame('content-type', 'application/problem+json');
$content = $response->toArray(false);
$this->assertArrayHasKey('violations', $content);
$this->assertNotEmpty($content['violations']);
$genderViolation = $this->findViolation($content['violations'], 'gender');
$this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.');
}
#[IgnoreDeprecations]
public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void
{
if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) {
$this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.');
}
$response = static::createClient()->request('POST', '/enum_validation_resources_collect', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['gender' => 'unknown'],
]);
$this->assertResponseStatusCodeSame(422);
$content = $response->toArray(false);
$this->assertNotNull($this->findViolation($content['violations'] ?? [], 'gender'));
}
/**
* @see https://github.com/api-platform/core/issues/8388
*/
#[IgnoreDeprecations]
public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void
{
if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) {
$this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.');
}
$response = static::createClient()->request('POST', '/enum_validation_resources_collect', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['gender' => true],
]);
$this->assertResponseStatusCodeSame(422);
$content = $response->toArray(false);
$genderViolation = $this->findViolation($content['violations'] ?? [], 'gender');
$this->assertNotNull($genderViolation);
// The message must name what a JSON consumer can actually send: the enum's backing scalar
// ("string") and, since the property is nullable, "null" — never the internal PHP type
// "GenderTypeEnum|null", which appears nowhere in the OpenAPI schema.
$this->assertSame('This value should be of type string|null.', $genderViolation['message']);
$this->assertStringNotContainsString('GenderTypeEnum', $genderViolation['message']);
$this->assertStringContainsString('enumeration case of type', $genderViolation['hint'] ?? '');
}
private function findViolation(array $violations, string $propertyPath): ?array
{
foreach ($violations as $violation) {
if (($violation['propertyPath'] ?? null) === $propertyPath) {
return $violation;
}
}
return null;
}
}
@@ -0,0 +1,70 @@
<?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\Tests\Functional;
use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource;
use ApiPlatform\Tests\SetupClassResourcesTrait;
use Composer\InstalledVersions;
use Composer\Semver\VersionParser;
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
/** @see https://github.com/symfony/symfony/issues/64159 */
final class NullOnNonNullablePropertyTest extends ApiTestCase
{
use SetupClassResourcesTrait;
protected static ?bool $alwaysBootKernel = false;
/**
* @return class-string[]
*/
public static function getResources(): array
{
return [NullOnNonNullableResource::class];
}
public function testNullOnNonNullablePropertyReturns400(): void
{
$response = self::createClient()->request('POST', '/null_on_non_nullable_resources', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => null],
]);
$this->assertResponseStatusCodeSame(400);
$this->assertResponseHeaderSame('content-type', 'application/problem+json');
$body = $response->toArray(false);
$this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? '');
}
#[IgnoreDeprecations]
public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void
{
if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) {
$this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.');
}
$response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => ['name' => null],
]);
$this->assertResponseStatusCodeSame(422);
$this->assertResponseHeaderSame('content-type', 'application/problem+json');
$content = $response->toArray(false);
$this->assertArrayHasKey('violations', $content);
$this->assertSame('name', $content['violations'][0]['propertyPath']);
}
}