Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 1bc8c67

Browse filesBrowse files
feature #49944 [Serializer] Make ProblemNormalizer give details about ValidationFailedException and PartialDenormalizationException (nicolas-grekas)
This PR was merged into the 6.3 branch. Discussion ---------- [Serializer] Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException` | Q | A | ------------- | --- | Branch? | 6.3 | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | - This PR provides some infrastructure that I think is missing and would be nice using in #49138 With this patch, if an `HttpException` is thrown and if it wraps either a `ValidationFailedException` or a `PartialDenormalizationException`, Symfony will return a JSON containing the details of the validation failures / missing properties when the request comes with `Accept: application/json`. I also fixed a few minor things that I found while digging the code on this topic. Commits ------- 576f026 [Serializer] Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException`
2 parents faecbba + 576f026 commit 1bc8c67
Copy full SHA for 1bc8c67

File tree

12 files changed

+308
-167
lines changed
Filter options

12 files changed

+308
-167
lines changed

‎.github/expected-missing-return-types.diff

Copy file name to clipboardExpand all lines: .github/expected-missing-return-types.diff
+145-134Lines changed: 145 additions & 134 deletions
Large diffs are not rendered by default.

‎src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+8Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@
155155
use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader;
156156
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
157157
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
158+
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
158159
use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer;
160+
use Symfony\Component\Serializer\SerializerAwareInterface;
159161
use Symfony\Component\Stopwatch\Stopwatch;
160162
use Symfony\Component\String\LazyString;
161163
use Symfony\Component\String\Slugger\SluggerInterface;
@@ -1750,6 +1752,12 @@ private function registerSerializerConfiguration(array $config, ContainerBuilder
17501752
$container->removeDefinition('serializer.normalizer.mime_message');
17511753
}
17521754

