From 60db9f97de6aa86ed0f8587fcd8131ca0756e0fd Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 21 May 2026 16:36:30 +0200 Subject: [PATCH] fix(state): convert BackedEnum denormalization errors into validation violations When a backed enum property received an invalid value, the deserializer threw NotNormalizableValueException producing a 400 response. Validation constraints (Assert\Choice, validation groups) were never executed. Catch the exception in DeserializeProvider when its expected types (directly or via the previous chain) point to a BackedEnum, convert to a ConstraintViolation and rethrow as ValidationException so the response is a 422 with a propertyPath. Other denormalization errors keep their current 400 behavior, preserving BC. Closes #8183 --- src/State/Provider/DeserializeProvider.php | 64 ++++++++++--- .../ApiResource/EnumValidationResource.php | 46 ++++++++++ .../EnumDenormalizationValidationTest.php | 90 +++++++++++++++++++ 3 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php create mode 100644 tests/Functional/EnumDenormalizationValidationTest.php diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index b73052cccc..338c837141 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -111,22 +111,21 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if (!$exception instanceof NotNormalizableValueException) { continue; } - $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); - $parameters = []; - if ($exception->canUseMessageForUser()) { - $parameters['hint'] = $exception->getMessage(); - } - if (!$expectedTypes && $exception->canUseMessageForUser()) { - $violationMessage = $exception->getMessage(); - $violations->add(new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); - } else { - $message = (new Type($expectedTypes))->message; - $violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR)); - } + $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'); @@ -153,4 +152,45 @@ private function normalizeExpectedTypes(?array $expectedTypes = null): array return $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/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php b/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php new file mode 100644 index 0000000000..816e3ba8c9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php @@ -0,0 +1,46 @@ + + * + * 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\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/enum_validation_resources', + processor: self::class.'::process', + ), + new Post( + uriTemplate: '/enum_validation_resources_collect', + processor: self::class.'::process', + collectDenormalizationErrors: true, + ), + ], +)] +class EnumValidationResource +{ + public int $id = 1; + + #[Assert\NotNull] + public ?GenderTypeEnum $gender = null; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php new file mode 100644 index 0000000000..8d34091543 --- /dev/null +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -0,0 +1,90 @@ + + * + * 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; + +/** + * @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; charset=utf-8'); + + $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.'); + } + + public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void + { + $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')); + } + + private function findViolation(array $violations, string $propertyPath): ?array + { + foreach ($violations as $violation) { + if (($violation['propertyPath'] ?? null) === $propertyPath) { + return $violation; + } + } + + return null; + } +}