Skip to content

Commit eca83f8

Browse files
committed
feat(state): constraint-aware 422 for denormalization errors
Translates PHP denormalization type errors (NotNormalizableValueException) into a 422 validation exception when the target property has a matching validation contract; rethrows for an honest 400 otherwise. Closes #7981. Architecture: - api-platform/state owns DenormalizationErrorHandlerInterface (no symfony/ validator dep). DeserializeProvider becomes a thin delegate: catches the denormalization exception, hands it to the handler, rethrows on miss. - api-platform/validator owns the Symfony impl (DenormalizationErrorHandler) which reads Symfony Validator metadata and throws ApiPlatform\Validator\ Exception\ValidationException. - api-platform/laravel owns ApiPlatform\Laravel\State\DenormalizationError Handler which reads Operation::getRules() and throws Laravel's native ApiPlatform\Laravel\ApiResource\ValidationError directly. The Laravel package does NOT depend on symfony/validator nor api-platform/validator. Symfony rule table: - null + NotBlank → IS_BLANK_ERROR + constraint message - null + NotNull → IS_NULL_ERROR + constraint message - wrong type + Type → INVALID_TYPE_ERROR + constraint message - wrong type + any constraint → generic Type @ 422 - no constraint → no match → rethrow → 400 Laravel rule table: - null + required/filled → IS_BLANK_ERROR - null + present → IS_NULL_ERROR - wrong type + string/integer/int/ → INVALID_TYPE_ERROR numeric/boolean/bool/array/date/json - wrong type + any other rule → INVALID_TYPE_ERROR - null + nullable (no required/present) → no match → rethrow - no rule → no match → rethrow → 400 Replaces the BackedEnum-only special case (commit 44bb18d) with a general constraint-aware rule. Enums with NotNull/Type/Choice still produce 422; enums with zero constraints regress to 400 — opt in via auto_mapping or add an explicit Assert\Type(EnumClass). In collect mode (collect_denormalization_errors=true), unconstrained errors still emit a generic Type/INVALID_TYPE_ERROR entry so the response surface stays consistent with prior behavior. FormRequest-class rules and pure-callable rule sets are intentionally skipped in the Laravel impl (v1): a FormRequest-based contract typically runs in the validation phase against the raw request. Tests: - src/Validator/Tests/DenormalizationErrorHandlerTest.php — 10 unit tests covering each row of the matching table + group filtering + collect mode - src/State/Tests/Provider/DeserializeProviderTest.php — handler-delegation assertions (drops the 3 tests that depended on Validator\Exception\ ValidationException; that coverage moved to the Validator handler test) - tests/Functional/DenormalizationValidationTest.php — 5 Symfony functional tests for null+NotBlank, null+NotNull, wrong+Type, no-constraint→400, collect mode mix - src/Laravel/Tests/DenormalizationValidationTest.php — 3 Laravel functional tests including typed-DTO+rule→422 with ValidationError shape verified Composer: - api-platform/state: drops api-platform/validator dev-dep (cycle fix) - api-platform/validator: requires api-platform/state - api-platform/laravel: no new dependencies Closes #7981 Refs #8183
1 parent 5ddf94a commit eca83f8

13 files changed

Lines changed: 1127 additions & 171 deletions

src/Laravel/ApiPlatformProvider.php

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@
173173
use ApiPlatform\State\Provider\ObjectMapperProvider;
174174
use ApiPlatform\State\Provider\ParameterProvider;
175175
use ApiPlatform\State\Provider\ReadProvider;
176+
use ApiPlatform\Laravel\State\DenormalizationErrorHandler as LaravelDenormalizationErrorHandler;
177+
use ApiPlatform\State\DenormalizationErrorHandlerInterface;
176178
use ApiPlatform\State\ProviderInterface;
177179
use ApiPlatform\State\SerializerContextBuilderInterface;
178180
use Http\Discovery\Psr17Factory;
@@ -422,8 +424,18 @@ public function register(): void
422424
);
423425
});
424426

427+
$this->app->singleton(DenormalizationErrorHandlerInterface::class, static function () {
428+
return new LaravelDenormalizationErrorHandler();
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(DenormalizationErrorHandlerInterface::class),
438+
);
427439
});
428440

