From 1f4669b90b9c483f40996d22856f5c7218f9fb71 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Wed, 7 Oct 2026 16:02:02 +0200 Subject: [PATCH] fix(validator): returns 422 for backed enum errors --- .../DenormalizationViolationFactory.php | 50 ++++++++++++++++++- .../DenormalizationViolationFactoryTest.php | 43 ++++++++++++++++ .../ApiResource/EnumValidationResource.php | 8 +++ .../EnumDenormalizationValidationTest.php | 20 ++++++++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php index e3052c7c168..bad23775b21 100644 --- a/src/Validator/DenormalizationViolationFactory.php +++ b/src/Validator/DenormalizationViolationFactory.php @@ -45,6 +45,10 @@ * | any wrong type | any other constraint | generic Type violation @ 422 | * | any wrong type | (no constraint) | none — single-error path rethrows → 400 | * + * A backed enum is the exception to the last row: its type already restricts the accepted + * values, so a denormalization error on such a property emits a generic Type violation (422) + * even without a declared constraint. See #8183 and #8641. + * * In collect mode (PartialDenormalizationException), unconstrained errors still emit * a generic Type violation so the response stays consistent with prior behavior. * @@ -97,8 +101,9 @@ public function handle(NotNormalizableValueException|PartialDenormalizationExcep * Returns a violation for the given error. * * When `$generic` is true, emits a Type-based fallback regardless of property metadata - * (used in collect mode to keep one violation per error). When false, returns null if - * no matching constraint is declared on the property — caller rethrows. + * (used in collect mode to keep one violation per error). When false, a declared constraint + * yields the most specific violation; failing that, a backed enum property still yields a + * generic 422 and anything else returns null so the caller rethrows (→ 400). */ private function buildViolation(NotNormalizableValueException $exception, Operation $operation, bool $generic = false): ?ConstraintViolationInterface { @@ -111,6 +116,16 @@ private function buildViolation(NotNormalizableValueException $exception, Operat return $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR); } + return $this->buildConstraintViolation($exception, $operation, $path) + ?? $this->backedEnumFallback($exception); + } + + /** + * Builds a violation from the constraints declared on the property, or null when none applies + * (unknown class, missing metadata, or no constraint matching the error). + */ + private function buildConstraintViolation(NotNormalizableValueException $exception, Operation $operation, string $path): ?ConstraintViolationInterface + { $class = $operation->getClass(); if (null === $class || (!class_exists($class) && !interface_exists($class))) { return null; @@ -151,6 +166,37 @@ private function buildViolation(NotNormalizableValueException $exception, Operat return $this->emitViolation($exception, new Type([]), (string) Type::INVALID_TYPE_ERROR); } + /** + * A backed enum restricts its accepted values by itself, so a denormalization error on such a + * property surfaces as a 422 constraint violation even when no constraint is declared — otherwise + * the single-error path would rethrow and answer 400. See #8183 and #8641. + */ + private function backedEnumFallback(NotNormalizableValueException $exception): ?ConstraintViolationInterface + { + if (!$this->isBackedEnumException($exception)) { + return null; + } + + return $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR); + } + + private function isBackedEnumException(NotNormalizableValueException $exception): bool + { + for ($e = $exception; $e instanceof \Throwable; $e = $e->getPrevious()) { + if (!$e instanceof NotNormalizableValueException) { + continue; + } + + foreach ($e->getExpectedTypes() ?? [] as $expectedType) { + if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { + return true; + } + } + } + + return false; + } + /** * @param array|null $validationGroups * diff --git a/src/Validator/Tests/DenormalizationViolationFactoryTest.php b/src/Validator/Tests/DenormalizationViolationFactoryTest.php index 5f94199487d..7c8d8f1df2a 100644 --- a/src/Validator/Tests/DenormalizationViolationFactoryTest.php +++ b/src/Validator/Tests/DenormalizationViolationFactoryTest.php @@ -120,6 +120,41 @@ public function testWrongTypeWithoutConstraintReturnsVoid(): void $this->expectNotToPerformAssertions(); } + public function testBackedEnumWithoutConstraintThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType( + 'The data must be one of the following values: "a", "b".', + 'invalid', + [DenormBackedEnumFixture::class, 'null'], + 'enum', + true, + ); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violation = $e->getConstraintViolationList()[0]; + $this->assertSame('enum', $violation->getPropertyPath()); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violation->getCode()); + // The enum's backing scalar is reported, never the internal FQCN. + $this->assertSame('This value should be of type string|null.', (string) $violation->getMessage()); + } + } + + public function testBackedEnumWrappedInPreviousExceptionThrowsValidationException(): void + { + $previous = NotNormalizableValueException::createForUnexpectedDataType('msg', 'invalid', [DenormBackedEnumFixture::class], 'enum', true); + $exception = new NotNormalizableValueException('The data must be one of the following values: "a", "b".', 0, $previous, 'string', null, 'enum', true); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + public function testUnknownClassReturnsVoid(): void { $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); @@ -215,4 +250,12 @@ class DenormHandlerFixture #[NotBlank(groups: ['admin'])] public string $adminOnly = ''; + + public ?DenormBackedEnumFixture $enum = null; +} + +enum DenormBackedEnumFixture: string +{ + case A = 'a'; + case B = 'b'; } diff --git a/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php b/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php index 816e3ba8c9b..fc1a0c83a27 100644 --- a/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php +++ b/tests/Fixtures/TestBundle/ApiResource/EnumValidationResource.php @@ -30,6 +30,10 @@ processor: self::class.'::process', collectDenormalizationErrors: true, ), + new Post( + uriTemplate: '/enum_validation_resources_without_constraint', + processor: self::class.'::process', + ), ], )] class EnumValidationResource @@ -39,6 +43,10 @@ class EnumValidationResource #[Assert\NotNull] public ?GenderTypeEnum $gender = null; + // No validation constraint: the backed enum type is the only thing restricting the value, + // so a denormalization error must still surface as a 422 and not a 400 (see #8641). + public ?GenderTypeEnum $genderWithoutConstraint = 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 index eb341223403..c0670ccac72 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -65,6 +65,26 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } + /** + * A backed enum property without any validation constraint still answers 422 (not 400) on an + * invalid value: the enum type restricts the accepted values on its own. + * + * @see https://github.com/api-platform/core/issues/8641 + */ + public function testInvalidBackedEnumValueWithoutConstraintProducesValidationViolation(): void + { + $response = static::createClient()->request('POST', '/enum_validation_resources_without_constraint', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['genderWithoutConstraint' => 'unknown'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('content-type', 'application/problem+json'); + + $content = $response->toArray(false); + $this->assertNotNull($this->findViolation($content['violations'] ?? [], 'genderWithoutConstraint')); + } + public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [