8000 [FrameworkBundle][Serializer] Add an ArgumentResolver to deserialize & validate user input by GaryPEGEOT · Pull Request #45628 · symfony/symfony · GitHub
[go: up one dir, main page]

Skip to content

[FrameworkBundle][Serializer] Add an ArgumentResolver to deserialize & validate user input #45628

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 13 commits into from
Closed
Prev Previous commit
Next Next commit
feat: make validation optional
  • Loading branch information
GaryPEGEOT committed Mar 9, 2022
commit 406f1eb48cc26a1af976a83397769f47f2777464
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions src/Symfony/Bundle/FrameworkBundle/Resources/config/serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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')])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\Ex 8000 ception\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;

/**
Expand All @@ -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)
{
}

Expand All @@ -55,12 +56,13 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable
]);
$format = $attribute->format ?? $request->attributes->get('_format', 'json');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use $request->getPreferredFormat('json') instead (it will take into account the acceptable content type when provided)

Copy link
Member
@derrabus derrabus Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither is correct, imho. getPreferredFormat() as well as the _format attribute hint at the format of the response. What we need here is the format of the request payload. This can be inferred from the Content-Type header of the request.

That being said, the developer should be able to limit the set of possible formats (and content types?) via the attribute. Maybe we should even default to JSON-only, so nobody enables CSV deserialization by accident. 😓

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, then we should use $request->getContentType() instead.

[Off topic] Btw, the name of this method is not exactly what you'd expect here, it returns json, xml, etc. even if that's what we want here it seems like the expected result (according to the method name) is application/json, etc. maybe this method should be renamed to getContentTypeFormat() or something like this, but that's another topic :)


$input = null;
try {
$input = $this->serializer->deserialize(data: $request->getContent(), type: $argument->getType(), format: $format, context: $context);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use ordered arguments.


$errors = $this->validator->validate(value: $input, groups: $attribute->validationGroups);
} catch (PartialDenormalizationException $e) {
if (null === $this->validator) {
throw new UnprocessableEntityHttpException(message: $e->getMessage(), previous: $e);
Copy link
Member
@yceruto yceruto Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will show an empty message which is not useful. However, we can still normalize the PartialDenormalizationException to show all errors. I mean, it shouldn't depend exclusively on the Validator ConstraintViolationList as it's optional.

}

$errors = new ConstraintViolationList();

foreach ($e->getErrors() as $exception) {
Copy link
Member
@yceruto yceruto Mar 7, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think it's the argument resolver's responsibility to normalize these errors, but the Serializer normalizers instead.

In order not to depend on the Validator component (as I've pointed out in another comment) you can add a new PartialDenormalizationProblemNormalizer just to handle this kind of exception.

Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -33,7 +33,7 @@ public function __invoke(ExceptionEvent $event): void
$throwable = $event->getThrowable();
$format = $event->getRequest()->attributes->get('_format', 'json');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better use getPreferredFormat that handles the content negotiation part:

Suggested change
$format = $event->getRequest()->attributes->get('_format', 'json');
$format = $event->getPreferredFormat('json');


if (!$throwable instanceof ValidationFailedException) {
if (!$throwable instanceof InputValidationFailedException) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Who throw this exception?

return;
}

Expand Down
17A7
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <garypegeot@gmail.com>
*/
class InputValidationFailedException extends ValidationFailedException
{
}
0