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
fix: switch from Marker Interface to Argument
  • Loading branch information
GaryPEGEOT committed Mar 9, 2022
commit 4bb22358f0d18767ce12728f918ed840e6436690

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down

This file was deleted.

This file was deleted.

31 changes: 31 additions & 0 deletions src/Symfony/Component/Serializer/Annotation/Input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

namespace Symfony\Component\Serializer\Annotation;

/**
* Indicates that this argument should be deserialized and (optionally) validated.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
#[\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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<?php

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\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\Validator\ValidatorInterface;

/**
* Deserialize & validate user input.
*
* Works in duo with Symfony\Bundle\FrameworkBundle\EventListener\InputValidationFailedExceptionListener.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
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,
Copy link
Contributor

Choose a reason for hiding this comment

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

What about adding AbstractObjectNormalizer::ALLOW_EXTRA_ATTRIBUTES => false?

]);
$format = $attribute->getFormat() ?? $request->attributes->get('_format', 'json');

$input A935 = 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->getValidationGroups());
} catch (PartialDenormalizationException $e) {
$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.

$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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

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\ValidationFailedException;

/**
* Works in duo with Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
class InputValidationFailedExceptionListener
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 this listener is really necessary to return the proper response (content + status code). Instead, you can go through the ErrorRenderer system by directly adding a custom Serializer normalizer. The Framework already knows how to handle exceptions according to the request format (see SearializerErrorRenderer).

{
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');
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) {
Copy link
Member

Choose a reason for hiding this comment

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

This change is potentially dangerous. Existing application might already use this exception and your listener will add unexpected sematics to it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Fixed! Validation is now optional as well

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()]);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Not super sure what to log here, as violations might contain sensitive information?

Copy link
Member

Choose a reason for hiding this comment

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

IMHO, the reasons are not really useful in the log


$event->setResponse($response);
Copy link
Contributor

Choose a reason for hiding this comment

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

What about stopping propagation as this listener is only here to create the HTTP response?

Copy link
Member

Choose a reason for hiding this comment

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

}
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
<?php

namespace Symfony\Bundle\FrameworkBundle\Tests\ArgumentResolver;
namespace Symfony\Component\Serializer\Tests\ArgumentResolver;

use PHPUnit\Framework\TestCase;
use Symfony\Bundle\FrameworkBundle\ArgumentResolver\UserInputResolver;
use Symfony\Bundle\FrameworkBundle\Exception\UnparsableInputException;
use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Validation\Category;
use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Validation\DummyDto;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;
use Symfony\Component\Serializer\Annotation\Input;
use Symfony\Component\Serializer\ArgumentResolver\UserInputResolver;
use Symfony\Component\Serializer\Encoder\JsonEncoder;
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Tests\Fixtures\DummyDto;
use Symfony\Component\Validator\Exception\ValidationFailedException;
use Symfony\Component\Validator\Validation;

Expand All @@ -31,7 +30,7 @@ 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');
$this->assertFalse($this->resolver->supports(new Request(), $this->createMetadata([])), 'Should not be supported');
}

public function testResolveWithValidValue(): void
Expand All @@ -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,
1CF5 'type' => DummyDto::class,
'attributes' => $attributes,
];

return new ArgumentMetadata(...$arguments);
Expand Down
Loading
381D
0