From da5e8a42d4e33f71f3897f0a60ed17131d38c577 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Thu, 3 Mar 2022 20:53:13 +0100 Subject: [PATCH 01/13] [FrameworkBundle] Add an ArgumentResolver to deserialize & validate user input. --- .../ArgumentResolver/UserInputInterface.php | 12 +++ .../ArgumentResolver/UserInputResolver.php | 55 +++++++++++++ .../FrameworkExtension.php | 13 ++++ ...InputValidationFailedExceptionListener.php | 61 +++++++++++++++ .../Exception/UnparsableInputException.php | 7 ++ .../UserInputResolverTest.php | 78 +++++++++++++++++++ ...tValidationFailedExceptionListenerTest.php | 59 ++++++++++++++ .../Tests/Fixtures/Validation/DummyDto.php | 15 ++++ 8 files changed, 300 insertions(+) create mode 100644 src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/ArgumentResolver/UserInputResolverTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php diff --git a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php new file mode 100644 index 0000000000000..bab8db93cb743 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php @@ -0,0 +1,12 @@ + + */ +interface UserInputInterface +{ +} diff --git a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php new file mode 100644 index 0000000000000..26902bc4ca6e5 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php @@ -0,0 +1,55 @@ + + */ +class UserInputResolver implements ArgumentValueResolverInterface +{ + public function __construct(private ValidatorInterface $validator, private SerializerInterface $serializer) + { + } + + /** + * {@inheritDoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + $className = $argument->getType(); + + return class_exists($className) && \in_array(UserInputInterface::class, class_implements($className) ?: [], true); + } + + /** + * {@inheritDoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + try { + $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), $request->attributes->get('_format', 'json')); + } catch (ExceptionInterface $exception) { + throw new UnparsableInputException($exception->getMessage(), 0, $exception); + } + + $errors = $this->validator->validate($input); + + if ($errors->count() > 0) { + throw new ValidationFailedException($input, $errors); + } + + yield $input; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 733341eeb2c7b..5078bafaf1940 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,7 +25,9 @@ use Psr\Log\LoggerAwareInterface; use Symfony\Bridge\Monolog\Processor\DebugProcessor; use Symfony\Bridge\Twig\Extension\CsrfExtension; +use Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Bundle\FrameworkBundle\EventListener\InputValidationFailedExceptionListener; use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; use Symfony\Bundle\FullStack; @@ -529,6 +531,17 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('mime_type.php'); } + if ($this->isConfigEnabled($container, $config['validation']) && $this->isConfigEnabled($container, $config['serializer'])) { + $container->register(InputValidationFailedExceptionListener::class) + ->setArguments([new Reference('serializer'), new Reference('logger')]) + // Must run before Symfony\Component\HttpKernel\EventListener\ErrorListener::onKernelException() + ->addTag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 10]); + + $container->register(UserInputResolver::class) + ->setArguments([new Reference('validator'), new Reference('serializer')]) + ->addTag('controller.argument_value_resolver'); + } + $container->registerForAutoconfiguration(PackageInterface::class) ->addTag('assets.package'); $container->registerForAutoconfiguration(Command::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php new file mode 100644 index 0000000000000..b5d0e2a69aed0 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php @@ -0,0 +1,61 @@ + + */ +class InputValidationFailedExceptionListener +{ + public function __construct(private SerializerInterface $serializer, private LoggerInterface $logger) + { + } + + public function __invoke(ExceptionEvent $event): void + { + $throwable = $event->getThrowable(); + $format = $event->getRequest()->attributes->get('_format', 'json'); + $response = null; + $reason = null; + + if ($throwable instanceof UnparsableInputException) { + $reason = $throwable->getMessage(); + $response = new Response($this->serializer->serialize(['message' => 'Invalid input'], $format), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if ($throwable instanceof ValidationFailedException) { + $data = [ + 'title' => 'Validation Failed', + 'errors' => [], + ]; + + foreach ($throwable->getViolations() as $violation) { + $data['errors'][] = [ + 'propertyPath' => $violation->getPropertyPath(), + 'message' => $violation->getMessage(), + 'code' => $violation->getCode(), + ]; + } + $response = new Response($this->serializer->serialize($data, $format), Response::HTTP_UNPROCESSABLE_ENTITY); + } + + if (null === $response) { + return; + } + + $this->logger->info('Invalid input rejected: "{reason}"', [ + 'reason' => $reason, + ]); + + $event->setResponse($response); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php new file mode 100644 index 0000000000000..6f7ad3b908c6f --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php @@ -0,0 +1,7 @@ +resolver = new UserInputResolver(Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(), new Serializer($normalizers, $encoders)); + } + + public function testSupports(): void + { + $this->assertTrue($this->resolver->supports(new Request(), $this->createMetadata()), 'Should be supported'); + + $this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata(Category::class)), 'Should not be supported'); + } + + public function testResolveWithValidValue(): void + { + $json = '{"randomText": "Lorem ipsum"}'; + $request = new Request(content: $json); + + $resolved = iterator_to_array($this->resolver->resolve($request, $this->createMetadata())); + + $this->assertCount(1, $resolved, 'Should resolve one argument'); + $this->assertInstanceOf(DummyDto::class, $resolved[0]); + $this->assertSame('Lorem ipsum', $resolved[0]->randomText); + } + + /** + * @dataProvider provideInvalidValues + */ + public function testResolveWithInvalidValue(string $content, string $expected): void + { + $this->expectException($expected); + $request = new Request(content: $content); + + iterator_to_array($this->resolver->resolve($request, $this->createMetadata())); + } + + public function provideInvalidValues(): \Generator + { + yield 'Invalid value' => ['{"itMustBeTrue": false}', ValidationFailedException::class]; + yield 'Not normalizable' => ['{"randomText": ["Did", "You", "Expect", "That?"]}', UnparsableInputException::class]; + } + + private function createMetadata(string $type = DummyDto::class): ArgumentMetadata + { + $arguments = [ + 'name' => 'foo', + 'isVariadic' => false, + 'hasDefaultValue' => false, + 'defaultValue' => null, + 'type' => $type, + ]; + + return new ArgumentMetadata(...$arguments); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php new file mode 100644 index 0000000000000..7f0b2d2c6afff --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php @@ -0,0 +1,59 @@ +serializer = new Serializer($normalizers, $encoders); + } + + /** + * @dataProvider provideExceptions + */ + public function testExceptionHandling(\Throwable $e, ?string $expected) + { + $listener = new InputValidationFailedExceptionListener($this->serializer, new NullLogger()); + $event = new ExceptionEvent($this->createMock(HttpKernelInterface::class), new Request(), HttpKernelInterface::MAIN_REQUEST, $e); + + $listener($event); + + if (null === $expected) { + $this->assertFalse($event->hasResponse(), 'Unexpected response'); + } else { + $this->assertTrue($event->hasResponse(), 'Expected a response'); + $this->assertStringContainsString($event->getResponse()->getContent(), $expected); + } + } + + public function provideExceptions(): \Generator + { + yield 'Unrelated exception' => [new \Exception('Nothing to see here'), null]; + yield 'Unparsable exception' => [new UnparsableInputException('Input is a mess.'), '{"message":"Invalid input"}']; + + $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); + $input = new DummyDto(); + $input->itMustBeTrue = false; + + yield 'Validation exception' => [new ValidationFailedException($input, $validator->validate($input)), 'This value should not be blank']; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php new file mode 100644 index 0000000000000..f76f2c9d6c7b3 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php @@ -0,0 +1,15 @@ + Date: Thu, 3 Mar 2022 21:18:22 +0100 Subject: [PATCH 02/13] fix: tests & update changelog --- src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md | 1 + .../InputValidationFailedExceptionListenerTest.php | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index bad0e2ad0e8fe..d0aefe3a25c8a 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Load PHP configuration files by default in the `MicroKernelTrait` * Add `cache:pool:invalidate-tags` command * Add `xliff` support in addition to `xlf` for `XliffFileDumper` + * Add an ArgumentResolver to deserialize & validate user input 6.0 --- diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php index 7f0b2d2c6afff..b3f4e3b66d77b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php @@ -41,7 +41,7 @@ public function testExceptionHandling(\Throwable $e, ?string $expected) $this->assertFalse($event->hasResponse(), 'Unexpected response'); } else { $this->assertTrue($event->hasResponse(), 'Expected a response'); - $this->assertStringContainsString($event->getResponse()->getContent(), $expected); + $this->assertStringContainsString($expected, $event->getResponse()->getContent()); } } From 6000089e042dd6c8de457e01454a5075f70b7dc7 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Thu, 3 Mar 2022 21:33:46 +0100 Subject: [PATCH 03/13] fix: empty reason --- .../EventListener/InputValidationFailedExceptionListener.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php index b5d0e2a69aed0..346c4881be9ba 100644 --- a/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php +++ b/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php @@ -37,6 +37,7 @@ public function __invoke(ExceptionEvent $event): void 'title' => 'Validation Failed', 'errors' => [], ]; + $reason = ''; foreach ($throwable->getViolations() as $violation) { $data['errors'][] = [ @@ -44,6 +45,7 @@ public function __invoke(ExceptionEvent $event): void 'message' => $violation->getMessage(), 'code' => $violation->getCode(), ]; + $reason .= "{$violation->getPropertyPath()}: {$violation->getMessage()} "; } $response = new Response($this->serializer->serialize($data, $format), Response::HTTP_UNPROCESSABLE_ENTITY); } From 4bb22358f0d18767ce12728f918ed840e6436690 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sat, 5 Mar 2022 10:20:21 +0100 Subject: [PATCH 04/13] fix: switch from Marker Interface to Argument --- .../ArgumentResolver/UserInputInterface.php | 12 --- .../ArgumentResolver/UserInputResolver.php | 55 ------------- .../FrameworkExtension.php | 4 +- ...InputValidationFailedExceptionListener.php | 63 --------------- .../Exception/UnparsableInputException.php | 7 -- .../Component/Serializer/Annotation/Input.php | 31 +++++++ .../ArgumentResolver/UserInputResolver.php | 80 +++++++++++++++++++ ...InputValidationFailedExceptionListener.php | 36 +++++++++ .../UserInputResolverTest.php | 27 ++++--- ...tValidationFailedExceptionListenerTest.php | 20 ++--- .../Serializer/Tests/Fixtures}/DummyDto.php | 8 +- 11 files changed, 175 insertions(+), 168 deletions(-) delete mode 100644 src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php delete mode 100644 src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php create mode 100644 src/Symfony/Component/Serializer/Annotation/Input.php create mode 100644 src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php create mode 100644 src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php rename src/Symfony/{Bundle/FrameworkBundle => Component/Serializer}/Tests/ArgumentResolver/UserInputResolverTest.php (72%) rename src/Symfony/{Bundle/FrameworkBundle => Component/Serializer}/Tests/EventListener/InputValidationFailedExceptionListenerTest.php (68%) rename src/Symfony/{Bundle/FrameworkBundle/Tests/Fixtures/Validation => Component/Serializer/Tests/Fixtures}/DummyDto.php (51%) diff --git a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php deleted file mode 100644 index bab8db93cb743..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - - */ -interface UserInputInterface -{ -} diff --git a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php b/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php deleted file mode 100644 index 26902bc4ca6e5..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/ArgumentResolver/UserInputResolver.php +++ /dev/null @@ -1,55 +0,0 @@ - - */ -class UserInputResolver implements ArgumentValueResolverInterface -{ - public function __construct(private ValidatorInterface $validator, private SerializerInterface $serializer) - { - } - - /** - * {@inheritDoc} - */ - public function supports(Request $request, ArgumentMetadata $argument): bool - { - $className = $argument->getType(); - - return class_exists($className) && \in_array(UserInputInterface::class, class_implements($className) ?: [], true); - } - - /** - * {@inheritDoc} - */ - public function resolve(Request $request, ArgumentMetadata $argument): iterable - { - try { - $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), $request->attributes->get('_format', 'json')); - } catch (ExceptionInterface $exception) { - throw new UnparsableInputException($exception->getMessage(), 0, $exception); - } - - $errors = $this->validator->validate($input); - - if ($errors->count() > 0) { - throw new ValidationFailedException($input, $errors); - } - - yield $input; - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 5078bafaf1940..2effbc23563f7 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -25,9 +25,7 @@ use Psr\Log\LoggerAwareInterface; use Symfony\Bridge\Monolog\Processor\DebugProcessor; use Symfony\Bridge\Twig\Extension\CsrfExtension; -use Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Bundle\FrameworkBundle\EventListener\InputValidationFailedExceptionListener; use Symfony\Bundle\FrameworkBundle\Routing\AnnotatedRouteControllerLoader; use Symfony\Bundle\FrameworkBundle\Routing\RouteLoaderInterface; use Symfony\Bundle\FullStack; @@ -185,8 +183,10 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; diff --git a/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php deleted file mode 100644 index 346c4881be9ba..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/EventListener/InputValidationFailedExceptionListener.php +++ /dev/null @@ -1,63 +0,0 @@ - - */ -class InputValidationFailedExceptionListener -{ - public function __construct(private SerializerInterface $serializer, private LoggerInterface $logger) - { - } - - public function __invoke(ExceptionEvent $event): void - { - $throwable = $event->getThrowable(); - $format = $event->getRequest()->attributes->get('_format', 'json'); - $response = null; - $reason = null; - - if ($throwable instanceof UnparsableInputException) { - $reason = $throwable->getMessage(); - $response = new Response($this->serializer->serialize(['message' => 'Invalid input'], $format), Response::HTTP_UNPROCESSABLE_ENTITY); - } - - if ($throwable instanceof ValidationFailedException) { - $data = [ - 'title' => 'Validation Failed', - 'errors' => [], - ]; - $reason = ''; - - foreach ($throwable->getViolations() as $violation) { - $data['errors'][] = [ - 'propertyPath' => $violation->getPropertyPath(), - 'message' => $violation->getMessage(), - 'code' => $violation->getCode(), - ]; - $reason .= "{$violation->getPropertyPath()}: {$violation->getMessage()} "; - } - $response = new Response($this->serializer->serialize($data, $format), Response::HTTP_UNPROCESSABLE_ENTITY); - } - - if (null === $response) { - return; - } - - $this->logger->info('Invalid input rejected: "{reason}"', [ - 'reason' => $reason, - ]); - - $event->setResponse($response); - } -} diff --git a/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php b/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php deleted file mode 100644 index 6f7ad3b908c6f..0000000000000 --- a/src/Symfony/Bundle/FrameworkBundle/Exception/UnparsableInputException.php +++ /dev/null @@ -1,7 +0,0 @@ - + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class Input +{ + public function __construct(private ?string $format = null, private array $serializationContext = [], private array $validationGroups = ['Default']) + { + } + + public function getFormat(): ?string + { + return $this->format; + } + + public function getSerializationContext(): array + { + return $this->serializationContext; + } + + public function getValidationGroups(): array + { + return $this->validationGroups; + } +} diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php new file mode 100644 index 0000000000000..24f474ef5a941 --- /dev/null +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -0,0 +1,80 @@ + + */ +class UserInputResolver implements ArgumentValueResolverInterface +{ + public function __construct(private ValidatorInterface $validator, private SerializerInterface $serializer, ) + { + } + + /** + * {@inheritDoc} + */ + public function supports(Request $request, ArgumentMetadata $argument): bool + { + return null !== $this->getAttribute($argument); + } + + /** + * {@inheritDoc} + */ + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $attribute = $this->getAttribute($argument); + $context = array_merge($attribute->getSerializationContext(), [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + $format = $attribute->getFormat() ?? $request->attributes->get('_format', 'json'); + + $input = null; + try { + $input = $this->serializer->deserialize(data: $request->getContent(), type: $argument->getType(), format: $format, context: $context); + + $errors = $this->validator->validate(value: $input, groups: $attribute->getValidationGroups()); + } catch (PartialDenormalizationException $e) { + $errors = new ConstraintViolationList(); + + foreach ($e->getErrors() as $exception) { + $message = sprintf('The type must be one of "%s" ("%s" given).', implode(', ', $exception->getExpectedTypes()), $exception->getCurrentType()); + $parameters = []; + + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + + $errors->add(new ConstraintViolation($message, '', $parameters, null, $exception->getPath(), null)); + } + } + + if ($errors->count() > 0) { + throw new ValidationFailedException($input, $errors); + } + + yield $input; + } + + private function getAttribute(ArgumentMetadata $argument): ?Input + { + return $argument->getAttributes(Input::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; + } +} diff --git a/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php new file mode 100644 index 0000000000000..1b75f7a26e2ee --- /dev/null +++ b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php @@ -0,0 +1,36 @@ + + */ +class InputValidationFailedExceptionListener +{ + public function __construct(private SerializerInterface $serializer, private LoggerInterface $logger) + { + } + + public function __invoke(ExceptionEvent $event): void + { + $throwable = $event->getThrowable(); + $format = $event->getRequest()->attributes->get('_format', 'json'); + + if (!$throwable instanceof ValidationFailedException) { + return; + } + + $response = new Response($this->serializer->serialize($throwable->getViolations(), $format), Response::HTTP_UNPROCESSABLE_ENTITY); + $this->logger->info('Invalid input rejected: "{reason}"', ['reason' => (string) $throwable->getViolations()]); + + $event->setResponse($response); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/ArgumentResolver/UserInputResolverTest.php b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php similarity index 72% rename from src/Symfony/Bundle/FrameworkBundle/Tests/ArgumentResolver/UserInputResolverTest.php rename to src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php index 17596ce3036f2..e0bf80201adcb 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/ArgumentResolver/UserInputResolverTest.php +++ b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php @@ -1,17 +1,16 @@ assertTrue($this->resolver->supports(new Request(), $this->createMetadata()), 'Should be supported'); - $this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata(Category::class)), 'Should not be supported'); + $this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata([])), 'Should not be supported'); } public function testResolveWithValidValue(): void @@ -49,28 +48,30 @@ public function testResolveWithValidValue(): void /** * @dataProvider provideInvalidValues */ - public function testResolveWithInvalidValue(string $content, string $expected): void + public function testResolveWithInvalidValue(string $content, array $groups = ['Default']): void { - $this->expectException($expected); + $this->expectException(ValidationFailedException::class); $request = new Request(content: $content); - iterator_to_array($this->resolver->resolve($request, $this->createMetadata())); + iterator_to_array($this->resolver->resolve($request, $this->createMetadata([new Input(validationGroups: $groups)]))); } public function provideInvalidValues(): \Generator { - yield 'Invalid value' => ['{"itMustBeTrue": false}', ValidationFailedException::class]; - yield 'Not normalizable' => ['{"randomText": ["Did", "You", "Expect", "That?"]}', UnparsableInputException::class]; + yield 'Invalid value' => ['{"itMustBeTrue": false}']; + yield 'Invalid value with groups' => ['{"randomText": "Valid"}', ['Default', 'Foo']]; + yield 'Not normalizable' => ['{"randomText": ["Did", "You", "Expect", "That?"]}']; } - private function createMetadata(string $type = DummyDto::class): ArgumentMetadata + private function createMetadata(array $attributes = [new Input()]): ArgumentMetadata { $arguments = [ 'name' => 'foo', 'isVariadic' => false, 'hasDefaultValue' => false, 'defaultValue' => null, - 'type' => $type, + 'type' => DummyDto::class, + 'attributes' => $attributes, ]; return new ArgumentMetadata(...$arguments); diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php similarity index 68% rename from src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php rename to src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php index b3f4e3b66d77b..48f9caa893e61 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/EventListener/InputValidationFailedExceptionListenerTest.php +++ b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php @@ -1,20 +1,20 @@ serializer = new Serializer($normalizers, $encoders); } @@ -48,12 +48,6 @@ public function testExceptionHandling(\Throwable $e, ?string $expected) public function provideExceptions(): \Generator { yield 'Unrelated exception' => [new \Exception('Nothing to see here'), null]; - yield 'Unparsable exception' => [new UnparsableInputException('Input is a mess.'), '{"message":"Invalid input"}']; - - $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); - $input = new DummyDto(); - $input->itMustBeTrue = false; - - yield 'Validation exception' => [new ValidationFailedException($input, $validator->validate($input)), 'This value should not be blank']; + yield 'Validation exception' => [new ValidationFailedException(new DummyDto(), ConstraintViolationList::createFromMessage('This value should not be blank')), 'This value should not be blank']; } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php similarity index 51% rename from src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php rename to src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php index f76f2c9d6c7b3..71973efd938e2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Fixtures/Validation/DummyDto.php +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php @@ -1,12 +1,14 @@ Date: Sat, 5 Mar 2022 10:40:17 +0100 Subject: [PATCH 05/13] fix: make FabBot happy --- src/Symfony/Component/Serializer/Annotation/Input.php | 9 +++++++++ .../Serializer/ArgumentResolver/UserInputResolver.php | 9 +++++++++ .../InputValidationFailedExceptionListener.php | 9 +++++++++ .../Tests/ArgumentResolver/UserInputResolverTest.php | 6 +++--- 4 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Serializer/Annotation/Input.php b/src/Symfony/Component/Serializer/Annotation/Input.php index 80dc3b625bd3a..a390712fab791 100644 --- a/src/Symfony/Component/Serializer/Annotation/Input.php +++ b/src/Symfony/Component/Serializer/Annotation/Input.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Serializer\Annotation; /** diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php index 24f474ef5a941..23b8b00f72369 100644 --- a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Serializer\ArgumentResolver; use Symfony\Component\HttpFoundation\Request; diff --git a/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php index 1b75f7a26e2ee..73645bfea26ef 100644 --- a/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php +++ b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php @@ -1,5 +1,14 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace Symfony\Component\Serializer\EventListener; use Psr\Log\LoggerInterface; diff --git a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php index e0bf80201adcb..2ba6edda7c5d7 100644 --- a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php +++ b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php @@ -26,14 +26,14 @@ protected function setUp(): void $this->resolver = new UserInputResolver(Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(), new Serializer($normalizers, $encoders)); } - public function testSupports(): void + public function testSupports() { $this->assertTrue($this->resolver->supports(new Request(), $this->createMetadata()), 'Should be supported'); $this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata([])), 'Should not be supported'); } - public function testResolveWithValidValue(): void + public function testResolveWithValidValue() { $json = '{"randomText": "Lorem ipsum"}'; $request = new Request(content: $json); @@ -48,7 +48,7 @@ public function testResolveWithValidValue(): void /** * @dataProvider provideInvalidValues */ - public function testResolveWithInvalidValue(string $content, array $groups = ['Default']): void + public function testResolveWithInvalidValue(string $content, array $groups = ['Default']) { $this->expectException(ValidationFailedException::class); $request = new Request(content: $content); From 2368c7f9d4dcae1ff9d483d969592a19ca9034ac Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 6 Mar 2022 18:15:19 +0100 Subject: [PATCH 06/13] Apply suggestion Co-authored-by: Alexander M. Turek --- .../Component/Serializer/Annotation/Input.php | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Symfony/Component/Serializer/Annotation/Input.php b/src/Symfony/Component/Serializer/Annotation/Input.php index a390712fab791..3e00b9d1df81d 100644 --- a/src/Symfony/Component/Serializer/Annotation/Input.php +++ b/src/Symfony/Component/Serializer/Annotation/Input.php @@ -19,22 +19,10 @@ #[\Attribute(\Attribute::TARGET_PARAMETER)] class Input { - public function __construct(private ?string $format = null, private array $serializationContext = [], private array $validationGroups = ['Default']) - { - } - - public function getFormat(): ?string - { - return $this->format; - } - - public function getSerializationContext(): array - { - return $this->serializationContext; - } - - public function getValidationGroups(): array - { - return $this->validationGroups; + public function __construct( + public readonly ?string $format = null, + public readonly array $serializationContext = [], + public readonly array $validationGroups = ['Default'] + ) { } } From 5905ae0dd8484fcac1a1c6f4703858bc334c26db Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 6 Mar 2022 18:22:16 +0100 Subject: [PATCH 07/13] fix: remove getters --- .../Serializer/ArgumentResolver/UserInputResolver.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php index 23b8b00f72369..92ee325a08867 100644 --- a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -50,16 +50,16 @@ public function supports(Request $request, ArgumentMetadata $argument): bool public function resolve(Request $request, ArgumentMetadata $argument): iterable { $attribute = $this->getAttribute($argument); - $context = array_merge($attribute->getSerializationContext(), [ + $context = array_merge($attribute->serializationContext, [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, ]); - $format = $attribute->getFormat() ?? $request->attributes->get('_format', 'json'); + $format = $attribute->format ?? $request->attributes->get('_format', 'json'); $input = null; try { $input = $this->serializer->deserialize(data: $request->getContent(), type: $argument->getType(), format: $format, context: $context); - $errors = $this->validator->validate(value: $input, groups: $attribute->getValidationGroups()); + $errors = $this->validator->validate(value: $input, groups: $attribute->validationGroups); } catch (PartialDenormalizationException $e) { $errors = new ConstraintViolationList(); From 406f1eb48cc26a1af976a83397769f47f2777464 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 6 Mar 2022 19:48:29 +0100 Subject: [PATCH 08/13] feat: make validation optional --- .../FrameworkExtension.php | 11 ---------- .../Resources/config/serializer.php | 14 ++++++++++++ .../ArgumentResolver/UserInputResolver.php | 22 +++++++++++++------ ...InputValidationFailedExceptionListener.php | 4 ++-- .../UserInputResolverTest.php | 5 +++-- .../InputValidationFailedException.php | 19 ++++++++++++++++ 6 files changed, 53 insertions(+), 22 deletions(-) create mode 100644 src/Symfony/Component/Validator/Exception/InputValidationFailedException.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2effbc23563f7..2d2f06d9d910c 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -531,17 +531,6 @@ public function load(array $configs, ContainerBuilder $container) $loader->load('mime_type.php'); } - if ($this->isConfigEnabled($container, $config['validation']) && $this->isConfigEnabled($container, $config['serializer'])) { - $container->register(InputValidationFailedExceptionListener::class) - ->setArguments([new Reference('serializer'), new Reference('logger')]) - // Must run before Symfony\Component\HttpKernel\EventListener\ErrorListener::onKernelException() - ->addTag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 10]); - - $container->register(UserInputResolver::class) - ->setArguments([new Reference('validator'), new Reference('serializer')]) - ->addTag('controller.argument_value_resolver'); - } - $container->registerForAutoconfiguration(PackageInterface::class) ->addTag('assets.package'); $container->registerForAutoconfiguration(Command::class) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index 5655c05a26e35..d5dd5d24db6d1 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -14,15 +14,18 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerCacheWarmer; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\ErrorHandler\ErrorRenderer\SerializerErrorRenderer; use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; +use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Encoder\YamlEncoder; +use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\CacheClassMetadataFactory; @@ -68,6 +71,17 @@ ->alias('serializer.property_accessor', 'property_accessor') + // Argument Resolvers + ->set(UserInputResolver::class) + ->args([service('serializer'), service('validator')->nullOnInvalid()]) + ->tag('controller.argument_value_resolver') + + // Event Listeners + ->set(InputValidationFailedExceptionListener::class) + ->args([service('serializer'), service('logger')]) + // Must run before Symfony\Component\HttpKernel\EventListener\ErrorListener::onKernelException() + ->tag('kernel.event_listener', ['event' => 'kernel.exception', 'priority' => 10]) + // Discriminator Map ->set('serializer.mapping.class_discriminator_resolver', ClassDiscriminatorFromClassMetadata::class) ->args([service('serializer.mapping.class_metadata_factory')]) diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php index 92ee325a08867..1e515f9727349 100644 --- a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -14,13 +14,14 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; use Symfony\Component\Serializer\Annotation\Input; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; use Symfony\Component\Validator\ConstraintViolation; use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Exception\InputValidationFailedException; use Symfony\Component\Validator\Validator\ValidatorInterface; /** @@ -32,7 +33,7 @@ */ class UserInputResolver implements ArgumentValueResolverInterface { - public function __construct(private ValidatorInterface $validator, private SerializerInterface $serializer, ) + public function __construct(private SerializerInterface $serializer, private ?ValidatorInterface $validator = null) { } @@ -55,12 +56,13 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable ]); $format = $attribute->format ?? $request->attributes->get('_format', 'json'); - $input = null; try { $input = $this->serializer->deserialize(data: $request->getContent(), type: $argument->getType(), format: $format, context: $context); - - $errors = $this->validator->validate(value: $input, groups: $attribute->validationGroups); } catch (PartialDenormalizationException $e) { + if (null === $this->validator) { + throw new UnprocessableEntityHttpException(message: $e->getMessage(), previous: $e); + } + $errors = new ConstraintViolationList(); foreach ($e->getErrors() as $exception) { @@ -73,10 +75,16 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $errors->add(new ConstraintViolation($message, '', $parameters, null, $exception->getPath(), null)); } + + throw new InputValidationFailedException(null, $errors); } - if ($errors->count() > 0) { - throw new ValidationFailedException($input, $errors); + if ($this->validator) { + $errors = $this->validator->validate(value: $input, groups: $attribute->validationGroups); + + if ($errors->count() > 0) { + throw new InputValidationFailedException($input, $errors); + } } yield $input; diff --git a/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php index 73645bfea26ef..97065775844d1 100644 --- a/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php +++ b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php @@ -15,7 +15,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Event\ExceptionEvent; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Exception\InputValidationFailedException; /** * Works in duo with Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver. @@ -33,7 +33,7 @@ public function __invoke(ExceptionEvent $event): void $throwable = $event->getThrowable(); $format = $event->getRequest()->attributes->get('_format', 'json'); - if (!$throwable instanceof ValidationFailedException) { + if (!$throwable instanceof InputValidationFailedException) { return; } diff --git a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php index 2ba6edda7c5d7..57f961e3a4fc6 100644 --- a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php +++ b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php @@ -22,8 +22,9 @@ protected function setUp(): void { $encoders = [new JsonEncoder()]; $normalizers = [new ObjectNormalizer()]; + $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); - $this->resolver = new UserInputResolver(Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(), new Serializer($normalizers, $encoders)); + $this->resolver = new UserInputResolver(serializer: new Serializer($normalizers, $encoders), validator: $validator); } public function testSupports() @@ -63,7 +64,7 @@ public function provideInvalidValues(): \Generator yield 'Not normalizable' => ['{"randomText": ["Did", "You", "Expect", "That?"]}']; } - private function createMetadata(array $attributes = [new Input()]): ArgumentMetadata + private function createMetadata(?array $attributes = [new Input()]): ArgumentMetadata { $arguments = [ 'name' => 'foo', diff --git a/src/Symfony/Component/Validator/Exception/InputValidationFailedException.php b/src/Symfony/Component/Validator/Exception/InputValidationFailedException.php new file mode 100644 index 0000000000000..444a875211122 --- /dev/null +++ b/src/Symfony/Component/Validator/Exception/InputValidationFailedException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Validator\Exception; + +/** + * @author Gary PEGEOT + */ +class InputValidationFailedException extends ValidationFailedException +{ +} From 7f7500c6acc4a2b572d8f1c173f8f6b2cc57aed4 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 6 Mar 2022 19:52:24 +0100 Subject: [PATCH 09/13] fix: cs --- .../FrameworkBundle/DependencyInjection/FrameworkExtension.php | 2 -- .../Bundle/FrameworkBundle/Resources/config/serializer.php | 1 - 2 files changed, 3 deletions(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 2d2f06d9d910c..733341eeb2c7b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -183,10 +183,8 @@ use Symfony\Component\Security\Core\Exception\AuthenticationException; use Symfony\Component\Security\Core\Security; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; -use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver; use Symfony\Component\Serializer\Encoder\DecoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; -use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index d5dd5d24db6d1..a09fc7702532f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -14,7 +14,6 @@ use Psr\Cache\CacheItemPoolInterface; use Symfony\Bundle\FrameworkBundle\CacheWarmer\SerializerCacheWarmer; use Symfony\Component\Cache\Adapter\PhpArrayAdapter; -use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\ErrorHandler\ErrorRenderer\HtmlErrorRenderer; use Symfony\Component\ErrorHandler\ErrorRenderer\SerializerErrorRenderer; use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; From eae458f0e284eabba4eba2d0e4cae7dd2eff2fbc Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Sun, 6 Mar 2022 20:07:29 +0100 Subject: [PATCH 10/13] fix: tests --- .../InputValidationFailedExceptionListenerTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php index 48f9caa893e61..189fbffc9ff80 100644 --- a/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php +++ b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php @@ -14,7 +14,7 @@ use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Tests\Fixtures\DummyDto; use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Exception\InputValidationFailedException; class InputValidationFailedExceptionListenerTest extends TestCase { @@ -48,6 +48,6 @@ public function testExceptionHandling(\Throwable $e, ?string $expected) public function provideExceptions(): \Generator { yield 'Unrelated exception' => [new \Exception('Nothing to see here'), null]; - yield 'Validation exception' => [new ValidationFailedException(new DummyDto(), ConstraintViolationList::createFromMessage('This value should not be blank')), 'This value should not be blank']; + yield 'Validation exception' => [new InputValidationFailedException(new DummyDto(), ConstraintViolationList::createFromMessage('This value should not be blank')), 'This value should not be blank']; } } From 15236179bd292b34ba82c70359bfbff9efa55a91 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Mon, 7 Mar 2022 19:59:44 +0100 Subject: [PATCH 11/13] fix: content-type guessing --- .../Serializer/ArgumentResolver/UserInputResolver.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php index 1e515f9727349..09e0d217d1e65 100644 --- a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -54,10 +54,10 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable $context = array_merge($attribute->serializationContext, [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, ]); - $format = $attribute->format ?? $request->attributes->get('_format', 'json'); + $format = $attribute->format ?? $request->getContentType() ?? 'json'; try { - $input = $this->serializer->deserialize(data: $request->getContent(), type: $argument->getType(), format: $format, context: $context); + $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context); } catch (PartialDenormalizationException $e) { if (null === $this->validator) { throw new UnprocessableEntityHttpException(message: $e->getMessage(), previous: $e); From 5976a4bf8d2cfe172bec877392ae91a5d677cd2e Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Wed, 9 Mar 2022 18:31:38 +0100 Subject: [PATCH 12/13] refactor: rename Input to RequestBody, remove validator from UserInputResolver to split functionalities --- .../Bundle/FrameworkBundle/CHANGELOG.md | 1 - .../Component/Serializer/Annotation/Input.php | 28 ---------- .../Serializer/Annotation/RequestBody.php | 29 ++++++++++ .../ArgumentResolver/UserInputResolver.php | 53 +++---------------- src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../UserInputResolverTest.php | 26 +++------ 6 files changed, 45 insertions(+), 93 deletions(-) delete mode 100644 src/Symfony/Component/Serializer/Annotation/Input.php create mode 100644 src/Symfony/Component/Serializer/Annotation/RequestBody.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index d0aefe3a25c8a..bad0e2ad0e8fe 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -8,7 +8,6 @@ CHANGELOG * Load PHP configuration files by default in the `MicroKernelTrait` * Add `cache:pool:invalidate-tags` command * Add `xliff` support in addition to `xlf` for `XliffFileDumper` - * Add an ArgumentResolver to deserialize & validate user input 6.0 --- diff --git a/src/Symfony/Component/Serializer/Annotation/Input.php b/src/Symfony/Component/Serializer/Annotation/Input.php deleted file mode 100644 index 3e00b9d1df81d..0000000000000 --- a/src/Symfony/Component/Serializer/Annotation/Input.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Annotation; - -/** - * Indicates that this argument should be deserialized and (optionally) validated. - * - * @author Gary PEGEOT - */ -#[\Attribute(\Attribute::TARGET_PARAMETER)] -class Input -{ - public function __construct( - public readonly ?string $format = null, - public readonly array $serializationContext = [], - public readonly array $validationGroups = ['Default'] - ) { - } -} diff --git a/src/Symfony/Component/Serializer/Annotation/RequestBody.php b/src/Symfony/Component/Serializer/Annotation/RequestBody.php new file mode 100644 index 0000000000000..0691227a21a06 --- /dev/null +++ b/src/Symfony/Component/Serializer/Annotation/RequestBody.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Annotation; + +/** + * Indicates that this argument should be deserialized from request body. + * + * @author Gary PEGEOT + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class RequestBody +{ + /** + * @param string|null $format Will be guessed from request if empty, and default to JSON. + * @param array $context The serialization context (Useful to set groups / ignore fields). + */ + public function __construct(public readonly ?string $format = null, public readonly array $context = []) + { + } +} diff --git a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php index 09e0d217d1e65..ad1d6f681f18d 100644 --- a/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -14,26 +14,18 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException; -use Symfony\Component\Serializer\Annotation\Input; -use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Annotation\RequestBody; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Component\Validator\Exception\InputValidationFailedException; -use Symfony\Component\Validator\Validator\ValidatorInterface; /** - * Deserialize & validate user input. - * - * Works in duo with Symfony\Bundle\FrameworkBundle\EventListener\InputValidationFailedExceptionListener. + * Deserialize request body if Symfony\Component\Serializer\Annotation\RequestBody attribute is present on an argument. * * @author Gary PEGEOT */ class UserInputResolver implements ArgumentValueResolverInterface { - public function __construct(private SerializerInterface $serializer, private ?ValidatorInterface $validator = null) + public function __construct(private SerializerInterface $serializer) { } @@ -51,47 +43,16 @@ public function supports(Request $request, ArgumentMetadata $argument): bool public function resolve(Request $request, ArgumentMetadata $argument): iterable { $attribute = $this->getAttribute($argument); - $context = array_merge($attribute->serializationContext, [ + $context = array_merge($attribute->context, [ DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, ]); $format = $attribute->format ?? $request->getContentType() ?? 'json'; - try { - $input = $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context); - } catch (PartialDenormalizationException $e) { - if (null === $this->validator) { - throw new UnprocessableEntityHttpException(message: $e->getMessage(), previous: $e); - } - - $errors = new ConstraintViolationList(); - - foreach ($e->getErrors() as $exception) { - $message = sprintf('The type must be one of "%s" ("%s" given).', implode(', ', $exception->getExpectedTypes()), $exception->getCurrentType()); - $parameters = []; - - if ($exception->canUseMessageForUser()) { - $parameters['hint'] = $exception->getMessage(); - } - - $errors->add(new ConstraintViolation($message, '', $parameters, null, $exception->getPath(), null)); - } - - throw new InputValidationFailedException(null, $errors); - } - - if ($this->validator) { - $errors = $this->validator->validate(value: $input, groups: $attribute->validationGroups); - - if ($errors->count() > 0) { - throw new InputValidationFailedException($input, $errors); - } - } - - yield $input; + yield $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context); } - private function getAttribute(ArgumentMetadata $argument): ?Input + private function getAttribute(ArgumentMetadata $argument): ?RequestBody { - return $argument->getAttributes(Input::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; + return $argument->getAttributes(RequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; } } diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 28b194edb8fca..103ce4c56f2ac 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG * Deprecate `ContextAwareDecoderInterface`, use `DecoderInterface` instead * Deprecate supporting denormalization for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead * Deprecate denormalizing to an abstract class in `UidNormalizer` + * Add an ArgumentResolver to deserialize arguments with `Symfony\Component\Serializer\Annotation\RequestBody` attribute 6.0 --- diff --git a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php index 57f961e3a4fc6..d811f5db01e30 100644 --- a/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php +++ b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php @@ -5,9 +5,10 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; -use Symfony\Component\Serializer\Annotation\Input; +use Symfony\Component\Serializer\Annotation\RequestBody; use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Tests\Fixtures\DummyDto; @@ -22,9 +23,8 @@ protected function setUp(): void { $encoders = [new JsonEncoder()]; $normalizers = [new ObjectNormalizer()]; - $validator = Validation::createValidatorBuilder()->enableAnnotationMapping()->getValidator(); - $this->resolver = new UserInputResolver(serializer: new Serializer($normalizers, $encoders), validator: $validator); + $this->resolver = new UserInputResolver(new Serializer($normalizers, $encoders)); } public function testSupports() @@ -46,25 +46,15 @@ public function testResolveWithValidValue() $this->assertSame('Lorem ipsum', $resolved[0]->randomText); } - /** - * @dataProvider provideInvalidValues - */ - public function testResolveWithInvalidValue(string $content, array $groups = ['Default']) + public function testResolveWithInvalidValue() { - $this->expectException(ValidationFailedException::class); - $request = new Request(content: $content); + $this->expectException(PartialDenormalizationException::class); + $request = new Request(content: '{"randomText": ["Did", "You", "Expect", "That?"]}'); - iterator_to_array($this->resolver->resolve($request, $this->createMetadata([new Input(validationGroups: $groups)]))); + iterator_to_array($this->resolver->resolve($request, $this->createMetadata())); } - public function provideInvalidValues(): \Generator - { - yield 'Invalid value' => ['{"itMustBeTrue": false}']; - yield 'Invalid value with groups' => ['{"randomText": "Valid"}', ['Default', 'Foo']]; - yield 'Not normalizable' => ['{"randomText": ["Did", "You", "Expect", "That?"]}']; - } - - private function createMetadata(?array $attributes = [new Input()]): ArgumentMetadata + private function createMetadata(?array $attributes = [new RequestBody()]): ArgumentMetadata { $arguments = [ 'name' => 'foo', From ebeee989ca4d6e19326adf9a8bbad2efe0335545 Mon Sep 17 00:00:00 2001 From: Gary PEGEOT Date: Wed, 9 Mar 2022 18:33:17 +0100 Subject: [PATCH 13/13] fix: remove useless argument --- .../Bundle/FrameworkBundle/Resources/config/serializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index a09fc7702532f..554f7483b1562 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -72,7 +72,7 @@ // Argument Resolvers ->set(UserInputResolver::class) - ->args([service('serializer'), service('validator')->nullOnInvalid()]) + ->args([service('serializer')]) ->tag('controller.argument_value_resolver') // Event Listeners