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
refactor: rename Input to RequestBody, remove validator from UserInpu…
…tResolver to split functionalities
  • Loading branch information
GaryPEGEOT committed Mar 9, 2022
commit 5976a4bf8d2cfe172bec877392ae91a5d677cd2e
1 change: 0 additions & 1 deletion src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
28 changes: 0 additions & 28 deletions src/Symfony/Component/Serializer/Annotation/Input.php

This file was deleted.

29 changes: 29 additions & 0 deletions src/Symfony/Component/Serializer/Annotation/RequestBody.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?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\Serializer\Annotation;

/**
* Indicates that this argument should be deserialized from request body.
*
* @author Gary PEGEOT <garypegeot@gmail.com>
*/
#[\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 = [])
{
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <garypegeot@gmail.com>
*/
class UserInputResolver implements ArgumentValueResolverInterface
{
public function __construct(private SerializerInterface $serializer, private ?ValidatorInterface $validator = null)
public function __construct(private SerializerInterface $serializer)
{
}

Expand All @@ -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,
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->format ?? $request->getContentType() ?? 'json';
Copy link
Member

Choose a reason for hiding this comment

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

For security reasons, I suggest to throw an exception if the format isn't provided in the Content-Type header and if the excepted format (the format explicitly passed as parameter by the user) doesn't match the value of Content-Type.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could we just use $request->toArray()? Or it may be too restrictive in terms of format?


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

Choose a reason for hiding this comment

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

It would be great to resolve input not only from request content but from query/request parameters

Something like this:

if ($attribute->isFromQueryString()) {
    $input = $this->denormalizer->denormalize($request->query->all(), $argument->getType());
} else {
    if ($request->getContentType() === 'form') {
        $input = $this->denormalizer->denormalize($request->request->all(), $argument->getType());
    } else {
        $input = $this->serializer->deserialize($request->getContent(), $type, $format);
    }
}

}

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;
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Serializer/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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()
Expand All @@ -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',
Expand Down
0