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 +{ +}