Skip to content

Commit 3c658d1

Browse files
authored
feat!(state): constraint-aware 422 for denormalization errors (#8211)
1 parent b2f1a5a commit 3c658d1

19 files changed

Lines changed: 1077 additions & 199 deletions

phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ parameters:
9292
- "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#"
9393
- "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#"
9494
- "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#"
95+
- "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#"
9596

9697
# See https://github.com/phpstan/phpstan-symfony/issues/27
9798
-

src/Laravel/ApiPlatformProvider.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
use ApiPlatform\Laravel\Security\ResourceAccessChecker;
108108
use ApiPlatform\Laravel\Serializer\EloquentOperationResourceClassResolver;
109109
use ApiPlatform\Laravel\State\AccessCheckerProvider;
110+
use ApiPlatform\Laravel\State\DenormalizationViolationFactory as LaravelDenormalizationViolationFactory;
110111
use ApiPlatform\Laravel\State\SwaggerUiProcessor;
111112
use ApiPlatform\Laravel\State\SwaggerUiProvider;
112113
use ApiPlatform\Laravel\State\ValidateProvider;
@@ -158,6 +159,7 @@
158159
use ApiPlatform\Serializer\SerializerContextBuilder;
159160
use ApiPlatform\State\CallableProcessor;
160161
use ApiPlatform\State\CallableProvider;
162+
use ApiPlatform\State\DenormalizationViolationFactoryInterface;
161163
use ApiPlatform\State\ErrorProvider;
162164
use ApiPlatform\State\Pagination\Pagination;
163165
use ApiPlatform\State\Pagination\PaginationOptions;
@@ -422,8 +424,18 @@ public function register(): void
422424
);
423425
});
424426

427+
$this->app->singleton(DenormalizationViolationFactoryInterface::class, static function () {
428+
return new LaravelDenormalizationViolationFactory();
429+
});
430+
425431
$this->app->singleton(DeserializeProvider::class, static function (Application $app) {
426-
return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class));
432+
return new DeserializeProvider(
433+
$app->make(SwaggerUiProvider::class),
434+
$app->make(SerializerInterface::class),
435+
$app->make(SerializerContextBuilderInterface::class),
436+
null,
437+
$app->make(DenormalizationViolationFactoryInterface::class),
438+
);
427439
});
428440