1755+
// compat with Symfony < 6.3
1756+
if (!is_subclass_of(ProblemNormalizer::class, SerializerAwareInterface::class)) {
1757+
$container->getDefinition('serializer.normalizer.problem')
1758+
->setArguments(['%kernel.debug%']);
1759+
}
1760+
17531761
$serializerLoaders = [];
17541762
if (isset($config['enable_annotations']) && $config['enable_annotations']) {
17551763
if ($container->getParameter('kernel.debug')) {

‎src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@
103103
->tag('serializer.normalizer', ['priority' => -950])
104104

105105
->set('serializer.normalizer.problem', ProblemNormalizer::class)
106-
->args([param('kernel.debug')])
106+
->args([param('kernel.debug'), '$translator' => service('translator')->nullOnInvalid()])
107107
->tag('serializer.normalizer', ['priority' => -890])
108108

109109
->set('serializer.denormalizer.unwrapping', UnwrappingDenormalizer::class)

‎src/Symfony/Component/DependencyInjection/Compiler/CheckTypeDeclarationsPass.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/DependencyInjection/Compiler/CheckTypeDeclarationsPass.php
+8-2Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,17 @@ private function checkTypeDeclarations(Definition $checkedDefinition, \Reflectio
136136
$envPlaceholderUniquePrefix = $this->container->getParameterBag() instanceof EnvPlaceholderParameterBag ? $this->container->getParameterBag()->getEnvPlaceholderUniquePrefix() : null;
137137

138138
for ($i = 0; $i < $checksCount; ++$i) {
139-
if (!$reflectionParameters[$i]->hasType() || $reflectionParameters[$i]->isVariadic()) {
139+
$p = $reflectionParameters[$i];
140+
if (!$p->hasType() || $p->isVariadic()) {
141+
continue;
142+
}
143+
if (\array_key_exists($p->name, $values)) {
144+
$i = $p->name;
145+
} elseif (!\array_key_exists($i, $values)) {
140146
continue;
141147
}
142148

143-
$this->checkType($checkedDefinition, $values[$i], $reflectionParameters[$i], $envPlaceholderUniquePrefix);
149+
$this->checkType($checkedDefinition, $values[$i], $p, $envPlaceholderUniquePrefix);
144150
}
145151

146152
if ($reflectionFunction->isVariadic() && ($lastParameter = end($reflectionParameters))->hasType()) {

‎src/Symfony/Component/Serializer/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/CHANGELOG.md
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ CHANGELOG
77
* Add `XmlEncoder::SAVE_OPTIONS` context option
88
* Add `BackedEnumNormalizer::ALLOW_INVALID_VALUES` context option
99
* Add method `getSupportedTypes(?string $format)` to `NormalizerInterface` and `DenormalizerInterface`
10+
* Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException`
1011
* Deprecate `MissingConstructorArgumentsException` in favor of `MissingConstructorArgumentException`
1112
* Deprecate `CacheableSupportsMethodInterface` in favor of the new `getSupportedTypes(?string $format)` methods
1213

‎src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Exception/PartialDenormalizationException.php
+13-7Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,26 @@
1616
*/
1717
class PartialDenormalizationException extends UnexpectedValueException
1818
{
19-
private $data;
20-
private $errors;
21-
22-
public function __construct($data, array $errors)
23-
{
24-
$this->data = $data;
25-
$this->errors = $errors;
19+
/**
20+
* @param NotNormalizableValueException[] $errors
21+
*/
22+
public function __construct(
23+
private mixed $data,
24+
private array $errors,
25+
) {
2626
}
2727

28+
/**
29+
* @return mixed
30+
*/
2831
public function getData()
2932
{
3033
return $this->data;
3134
}
3235

36+
/**
37+
* @return NotNormalizableValueException[]
38+
*/
3339
public function getErrors(): array
3440
{
3541
return $this->errors;

‎src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Normalizer/ConstraintViolationListNormalizer.php
+1Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ public function normalize(mixed $object, string $format = null, array $context =
6868
$violationEntry = [
6969
'propertyPath' => $propertyPath,
7070
'title' => $violation->getMessage(),
71+
'template' => $violation->getMessageTemplate(),
7172
'parameters' => $violation->getParameters(),
7273
];
7374
if (null !== $code = $violation->getCode()) {

‎src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Normalizer/MimeMessageNormalizer.php
+8-4Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use Symfony\Component\Mime\Header\UnstructuredHeader;
1818
use Symfony\Component\Mime\Message;
1919
use Symfony\Component\Mime\Part\AbstractPart;
20+
use Symfony\Component\Serializer\Exception\LogicException;
2021
use Symfony\Component\Serializer\SerializerAwareInterface;
2122
use Symfony\Component\Serializer\SerializerInterface;
2223

@@ -30,10 +31,10 @@
3031
*/
3132
final class MimeMessageNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
3233
{
33-
private $serializer;
34-
private $normalizer;
35-
private $headerClassMap;
36-
private $headersProperty;
34+
private NormalizerInterface&DenormalizerInterface $serializer;
35+
private PropertyNormalizer $normalizer;
36+
private array $headerClassMap;
37+
private \ReflectionProperty $headersProperty;
3738

3839
public function __construct(PropertyNormalizer $normalizer)
3940
{
@@ -57,6 +58,9 @@ public function getSupportedTypes(?string $format): array
5758

5859
public function setSerializer(SerializerInterface $serializer): void
5960
{
61+
if (!$serializer instanceof NormalizerInterface || !$serializer instanceof DenormalizerInterface) {
62+
throw new LogicException(sprintf('The passed serializer should implement both NormalizerInterface and DenormalizerInterface, "%s" given.', get_debug_type($serializer)));
63+
}
6064
$this->serializer = $serializer;
6165
$this->normalizer->setSerializer($serializer);
6266
}

‎src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Normalizer/ProblemNormalizer.php
+49-15Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@
1212
namespace Symfony\Component\Serializer\Normalizer;
1313

1414
use Symfony\Component\ErrorHandler\Exception\FlattenException;
15+
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
1516
use Symfony\Component\Serializer\Exception\InvalidArgumentException;
17+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
18+
use Symfony\Component\Serializer\SerializerAwareInterface;
19+
use Symfony\Component\Serializer\SerializerAwareTrait;
20+
use Symfony\Component\Validator\Exception\ValidationFailedException;
21+
use Symfony\Contracts\Translation\TranslatorInterface;
1622

1723
/**
1824
* Normalizes errors according to the API Problem spec (RFC 7807).
@@ -22,22 +28,19 @@
2228
* @author Kévin Dunglas <dunglas@gmail.com>
2329
* @author Yonel Ceruto <yonelceruto@gmail.com>
2430
*/
25-
class ProblemNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface
31+
class ProblemNormalizer implements NormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface
2632
{
33+
use SerializerAwareTrait;
34+
2735
public const TITLE = 'title';
2836
public const TYPE = 'type';
2937
public const STATUS = 'status';
3038

31-
private $debug;
32-
private $defaultContext = [
33-
self::TYPE => 'https://tools.ietf.org/html/rfc2616#section-10',
34-
self::TITLE => 'An error occurred',
35-
];
36-
37-
public function __construct(bool $debug = false, array $defaultContext = [])
38-
{
39-
$this->debug = $debug;
40-
$this->defaultContext = $defaultContext + $this->defaultContext;
39+
public function __construct(
40+
private bool $debug = false,
41+
private array $defaultContext = [],
42+
private ?TranslatorInterface $translator = null,
43+
) {
4144
}
4245

4346
public function getSupportedTypes(?string $format): array
@@ -53,15 +56,46 @@ public function normalize(mixed $object, string $format = null, array $context =
5356
throw new InvalidArgumentException(sprintf('The object must implement "%s".', FlattenException::class));
5457
}
5558

59+
$data = [];
5660
$context += $this->defaultContext;
5761
$debug = $this->debug && ($context['debug'] ?? true);
62+
$exception = $context['exception'] ?? null;
63+
if ($exception instanceof HttpExceptionInterface) {
64+
$exception = $exception->getPrevious();
65+
66+
if ($exception instanceof PartialDenormalizationException) {
67+
$trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p);
68+
$template = 'This value should be of type {{ type }}.';
69+
$data = [
70+
self::TYPE => 'https://symfony.com/errors/validation',
71+
'violations' => array_map(
72+
fn ($e) => [
73+
'propertyPath' => $e->getPath(),
74+
'title' => $trans($template, [
75+
'{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']),
76+
], 'validators'),
77+
'template' => $template,
78+
'parameter' => [
79+
'{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']),
80+
],
81+
] + ($debug || $e->canUseMessageForUser() ? ['hint' => $e->getMessage()] : []),
82+
$exception->getErrors()
83+
),
84+
];
85+
} elseif ($exception instanceof ValidationFailedException
86+
&& $this->serializer instanceof NormalizerInterface
87+
&& $this->serializer->supportsNormalization($exception->getViolations(), $format, $context)
88+
) {
89+
$data = $this->serializer->normalize($exception->getViolations(), $format, $context);
90+
}
91+
}
5892

5993
$data = [
60-
self::TYPE => $context['type'],
61-
self::TITLE => $context['title'],
62-
self::STATUS => $context['status'] ?? $object->getStatusCode(),
94+
self::TYPE => $data[self::TYPE] ?? $context[self::TYPE] ?? 'https://tools.ietf.org/html/rfc2616#section-10',
95+
self::TITLE => $data[self::TITLE] ?? $context[self::TITLE] ?? 'An error occurred',
96+
self::STATUS => $context[self::STATUS] ?? $object->getStatusCode(),
6397
'detail' => $debug ? $object->getMessage() : $object->getStatusText(),
64-
];
98+
] + $data;
6599
if ($debug) {
66100
$data['class'] = $object->getClass();
67101
$data['trace'] = $object->getTrace();

‎src/Symfony/Component/Serializer/Normalizer/UnwrappingDenormalizer.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Normalizer/UnwrappingDenormalizer.php
+7-2Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\PropertyAccess\PropertyAccess;
1515
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
16+
use Symfony\Component\Serializer\Exception\LogicException;
1617
use Symfony\Component\Serializer\SerializerAwareInterface;
1718
use Symfony\Component\Serializer\SerializerAwareTrait;
1819

@@ -37,7 +38,7 @@ public function getSupportedTypes(?string $format): array
3738
return ['*' => false];
3839
}
3940

40-
public function denormalize(mixed $data, string $class, string $format = null, array $context = []): mixed
41+
public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed
4142
{
4243
$propertyPath = $context[self::UNWRAP_PATH];
4344
$context['unwrapped'] = true;
@@ -50,7 +51,11 @@ public function denormalize(mixed $data, string $class, string $format = null, a
5051
$data = $this->propertyAccessor->getValue($data, $propertyPath);
5152
}
5253

53-
return $this->serializer->denormalize($data, $class, $format, $context);
54+
if (!$this->serializer instanceof DenormalizerInterface) {
55+
throw new LogicException('Cannot unwrap path because the injected serializer is not a denormalizer.');
56+
}
57+
58+
return $this->serializer->denormalize($data, $type, $format, $context);
5459
}
5560

5661
public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool

‎src/Symfony/Component/Serializer/Tests/Normalizer/ConstraintViolationListNormalizerTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Tests/Normalizer/ConstraintViolationListNormalizerTest.php
+7-2Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ public function testNormalize()
5353
[
5454
'propertyPath' => 'd',
5555
'title' => 'a',
56+
'template' => 'b',
5657
'type' => 'urn:uuid:f',
5758
'parameters' => [
5859
'value' => 'foo',
@@ -61,6 +62,7 @@ public function testNormalize()
6162
[
6263
'propertyPath' => '4',
6364
'title' => '1',
65+
'template' => '2',
6466
'type' => 'urn:uuid:6',
6567
'parameters' => [],
6668
],
@@ -75,9 +77,9 @@ public function testNormalizeWithNameConverter()
7577
$normalizer = new ConstraintViolationListNormalizer([], new CamelCaseToSnakeCaseNameConverter());
7678

7779
$list = new ConstraintViolationList([
78-
new ConstraintViolation('too short', 'a', [], 'c', 'shortDescription', ''),
80+
new ConstraintViolation('too short', 'a', [], '3', 'shortDescription', ''),
7981
new ConstraintViolation('too long', 'b', [], '3', 'product.shortDescription', 'Lorem ipsum dolor sit amet'),
80-
new ConstraintViolation('error', 'b', [], '3', '', ''),
82+
new ConstraintViolation('error', 'c', [], '3', '', ''),
8183
]);
8284

8385
$expected = [
@@ -90,16 +92,19 @@ public function testNormalizeWithNameConverter()
9092
[
9193
'propertyPath' => 'short_description',
9294
'title' => 'too short',
95+
'template' => 'a',
9396
'parameters' => [],
9497
],
9598
[
9699
'propertyPath' => 'product.short_description',
97100
'title' => 'too long',
101+
'template' => 'b',
98102
'parameters' => [],
99103
],
100104
[
101105
'propertyPath' => '',
102106
'title' => 'error',
107+
'template' => 'c',
103108
'parameters' => [],
104109
],
105110
],

‎src/Symfony/Component/Serializer/Tests/Normalizer/ProblemNormalizerTest.php

Copy file name to clipboardExpand all lines: src/Symfony/Component/Serializer/Tests/Normalizer/ProblemNormalizerTest.php
+60Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@
1313

1414
use PHPUnit\Framework\TestCase;
1515
use Symfony\Component\ErrorHandler\Exception\FlattenException;
16+
use Symfony\Component\HttpKernel\Exception\HttpException;
17+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
18+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
19+
use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer;
1620
use Symfony\Component\Serializer\Normalizer\ProblemNormalizer;
21+
use Symfony\Component\Serializer\Serializer;
22+
use Symfony\Component\Validator\ConstraintViolation;
23+
use Symfony\Component\Validator\ConstraintViolationList;
24+
use Symfony\Component\Validator\Exception\ValidationFailedException;
1725

1826
class ProblemNormalizerTest extends TestCase
1927
{
@@ -45,4 +53,56 @@ public function testNormalize()
4553

4654
$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable(new \RuntimeException('Error'))));
4755
}
56+
57+
public function testNormalizePartialDenormalizationException()
58+
{
59+
$this->normalizer->setSerializer(new Serializer());
60+
61+
$expected = [
62+
'type' => 'https://symfony.com/errors/validation',
63+
'title' => 'An error occurred',
64+
'status' => 422,
65+
'detail' => 'Unprocessable Content',
66+
'violations' => [
67+
[
68+
'propertyPath' => 'foo',
69+
'title' => 'This value should be of type int.',
70+
'template' => 'This value should be of type {{ type }}.',
71+
'parameters' => [
72+
'{{ type }}' => 'int',
73+
],
74+
'hint' => 'Invalid value',
75+
],
76+
],
77+
];
78+
79+
$exception = NotNormalizableValueException::createForUnexpectedDataType('Invalid value', null, ['int'], 'foo', true);
80+
$exception = new PartialDenormalizationException('Validation Failed', [$exception]);
81+
$exception = new HttpException(422, 'Validation Failed', $exception);
82+
$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception]));
83+
}
84+
85+
public function testNormalizeValidationFailedException()
86+
{
87+
$this->normalizer->setSerializer(new Serializer([new ConstraintViolationListNormalizer()]));
88+
89+
$expected = [
90+
'type' => 'https://symfony.com/errors/validation',
91+
'title' => 'Validation Failed',
92+
'status' => 422,
93+
'detail' => 'Unprocessable Content',
94+
'violations' => [
95+
[
96+
'propertyPath' => '',
97+
'title' => 'Invalid value',
98+
'template' => '',
99+
'parameters' => [],
100+
],
101+
],
102+
];
103+
104+
$exception = new ValidationFailedException('Validation Failed', new ConstraintViolationList([new ConstraintViolation('Invalid value', '', [], '', null, null)]));
105+
$exception = new HttpException(422, 'Validation Failed', $exception);
106+
$this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception]));
107+
}
48108
}

0 commit comments

Comments
0 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.