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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy