-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[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
Changes from all commits
da5e8a4
3012614
6000089
4bb2235
642c7d0
2368c7f
5905ae0
406f1eb
7f7500c
eae458f
1523617
5976a4b
ebeee98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
#[\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 |
---|---|---|
@@ -0,0 +1,58 @@ | ||
<?php | ||
|
||
/* | ||
* This file is part of the Symfony package. | ||
* | ||
* (c) Fabien Potencier <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we just use |
||
|
||
yield $this->serializer->deserialize($request->getContent(), $argument->getType(), $format, $context); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
|
||
} | ||
|
||
private function getAttribute(ArgumentMetadata $argument): ?RequestBody | ||
{ | ||
return $argument->getAttributes(RequestBody::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,45 @@ | ||||||
<?php | ||||||
|
||||||
/* | ||||||
* This file is part of the Symfony package. | ||||||
* | ||||||
* (c) Fabien Potencier <[email protected]> | ||||||
* | ||||||
* 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 <[email protected]> | ||||||
*/ | ||||||
class InputValidationFailedExceptionListener | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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'); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better use
Suggested change
|
||||||
|
||||||
if (!$throwable instanceof InputValidationFailedException) { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Who throw this exception? |
||||||
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()]); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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); | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||
} | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
<?php | ||
|
||
namespace Symfony\Component\Serializer\Tests\ArgumentResolver; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; | ||
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; | ||
use Symfony\Component\Validator\Exception\ValidationFailedException; | ||
use Symfony\Component\Validator\Validation; | ||
|
||
class UserInputResolverTest extends TestCase | ||
{ | ||
private UserInputResolver $resolver; | ||
|
||
protected function setUp(): void | ||
{ | ||
$encoders = [new JsonEncoder()]; | ||
$normalizers = [new ObjectNormalizer()]; | ||
|
||
$this->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); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
<?php | ||
|
||
namespace Symfony\Component\Serializer\Tests\EventListener; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use Psr\Log\NullLogger; | ||
use Symfony\Component\HttpFoundation\Request; | ||
use Symfony\Component\HttpKernel\Event\ExceptionEvent; | ||
use Symfony\Component\HttpKernel\HttpKernelInterface; | ||
use Symfony\Component\Serializer\Encoder\JsonEncoder; | ||
use Symfony\Component\Serializer\EventListener\InputValidationFailedExceptionListener; | ||
use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer; | ||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; | ||
use Symfony\Component\Serializer\Serializer; | ||
use Symfony\Component\Serializer\Tests\Fixtures\DummyDto; | ||
use Symfony\Component\Validator\ConstraintViolationList; | ||
use Symfony\Component\Validator\Exception\InputValidationFailedException; | ||
|
||
class InputValidationFailedExceptionListenerTest extends TestCase | ||
{ | ||
private Serializer $serializer; | ||
|
||
protected function setUp(): void | ||
{ | ||
$encoders = [new JsonEncoder()]; | ||
$normalizers = [new ConstraintViolationListNormalizer(), new ObjectNormalizer()]; | ||
$this->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']; | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
<?php | ||
|
||
namespace Symfony\Component\Serializer\Tests\Fixtures; | ||
|
||
use Symfony\Component\Validator\Constraints as Assert; | ||
|
||
class DummyDto | ||
{ | ||
#[Assert\NotBlank(groups: ['Foo'])] | ||
public ?string $propWithValidationGroups = null; | ||
|
||
#[Assert\NotBlank()] | ||
public ?string $randomText = null; | ||
|
||
#[Assert\IsTrue()] | ||
public bool $itMustBeTrue = true; | ||
} |
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 <[email protected]> | ||
* | ||
* 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 <[email protected]> | ||
*/ | ||
class InputValidationFailedException extends ValidationFailedException | ||
{ | ||
} |
There was a problem hiding this comment.
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
?