Skip to content
Open
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
98 changes: 98 additions & 0 deletions api/src/Serializer/BackedEnumNormalizerDecorator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace App\Serializer;

use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;

/**
* Reproducer for https://github.com/api-platform/demo/issues/601.
*
* This decorator simulates the behavior introduced in symfony/serializer by
* Symfony PR #62574 (commit 35b1aec), which improves BackedEnumNormalizer
* error messages. That change was temporarily in v8.0.5 (then reverted) and
* will ship in Symfony 8.1.
*
* The improved normalizer distinguishes two error cases:
* 1. Type mismatch: data is not int/string → expectedTypes = [$backingType]
* 2. Invalid value: data is the right type but not a valid enum case
* → expectedTypes = null, message lists valid values
*
* Case 2 exposes a bug in api-platform/state's DeserializeProvider which does
* not handle null/empty expectedTypes, producing: "This value should be of type ."
*
* @todo Remove this decorator once Symfony 8.1 is adopted and the upstream
* API Platform bug is fixed.
*/
#[AsDecorator('serializer.normalizer.backed_enum')]
final class BackedEnumNormalizerDecorator implements NormalizerInterface, DenormalizerInterface
{
public function __construct(
private readonly BackedEnumNormalizer $inner,
) {
}

public function normalize(mixed $data, ?string $format = null, array $context = []): int|string
{
return $this->inner->normalize($data, $format, $context);
}

public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $this->inner->supportsNormalization($data, $format, $context);
}

public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
{
if (!is_subclass_of($type, \BackedEnum::class)) {
return $this->inner->denormalize($data, $type, $format, $context);
}

$backingType = (new \ReflectionEnum($type))->getBackingType()?->getName();

// Case 1: Type mismatch — data is not the expected backing type
if (null === $data || ('int' === $backingType && !\is_int($data)) || ('string' === $backingType && !\is_string($data))) {
throw NotNormalizableValueException::createForUnexpectedDataType(
\sprintf('The data must be of type %s.', $backingType),
$data,
[$backingType],
$context['deserialization_path'] ?? null,
true,
);
}

// Case 2: Invalid value — right type but not a valid enum case
try {
return $type::from($data);
} catch (\ValueError|\TypeError $e) {
$validValues = array_map(
static fn (\BackedEnum $case): string => \is_string($case->value)
? \sprintf("'%s'", $case->value)
: (string) $case->value,
$type::cases(),
);

throw new NotNormalizableValueException(
message: \sprintf('The data must be one of the following values: %s', implode(', ', $validValues)),
previous: $e,
path: $context['deserialization_path'] ?? null,
useMessageForUser: true,
);
}
}

public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
{
return $this->inner->supportsDenormalization($data, $type, $format, $context);
}

public function getSupportedTypes(?string $format): array
{
return $this->inner->getSupportedTypes($format);
}
}
9 changes: 5 additions & 4 deletions api/tests/Api/Admin/BookTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,7 @@ public static function getInvalidDataOnCreate(): iterable

public static function getInvalidData(): iterable
{
$validValuesHint = "The data must be one of the following values: 'https://schema.org/NewCondition', 'https://schema.org/RefurbishedCondition', 'https://schema.org/DamagedCondition', 'https://schema.org/UsedCondition'";
yield 'empty data' => [
[
'book' => '',
Expand All @@ -317,11 +318,11 @@ public static function getInvalidData(): iterable
[
'@type' => 'ConstraintViolation',
'title' => 'An error occurred',
'description' => 'condition: This value should be of type int|string.',
'description' => 'condition: ' . $validValuesHint,
'violations' => [
[
'propertyPath' => 'condition',
'hint' => 'The data must belong to a backed enumeration of type ' . BookCondition::class,
'hint' => $validValuesHint,
],
],
],
Expand All @@ -335,11 +336,11 @@ public static function getInvalidData(): iterable
[
'@type' => 'ConstraintViolation',
'title' => 'An error occurred',
'description' => 'condition: This value should be of type int|string.',
'description' => 'condition: ' . $validValuesHint,
'violations' => [
[
'propertyPath' => 'condition',
'hint' => 'The data must belong to a backed enumeration of type ' . BookCondition::class,
'hint' => $validValuesHint,
],
],
],
Expand Down
Loading