Skip to content

Commit bea2f83

Browse files
committed
feature: Add handling for enum choices
1 parent e79be9f commit bea2f83

10 files changed

+264
-2
lines changed

composer.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "fusonic/http-kernel-bundle",
33
"description": "Symfony bundle with extensions for Symfony's HttpKernel",
4-
"version": "1.1.0",
4+
"version": "1.2.0",
55
"type": "library",
66
"license": "MIT",
77
"authors": [

config/services.php

+5
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
use Fusonic\HttpKernelBundle\Controller\RequestDtoResolver;
1111
use Fusonic\HttpKernelBundle\Normalizer\ConstraintViolationExceptionNormalizer;
12+
use Fusonic\HttpKernelBundle\Normalizer\DecoratedBackedEnumNormalizer;
1213
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
1314

1415
use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
@@ -22,6 +23,10 @@
2223
'priority' => 50,
2324
]);
2425

26+
$services->set(DecoratedBackedEnumNormalizer::class)
27+
->decorate('serializer.normalizer.backed_enum')
28+
->args([service('.inner')]);
29+
2530
$services->set(ConstraintViolationExceptionNormalizer::class)
2631
->autoconfigure()
2732
->arg('$normalizer', service('serializer.normalizer.constraint_violation_list'));
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Fusonic GmbH. All rights reserved.
5+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Fusonic\HttpKernelBundle\ConstraintViolation;
11+
12+
use Fusonic\HttpKernelBundle\Exception\InvalidEnumException;
13+
use Symfony\Component\Validator\Constraints\Choice;
14+
use Symfony\Component\Validator\ConstraintViolation;
15+
16+
/**
17+
* Wraps an {@see InvalidEnumException} into a {@see ConstraintViolation} to provide information about the valid choices.
18+
*/
19+
class InvalidEnumConstraintViolation extends ConstraintViolation
20+
{
21+
/**
22+
* @param class-string<\UnitEnum> $enumClass
23+
*
24+
* @throws \ReflectionException
25+
*/
26+
public function __construct(string $enumClass, mixed $data, ?string $propertyPath)
27+
{
28+
$reflectionEnum = new \ReflectionEnum($enumClass);
29+
30+
$choices = array_map(static fn (\ReflectionEnumUnitCase $case) => $case->getName(), $reflectionEnum->getCases());
31+
$constraint = new Choice(choices: $choices);
32+
33+
parent::__construct(
34+
message: $constraint->message,
35+
messageTemplate: $constraint->message,
36+
parameters: ['{{ choices }}' => $choices, '{{ value }}' => $propertyPath],
37+
root: null,
38+
propertyPath: $propertyPath,
39+
invalidValue: $data,
40+
code: Choice::NO_SUCH_CHOICE_ERROR,
41+
constraint: $constraint
42+
);
43+
}
44+
}

src/ConstraintViolation/NotNormalizableValueConstraintViolation.php

+4
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ public function __construct(NotNormalizableValueException $exception, array $dat
6666
}
6767

6868
$propertyPath = $this->determinePropertyPath($data, $className);
69+
} elseif (str_starts_with($message, 'The data is neither an integer nor a string, you should pass an integer or a string that can be parsed as an enumeration case of type')) {
70+
$propertyPath = $exception->getPath();
71+
$invalidValue = $exception->getCurrentType();
72+
$expectedType = implode('|', $exception->getExpectedTypes());
6973
} else {
7074
throw $exception;
7175
}

src/ErrorHandler/ConstraintViolationErrorHandler.php

+8
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
namespace Fusonic\HttpKernelBundle\ErrorHandler;
1111

