diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9477d5c8a15..d87789a5dd5 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -173,6 +173,8 @@ use ApiPlatform\State\Provider\ObjectMapperProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; +use ApiPlatform\Laravel\State\DenormalizationErrorHandler as LaravelDenormalizationErrorHandler; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Http\Discovery\Psr17Factory; @@ -422,8 +424,18 @@ public function register(): void ); }); + $this->app->singleton(DenormalizationErrorHandlerInterface::class, static function () { + return new LaravelDenormalizationErrorHandler(); + }); + $this->app->singleton(DeserializeProvider::class, static function (Application $app) { - return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); + return new DeserializeProvider( + $app->make(SwaggerUiProvider::class), + $app->make(SerializerInterface::class), + $app->make(SerializerContextBuilderInterface::class), + null, + $app->make(DenormalizationErrorHandlerInterface::class), + ); }); $this->app->singleton(ValidateProvider::class, static function (Application $app) { diff --git a/src/Laravel/State/DenormalizationErrorHandler.php b/src/Laravel/State/DenormalizationErrorHandler.php new file mode 100644 index 00000000000..36cc27997c3 --- /dev/null +++ b/src/Laravel/State/DenormalizationErrorHandler.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\State; + +use ApiPlatform\Laravel\ApiResource\ValidationError; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; +use Illuminate\Contracts\Validation\Rule as LaravelRule; +use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Foundation\Http\FormRequest; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Laravel-flavored denormalization error handler — translates Symfony serializer type + * errors into a 422 {@see ValidationError} when the Operation's Laravel rules describe + * the property. + * + * Reads rules declared on the operation (string|array form, e.g. `'required|string'` + * or `['required', 'string']`). FormRequest-class rules and pure-callable rule sets + * are intentionally skipped in v1: a FormRequest-based contract typically runs in the + * validation phase against the raw request, not the denormalized body. + * + * Mapping: + * + * | Exception "current type" | Matching Laravel rule | Emitted code | + * |--------------------------|---------------------------------------------|----------------| + * | null | required, filled | blank | + * | null | present | null | + * | any wrong type | string, integer, int, numeric, boolean, | invalid_type | + * | | bool, array, date, json | | + * | any wrong type | any other rule (no `nullable`) | invalid_type | + * | null | nullable (no required/present/filled) | (no match) | + * | any | (no rule) | (no match) | + * + * In collect mode, unconstrained errors still emit a generic `invalid_type` entry so + * the response surface stays consistent with prior behavior. + * + * Codes are plain semantic strings — the Laravel package does not depend on Symfony + * Validator. + * + * @author Antoine Bluchet + */ +final class DenormalizationErrorHandler implements DenormalizationErrorHandlerInterface +{ + public const CODE_BLANK = 'blank'; + public const CODE_NULL = 'null'; + public const CODE_INVALID_TYPE = 'invalid_type'; + + private const REQUIRED_RULES = ['required' => true, 'filled' => true]; + private const PRESENT_RULES = ['present' => true]; + private const TYPE_RULES = [ + 'string' => true, + 'integer' => true, + 'int' => true, + 'numeric' => true, + 'boolean' => true, + 'bool' => true, + 'array' => true, + 'date' => true, + 'json' => true, + ]; + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof PartialDenormalizationException) { + $violations = []; + foreach ($exception->getErrors() as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error); + } + + if (!$violations) { + return; + } + + $paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations)); + $message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations)); + + throw new ValidationError($message, $this->makeId($paths), $exception, $violations); + } + + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]); + } + + /** + * @return array{propertyPath: string, message: string, code: string}|null + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array + { + $rules = $operation->getRules(); + if (\is_callable($rules)) { + $rules = $rules(); + } + + if (\is_string($rules) && is_a($rules, FormRequest::class, true)) { + return null; + } + + if (!\is_array($rules)) { + return null; + } + + $path = $exception->getPath(); + if (null === $path || '' === $path || !\array_key_exists($path, $rules)) { + return null; + } + + $propertyRules = $this->extractRuleTokens($rules[$path]); + if (!$propertyRules) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + $hasRequired = (bool) array_intersect_key(self::REQUIRED_RULES, $propertyRules); + $hasPresent = (bool) array_intersect_key(self::PRESENT_RULES, $propertyRules); + + // `nullable` explicitly permits null when no required/present/filled is set. + if (isset($propertyRules['nullable']) && !$hasRequired && !$hasPresent) { + return null; + } + + if ($hasRequired) { + return $this->violation($path, 'This value should not be blank.', self::CODE_BLANK); + } + if ($hasPresent) { + return $this->violation($path, 'This value should not be null.', self::CODE_NULL); + } + } + + return $this->violation($path, $this->typeMessage($exception), self::CODE_INVALID_TYPE); + } + + /** + * @return array rule tokens as a keyed map for O(1) lookup + */ + private function extractRuleTokens(mixed $raw): array + { + if (\is_string($raw)) { + $items = explode('|', $raw); + } elseif (\is_array($raw)) { + $items = $raw; + } else { + return []; + } + + $tokens = []; + foreach ($items as $item) { + if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) { + continue; + } + if (!\is_string($item)) { + continue; + } + $name = strtolower(strstr($item, ':', true) ?: $item); + if ('' === $name) { + continue; + } + $tokens[$name] = true; + } + + return $tokens; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function violation(string $path, string $message, string $code): array + { + return [ + 'propertyPath' => $path, + 'message' => $message, + 'code' => $code, + ]; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function buildGenericViolation(NotNormalizableValueException $exception): array + { + return $this->violation( + (string) $exception->getPath(), + $exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception), + self::CODE_INVALID_TYPE, + ); + } + + private function typeMessage(NotNormalizableValueException $exception): string + { + $expectedTypes = array_filter($exception->getExpectedTypes() ?? [], static fn ($t): bool => \is_string($t)); + if (!$expectedTypes) { + return 'This value should be of the right type.'; + } + + return \sprintf('This value should be of type %s.', implode('|', $expectedTypes)); + } + + /** + * @param string[] $paths + */ + private function makeId(array $paths): string + { + return hash('xxh3', implode(',', $paths) ?: 'denormalization'); + } +} diff --git a/src/Laravel/Tests/DenormalizationValidationTest.php b/src/Laravel/Tests/DenormalizationValidationTest.php new file mode 100644 index 00000000000..4f0f538ce25 --- /dev/null +++ b/src/Laravel/Tests/DenormalizationValidationTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +class DenormalizationValidationTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]); + $config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]); + }); + } + + public function testWrongTypeOnTypedDtoWithRuleProduces422(): void + { + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['prop' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + $body = json_decode($response->getContent(), true); + $this->assertSame('ValidationError', $body['@type'] ?? null); + $this->assertNotEmpty($body['violations'] ?? []); + $this->assertSame('prop', $body['violations'][0]['propertyPath']); + } + + public function testWrongTypeWithoutRuleRethrows(): void + { + // `max` rule is `lt:2` (no required, no type rule) — but per the rule table, ANY rule + // on the property triggers a generic Type @ 422 (consistent with Symfony's + // "any wrong type | any other constraint" branch). + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['max' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } + + public function testEloquentNullOnRequiredFieldStillReturns422(): void + { + // Eloquent dynamic attrs → no denormalization error. Validation layer catches null + required. + $response = $this->postJson( + '/api/issue_6932', + ['sur_name' => null], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } +} diff --git a/src/State/DenormalizationErrorHandlerInterface.php b/src/State/DenormalizationErrorHandlerInterface.php new file mode 100644 index 00000000000..8008af6f503 --- /dev/null +++ b/src/State/DenormalizationErrorHandlerInterface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State; + +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Translates Symfony serializer denormalization errors into HTTP-level validation + * exceptions when the target {@see Operation} declares a matching validation contract. + * + * Each framework integration provides its own implementation: the Symfony bundle reads + * Symfony Validator metadata and throws {@see \ApiPlatform\Validator\Exception\ValidationException}; + * the Laravel package reads Illuminate validation rules and throws Laravel's native + * {@see \ApiPlatform\Laravel\ApiResource\ValidationError}. Implementations must NOT + * depend on a sibling framework's validation stack. + * + * Contract: throw an HTTP exception (typically 422) when at least one error has a + * matching validation contract; return void when nothing matches so the caller can + * rethrow the original denormalization exception for an honest 400. + * + * @author Antoine Bluchet + * + * @see https://github.com/api-platform/core/issues/7981 + */ +interface DenormalizationErrorHandlerInterface +{ + /** + * Handles a denormalization error. + * + * Accepts either a single {@see NotNormalizableValueException} (raised when the + * serializer fails on the first type mismatch) or a {@see PartialDenormalizationException} + * (raised when `collect_denormalization_errors=true` collects every type mismatch in + * a batch). Implementations dispatch on the concrete type. + * + * @throws \Throwable when at least one error has a matching validation contract + */ + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void; +} diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 338c8371418..6be94795953 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -15,22 +15,17 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\State\StopwatchAwareInterface; use ApiPlatform\State\StopwatchAwareTrait; -use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Contracts\Translation\LocaleAwareInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use Symfony\Contracts\Translation\TranslatorTrait; final class DeserializeProvider implements ProviderInterface, StopwatchAwareInterface { @@ -41,13 +36,8 @@ public function __construct( private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, private ?TranslatorInterface $translator = null, + private readonly ?DenormalizationErrorHandlerInterface $errorHandler = null, ) { - if (null === $this->translator) { - $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { - use TranslatorTrait; - }; - $this->translator->setLocale('en'); - } } public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null @@ -101,31 +91,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $data = $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext); - } catch (PartialDenormalizationException $e) { - if (!class_exists(ConstraintViolationList::class)) { - throw $e; - } + } catch (PartialDenormalizationException|NotNormalizableValueException $e) { + $this->errorHandler?->handle($e, $operation); - $violations = new ConstraintViolationList(); - foreach ($e->getErrors() as $exception) { - if (!$exception instanceof NotNormalizableValueException) { - continue; - } - $violations->add($this->createViolationFromException($exception)); - } - if (0 !== \count($violations)) { - throw new ValidationException($violations); - } - } catch (NotNormalizableValueException $e) { - // BackedEnum denormalization errors should surface as validation violations (422) - // rather than denormalization errors (400). See https://github.com/api-platform/core/issues/8183. - if (!class_exists(ConstraintViolationList::class) || !$this->isBackedEnumException($e)) { - throw $e; - } - - $violations = new ConstraintViolationList(); - $violations->add($this->createViolationFromException($e)); - throw new ValidationException($violations); + throw $e; } $this->stopwatch?->stop('api_platform.provider.deserialize'); @@ -134,63 +103,4 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } - - private function normalizeExpectedTypes(?array $expectedTypes = null): array - { - $normalizedTypes = []; - - foreach ($expectedTypes ?? [] as $expectedType) { - $normalizedType = $expectedType; - - if (class_exists($expectedType) || interface_exists($expectedType)) { - $classReflection = new \ReflectionClass($expectedType); - $normalizedType = $classReflection->getShortName(); - } - - $normalizedTypes[] = $normalizedType; - } - - return $normalizedTypes; - } - - private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation - { - $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); - $parameters = []; - if ($exception->canUseMessageForUser()) { - $parameters['hint'] = $exception->getMessage(); - } - - if (!$expectedTypes && $exception->canUseMessageForUser()) { - $violationMessage = $exception->getMessage(); - - return new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - $message = (new Type($expectedTypes))->message; - - return new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - private function isBackedEnumException(NotNormalizableValueException $exception): bool - { - foreach ($exception->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - - for ($previous = $exception->getPrevious(); $previous instanceof \Throwable; $previous = $previous->getPrevious()) { - if (!$previous instanceof NotNormalizableValueException) { - continue; - } - foreach ($previous->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - } - - return false; - } } diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 1fced0c05eb..f901e77825b 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -18,10 +18,10 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; -use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; @@ -31,7 +31,6 @@ use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; class DeserializeProviderTest extends TestCase { @@ -208,70 +207,41 @@ public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void } #[IgnoreDeprecations] - public function testDeserializeKeepsTypeMessageWhenExpectedTypesAreSet(): void + public function testDeserializeDelegatesSingleErrorToHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'The data must belong to a backed enumeration of type Suit.', - 'invalid', - ['string'], - 'status', - true, - ); - $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); $serializerContextBuilder->method('createFromRequest')->willReturn([]); $serializer = $this->createMock(SerializerInterface::class); - $serializer->method('deserialize')->willThrowException($partialException); + $serializer->method('deserialize')->willThrowException($exception); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $handler = $this->createMock(DenormalizationErrorHandlerInterface::class); + $handler->expects($this->once())->method('handle')->with($exception, $operation) + ->willThrowException(new \LogicException('handler-threw')); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame('This value should be of type string.', $violations[0]->getMessage()); - $this->assertSame('status', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getParameters()['hint'] ?? null); - } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw'); + $provider->provide($operation, [], ['request' => $request]); } - /** - * Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574): - * when a value has the right type but is not a valid enum case, the exception - * is created with expectedTypes=null and a user-friendly message listing valid values. - */ #[IgnoreDeprecations] - public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void + public function testDeserializeDelegatesPartialErrorToHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct'); - if ($ctor->getNumberOfParameters() <= 3) { - $this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.'); - } - - $exception = new NotNormalizableValueException( - "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", - 0, - null, - null, - null, - 'suit', - true, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -279,38 +249,51 @@ public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): vo $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); + $handler = $this->createMock(DenormalizationErrorHandlerInterface::class); + $handler->expects($this->once())->method('handle')->with($partialException, $operation) + ->willThrowException(new \LogicException('handler-threw-partial')); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw-partial'); + $provider->provide($operation, [], ['request' => $request]); + } + + #[IgnoreDeprecations] + public function testDeserializeRethrowsSingleErrorWhenNoHandler(): void + { + $operation = new Post(deserialize: true, class: \stdClass::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize')->willThrowException($exception); + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"suit":"invalid"}'); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage()); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate()); - $this->assertSame('suit', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - } + $this->expectException(NotNormalizableValueException::class); + $provider->provide($operation, [], ['request' => $request]); } #[IgnoreDeprecations] - public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void + public function testDeserializeRethrowsPartialErrorWhenHandlerReturnsVoid(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'Internal error detail', - 42, - ['string'], - 'name', - false, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -318,22 +301,16 @@ public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): voi $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"name":42}'); + $handler = $this->createMock(DenormalizationErrorHandlerInterface::class); + $handler->expects($this->once())->method('handle'); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertStringContainsString('string', $violations[0]->getMessage()); - $this->assertSame('name', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertArrayNotHasKey('hint', $violations[0]->getParameters()); - } + $this->expectException(PartialDenormalizationException::class); + $provider->provide($operation, [], ['request' => $request]); } public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void diff --git a/src/State/composer.json b/src/State/composer.json index 9e383a3b0af..5ff1bccf362 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -37,7 +37,6 @@ }, "require-dev": { "api-platform/serializer": "^4.3", - "api-platform/validator": "^4.3.1", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index f31fc2bc7c1..91b55cc2748 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -13,11 +13,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\Symfony\EventListener\ErrorListener; +use ApiPlatform\Validator\DenormalizationErrorHandler; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -40,6 +42,14 @@ service('api_platform.serializer.context_builder'), ]); + $services->set('api_platform.state.denormalization_error_handler', DenormalizationErrorHandler::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationErrorHandlerInterface::class, 'api_platform.state.denormalization_error_handler'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->decorate('api_platform.state_provider.main', null, 300) ->args([ @@ -47,6 +57,7 @@ service('api_platform.serializer'), service('api_platform.serializer.context_builder'), service('translator')->nullOnInvalid(), + service('api_platform.state.denormalization_error_handler')->nullOnInvalid(), ]); $services->set('api_platform.error_listener', ErrorListener::class) diff --git a/src/Validator/DenormalizationErrorHandler.php b/src/Validator/DenormalizationErrorHandler.php new file mode 100644 index 00000000000..0964897bbcc --- /dev/null +++ b/src/Validator/DenormalizationErrorHandler.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Validator; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationErrorHandlerInterface; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Exception\NoSuchMetadataException; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; +use Symfony\Contracts\Translation\LocaleAwareInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; + +/** + * Constraint-aware denormalization error handler — Symfony Validator flavor. + * + * Rule table (see issue #7981): + * + * | Exception "current type" | Matching constraint | Emitted violation | + * |--------------------------|----------------------|---------------------------------------------------| + * | null | NotBlank | NotBlank::IS_BLANK_ERROR + constraint message | + * | null | NotNull | NotNull::IS_NULL_ERROR + constraint message | + * | any wrong type | Type | Type::INVALID_TYPE_ERROR + constraint message | + * | any wrong type | any other constraint | generic Type violation @ 422 | + * | any wrong type | (no constraint) | none — single-error path rethrows → 400 | + * + * In collect mode (PartialDenormalizationException), unconstrained errors still emit + * a generic Type violation so the response stays consistent with prior behavior. + * + * @author Antoine Bluchet + */ +final class DenormalizationErrorHandler implements DenormalizationErrorHandlerInterface +{ + private TranslatorInterface $translator; + + public function __construct( + private readonly MetadataFactoryInterface $metadataFactory, + ?TranslatorInterface $translator = null, + ) { + if (null === $translator) { + $translator = new class implements TranslatorInterface, LocaleAwareInterface { + use TranslatorTrait; + }; + $translator->setLocale('en'); + } + + $this->translator = $translator; + } + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof PartialDenormalizationException) { + $violations = new ConstraintViolationList(); + foreach ($exception->getErrors() as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations->add($this->buildViolation($error, $operation) ?? $this->buildViolation($error, $operation, generic: true)); + } + if (\count($violations) > 0) { + throw new ValidationException($violations); + } + + return; + } + + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + /** + * Returns a violation for the given error. + * + * When `$generic` is true, emits a Type-based fallback regardless of property metadata + * (used in collect mode to keep one violation per error). When false, returns null if + * no matching constraint is declared on the property — caller rethrows. + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation, bool $generic = false): ?ConstraintViolationInterface + { + $path = $exception->getPath(); + if (null === $path || '' === $path) { + return $generic ? $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR) : null; + } + + if ($generic) { + return $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR); + } + + $class = $operation->getClass(); + if (null === $class || (!class_exists($class) && !interface_exists($class))) { + return null; + } + + try { + $classMetadata = $this->metadataFactory->getMetadataFor($class); + } catch (NoSuchMetadataException) { + return null; + } + + if (!$classMetadata instanceof ClassMetadataInterface || !$classMetadata->hasPropertyMetadata($path)) { + return null; + } + + $validationGroups = ($operation->getValidationContext() ?? [])['groups'] ?? null; + $constraints = $this->collectConstraints($classMetadata, $path, $validationGroups); + if (!$constraints) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + if (isset($constraints[NotBlank::class])) { + return $this->emitViolation($exception, $constraints[NotBlank::class], (string) NotBlank::IS_BLANK_ERROR); + } + if (isset($constraints[NotNull::class])) { + return $this->emitViolation($exception, $constraints[NotNull::class], (string) NotNull::IS_NULL_ERROR); + } + } + + if (isset($constraints[Type::class])) { + return $this->emitViolation($exception, $constraints[Type::class], (string) Type::INVALID_TYPE_ERROR); + } + + // Property has constraints but none match by class → still 422 with a generic Type message. + return $this->emitViolation($exception, new Type([]), (string) Type::INVALID_TYPE_ERROR); + } + + /** + * @param array|null $validationGroups + * + * @return array, Constraint> indexed by constraint class; later entries overwrite earlier + */ + private function collectConstraints(ClassMetadataInterface $classMetadata, string $property, ?array $validationGroups): array + { + $groups = $validationGroups ?: [Constraint::DEFAULT_GROUP]; + $constraints = []; + + foreach ($classMetadata->getPropertyMetadata($property) as $propertyMetadata) { + foreach ($groups as $group) { + foreach ($propertyMetadata->findConstraints($group) as $constraint) { + $constraints[$constraint::class] = $constraint; + } + } + } + + return $constraints; + } + + private function emitViolation(NotNormalizableValueException $exception, ?Constraint $constraint, string $code): ConstraintViolation + { + $parameters = []; + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + + $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); + + // No constraint + no expected types + user-friendly message → use the exception message verbatim. + if (null === $constraint && !$expectedTypes && $exception->canUseMessageForUser()) { + $message = $exception->getMessage(); + + return new ConstraintViolation($message, $message, $parameters, null, $exception->getPath(), null, null, $code); + } + + $message = $this->resolveMessage($constraint, $expectedTypes); + $translationParameters = []; + if ($expectedTypes && str_contains($message, '{{ type }}')) { + $translationParameters['{{ type }}'] = implode('|', $expectedTypes); + } + + return new ConstraintViolation( + $this->translator->trans($message, $translationParameters, 'validators'), + $message, + $parameters, + null, + $exception->getPath(), + null, + null, + $code, + $constraint, + ); + } + + /** + * @param string[] $expectedTypes + */ + private function resolveMessage(?Constraint $constraint, array $expectedTypes): string + { + if ($constraint instanceof NotBlank || $constraint instanceof NotNull || $constraint instanceof Type) { + return $constraint->message; + } + + return (new Type($expectedTypes))->message; + } + + /** + * @param string[]|null $expectedTypes + * + * @return string[] + */ + private function normalizeExpectedTypes(?array $expectedTypes): array + { + $normalized = []; + foreach ($expectedTypes ?? [] as $expectedType) { + if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + $pos = strrpos($expectedType, '\\'); + $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); + continue; + } + $normalized[] = $expectedType; + } + + return $normalized; + } +} diff --git a/src/Validator/Tests/DenormalizationErrorHandlerTest.php b/src/Validator/Tests/DenormalizationErrorHandlerTest.php new file mode 100644 index 00000000000..267a08e40c1 --- /dev/null +++ b/src/Validator/Tests/DenormalizationErrorHandlerTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Validator\Tests; + +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\DenormalizationErrorHandler; +use ApiPlatform\Validator\Exception\ValidationException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +final class DenormalizationErrorHandlerTest extends TestCase +{ + private DenormalizationErrorHandler $handler; + + protected function setUp(): void + { + $this->handler = new DenormalizationErrorHandler( + new LazyLoadingMetadataFactory(new AttributeLoader()), + ); + } + + public function testNullCurrentTypeWithNotBlankThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + try { + $this->handler->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violation = $e->getConstraintViolationList()[0]; + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation->getCode()); + $this->assertSame('name', $violation->getPropertyPath()); + } + } + + public function testNullCurrentTypeWithNotNullThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'description'); + + try { + $this->handler->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotNull::IS_NULL_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithTypeConstraintThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'score'); + + try { + $this->handler->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithOtherConstraintThrowsGenericTypeViolation(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 123, ['string'], 'choice'); + + try { + $this->handler->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithoutConstraintReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'rawFloat'); + + // Returns without throwing → caller rethrows for 400. + $this->handler->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownClassReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + $this->handler->handle($exception, $this->operation('NotAClass')); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownPropertyReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'missingProperty'); + + $this->handler->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testNestedPathReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'address.street'); + + $this->handler->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testGroupFilteringExcludesConstraintsOutsideActiveGroups(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'adminOnly'); + + // Default group → constraint scoped to "admin" excluded → returns void. + $this->handler->handle($exception, $this->operation()); + + // Active "admin" group → matches. + try { + $this->handler->handle($exception, $this->operation(DenormHandlerFixture::class, ['admin'])); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testHandlePartialAggregatesAllErrors(): void + { + $errors = [ + NotNormalizableValueException::createForUnexpectedDataType('msg', null, ['string'], 'name'), + NotNormalizableValueException::createForUnexpectedDataType('msg', 'abc', ['float'], 'rawFloat'), + ]; + $partial = new PartialDenormalizationException(null, $errors); + + try { + $this->handler->handle($partial, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertCount(2, $e->getConstraintViolationList()); + $codes = []; + foreach ($e->getConstraintViolationList() as $violation) { + $codes[$violation->getPropertyPath()] = $violation->getCode(); + } + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $codes['name']); + // Unconstrained → generic Type fallback @ INVALID_TYPE_ERROR + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $codes['rawFloat']); + } + } + + /** + * @param array|null $groups + */ + private function operation(string $class = DenormHandlerFixture::class, ?array $groups = null): Post + { + $operation = new Post(class: $class); + if (null !== $groups) { + $operation = $operation->withValidationContext(['groups' => $groups]); + } + + return $operation; + } +} + +class DenormHandlerFixture +{ + #[Assert\NotBlank] + public string $name = ''; + + #[Assert\NotNull] + public string $description = ''; + + #[Assert\Type('numeric')] + public float $score = 0.0; + + #[Assert\Choice(choices: ['a', 'b'])] + public string $choice = 'a'; + + public float $rawFloat = 0.0; + + #[Assert\NotBlank(groups: ['admin'])] + public string $adminOnly = ''; +} diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 392e1f5a8bd..298f6527eed 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -24,6 +24,7 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "symfony/type-info": "^7.3 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", diff --git a/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php new file mode 100644 index 00000000000..43e253d86b2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/denormalization_validation_resources', + processor: self::class.'::process', + ), + new Post( + uriTemplate: '/denormalization_validation_resources_collect', + processor: self::class.'::process', + collectDenormalizationErrors: true, + ), + ], +)] +class DenormalizationValidationResource +{ + public int $id = 1; + + #[Assert\NotBlank] + public string $name = ''; + + #[Assert\NotNull] + public string $description = ''; + + #[Assert\Type('numeric')] + public float $score = 0.0; + + public float $rawFloat = 0.0; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } +} diff --git a/tests/Functional/DenormalizationValidationTest.php b/tests/Functional/DenormalizationValidationTest.php new file mode 100644 index 00000000000..e0c8311cea9 --- /dev/null +++ b/tests/Functional/DenormalizationValidationTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DenormalizationValidationResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +final class DenormalizationValidationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DenormalizationValidationResource::class]; + } + + public function testNullOnNotBlankPropertyProduces422WithNotBlankViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'name'); + $this->assertNotNull($violation, 'Expected a violation on "name".'); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation['code'] ?? null); + } + + public function testNullOnNotNullPropertyProduces422WithNotNullViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['description' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'description'); + $this->assertNotNull($violation, 'Expected a violation on "description".'); + $this->assertSame((string) NotNull::IS_NULL_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeOnTypeConstrainedPropertyProduces422WithTypeViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['score' => 'abc'], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'score'); + $this->assertNotNull($violation, 'Expected a violation on "score".'); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeWithoutConstraintProduces400(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['rawFloat' => 'abc'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testCollectMixedConstrainedAndUnconstrainedProduces422WithSpecificCodes(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => null, + 'score' => 'abc', + 'rawFloat' => 'abc', + ], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violations = $content['violations'] ?? []; + + $nameViolation = $this->findViolation($violations, 'name'); + $this->assertNotNull($nameViolation); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $nameViolation['code'] ?? null); + + $scoreViolation = $this->findViolation($violations, 'score'); + $this->assertNotNull($scoreViolation); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $scoreViolation['code'] ?? null); + + // Unconstrained property still translates to a generic Type violation in collect mode + // (consistent with prior behavior — collect mode never re-throws single errors). + $rawFloatViolation = $this->findViolation($violations, 'rawFloat'); + $this->assertNotNull($rawFloatViolation); + } + + private function findViolation(array $violations, string $propertyPath): ?array + { + foreach ($violations as $violation) { + if (($violation['propertyPath'] ?? null) === $propertyPath) { + return $violation; + } + } + + return null; + } +}