429441
$this->app->singleton(ValidateProvider::class, static function (Application $app) {
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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\DenormalizationErrorHandlerInterface;
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 error handler — translates Symfony serializer type
27+
* errors into a 422 {@see ValidationError} when the Operation's Laravel rules describe
28+
* 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 | IS_BLANK_ERROR |
40+
* | null | present | IS_NULL_ERROR |
41+
* | any wrong type | string, integer, int, numeric, boolean, | INVALID_TYPE_ERROR |
42+
* | | bool, array, date, json | |
43+
* | any wrong type | any other rule (with no `nullable`) | INVALID_TYPE_ERROR |
44+
* | null | nullable (and no required/present/filled) | (no match) — caller |
45+
* | | | rethrows |
46+
* | any | (no rule) | (no match) — caller |
47+
* | | | rethrows |
48+
*
49+
* In collect mode, unconstrained errors still emit a generic INVALID_TYPE_ERROR entry
50+
* so the response surface stays consistent with prior behavior.
51+
*
52+
* Codes are plain strings — the Laravel package does not depend on Symfony Validator.
53+
*
54+
* @author Antoine Bluchet <soyuka@gmail.com>
55+
*/
56+
final class DenormalizationErrorHandler implements DenormalizationErrorHandlerInterface
57+
{
58+
public const IS_BLANK_ERROR = 'c1051bb4-d103-4f74-8988-acbcafc7fdc3';
59+
public const IS_NULL_ERROR = 'ad32d13f-c3d4-423b-909a-857b961eb720';
60+
public const INVALID_TYPE_ERROR = 'ba785a8c-82cb-4283-967c-3cf342181b40';
61+
62+
private const REQUIRED_RULES = ['required', 'filled'];
63+
private const PRESENT_RULES = ['present'];
64+
private const TYPE_RULES = ['string', 'integer', 'int', 'numeric', 'boolean', 'bool', 'array', 'date', 'json'];
65+
66+
public function handle(NotNormalizableValueException $exception, Operation $operation): void
67+
{
68+
$violation = $this->buildViolation($exception, $operation);
69+
if (null === $violation) {
70+
return;
71+
}
72+
73+
throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]);
74+
}
75+
76+
public function handlePartial(PartialDenormalizationException $exception, Operation $operation): void
77+
{
78+
$violations = [];
79+
80+
foreach ($exception->getErrors() as $error) {
81+
if (!$error instanceof NotNormalizableValueException) {
82+
continue;
83+
}
84+
$violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error);
85+
}
86+
87+
if (!$violations) {
88+
return;
89+
}
90+
91+
$paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations));
92+
$message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations));
93+
94+
throw new ValidationError($message, $this->makeId($paths), $exception, $violations);
95+
}
96+
97+
/**
98+
* @return array{propertyPath: string, message: string, code: string}|null
99+
*/
100+
private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array
101+
{
102+
$rules = $operation->getRules();
103+
if (\is_callable($rules)) {
104+
$rules = $rules();
105+
}
106+
107+
if (\is_string($rules) && is_a($rules, FormRequest::class, true)) {
108+
return null;
109+
}
110+
111+
if (!\is_array($rules)) {
112+
return null;
113+
}
114+
115+
$path = $exception->getPath();
116+
if (null === $path || '' === $path) {
117+
return null;
118+
}
119+
120+
if (str_contains($path, '.') || str_contains($path, '[')) {
121+
return null;
122+
}
123+
124+
if (!\array_key_exists($path, $rules)) {
125+
return null;
126+
}
127+
128+
$propertyRules = $this->extractRuleTokens($rules[$path]);
129+
if (!$propertyRules) {
130+
return null;
131+
}
132+
133+
$isNull = 'null' === strtolower((string) $exception->getCurrentType());
134+
135+
return $this->match($propertyRules, $isNull, $path, $exception);
136+
}
137+
138+
/**
139+
* @return list<string>
140+
*/
141+
private function extractRuleTokens(mixed $raw): array
142+
{
143+
if (\is_string($raw)) {
144+
$items = explode('|', $raw);
145+
} elseif (\is_array($raw)) {
146+
$items = $raw;
147+
} else {
148+
return [];
149+
}
150+
151+
$tokens = [];
152+
foreach ($items as $item) {
153+
if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) {
154+
continue;
155+
}
156+
if (!\is_string($item)) {
157+
continue;
158+
}
159+
$name = strtolower(strstr($item, ':', true) ?: $item);
160+
if ('' === $name) {
161+
continue;
162+
}
163+
$tokens[] = $name;
164+
}
165+
166+
return $tokens;
167+
}
168+
169+
/**
170+
* @param list<string> $rules
171+
*
172+
* @return array{propertyPath: string, message: string, code: string}|null
173+
*/
174+
private function match(array $rules, bool $isNull, string $path, NotNormalizableValueException $exception): ?array
175+
{
176+
$hasNullable = \in_array('nullable', $rules, true);
177+
178+
if ($isNull) {
179+
if ($hasNullable && !array_intersect(self::REQUIRED_RULES, $rules) && !array_intersect(self::PRESENT_RULES, $rules)) {
180+
return null;
181+
}
182+
183+
if (array_intersect(self::REQUIRED_RULES, $rules)) {
184+
return [
185+
'propertyPath' => $path,
186+
'message' => 'This value should not be blank.',
187+
'code' => self::IS_BLANK_ERROR,
188+
];
189+
}
190+
if (array_intersect(self::PRESENT_RULES, $rules)) {
191+
return [
192+
'propertyPath' => $path,
193+
'message' => 'This value should not be null.',
194+
'code' => self::IS_NULL_ERROR,
195+
];
196+
}
197+
}
198+
199+
if (array_intersect(self::TYPE_RULES, $rules) || $rules) {
200+
return [
201+
'propertyPath' => $path,
202+
'message' => $this->typeMessage($exception),
203+
'code' => self::INVALID_TYPE_ERROR,
204+
];
205+
}
206+
207+
return null;
208+
}
209+
210+
/**
211+
* @return array{propertyPath: string, message: string, code: string}
212+
*/
213+
private function buildGenericViolation(NotNormalizableValueException $exception): array
214+
{
215+
return [
216+
'propertyPath' => (string) $exception->getPath(),
217+
'message' => $exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception),
218+
'code' => self::INVALID_TYPE_ERROR,
219+
];
220+
}
221+
222+
private function typeMessage(NotNormalizableValueException $exception): string
223+
{
224+
$expectedTypes = array_filter($exception->getExpectedTypes() ?? [], static fn ($t): bool => \is_string($t));
225+
if (!$expectedTypes) {
226+
return 'This value should be of the right type.';
227+
}
228+
229+
return \sprintf('This value should be of type %s.', implode('|', $expectedTypes));
230+
}
231+
232+
/**
233+
* @param string[] $paths
234+
*/
235+
private function makeId(array $paths): string
236+
{
237+
return hash('xxh3', implode(',', $paths) ?: 'denormalization');
238+
}
239+
}
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($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+
}

0 commit comments

Comments
 (0)