1212
use Fusonic\HttpKernelBundle\ConstraintViolation\ArgumentCountConstraintViolation;
13+
use Fusonic\HttpKernelBundle\ConstraintViolation\InvalidEnumConstraintViolation;
1314
use Fusonic\HttpKernelBundle\ConstraintViolation\MissingConstructorArgumentsConstraintViolation;
1415
use Fusonic\HttpKernelBundle\ConstraintViolation\NotNormalizableValueConstraintViolation;
1516
use Fusonic\HttpKernelBundle\ConstraintViolation\TypeConstraintViolation;
1617
use Fusonic\HttpKernelBundle\Exception\ConstraintViolationException;
18+
use Fusonic\HttpKernelBundle\Exception\InvalidEnumException;
1719
use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException;
1820
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
1921
use Symfony\Component\Validator\ConstraintViolationListInterface;
@@ -26,6 +28,12 @@ class ConstraintViolationErrorHandler implements ErrorHandlerInterface
2628
*/
2729
public function handleDenormalizeError(\Throwable $ex, array $data, string $className): \Throwable
2830
{
31+
if ($ex instanceof InvalidEnumException) {
32+
return ConstraintViolationException::fromConstraintViolation(
33+
new InvalidEnumConstraintViolation($ex->enumClass, $ex->data, $ex->propertyPath)
34+
);
35+
}
36+
2937
if ($ex instanceof NotNormalizableValueException) {
3038
return ConstraintViolationException::fromConstraintViolation(
3139
new NotNormalizableValueConstraintViolation($ex, $data, $className)
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Fusonic GmbH. All rights reserved.
5+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Fusonic\HttpKernelBundle\Exception;
11+
12+
class InvalidEnumException extends \InvalidArgumentException
13+
{
14+
/**
15+
* @param class-string<\UnitEnum> $enumClass
16+
*/
17+
public function __construct(
18+
public readonly string $enumClass,
19+
public readonly mixed $data,
20+
public readonly ?string $propertyPath
21+
) {
22+
parent::__construct(sprintf('Invalid enum value for %s: %s', $enumClass, $data));
23+
}
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Fusonic GmbH. All rights reserved.
5+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Fusonic\HttpKernelBundle\Normalizer;
11+
12+
use Fusonic\HttpKernelBundle\Exception\InvalidEnumException;
13+
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
14+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
15+
use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer;
16+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
17+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
18+
19+
/**
20+
* Decorate the original BackedEnumNormalizer to be able to provide a better error message.
21+
*/
22+
final class DecoratedBackedEnumNormalizer implements NormalizerInterface, DenormalizerInterface
23+
{
24+
public function __construct(
25+
private readonly BackedEnumNormalizer $inner
26+
) {
27+
}
28+
29+
public function getSupportedTypes(?string $format): array
30+
{
31+
return $this->inner->getSupportedTypes($format);
32+
}
33+
34+
public function normalize(mixed $object, ?string $format = null, array $context = []): int|string
35+
{
36+
return $this->inner->normalize($object, $format, $context);
37+
}
38+
39+
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
40+
{
41+
return $this->inner->supportsNormalization($data, $format, $context);
42+
}
43+
44+
/**
45+
* @throws NotNormalizableValueException
46+
*/
47+
public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed
48+
{
49+
try {
50+
return $this->inner->denormalize($data, $type, $format, $context);
51+
// @phpstan-ignore-next-line Ignore since the normalizer doesn't have the correct @throws tag
52+
} catch (InvalidArgumentException) {
53+
throw new InvalidEnumException($type, $data, $context['deserialization_path'] ?? null);
54+
}
55+
}
56+
57+
public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool
58+
{
59+
return $this->inner->supportsDenormalization($data, $type, $format, $context);
60+
}
61+
}

tests/Controller/RequestDtoResolverTest.php

+84-1
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,14 @@
1717
use Fusonic\HttpKernelBundle\Controller\RequestDtoResolver;
1818
use Fusonic\HttpKernelBundle\Exception\ConstraintViolationException;
1919
use Fusonic\HttpKernelBundle\Normalizer\ConstraintViolationExceptionNormalizer;
20+
use Fusonic\HttpKernelBundle\Normalizer\DecoratedBackedEnumNormalizer;
2021
use Fusonic\HttpKernelBundle\Provider\ContextAwareProviderInterface;
2122
use Fusonic\HttpKernelBundle\Request\StrictRequestDataCollector;
2223
use Fusonic\HttpKernelBundle\Tests\Dto\ArrayDto;
2324
use Fusonic\HttpKernelBundle\Tests\Dto\DummyClassA;
2425
use Fusonic\HttpKernelBundle\Tests\Dto\EmptyDto;
26+
use Fusonic\HttpKernelBundle\Tests\Dto\EnumDto;
27+
use Fusonic\HttpKernelBundle\Tests\Dto\ExampleEnum;
2528
use Fusonic\HttpKernelBundle\Tests\Dto\IntArrayDto;
2629
use Fusonic\HttpKernelBundle\Tests\Dto\NestedDto;
2730
use Fusonic\HttpKernelBundle\Tests\Dto\NotADto;
@@ -199,6 +202,85 @@ public function testStrictTypeMappingForPostFormRequestBody(): void
199202
self::assertSame('barfoo', $dto->getSubType()->getTest());
200203
}
201204

205+
public function testValidEnumFormRequestBody(): void
206+
{
207+
$data = [
208+
'exampleEnum' => 'CHOICE_1',
209+
];
210+
211+
$request = new Request([], $data, [], [], [], []);
212+
$request->setMethod(Request::METHOD_POST);
213+
$argument = $this->createArgumentMetadata(EnumDto::class, [new FromRequest()]);
214+
215+
$resolver = new RequestDtoResolver($this->getDenormalizer(), $this->getValidator());
216+
$generator = $resolver->resolve($request, $argument);
217+
218+
$dto = $generator->current();
219+
220+
self::assertInstanceOf(EnumDto::class, $dto);
221+
self::assertSame(ExampleEnum::CHOICE_1, $dto->exampleEnum);
222+
}
223+
224+
public function testInvalidEnumFormRequestBody(): void
225+
{
226+
$data = [
227+
'exampleEnum' => 'WRONG_CHOICE',
228+
];
229+
230+
$request = new Request([], $data, [], [], [], []);
231+
$request->setMethod(Request::METHOD_POST);
232+
$argument = $this->createArgumentMetadata(EnumDto::class, [new FromRequest()]);
233+
234+
$resolver = new RequestDtoResolver($this->getDenormalizer(), $this->getValidator());
235+
$generator = $resolver->resolve($request, $argument);
236+
237+
$this->expectException(ConstraintViolationException::class);
238+
$this->expectExceptionMessage(
239+
'The value you selected is not a valid choice.'
240+
);
241+
242+
$generator->current();
243+
}
244+
245+
public function testInvalidEnumFormQuery(): void
246+
{
247+
$request = new Request([
248+
'exampleEnum' => 'WRONG_CHOICE',
249+
]);
250+
$request->setMethod(Request::METHOD_GET);
251+
$argument = $this->createArgumentMetadata(EnumDto::class, [new FromRequest()]);
252+
253+
$resolver = new RequestDtoResolver($this->getDenormalizer(), $this->getValidator());
254+
$generator = $resolver->resolve($request, $argument);
255+
256+
$this->expectException(ConstraintViolationException::class);
257+
$this->expectExceptionMessage(
258+
'The value you selected is not a valid choice.'
259+
);
260+
261+
$generator->current();
262+
}
263+
264+
public function testInvalidEnumTypeFormBody(): void
265+
{
266+
$data = [
267+
'exampleEnum' => [],
268+
];
269+
270+
$request = new Request([], $data, [], [], [], []);
271+
$request->setMethod(Request::METHOD_POST);
272+
$argument = $this->createArgumentMetadata(EnumDto::class, [new FromRequest()]);
273+
274+
$resolver = new RequestDtoResolver($this->getDenormalizer(), $this->getValidator());
275+
$generator = $resolver->resolve($request, $argument);
276+
277+
$this->expectException(ConstraintViolationException::class);
278+
$this->expectExceptionMessage(
279+
'ConstraintViolation: This value should be of type int|string.'
280+
);
281+
$generator->current();
282+
}
283+
202284
public function testSkippingBodyGetRequest(): void
203285
{
204286
$this->expectException(ConstraintViolationException::class);
@@ -505,7 +587,7 @@ public function testIntegerRouteParameterTypeError(): void
505587
);
506588
$generator = $resolver->resolve($request, $argument);
507589

508-
self::expectExceptionMessage('ConstraintViolation: This value should be of type int.');
590+
$this->expectExceptionMessage('ConstraintViolation: This value should be of type int.');
509591

510592
/* @var DummyClassA $dto */
511593
$generator->current();
@@ -632,6 +714,7 @@ private function getDenormalizer(): DenormalizerInterface
632714
$normalizers = [
633715
new UnwrappingDenormalizer(),
634716
new ConstraintViolationExceptionNormalizer($constraintViolationListNormalizer),
717+
new DecoratedBackedEnumNormalizer(new BackedEnumNormalizer()),
635718
new ProblemNormalizer(),
636719
new UidNormalizer(),
637720
new JsonSerializableNormalizer(),

tests/Dto/EnumDto.php

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Fusonic GmbH. All rights reserved.
5+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Fusonic\HttpKernelBundle\Tests\Dto;
11+
12+
class EnumDto
13+
{
14+
public function __construct(public ExampleEnum $exampleEnum)
15+
{
16+
}
17+
}

tests/Dto/ExampleEnum.php

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Fusonic GmbH. All rights reserved.
5+
* Licensed under the MIT License. See LICENSE file in the project root for license information.
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Fusonic\HttpKernelBundle\Tests\Dto;
11+
12+
enum ExampleEnum: string
13+
{
14+
case CHOICE_1 = 'CHOICE_1';
15+
case CHOICE_2 = 'CHOICE_2';
16+
}

0 commit comments

Comments
 (0)