diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php index 5655c05a26e35..554f7483b1562 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php @@ -17,12 +17,14 @@ 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 +70,17 @@ ->alias('serializer.property_accessor', 'property_accessor') + // Argument Resolvers + ->set(UserInputResolver::class) + ->args([service('serializer')]) + ->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/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 new file mode 100644 index 0000000000000..ad1d6f681f18d --- /dev/null +++ b/src/Symfony/Component/Serializer/ArgumentResolver/UserInputResolver.php @@ -0,0 +1,58 @@ + + * + * 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; +use Symfony\Component\HttpKernel\Controller\ArgumentValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\Serializer\Annotation\RequestBody; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * 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) + { + } + + /** + * {@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->context, [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + $format = $attribute->format ?? $request->getContentType() ?? 'json'; + + yield $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context); + } + + private function getAttribute(ArgumentMetadata $argument): ?RequestBody + { + 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/EventListener/InputValidationFailedExceptionListener.php b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php new file mode 100644 index 0000000000000..97065775844d1 --- /dev/null +++ b/src/Symfony/Component/Serializer/EventListener/InputValidationFailedExceptionListener.php @@ -0,0 +1,45 @@ + + * + * 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; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ExceptionEvent; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\Exception\InputValidationFailedException; + +/** + * Works in duo with Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver. + * + * @author Gary PEGEOT + */ +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 InputValidationFailedException) { + 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/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php new file mode 100644 index 0000000000000..d811f5db01e30 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/ArgumentResolver/UserInputResolverTest.php @@ -0,0 +1,70 @@ +resolver = new UserInputResolver(new Serializer($normalizers, $encoders)); + } + + 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() + { + $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); + } + + public function testResolveWithInvalidValue() + { + $this->expectException(PartialDenormalizationException::class); + $request = new Request(content: '{"randomText": ["Did", "You", "Expect", "That?"]}'); + + iterator_to_array($this->resolver->resolve($request, $this->createMetadata())); + } + + private function createMetadata(?array $attributes = [new RequestBody()]): ArgumentMetadata + { + $arguments = [ + 'name' => 'foo', + 'isVariadic' => false, + 'hasDefaultValue' => false, + 'defaultValue' => null, + 'type' => DummyDto::class, + 'attributes' => $attributes, + ]; + + return new ArgumentMetadata(...$arguments); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php new file mode 100644 index 0000000000000..189fbffc9ff80 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/EventListener/InputValidationFailedExceptionListenerTest.php @@ -0,0 +1,53 @@ +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($expected, $event->getResponse()->getContent()); + } + } + + public function provideExceptions(): \Generator + { + yield 'Unrelated exception' => [new \Exception('Nothing to see here'), null]; + yield 'Validation exception' => [new InputValidationFailedException(new DummyDto(), ConstraintViolationList::createFromMessage('This value should not be blank')), 'This value should not be blank']; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php new file mode 100644 index 0000000000000..71973efd938e2 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/DummyDto.php @@ -0,0 +1,17 @@ + + * + * 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 +{ +} 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