429441
$this->app->singleton(ValidateProvider::class, static function (Application $app) {
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\State;
15+
16+
use ApiPlatform\Laravel\ApiResource\ValidationError;
17+
use ApiPlatform\Metadata\Operation;
18+
use ApiPlatform\State\DenormalizationViolationFactoryInterface;
19+
use Illuminate\Contracts\Validation\Rule as LaravelRule;
20+
use Illuminate\Contracts\Validation\ValidationRule;
21+
use Illuminate\Foundation\Http\FormRequest;
22+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
23+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
24+
25+
/**
26+
* Laravel-flavored denormalization violation factory — translates Symfony serializer
27+
* type errors into a 422 {@see ValidationError} when the Operation's Laravel rules
28+
* describe the property.
29+
*
30+
* Reads rules declared on the operation (string|array form, e.g. `'required|string'`
31+
* or `['required', 'string']`). FormRequest-class rules and pure-callable rule sets
32+
* are intentionally skipped in v1: a FormRequest-based contract typically runs in the
33+
* validation phase against the raw request, not the denormalized body.
34+
*
35+
* Mapping:
36+
*
37+
* | Exception "current type" | Matching Laravel rule | Emitted code |
38+
* |--------------------------|---------------------------------------------|----------------|
39+
* | null | required, filled | blank |
40+
* | null | present | null |
41+
* | any wrong type | string, integer, int, numeric, boolean, | invalid_type |
42+
* | | bool, array, date, json | |
43+
* | any wrong type | any other rule (no `nullable`) | invalid_type |
44+
* | null | nullable (no required/present/filled) | (no match) |
45+
* | any | (no rule) | (no match) |
46+
*
47+
* In collect mode, unconstrained errors still emit a generic `invalid_type` entry so
48+
* the response surface stays consistent with prior behavior.
49+
*
50+
* Codes are plain semantic strings — the Laravel package does not depend on Symfony
51+
* Validator.
52+
*
53+
* @author Antoine Bluchet <soyuka@gmail.com>
54+
*/
55+
final class DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface
56+
{
57+
public const CODE_BLANK = 'blank';
58+
public const CODE_NULL = 'null';
59+
public const CODE_INVALID_TYPE = 'invalid_type';
60+
61+
private const REQUIRED_RULES = ['required' => true, 'filled' => true];
62+
private const PRESENT_RULES = ['present' => true];
63+
64+
public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void
65+
{
66+
if ($exception instanceof NotNormalizableValueException) {
67+
$violation = $this->buildViolation($exception, $operation);
68+
if (null === $violation) {
69+
return;
70+
}
71+
72+
throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]);
73+
}
74+
75+
$violations = [];
76+
$errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors();
77+
foreach ($errors as $error) {
78+
if (!$error instanceof NotNormalizableValueException) {
79+
continue;
80+
}
81+
$violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error);
82+
}
83+
84+
if (!$violations) {
85+
return;
86+
}
87+
88+
$paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations));
89+
$message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations));
90+
91+
throw new ValidationError($message, $this->makeId($paths), $exception, $violations);
92+
}
93+
94+
/**
95+
* @return array{propertyPath: string, message: string, code: string}|null
96+
*/
97+
private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array
98+
{
99+
$rules = $operation->getRules();
100+
if (\is_callable($rules)) {
101+
$rules = $rules();
102+
}
103+
104+
if (\is_string($rules) && is_a($rules, FormRequest::class, true)) {
105+
return null;
106+
}
107+
108+
if (!\is_array($rules)) {
109+
return null;
110+
}
111+
112+
$path = $exception->getPath();
113+
if (null === $path || '' === $path || !\array_key_exists($path, $rules)) {
114+
return null;
115+
}
116+
117+
$propertyRules = $this->extractRuleTokens($rules[$path]);
118+
if (!$propertyRules) {
119+
return null;
120+
}
121+
122+
$isNull = 'null' === strtolower((string) $exception->getCurrentType());
123+
124+
if ($isNull) {
125+
$hasRequired = (bool) array_intersect_key(self::REQUIRED_RULES, $propertyRules);
126+
$hasPresent = (bool) array_intersect_key(self::PRESENT_RULES, $propertyRules);
127+
128+
// `nullable` explicitly permits null when no required/present/filled is set.
129+
if (isset($propertyRules['nullable']) && !$hasRequired && !$hasPresent) {
130+
return null;
131+
}
132+
133+
if ($hasRequired) {
134+
return $this->violation($path, 'This value should not be blank.', self::CODE_BLANK);
135+
}
136+
if ($hasPresent) {
137+
return $this->violation($path, 'This value should not be null.', self::CODE_NULL);
138+
}
139+
}
140+
141+
return $this->violation($path, $this->typeMessage($exception), self::CODE_INVALID_TYPE);
142+
}
143+
144+
/**
145+
* @return array<string, true> rule tokens as a keyed map for O(1) lookup
146+
*/
147+
private function extractRuleTokens(mixed $raw): array
148+
{
149+
if (\is_string($raw)) {
150+
$items = explode('|', $raw);
151+
} elseif (\is_array($raw)) {
152+
$items = $raw;
153+
} else {
154+
return [];
155+
}
156+
157+
$tokens = [];
158+
foreach ($items as $item) {
159+
if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) {
160+
continue;
161+
}
162+
if (!\is_string($item)) {
163+
continue;
164+
}
165+
$name = strtolower(strstr($item, ':', true) ?: $item);
166+
if ('' === $name) {
167+
continue;
168+
}
169+
$tokens[$name] = true;
170+
}
171+
172+
return $tokens;
173+
}
174+
175+
/**
176+
* @return array{propertyPath: string, message: string, code: string}
177+
*/
178+
private function violation(string $path, string $message, string $code): array
179+
{
180+
return [
181+
'propertyPath' => $path,
182+
'message' => $message,
183+
'code' => $code,
184+
];
185+
}
186+
187+
/**
188+
* @return array{propertyPath: string, message: string, code: string}
189+
*/
190+
private function buildGenericViolation(NotNormalizableValueException $exception): array
191+
{
192+
return $this->violation(
193+
(string) $exception->getPath(),
194+
$exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception),
195+
self::CODE_INVALID_TYPE,
196+
);
197+
}
198+
199+
private function typeMessage(NotNormalizableValueException $exception): string
200+
{
201+
$expectedTypes = $exception->getExpectedTypes() ?? [];
202+
if (!$expectedTypes) {
203+
return 'This value should be of the right type.';
204+
}
205+
206+
return \sprintf('This value should be of type %s.', implode('|', $expectedTypes));
207+
}
208+
209+
/**
210+
* @param string[] $paths
211+
*/
212+
private function makeId(array $paths): string
213+
{
214+
return hash('xxh3', implode(',', $paths) ?: 'denormalization');
215+
}
216+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\Laravel\Tests;
15+
16+
use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait;
17+
use Illuminate\Contracts\Config\Repository;
18+
use Illuminate\Foundation\Testing\RefreshDatabase;
19+
use Orchestra\Testbench\Concerns\WithWorkbench;
20+
use Orchestra\Testbench\TestCase;
21+
22+
/**
23+
* @see https://github.com/api-platform/core/issues/7981
24+
*/
25+
class DenormalizationValidationTest extends TestCase
26+
{
27+
use ApiTestAssertionsTrait;
28+
use RefreshDatabase;
29+
use WithWorkbench;
30+
31+
protected function defineEnvironment($app): void
32+
{
33+
tap($app['config'], static function (Repository $config): void {
34+
$config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]);
35+
$config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]);
36+
});
37+
}
38+
39+
public function testWrongTypeOnTypedDtoWithRuleProduces422(): void
40+
{
41+
$response = $this->postJson(
42+
'/api/issue6745/rule_validations',
43+
['prop' => 'abc'],
44+
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
45+
);
46+
47+
$response->assertStatus(422);
48+
$body = json_decode((string) $response->getContent(), true);
49+
$this->assertSame('ValidationError', $body['@type'] ?? null);
50+
$this->assertNotEmpty($body['violations'] ?? []);
51+
$this->assertSame('prop', $body['violations'][0]['propertyPath']);
52+
}
53+
54+
public function testWrongTypeWithoutRuleRethrows(): void
55+
{
56+
// `max` rule is `lt:2` (no required, no type rule) — but per the rule table, ANY rule
57+
// on the property triggers a generic Type @ 422 (consistent with Symfony's
58+
// "any wrong type | any other constraint" branch).
59+
$response = $this->postJson(
60+
'/api/issue6745/rule_validations',
61+
['max' => 'abc'],
62+
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
63+
);
64+
65+
$response->assertStatus(422);
66+
}
67+
68+
public function testEloquentNullOnRequiredFieldStillReturns422(): void
69+
{
70+
// Eloquent dynamic attrs → no denormalization error. Validation layer catches null + required.
71+
$response = $this->postJson(
72+
'/api/issue_6932',
73+
['sur_name' => null],
74+
['accept' => 'application/ld+json', 'content-type' => 'application/ld+json']
75+
);
76+
77+
$response->assertStatus(422);
78+
}
79+
}

