From d987093a538c70fc4a2d01b046913a2820341699 Mon Sep 17 00:00:00 2001 From: Konstantin Myakshin Date: Sat, 28 Jan 2023 11:37:55 +0200 Subject: [PATCH] [HttpKernel] Create Attributes `#[MapRequestPayload]` and `#[MapQueryString]` to map Request input to typed objects --- .../Compiler/UnusedTagsPass.php | 1 + .../FrameworkExtension.php | 11 +- .../FrameworkBundle/Resources/config/web.php | 8 + .../Tests/Functional/ApiAttributesTest.php | 416 ++++++++++++++++++ .../app/ApiAttributesTest/bundles.php | 16 + .../app/ApiAttributesTest/config.yml | 8 + .../app/ApiAttributesTest/routing.yml | 7 + .../Bundle/FrameworkBundle/composer.json | 4 +- .../HttpKernel/Attribute/MapQueryString.php | 30 ++ .../Attribute/MapRequestPayload.php | 30 ++ src/Symfony/Component/HttpKernel/CHANGELOG.md | 2 + .../RequestPayloadValueResolver.php | 127 ++++++ .../RequestPayloadValueResolverTest.php | 167 +++++++ .../Component/HttpKernel/composer.json | 5 +- src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Exception/UnsupportedFormatException.php | 19 + .../Component/Serializer/Serializer.php | 6 +- 17 files changed, 851 insertions(+), 7 deletions(-) create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/bundles.php create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml create mode 100644 src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml create mode 100644 src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php create mode 100644 src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php create mode 100644 src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php create mode 100644 src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php create mode 100644 src/Symfony/Component/Serializer/Exception/UnsupportedFormatException.php diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 7fa0fb289005a..9da5b91bb3bb0 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -46,6 +46,7 @@ class UnusedTagsPass implements CompilerPassInterface 'container.stack', 'controller.argument_value_resolver', 'controller.service_arguments', + 'controller.targeted_value_resolver', 'data_collector', 'event_dispatcher.dispatcher', 'form.type', diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 716d648454435..db536027e86c6 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -157,6 +157,7 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; +use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Stopwatch\Stopwatch; use Symfony\Component\String\LazyString; @@ -357,11 +358,19 @@ public function load(array $configs, ContainerBuilder $container) $container->getDefinition('exception_listener')->replaceArgument(3, $config['exceptions']); if ($this->readConfigEnabled('serializer', $container, $config['serializer'])) { - if (!class_exists(\Symfony\Component\Serializer\Serializer::class)) { + if (!class_exists(Serializer::class)) { throw new LogicException('Serializer support cannot be enabled as the Serializer component is not installed. Try running "composer require symfony/serializer-pack".'); } $this->registerSerializerConfiguration($config['serializer'], $container, $loader); + } else { + $container->register('.argument_resolver.request_payload.no_serializer', Serializer::class) + ->addError('You can neither use "#[MapRequestPayload]" nor "#[MapQueryString]" since the Serializer component is not ' + .(class_exists(Serializer::class) ? 'enabled. Try setting "framework.serializer" to true.' : 'installed. Try running "composer require symfony/serializer-pack".') + ); + + $container->getDefinition('argument_resolver.request_payload') + ->replaceArgument(0, new Reference('.argument_resolver.request_payload.no_serializer', ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE)); } if ($propertyInfoEnabled) { diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php index 8c6b5a9ba966d..1a41a60fe1ddd 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/web.php @@ -17,6 +17,7 @@ use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DateTimeValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\DefaultValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestAttributeValueResolver; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\ServiceValueResolver; use Symfony\Component\HttpKernel\Controller\ArgumentResolver\SessionValueResolver; @@ -61,6 +62,13 @@ ]) ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => DateTimeValueResolver::class]) + ->set('argument_resolver.request_payload', RequestPayloadValueResolver::class) + ->args([ + service('serializer'), + service('validator')->nullOnInvalid(), + ]) + ->tag('controller.targeted_value_resolver', ['name' => RequestPayloadValueResolver::class]) + ->set('argument_resolver.request_attribute', RequestAttributeValueResolver::class) ->tag('controller.argument_value_resolver', ['priority' => 100, 'name' => RequestAttributeValueResolver::class]) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php new file mode 100644 index 0000000000000..207edcf7aa707 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ApiAttributesTest.php @@ -0,0 +1,416 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\Tests\Functional; + +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\Validator\Constraints as Assert; + +class ApiAttributesTest extends AbstractWebTestCase +{ + /** + * @dataProvider mapQueryStringProvider + */ + public function testMapQueryString(array $query, string $expectedResponse, int $expectedStatusCode) + { + $client = self::createClient(['test_case' => 'ApiAttributesTest']); + + $client->request('GET', '/map-query-string.json', $query); + + $response = $client->getResponse(); + if ($expectedResponse) { + self::assertJsonStringEqualsJsonString($expectedResponse, $response->getContent()); + } else { + self::assertEmpty($response->getContent()); + } + self::assertSame($expectedStatusCode, $response->getStatusCode()); + } + + public static function mapQueryStringProvider(): iterable + { + yield 'empty' => [ + 'query' => [], + 'expectedResponse' => '', + 'expectedStatusCode' => 204, + ]; + + yield 'valid' => [ + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '4']], + 'expectedResponse' => <<<'JSON' + { + "filter": { + "status": "approved", + "quantity": 4 + } + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid' => [ + 'query' => ['filter' => ['status' => 'approved', 'quantity' => '200']], + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 404, + "detail": "filter.quantity: This value should be less than 10.", + "violations": [ + { + "propertyPath": "filter.quantity", + "title": "This value should be less than 10.", + "template": "This value should be less than {{ compared_value }}.", + "parameters": { + "{{ value }}": "200", + "{{ compared_value }}": "10", + "{{ compared_value_type }}": "int" + }, + "type": "urn:uuid:079d7420-2d13-460c-8756-de810eeb37d2" + } + ] + } + JSON, + 'expectedStatusCode' => 404, + ]; + } + + /** + * @dataProvider mapRequestPayloadProvider + */ + public function testMapRequestPayload(string $format, array $parameters, ?string $content, string $expectedResponse, int $expectedStatusCode) + { + $client = self::createClient(['test_case' => 'ApiAttributesTest']); + + [$acceptHeader, $assertion] = [ + 'html' => ['text/html', self::assertStringContainsString(...)], + 'json' => ['application/json', self::assertJsonStringEqualsJsonString(...)], + 'xml' => ['text/xml', self::assertXmlStringEqualsXmlString(...)], + 'dummy' => ['application/dummy', self::assertStringContainsString(...)], + ][$format]; + + $client->request( + 'POST', + '/map-request-body.'.$format, + $parameters, + [], + ['HTTP_ACCEPT' => $acceptHeader, 'CONTENT_TYPE' => $acceptHeader], + $content + ); + + $response = $client->getResponse(); + $responseContent = $response->getContent(); + + if ($expectedResponse) { + $assertion($expectedResponse, $responseContent); + } else { + self::assertSame('', $responseContent); + } + + self::assertSame($expectedStatusCode, $response->getStatusCode()); + } + + public static function mapRequestPayloadProvider(): iterable + { + yield 'empty' => [ + 'format' => 'json', + 'parameters' => [], + 'content' => '', + 'expectedResponse' => '', + 'expectedStatusCode' => 204, + ]; + + yield 'valid json' => [ + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'malformed json' => [ + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false, + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/tools.ietf.org\/html\/rfc2616#section-10", + "title": "An error occurred", + "status": 400, + "detail": "Bad Request" + } + JSON, + 'expectedStatusCode' => 400, + ]; + + yield 'unsupported format' => [ + 'format' => 'dummy', + 'parameters' => [], + 'content' => 'Hello', + 'expectedResponse' => '415 Unsupported Media Type', + 'expectedStatusCode' => 415, + ]; + + yield 'valid xml' => [ + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + Hello everyone! + true + + XML, + 'expectedResponse' => <<<'XML' + + Hello everyone! + 1 + + XML, + 'expectedStatusCode' => 200, + ]; + + yield 'invalid type' => [ + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": "string instead of bool" + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "approved: This value should be of type bool.", + "violations": [ + { + "propertyPath": "approved", + "title": "This value should be of type bool.", + "template": "This value should be of type {{ type }}.", + "parameters": { + "{{ type }}": "bool" + } + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + + yield 'validation error json' => [ + 'format' => 'json', + 'parameters' => [], + 'content' => <<<'JSON' + { + "comment": "", + "approved": true + } + JSON, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "comment: This value should not be blank.\ncomment: This value is too short. It should have 10 characters or more.", + "violations": [ + { + "propertyPath": "comment", + "title": "This value should not be blank.", + "template": "This value should not be blank.", + "parameters": { + "{{ value }}": "\"\"" + }, + "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3" + }, + { + "propertyPath": "comment", + "title": "This value is too short. It should have 10 characters or more.", + "template": "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", + "parameters": { + "{{ value }}": "\"\"", + "{{ limit }}": "10" + }, + "type": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + + yield 'validation error xml' => [ + 'format' => 'xml', + 'parameters' => [], + 'content' => <<<'XML' + + H + false + + XML, + 'expectedResponse' => <<<'XML' + + + https://symfony.com/errors/validation + Validation Failed + 422 + comment: This value is too short. It should have 10 characters or more. + + comment + This value is too short. It should have 10 characters or more. + + + "H" + 10 + + urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45 + + + XML, + 'expectedStatusCode' => 422, + ]; + + yield 'valid input' => [ + 'format' => 'json', + 'input' => ['comment' => 'Hello everyone!', 'approved' => '0'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "comment": "Hello everyone!", + "approved": false + } + JSON, + 'expectedStatusCode' => 200, + ]; + + yield 'validation error input' => [ + 'format' => 'json', + 'input' => ['comment' => '', 'approved' => '1'], + 'content' => null, + 'expectedResponse' => <<<'JSON' + { + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "status": 422, + "detail": "comment: This value should not be blank.\ncomment: This value is too short. It should have 10 characters or more.", + "violations": [ + { + "propertyPath": "comment", + "title": "This value should not be blank.", + "template": "This value should not be blank.", + "parameters": { + "{{ value }}": "\"\"" + }, + "type": "urn:uuid:c1051bb4-d103-4f74-8988-acbcafc7fdc3" + }, + { + "propertyPath": "comment", + "title": "This value is too short. It should have 10 characters or more.", + "template": "This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} characters or more.", + "parameters": { + "{{ value }}": "\"\"", + "{{ limit }}": "10" + }, + "type": "urn:uuid:9ff3fdc4-b214-49db-8718-39c315e33d45" + } + ] + } + JSON, + 'expectedStatusCode' => 422, + ]; + } +} + +class WithMapQueryStringController +{ + public function __invoke(#[MapQueryString] ?QueryString $query): Response + { + if (!$query) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + return new JsonResponse( + ['filter' => ['status' => $query->filter->status, 'quantity' => $query->filter->quantity]], + ); + } +} + +class WithMapRequestPayloadController +{ + public function __invoke(#[MapRequestPayload] ?RequestBody $body, Request $request): Response + { + if ('json' === $request->getPreferredFormat('json')) { + if (!$body) { + return new Response('', Response::HTTP_NO_CONTENT); + } + + return new JsonResponse(['comment' => $body->comment, 'approved' => $body->approved]); + } + + return new Response( + << + {$body->comment} + {$body->approved} + + XML + ); + } +} + +class QueryString +{ + public function __construct( + #[Assert\Valid] + public readonly Filter $filter, + ) { + } +} + +class Filter +{ + public function __construct( + public readonly string $status, + #[Assert\LessThan(10)] + public readonly int $quantity, + ) { + } +} + +class RequestBody +{ + public function __construct( + #[Assert\NotBlank] + #[Assert\Length(min: 10)] + public readonly string $comment, + public readonly bool $approved, + ) { + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/bundles.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/bundles.php new file mode 100644 index 0000000000000..13ab9fddee4a6 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/bundles.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; + +return [ + new FrameworkBundle(), +]; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml new file mode 100644 index 0000000000000..8b218d48cbb06 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/config.yml @@ -0,0 +1,8 @@ +imports: + - { resource: ../config/default.yml } + +framework: + serializer: + enabled: true + validation: true + property_info: { enabled: true } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml new file mode 100644 index 0000000000000..9ec40e1708c2b --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ApiAttributesTest/routing.yml @@ -0,0 +1,7 @@ +map_query_string: + path: /map-query-string.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapQueryStringController + +map_request_body: + path: /map-request-body.{_format} + controller: Symfony\Bundle\FrameworkBundle\Tests\Functional\WithMapRequestPayloadController diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 795e8bca67153..a7e31039452dc 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -57,7 +57,7 @@ "symfony/scheduler": "^6.3", "symfony/security-bundle": "^5.4|^6.0", "symfony/semaphore": "^5.4|^6.0", - "symfony/serializer": "^6.1", + "symfony/serializer": "^6.3", "symfony/stopwatch": "^5.4|^6.0", "symfony/string": "^5.4|^6.0", "symfony/translation": "^6.2.8", @@ -90,7 +90,7 @@ "symfony/mime": "<6.2", "symfony/property-info": "<5.4", "symfony/property-access": "<5.4", - "symfony/serializer": "<6.1", + "symfony/serializer": "<6.3", "symfony/security-csrf": "<5.4", "symfony/security-core": "<5.4", "symfony/stopwatch": "<5.4", diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php new file mode 100644 index 0000000000000..411937a4f04fc --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; + +/** + * Controller parameter tag to map the query string of the request to typed object and validate it. + * + * @author Konstantin Myakshin + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapQueryString extends ValueResolver +{ + public function __construct( + public readonly array $context = [], + string $resolver = RequestPayloadValueResolver::class, + ) { + parent::__construct($resolver); + } +} diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php new file mode 100644 index 0000000000000..1fdb9d67db5d4 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Attribute; + +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; + +/** + * Controller parameter tag to map the request content to typed object and validate it. + * + * @author Konstantin Myakshin + */ +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class MapRequestPayload extends ValueResolver +{ + public function __construct( + public readonly array $context = [], + string $resolver = RequestPayloadValueResolver::class, + ) { + parent::__construct($resolver); + } +} diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index b3fbb240d4a65..22b0a0d52bd1c 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -11,6 +11,8 @@ CHANGELOG * Add `#[WithLogLevel]` for defining log levels for exceptions * Add `skip_response_headers` to the `HttpCache` options * Introduce targeted value resolvers with `#[ValueResolver]` and `#[AsTargetedValueResolver]` + * Add `#[MapRequestPayload]` to map and validate request payload from `Request::getContent()` or `Request::$request->all()` to typed objects + * Add `#[MapQueryString]` to map and validate request query string from `Request::$query->all()` to typed objects 6.2 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php new file mode 100644 index 0000000000000..d35b2c750420a --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Controller\ArgumentResolver; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Controller\ValueResolverInterface; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +/** + * @author Konstantin Myakshin + */ +final class RequestPayloadValueResolver implements ValueResolverInterface +{ + /** + * @see \Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT + * @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS + */ + private const CONTEXT_DENORMALIZE = [ + 'disable_type_enforcement' => true, + 'collect_denormalization_errors' => true, + ]; + + /** + * @see DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS + */ + private const CONTEXT_DESERIALIZE = [ + 'collect_denormalization_errors' => true, + ]; + + public function __construct( + private readonly SerializerInterface&DenormalizerInterface $serializer, + private readonly ?ValidatorInterface $validator, + ) { + } + + public function resolve(Request $request, ArgumentMetadata $argument): iterable + { + $payloadMappers = [ + MapQueryString::class => ['mapQueryString', Response::HTTP_NOT_FOUND], + MapRequestPayload::class => ['mapRequestPayload', Response::HTTP_UNPROCESSABLE_ENTITY], + ]; + + foreach ($payloadMappers as $mappingAttribute => [$payloadMapper, $validationFailedCode]) { + if (!$attributes = $argument->getAttributesOfType($mappingAttribute, ArgumentMetadata::IS_INSTANCEOF)) { + continue; + } + + if (!$type = $argument->getType()) { + throw new \LogicException(sprintf('Could not resolve the "$%s" controller argument: argument should be typed.', $argument->getName())); + } + + try { + $payload = $this->$payloadMapper($request, $type, $attributes[0]); + } catch (PartialDenormalizationException $e) { + throw new HttpException($validationFailedCode, implode("\n", array_map(static fn (NotNormalizableValueException $e) => $e->getMessage(), $e->getErrors())), $e); + } + + if (null !== $payload && \count($violations = $this->validator?->validate($payload) ?? [])) { + throw new HttpException($validationFailedCode, implode("\n", array_map(static fn (ConstraintViolationInterface $e) => $e->getMessage(), iterator_to_array($violations))), new ValidationFailedException($payload, $violations)); + } + + if (null !== $payload || $argument->isNullable()) { + return [$payload]; + } + } + + return []; + } + + private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object + { + if (!$data = $request->query->all()) { + return null; + } + + return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->context); + } + + private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): ?object + { + if ($data = $request->request->all()) { + return $this->serializer->denormalize($data, $type, null, self::CONTEXT_DENORMALIZE + $attribute->context); + } + + if ('' === $data = $request->getContent()) { + return null; + } + + if (null === $format = $request->getContentTypeFormat()) { + throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, 'Unsupported format.'); + } + + if ('form' === $format) { + throw new HttpException(Response::HTTP_BAD_REQUEST, 'Request payload contains invalid "form" data.'); + } + + try { + return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->context); + } catch (UnsupportedFormatException $e) { + throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e); + } catch (NotEncodableValueException $e) { + throw new HttpException(Response::HTTP_BAD_REQUEST, sprintf('Request payload contains invalid "%s" data.', $format), $e); + } + } +} diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php new file mode 100644 index 0000000000000..07da5cbe647d7 --- /dev/null +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpKernel\Tests\Controller\ArgumentResolver; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; +use Symfony\Component\HttpKernel\Attribute\MapRequestPayload; +use Symfony\Component\HttpKernel\Controller\ArgumentResolver\RequestPayloadValueResolver; +use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Component\Validator\Validator\ValidatorInterface; + +class RequestPayloadValueResolverTest extends TestCase +{ + public function testNotTypedArgument() + { + $resolver = new RequestPayloadValueResolver( + new Serializer(), + $this->createMock(ValidatorInterface::class), + ); + + $argument = new ArgumentMetadata('notTyped', null, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', server: ['HTTP_CONTENT_TYPE' => 'application/json']); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Could not resolve the "$notTyped" controller argument: argument should be typed.'); + + $resolver->resolve($request, $argument); + } + + public function testValidationNotPassed() + { + $content = '{"price": 50}'; + $payload = new RequestPayload(50); + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->with($payload) + ->willReturn(new ConstraintViolationList([new ConstraintViolation('Test', null, [], '', null, '')])); + + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('invalid', RequestPayload::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => 'application/json'], content: $content); + + try { + $resolver->resolve($request, $argument); + $this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class)); + } catch (HttpException $e) { + $this->assertInstanceOf(ValidationFailedException::class, $e->getPrevious()); + } + } + + public function testUnsupportedMedia() + { + $serializer = new Serializer(); + + $resolver = new RequestPayloadValueResolver($serializer, null); + + $argument = new ArgumentMetadata('invalid', \stdClass::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => 'foo/bar'], content: 'foo-bar'); + + try { + $resolver->resolve($request, $argument); + + $this->fail(sprintf('Expected "%s" to be thrown.', HttpException::class)); + } catch (HttpException $e) { + $this->assertSame(415, $e->getStatusCode()); + } + } + + public function testRequestContentValidationPassed() + { + $content = '{"price": 50}'; + $payload = new RequestPayload(50); + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->willReturn(new ConstraintViolationList()); + + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', server: ['CONTENT_TYPE' => 'application/json'], content: $content); + + $this->assertEquals($payload, $resolver->resolve($request, $argument)[0]); + } + + public function testQueryStringValidationPassed() + { + $payload = new RequestPayload(50); + $query = ['price' => '50']; + + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->willReturn(new ConstraintViolationList()); + + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + MapQueryString::class => new MapQueryString(), + ]); + $request = Request::create('/', 'GET', $query); + + $this->assertEquals($payload, $resolver->resolve($request, $argument)[0]); + } + + public function testRequestInputValidationPassed() + { + $input = ['price' => '50']; + $payload = new RequestPayload(50); + + $serializer = new Serializer([new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $validator = $this->createMock(ValidatorInterface::class); + $validator->expects($this->once()) + ->method('validate') + ->willReturn(new ConstraintViolationList()); + + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST', $input); + + $this->assertEquals($payload, $resolver->resolve($request, $argument)[0]); + } +} + +class RequestPayload +{ + public function __construct(public readonly float $price) + { + } +} diff --git a/src/Symfony/Component/HttpKernel/composer.json b/src/Symfony/Component/HttpKernel/composer.json index 04b6c09677f90..c75d116794bb8 100644 --- a/src/Symfony/Component/HttpKernel/composer.json +++ b/src/Symfony/Component/HttpKernel/composer.json @@ -20,7 +20,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/error-handler": "^6.3", "symfony/event-dispatcher": "^5.4|^6.0", - "symfony/http-foundation": "^5.4.21|^6.2.7", + "symfony/http-foundation": "^6.2.7", "symfony/polyfill-ctype": "^1.8", "psr/log": "^1|^2|^3" }, @@ -36,11 +36,14 @@ "symfony/finder": "^5.4|^6.0", "symfony/http-client-contracts": "^2.5|^3", "symfony/process": "^5.4|^6.0", + "symfony/property-access": "^5.4|^6.0", "symfony/routing": "^5.4|^6.0", + "symfony/serializer": "^6.3", "symfony/stopwatch": "^5.4|^6.0", "symfony/translation": "^5.4|^6.0", "symfony/translation-contracts": "^2.5|^3", "symfony/uid": "^5.4|^6.0", + "symfony/validator": "^5.4|^6.0", "psr/cache": "^1.0|^2.0|^3.0", "twig/twig": "^2.13|^3.0.4" }, diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 622d4152e4ad6..7c2dd31143551 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add `XmlEncoder::SAVE_OPTIONS` context option * Add `BackedEnumNormalizer::ALLOW_INVALID_VALUES` context option + * Add `UnsupportedFormatException` which is thrown when there is no decoder for a given format * Add method `getSupportedTypes(?string $format)` to `NormalizerInterface` and `DenormalizerInterface` * Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException` * Deprecate `MissingConstructorArgumentsException` in favor of `MissingConstructorArgumentException` diff --git a/src/Symfony/Component/Serializer/Exception/UnsupportedFormatException.php b/src/Symfony/Component/Serializer/Exception/UnsupportedFormatException.php new file mode 100644 index 0000000000000..9b87bcc5b1258 --- /dev/null +++ b/src/Symfony/Component/Serializer/Exception/UnsupportedFormatException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +/** + * @author Konstantin Myakshin + */ +class UnsupportedFormatException extends NotEncodableValueException +{ +} diff --git a/src/Symfony/Component/Serializer/Serializer.php b/src/Symfony/Component/Serializer/Serializer.php index ea219f40351bb..e79ca7ba78412 100644 --- a/src/Symfony/Component/Serializer/Serializer.php +++ b/src/Symfony/Component/Serializer/Serializer.php @@ -19,9 +19,9 @@ use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; @@ -124,7 +124,7 @@ public function __construct(array $normalizers = [], array $encoders = []) final public function serialize(mixed $data, string $format, array $context = []): string { if (!$this->supportsEncoding($format, $context)) { - throw new NotEncodableValueException(sprintf('Serialization for the format "%s" is not supported.', $format)); + throw new UnsupportedFormatException(sprintf('Serialization for the format "%s" is not supported.', $format)); } if ($this->encoder->needsNormalization($format, $context)) { @@ -137,7 +137,7 @@ final public function serialize(mixed $data, string $format, array $context = [] final public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed { if (!$this->supportsDecoding($format, $context)) { - throw new NotEncodableValueException(sprintf('Deserialization for the format "%s" is not supported.', $format)); + throw new UnsupportedFormatException(sprintf('Deserialization for the format "%s" is not supported.', $format)); } $data = $this->decode($data, $format, $context);