From 39b278c5bb1cb4abf3d61cdd93ffc759e74e6b41 Mon Sep 17 00:00:00 2001 From: soyuka Date: Wed, 8 Jul 2026 12:17:10 +0200 Subject: [PATCH] fix(serializer): report enum backing type in denormalization violations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a wrong-typed value is sent for a nullable backed enum property (e.g. `?Status`), collectDenormalizationErrors produced the violation message "This value should be of type Status|null." — the internal PHP type, which appears nowhere in the OpenAPI schema and names none of the accepted values. AbstractItemNormalizer delegates nullable backed enums to BackedEnumNormalizer, which throws an actionable exception (accepted scalars + hint). The post-loop block then rewrapped it, replacing its expectedTypes with `(string) $types` ("Status|null") and dropping the user-facing hint flag. Preserve the hint flag when the failure comes from an object/enum normalizer, and in DeserializeProvider render a BackedEnum expected type as its JSON-visible backing scalar ("string"/"int") instead of the enum FQCN. The enum class stays in expectedTypes so the 400->422 promotion (#8183) keeps working. Now `{"status": true}` on a `?Status` property yields "This value should be of type string|null." plus the enum hint. Closes #8388 --- src/Serializer/AbstractItemNormalizer.php | 10 +++++-- src/State/Provider/DeserializeProvider.php | 11 ++++++-- .../EnumDenormalizationValidationTest.php | 28 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 43a8ac5e079..253f1a0b305 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -1445,12 +1445,16 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($denormalizationException) { if ($type instanceof Type && $type->isSatisfiedBy(static fn ($type) => $type instanceof BuiltinType) && !$type->isSatisfiedBy($typeIsResourceClass)) { - // If the exception came from object denormalization, preserve its message as it's more specific - $message = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType) + // If the exception came from object denormalization (e.g. BackedEnumNormalizer for a + // nullable backed enum), preserve its more specific message and its user-facing hint flag; + // the expected types still carry the object/enum class so the failure stays recognizable + // downstream (see DeserializeProvider). + $isObject = $type->isSatisfiedBy(static fn ($type) => $type instanceof ObjectType); + $message = $isObject ? $denormalizationException->getMessage() : \sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $type, \gettype($value)); - throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, false, 0, $denormalizationException); + throw NotNormalizableValueException::createForUnexpectedDataType($message, $value, array_map(strval(...), $types), $context['deserialization_path'] ?? null, $isObject && $denormalizationException->canUseMessageForUser(), 0, $denormalizationException); } throw $denormalizationException; diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 338c8371418..e264cc19e0c 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -143,14 +143,19 @@ private function normalizeExpectedTypes(?array $expectedTypes = null): array $normalizedType = $expectedType; if (class_exists($expectedType) || interface_exists($expectedType)) { - $classReflection = new \ReflectionClass($expectedType); - $normalizedType = $classReflection->getShortName(); + // 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 $normalizedTypes; + return array_values(array_unique($normalizedTypes)); } private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 3fa939623c8..5b6e8cd587e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -84,6 +84,34 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo $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) {