src/Laravel/phpstan.neon.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,4 @@ parameters:
1818
- Tests
1919
ignoreErrors:
2020
- '#Cannot call method expectsQuestion#'
21+
- "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#"
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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\State;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
18+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
19+
20+
/**
21+
* Promotes Symfony serializer denormalization errors (raw type mismatches that would
22+
* otherwise produce a 400) into HTTP-level validation violations (422) when the target
23+
* {@see Operation} declares a matching validation contract.
24+
*
25+
* Each framework integration provides its own implementation: the Symfony bundle reads
26+
* Symfony Validator metadata and throws {@see \ApiPlatform\Validator\Exception\ValidationException};
27+
* the Laravel package reads Illuminate validation rules and throws Laravel's native
28+
* {@see \ApiPlatform\Laravel\ApiResource\ValidationError}. Implementations must NOT
29+
* depend on a sibling framework's validation stack.
30+
*
31+
* Contract: throw an HTTP exception (typically 422) when at least one error has a
32+
* matching validation contract; return void when nothing matches so the caller can
33+
* rethrow the original denormalization exception for an honest 400.
34+
*
35+
* @author Antoine Bluchet <soyuka@gmail.com>
36+
*
37+
* @see https://github.com/api-platform/core/issues/7981
38+
*/
39+
interface DenormalizationViolationFactoryInterface
40+
{
41+
/**
42+
* Builds and throws a validation violation from a denormalization error.
43+
*
44+
* Accepts either a single {@see NotNormalizableValueException} (raised when the
45+
* serializer fails on the first type mismatch) or a {@see PartialDenormalizationException}
46+
* (raised when `collect_denormalization_errors=true` collects every type mismatch in
47+
* a batch). Implementations dispatch on the concrete type.
48+
*
49+
* @throws \Throwable when at least one error has a matching validation contract
50+
*/
51+
public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void;
52+
}

0 commit comments

Comments
 (0)