Skip to content

Commit 4eca886

Browse files
committed
feat: support collect dernormalization errors
1 parent 114b31e commit 4eca886

File tree

11 files changed

+248
-13
lines changed

11 files changed

+248
-13
lines changed

features/main/validation.feature

+35
Original file line numberDiff line numberDiff line change
@@ -72,3 +72,38 @@ Feature: Using validations groups
7272
}
7373
"""
7474
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"
75+
76+
@createSchema
77+
Scenario: Create a resource with collectDenormalizationErrors
78+
When I add "Content-type" header equal to "application/ld+json"
79+
And I send a "POST" request to "/dummy_collect_denormalization" with body:
80+
"""
81+
{
82+
"foo": 3,
83+
"bar": "baz"
84+
}
85+
"""
86+
Then the response status code should be 422
87+
And the response should be in JSON
88+
And the JSON should be equal to:
89+
"""
90+
{
91+
"@context": "/contexts/ConstraintViolationList",
92+
"@type": "ConstraintViolationList",
93+
"hydra:title": "An error occurred",
94+
"hydra:description": "foo: The type of the \"foo\" attribute must be \"bool\", \"int\" given.\nbar: The type of the \"bar\" attribute must be \"int\", \"string\" given.",
95+
"violations": [
96+
{
97+
"propertyPath": "foo",
98+
"message": "The type of the \"foo\" attribute must be \"bool\", \"int\" given.",
99+
"code": "0"
100+
},
101+
{
102+
"propertyPath": "bar",
103+
"message": "The type of the \"bar\" attribute must be \"int\", \"string\" given.",
104+
"code": "0"
105+
}
106+
]
107+
}
108+
"""
109+
And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8"

src/Metadata/ApiResource.php

+15-1
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ public function __construct(
144144
protected ?array $graphQlOperations = null,
145145
$provider = null,
146146
$processor = null,
147-
protected array $extraProperties = []
147+
protected array $extraProperties = [],
148+
protected ?bool $collectDenormalizationErrors = null
148149
) {
149150
$this->operations = null === $operations ? null : new Operations($operations);
150151
$this->provider = $provider;
@@ -1004,4 +1005,17 @@ public function withExtraProperties(array $extraProperties): self
10041005

10051006
return $self;
10061007
}
1008+
1009+
public function getCollectDenormalizationErrors(): ?bool
1010+
{
1011+
return $this->collectDenormalizationErrors;
1012+
}
1013+
1014+
public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): self
1015+
{
1016+
$self = clone $this;
1017+
$self->collectDenormalizationErrors = $collectDenormalizationErrors;
1018+
1019+
return $self;
1020+
}
10071021
}

src/Metadata/GraphQl/Operation.php

+3-1
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ public function __construct(
7575
?string $name = null,
7676
?string $provider = null,
7777
?string $processor = null,
78-
array $extraProperties = []
78+
array $extraProperties = [],
79+
?bool $collectDenormalizationErrors = null
7980
) {
8081
// Abstract operation properties
8182
$this->shortName = $shortName;
@@ -122,6 +123,7 @@ public function __construct(
122123
$this->provider = $provider;
123124
$this->processor = $processor;
124125
$this->extraProperties = $extraProperties;
126+
$this->collectDenormalizationErrors = $collectDenormalizationErrors;
125127
}
126128

127129
public function getResolver(): ?string

src/Metadata/HttpOperation.php

+16-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,8 @@ public function __construct(
143143
?string $name = null,
144144
$provider = null,
145145
$processor = null,
146-
array $extraProperties = []
146+
array $extraProperties = [],
147+
?bool $collectDenormalizationErrors = null
147148
) {
148149
$this->shortName = $shortName;
149150
$this->description = $description;
@@ -189,6 +190,7 @@ public function __construct(
189190
$this->provider = $provider;
190191
$this->processor = $processor;
191192
$this->extraProperties = $extraProperties;
193+
$this->collectDenormalizationErrors = $collectDenormalizationErrors;
192194
}
193195

194196
public function getMethod(): ?string
@@ -549,4 +551,17 @@ public function withQueryParameterValidationEnabled(bool $queryParameterValidati
549551

550552
return $self;
551553
}
554+
555+
public function getCollectDenormalizationErrors(): ?bool
556+
{
557+
return $this->collectDenormalizationErrors;
558+
}
559+
560+
public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): self
561+
{
562+
$self = clone $this;
563+
$self->collectDenormalizationErrors = $collectDenormalizationErrors;
564+
565+
return $self;
566+
}
552567
}

src/Metadata/Operation.php

+15-1
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,8 @@ public function __construct(
9090
protected ?string $name = null,
9191
$provider = null,
9292
$processor = null,
93-
protected array $extraProperties = []
93+
protected array $extraProperties = [],
94+
protected ?bool $collectDenormalizationErrors = null
9495
) {
9596
$this->input = $input;
9697
$this->output = $output;
@@ -690,4 +691,17 @@ public function withExtraProperties(array $extraProperties = []): self
690691

691692
return $self;
692693
}
694+
695+
public function getCollectDenormalizationErrors(): ?bool
696+
{
697+
return $this->collectDenormalizationErrors;
698+
}
699+
700+
public function withCollectDenormalizationErrors(bool $collectDenormalizationErrors = null): self
701+
{
702+
$self = clone $this;
703+
$self->collectDenormalizationErrors = $collectDenormalizationErrors;
704+
705+
return $self;
706+
}
693707
}

src/Serializer/AbstractItemNormalizer.php

+7-3
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v
419419
*
420420
* @throws InvalidArgumentException
421421
*/
422-
protected function validateType(string $attribute, Type $type, mixed $value, string $format = null): void
422+
protected function validateType(string $attribute, Type $type, mixed $value, string $format = null, array &$context): void
423423
{
424424
$builtinType = $type->getBuiltinType();
425425
if (Type::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) {
@@ -429,7 +429,11 @@ protected function validateType(string $attribute, Type $type, mixed $value, str
429429
}
430430

431431
if (!$isValid) {
432-
throw new UnexpectedValueException(sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)));
432+
$exception = NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)), $value, [$builtinType], $context['deserialization_path'] ?? null);
433+
if (!isset($context['not_normalizable_value_exceptions'])) {
434+
throw $exception;
435+
}
436+
$context['not_normalizable_value_exceptions'][] = $exception;
433437
}
434438
}
435439

@@ -773,7 +777,7 @@ private function createAttributeValue(string $attribute, mixed $value, string $f
773777
return $value;
774778
}
775779

776-
$this->validateType($attribute, $type, $value, $format);
780+
$this->validateType($attribute, $type, $value, $format, $context);
777781

778782
return $value;
779783
}

src/Serializer/SerializerContextBuilder.php

+4-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
use ApiPlatform\Util\RequestAttributesExtractor;
1919
use Symfony\Component\HttpFoundation\Request;
2020
use Symfony\Component\Serializer\Encoder\CsvEncoder;
21+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2122

2223
/**
2324
* {@inheritdoc}
@@ -38,7 +39,6 @@ public function createFromRequest(Request $request, bool $normalization, array $
3839
if (null === $attributes && !$attributes = RequestAttributesExtractor::extractAttributes($request)) {
3940
throw new RuntimeException('Request attributes are not valid.');
4041
}
41-
4242
$operation = $attributes['operation'] ?? $this->resourceMetadataFactory->create($attributes['resource_class'])->getOperation($attributes['operation_name']);
4343
$context = $normalization ? ($operation->getNormalizationContext() ?? []) : ($operation->getDenormalizationContext() ?? []);
4444
$context['operation_name'] = $operation->getName();
@@ -76,6 +76,9 @@ public function createFromRequest(Request $request, bool $normalization, array $
7676
$context[CsvEncoder::AS_COLLECTION_KEY] = false;
7777
}
7878
}
79+
if ($operation->getCollectDenormalizationErrors() ?? false) {
80+
$context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS] = true;
81+
}
7982

8083
return $context;
8184
}

src/Symfony/EventListener/DeserializeListener.php

+27-5
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,18 @@
1616
use ApiPlatform\Api\FormatMatcher;
1717
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
1818
use ApiPlatform\Serializer\SerializerContextBuilderInterface;
19+
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
1920
use ApiPlatform\Util\OperationRequestInitiatorTrait;
2021
use ApiPlatform\Util\RequestAttributesExtractor;
2122
use Symfony\Component\HttpFoundation\Request;
2223
use Symfony\Component\HttpKernel\Event\RequestEvent;
2324
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
25+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
26+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
2427
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
2528
use Symfony\Component\Serializer\SerializerInterface;
29+
use Symfony\Component\Validator\ConstraintViolation;
30+
use Symfony\Component\Validator\ConstraintViolationList;
2631

2732
/**
2833
* Updates the entity retrieved by the data provider with data contained in the request body.
@@ -72,11 +77,28 @@ public function onKernelRequest(RequestEvent $event): void
7277
if (null !== $data) {
7378
$context[AbstractNormalizer::OBJECT_TO_POPULATE] = $data;
7479
}
75-
76-
$request->attributes->set(
77-
'data',
78-
$this->serializer->deserialize($request->getContent(), $context['resource_class'], $format, $context)
79-
);
80+
try {
81+
$request->attributes->set(
82+
'data',
83+
$this->serializer->deserialize($request->getContent(), $context['resource_class'], $format, $context)
84+
);
85+
} catch (PartialDenormalizationException $e) {
86+
$violations = new ConstraintViolationList();
87+
foreach ($e->getErrors() as $exception) {
88+
if (!$exception instanceof NotNormalizableValueException) {
89+
continue;
90+
}
91+
$message = sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $exception->getPath(), implode(', ', $exception->getExpectedTypes() ?? []), $exception->getCurrentType());
92+
$parameters = [];
93+
if ($exception->canUseMessageForUser()) {
94+
$parameters['hint'] = $exception->getMessage();
95+
}
96+
$violations->add(new ConstraintViolation($message, '', $parameters, null, $exception->getPath(), null, null, (string) $exception->getCode()));
97+
}
98+
if (0 !== \count($violations)) {
99+
throw new ValidationException($violations);
100+
}
101+
}
80102
}
81103

82104
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity;
15+
16+
use ApiPlatform\Metadata\ApiResource;
17+
use ApiPlatform\Metadata\Post;
18+
use Doctrine\ORM\Mapping as ORM;
19+
20+
/**
21+
* DummyWithCollectDenormalizationErrors.
22+
*/
23+
#[ApiResource(
24+
operations: [
25+
new Post(uriTemplate: 'dummy_collect_denormalization'),
26+
],
27+
collectDenormalizationErrors: true
28+
)]
29+
#[ORM\Entity]
30+
class DummyWithCollectDenormalizationErrors
31+
{
32+
#[ORM\Id]
33+
#[ORM\Column(type: 'integer')]
34+
#[ORM\GeneratedValue(strategy: 'AUTO')]
35+
private ?int $id = null;
36+
37+
#[ORM\Column(nullable: true)]
38+
public ?bool $foo;
39+
40+
#[ORM\Column(nullable: true)]
41+
public ?int $bar;
42+
43+
public function getId(): ?int
44+
{
45+
return $this->id;
46+
}
47+
48+
public function getFoo(): ?bool
49+
{
50+
return $this->foo;
51+
}
52+
53+
public function setFoo(?bool $foo): void
54+
{
55+
$this->foo = $foo;
56+
}
57+
58+
public function getBar(): ?int
59+
{
60+
return $this->bar;
61+
}
62+
63+
public function setBar(?int $bar): void
64+
{
65+
$this->bar = $bar;
66+
}
67+
}

tests/Serializer/SerializerContextBuilderTest.php

+10
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,14 @@ public function testReuseExistingAttributes(): void
118118
$expected = ['bar' => 'baz', 'operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false, 'skip_null_values' => true, 'operation' => $this->operation];
119119
$this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'operation_name' => 'get']));
120120
}
121+
122+
public function testCreateFromRequestKeyCollectDenormalizationErrorsIsInContext(): void
123+
{
124+
$operationWithCollectDenormalizationErrors = $this->operation->withCollectDenormalizationErrors(true);
125+
$request = Request::create('/foos', 'POST');
126+
$request->attributes->replace(['_api_resource_class' => 'Foo', '_api_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml', '_api_operation' => $operationWithCollectDenormalizationErrors]);
127+
$serializerContext = $this->builder->createFromRequest($request, false);
128+
$this->assertArrayHasKey('collect_denormalization_errors', $serializerContext);
129+
$this->assertTrue($serializerContext['collect_denormalization_errors']);
130+
}
121131
}

0 commit comments

Comments
 (0)