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.
+ This value is too short. It should have {{ limit }} character or more.|This value is too short. It should have {{ limit }} 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);