diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0fd1288f02d..b1cf2e3823f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -92,6 +92,7 @@ parameters: - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" # See https://github.com/phpstan/phpstan-symfony/issues/27 - diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9477d5c8a15..2e6be65777e 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -107,6 +107,7 @@ use ApiPlatform\Laravel\Security\ResourceAccessChecker; use ApiPlatform\Laravel\Serializer\EloquentOperationResourceClassResolver; use ApiPlatform\Laravel\State\AccessCheckerProvider; +use ApiPlatform\Laravel\State\DenormalizationViolationFactory as LaravelDenormalizationViolationFactory; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; @@ -158,6 +159,7 @@ use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\ErrorProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; @@ -422,8 +424,18 @@ public function register(): void ); }); + $this->app->singleton(DenormalizationViolationFactoryInterface::class, static function () { + return new LaravelDenormalizationViolationFactory(); + }); + $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(DenormalizationViolationFactoryInterface::class), + ); }); $this->app->singleton(ValidateProvider::class, static function (Application $app) { diff --git a/src/Laravel/State/DenormalizationViolationFactory.php b/src/Laravel/State/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..c09c821a70a --- /dev/null +++ b/src/Laravel/State/DenormalizationViolationFactory.php @@ -0,0 +1,216 @@ + + * + * 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\DenormalizationViolationFactoryInterface; +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 violation factory — 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 DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + 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]; + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]); + } + + $violations = []; + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors 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); + } + + /** + * @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 = $exception->getExpectedTypes() ?? []; + 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..a3375374b07 --- /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((string) $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/Laravel/phpstan.neon.dist b/src/Laravel/phpstan.neon.dist index 0e2effea271..3ef80c407f8 100644 --- a/src/Laravel/phpstan.neon.dist +++ b/src/Laravel/phpstan.neon.dist @@ -18,3 +18,4 @@ parameters: - Tests ignoreErrors: - '#Cannot call method expectsQuestion#' + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" diff --git a/src/State/DenormalizationViolationFactoryInterface.php b/src/State/DenormalizationViolationFactoryInterface.php new file mode 100644 index 00000000000..bc414423fab --- /dev/null +++ b/src/State/DenormalizationViolationFactoryInterface.php @@ -0,0 +1,52 @@ + + * + * 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; + +/** + * Promotes Symfony serializer denormalization errors (raw type mismatches that would + * otherwise produce a 400) into HTTP-level validation violations (422) 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 DenormalizationViolationFactoryInterface +{ + /** + * Builds and throws a validation violation from 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..02572ac9b1a 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\DenormalizationViolationFactoryInterface; 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 { @@ -40,13 +35,11 @@ public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, - private ?TranslatorInterface $translator = null, + ?TranslatorInterface $translator = null, + private readonly ?DenormalizationViolationFactoryInterface $violationFactory = null, ) { - if (null === $this->translator) { - $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { - use TranslatorTrait; - }; - $this->translator->setLocale('en'); + if (null !== $translator) { + trigger_deprecation('api-platform/core', '4.4', 'Passing a "%s" to "%s" is deprecated and will be removed in 5.0. Translation is now handled by "%s".', TranslatorInterface::class, self::class, DenormalizationViolationFactoryInterface::class); } } @@ -101,31 +94,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; - } - - $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; - } + } catch (PartialDenormalizationException|NotNormalizableValueException $e) { + $this->violationFactory?->handle($e, $operation); - $violations = new ConstraintViolationList(); - $violations->add($this->createViolationFromException($e)); - throw new ValidationException($violations); + throw $e; } $this->stopwatch?->stop('api_platform.provider.deserialize'); @@ -134,63 +106,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..608df3bf09c 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\DenormalizationViolationFactoryInterface; 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(DenormalizationViolationFactoryInterface::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(DenormalizationViolationFactoryInterface::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(DenormalizationViolationFactoryInterface::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/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index f31fc2bc7c1..e57c02f59a5 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\DenormalizationViolationFactoryInterface; 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\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -40,13 +42,22 @@ service('api_platform.serializer.context_builder'), ]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->decorate('api_platform.state_provider.main', null, 300) ->args([ service('api_platform.state_provider.deserialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.error_listener', ErrorListener::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..23105235373 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; @@ -31,6 +32,7 @@ use ApiPlatform\Symfony\EventListener\RespondListener; use ApiPlatform\Symfony\EventListener\SerializeListener; use ApiPlatform\Symfony\EventListener\WriteListener; +use ApiPlatform\Validator\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -70,12 +72,21 @@ ]) ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 4]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->args([ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.listener.request.deserialize', DeserializeListener::class) diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..76b38e69cb2 --- /dev/null +++ b/src/Validator/DenormalizationViolationFactory.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\DenormalizationViolationFactoryInterface; +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 violation factory — 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 DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + 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 NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + $violations = new ConstraintViolationList(); + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations->add($this->buildViolation($error, $operation) ?? $this->buildViolation($error, $operation, true)); + } + + if (\count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * 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/DenormalizationViolationFactoryTest.php b/src/Validator/Tests/DenormalizationViolationFactoryTest.php new file mode 100644 index 00000000000..5befc8cd93b --- /dev/null +++ b/src/Validator/Tests/DenormalizationViolationFactoryTest.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\DenormalizationViolationFactory; +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 DenormalizationViolationFactoryTest extends TestCase +{ + private DenormalizationViolationFactory $factory; + + protected function setUp(): void + { + $this->factory = new DenormalizationViolationFactory( + new LazyLoadingMetadataFactory(new AttributeLoader()), + ); + } + + public function testNullCurrentTypeWithNotBlankThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + try { + $this->factory->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->factory->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->factory->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->factory->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->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownClassReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + $this->factory->handle($exception, $this->operation('NotAClass')); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownPropertyReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'missingProperty'); + + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testNestedPathReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'address.street'); + + $this->factory->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->factory->handle($exception, $this->operation()); + + // Active "admin" group → matches. + try { + $this->factory->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->factory->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 +{ + #[NotBlank] + public string $name = ''; + + #[NotNull] + public string $description = ''; + + #[Type('numeric')] + public float $score = 0.0; + + #[Assert\Choice(choices: ['a', 'b'])] + public string $choice = 'a'; + + public float $rawFloat = 0.0; + + #[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; + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 3fa939623c8..8d340915433 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,8 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -67,13 +65,8 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } - #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index eba8ce3f10c..d6aa24c078f 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,9 +16,6 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -48,13 +45,8 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } - #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/Security/SecurityHeadersTest.php b/tests/Functional/Security/SecurityHeadersTest.php index cacbf430537..30793614cd3 100644 --- a/tests/Functional/Security/SecurityHeadersTest.php +++ b/tests/Functional/Security/SecurityHeadersTest.php @@ -55,7 +55,7 @@ public function testDeserializationErrorResponseIncludesSecurityHeaders(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); $this->assertResponseHeaderSame('x-frame-options', 'deny'); } diff --git a/tests/Functional/Security/StrongTypingTest.php b/tests/Functional/Security/StrongTypingTest.php index d42b6a8aab9..78a906b94dc 100644 --- a/tests/Functional/Security/StrongTypingTest.php +++ b/tests/Functional/Security/StrongTypingTest.php @@ -86,12 +86,12 @@ public function testNullValueForRequiredStringTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "NULL" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should not be blank.', ]); } @@ -198,12 +198,12 @@ public function testIntegerInsteadOfStringScalarTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "integer" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should be of type string.', ]); }