Skip to content

Commit 37f248a

Browse files
fix(state): use exception message for user-facing violation when available (#7894)
1 parent 870cb68 commit 37f248a

3 files changed

Lines changed: 141 additions & 6 deletions

File tree

src/State/Provider/DeserializeProvider.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,12 +112,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
112112
continue;
113113
}
114114
$expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes());
115-
$message = (new Type($expectedTypes))->message;
116115
$parameters = [];
117116
if ($exception->canUseMessageForUser()) {
118117
$parameters['hint'] = $exception->getMessage();
118+
$violationMessage = $exception->getMessage();
119+
$violations->add(new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR));
120+
} else {
121+
$message = (new Type($expectedTypes))->message;
122+
$violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR));
119123
}
120-
$violations->add(new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR));
121124
}
122125
if (0 !== \count($violations)) {
123126
throw new ValidationException($violations);

src/State/Tests/Provider/DeserializeProviderTest.php

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@
2121
use ApiPlatform\State\Provider\DeserializeProvider;
2222
use ApiPlatform\State\ProviderInterface;
2323
use ApiPlatform\State\SerializerContextBuilderInterface;
24+
use ApiPlatform\Validator\Exception\ValidationException;
2425
use PHPUnit\Framework\Attributes\DataProvider;
2526
use PHPUnit\Framework\Attributes\IgnoreDeprecations;
2627
use PHPUnit\Framework\TestCase;
2728
use Symfony\Component\HttpFoundation\Request;
2829
use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException;
30+
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
31+
use Symfony\Component\Serializer\Exception\PartialDenormalizationException;
2932
use Symfony\Component\Serializer\Normalizer\AbstractNormalizer;
3033
use Symfony\Component\Serializer\SerializerInterface;
34+
use Symfony\Component\Validator\Constraints\Type;
3135

3236
class DeserializeProviderTest extends TestCase
3337
{
@@ -203,6 +207,135 @@ public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void
203207
$provider->provide($operation, ['id' => 1], ['request' => $request]);
204208
}
205209

210+
#[IgnoreDeprecations]
211+
public function testDeserializeUsesExceptionMessageWhenCanUseMessageForUser(): void
212+
{
213+
$operation = new Post(deserialize: true, class: \stdClass::class);
214+
$decorated = $this->createStub(ProviderInterface::class);
215+
$decorated->method('provide')->willReturn(null);
216+
217+
$exception = NotNormalizableValueException::createForUnexpectedDataType(
218+
'The data must belong to a backed enumeration of type Suit.',
219+
'invalid',
220+
['string'],
221+
'status',
222+
true,
223+
);
224+
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
225+
226+
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
227+
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
228+
$serializer = $this->createMock(SerializerInterface::class);
229+
$serializer->method('deserialize')->willThrowException($partialException);
230+
231+
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
232+
$request = new Request(content: '{"status":"invalid"}');
233+
$request->headers->set('CONTENT_TYPE', 'application/json');
234+
$request->attributes->set('input_format', 'json');
235+
236+
try {
237+
$provider->provide($operation, [], ['request' => $request]);
238+
$this->fail('Expected ValidationException');
239+
} catch (ValidationException $e) {
240+
$violations = $e->getConstraintViolationList();
241+
$this->assertCount(1, $violations);
242+
$this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getMessage());
243+
$this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getMessageTemplate());
244+
$this->assertSame('status', $violations[0]->getPropertyPath());
245+
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
246+
}
247+
}
248+
249+
/**
250+
* Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574):
251+
* when a value has the right type but is not a valid enum case, the exception
252+
* is created with expectedTypes=null and a user-friendly message listing valid values.
253+
*/
254+
#[IgnoreDeprecations]
255+
public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void
256+
{
257+
$operation = new Post(deserialize: true, class: \stdClass::class);
258+
$decorated = $this->createStub(ProviderInterface::class);
259+
$decorated->method('provide')->willReturn(null);
260+
261+
$ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct');
262+
if ($ctor->getNumberOfParameters() <= 3) {
263+
$this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.');
264+
}
265+
266+
$exception = new NotNormalizableValueException(
267+
"The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'",
268+
0,
269+
null,
270+
null,
271+
null,
272+
'suit',
273+
true,
274+
);
275+
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
276+
277+
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
278+
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
279+
$serializer = $this->createMock(SerializerInterface::class);
280+
$serializer->method('deserialize')->willThrowException($partialException);
281+
282+
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
283+
$request = new Request(content: '{"suit":"invalid"}');
284+
$request->headers->set('CONTENT_TYPE', 'application/json');
285+
$request->attributes->set('input_format', 'json');
286+
287+
try {
288+
$provider->provide($operation, [], ['request' => $request]);
289+
$this->fail('Expected ValidationException');
290+
} catch (ValidationException $e) {
291+
$violations = $e->getConstraintViolationList();
292+
$this->assertCount(1, $violations);
293+
$this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage());
294+
$this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate());
295+
$this->assertSame('suit', $violations[0]->getPropertyPath());
296+
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
297+
}
298+
}
299+
300+
#[IgnoreDeprecations]
301+
public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void
302+
{
303+
$operation = new Post(deserialize: true, class: \stdClass::class);
304+
$decorated = $this->createStub(ProviderInterface::class);
305+
$decorated->method('provide')->willReturn(null);
306+
307+
$exception = NotNormalizableValueException::createForUnexpectedDataType(
308+
'Internal error detail',
309+
42,
310+
['string'],
311+
'name',
312+
false,
313+
);
314+
$partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]);
315+
316+
$serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class);
317+
$serializerContextBuilder->method('createFromRequest')->willReturn([]);
318+
$serializer = $this->createMock(SerializerInterface::class);
319+
$serializer->method('deserialize')->willThrowException($partialException);
320+
321+
$provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder);
322+
$request = new Request(content: '{"name":42}');
323+
$request->headers->set('CONTENT_TYPE', 'application/json');
324+
$request->attributes->set('input_format', 'json');
325+
326+
try {
327+
$provider->provide($operation, [], ['request' => $request]);
328+
$this->fail('Expected ValidationException');
329+
} catch (ValidationException $e) {
330+
$violations = $e->getConstraintViolationList();
331+
$this->assertCount(1, $violations);
332+
$this->assertStringContainsString('string', $violations[0]->getMessage());
333+
$this->assertSame('name', $violations[0]->getPropertyPath());
334+
$this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode());
335+
$this->assertArrayNotHasKey('hint', $violations[0]->getParameters());
336+
}
337+
}
338+
206339
public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void
207340
{
208341
$objectToPopulate = new \stdClass();

tests/Functional/ValidationTest.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function testPostWithDenormalizationErrorsCollected(): void
8585

8686
$violationBaz = $findViolation('baz');
8787
$this->assertNotNull($violationBaz, 'Violation for "baz" not found.');
88-
$this->assertSame('This value should be of type string.', $violationBaz['message']);
88+
$this->assertSame('Failed to create object because the class misses the "baz" property.', $violationBaz['message']);
8989
$this->assertArrayHasKey('hint', $violationBaz);
9090
$this->assertSame('Failed to create object because the class misses the "baz" property.', $violationBaz['hint']);
9191

@@ -116,16 +116,15 @@ public function testPostWithDenormalizationErrorsCollected(): void
116116

117117
$violationUuid = $findViolation('uuid');
118118
$this->assertNotNull($violationUuid);
119-
$this->assertNotNull($violationUuid);
120119
if (!method_exists(PropertyInfoExtractor::class, 'getType')) {
121-
$this->assertSame('This value should be of type uuid.', $violationUuid['message']);
120+
$this->assertSame('Invalid UUID string: y', $violationUuid['message']);
122121
} else {
123122
$this->assertSame('This value should be of type UuidInterface|null.', $violationUuid['message']);
124123
}
125124

126125
$violationRelatedDummy = $findViolation('relatedDummy');
127126
$this->assertNotNull($violationRelatedDummy);
128-
$this->assertSame('This value should be of type array|string.', $violationRelatedDummy['message']);
127+
$this->assertSame('The type of the "relatedDummy" attribute must be "array" (nested document) or "string" (IRI), "integer" given.', $violationRelatedDummy['message']);
129128

130129
$violationRelatedDummies = $findViolation('relatedDummies');
131130
$this->assertNotNull($violationRelatedDummies);

0 commit comments

Comments
 (0)