Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions src/Validator/DenormalizationViolationFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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
{
Expand All @@ -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;
Expand Down Expand Up @@ -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<string>|null $validationGroups
*
Expand Down
43 changes: 43 additions & 0 deletions src/Validator/Tests/DenormalizationViolationFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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';
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
processor: self::class.'::process',
collectDenormalizationErrors: true,
),
new Post(
uriTemplate: '/enum_validation_resources_without_constraint',
processor: self::class.'::process',
),
],
)]
class EnumValidationResource
Expand All @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions tests/Functional/EnumDenormalizationValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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', [
Expand Down
Loading