From b2a14e558d7e9311419af548138300785b443f23 Mon Sep 17 00:00:00 2001 From: upstream Date: Sat, 10 Oct 2026 11:43:47 +0200 Subject: [PATCH] Upstream api-platform/core state of the touched files before 5e95dd3773 --- phpstan.neon.dist | 204 ++++++++++ src/State/Provider/DeserializeProvider.php | 201 ++++++++++ .../Provider/DeserializeProviderTest.php | 368 ++++++++++++++++++ .../EnumDenormalizationValidationTest.php | 125 ++++++ .../NullOnNonNullablePropertyTest.php | 70 ++++ 5 files changed, 968 insertions(+) create mode 100644 phpstan.neon.dist create mode 100644 src/State/Provider/DeserializeProvider.php create mode 100644 src/State/Tests/Provider/DeserializeProviderTest.php create mode 100644 tests/Functional/EnumDenormalizationValidationTest.php create mode 100644 tests/Functional/NullOnNonNullablePropertyTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..62abec7 --- /dev/null +++ b/phpstan.neon.dist @@ -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 diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php new file mode 100644 index 0000000..e59ce9e --- /dev/null +++ b/src/State/Provider/DeserializeProvider.php @@ -0,0 +1,201 @@ + + * + * 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; + } +} diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php new file mode 100644 index 0000000..65ae14a --- /dev/null +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -0,0 +1,368 @@ + + * + * 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]); + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php new file mode 100644 index 0000000..51f81eb --- /dev/null +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -0,0 +1,125 @@ + + * + * 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; + } +} diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php new file mode 100644 index 0000000..2203c64 --- /dev/null +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -0,0 +1,70 @@ + + * + * 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']); + } +}