diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php b/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php index 83722266ee4e3..5eb419091b5db 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapQueryString.php @@ -31,6 +31,7 @@ public function __construct( public readonly string|GroupSequence|array|null $validationGroups = null, string $resolver = RequestPayloadValueResolver::class, public readonly int $validationFailedStatusCode = Response::HTTP_NOT_FOUND, + public bool $mapEmpty = false, ) { parent::__construct($resolver); } diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php index cbac606e83fe1..d708e913b1156 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php @@ -32,6 +32,7 @@ public function __construct( public readonly string|GroupSequence|array|null $validationGroups = null, string $resolver = RequestPayloadValueResolver::class, public readonly int $validationFailedStatusCode = Response::HTTP_UNPROCESSABLE_ENTITY, + public bool $mapEmpty = false, ) { parent::__construct($resolver); } diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index 5714af6e68dcf..6e7550ab10eb6 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -17,6 +17,7 @@ CHANGELOG * Deprecate `FileLinkFormatter`, use `FileLinkFormatter` from the ErrorHandler component instead * Add argument `$buildDir` to `WarmableInterface` * Add argument `$filter` to `Profiler::find()` and `FileProfilerStorage::find()` + * Add argument `$mapEmpty` to `MapQueryString` and `MapRequestPayload` for always attempting denormalization with empty query and request payload 6.3 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index f7f6030769699..7b60da7af6f70 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -157,7 +157,7 @@ public static function getSubscribedEvents(): array private function mapQueryString(Request $request, string $type, MapQueryString $attribute): ?object { - if (!$data = $request->query->all()) { + if ((!$data = $request->query->all()) && !$attribute->mapEmpty) { return null; } @@ -174,11 +174,11 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format)); } - if ($data = $request->request->all()) { + if (($data = $request->request->all()) || ($attribute->mapEmpty && '' === $content = $request->getContent())) { return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE); } - if ('' === $data = $request->getContent()) { + if ('' === $content ??= $request->getContent()) { return null; } @@ -187,7 +187,7 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay } try { - return $this->serializer->deserialize($data, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->serializationContext); + return $this->serializer->deserialize($content, $type, $format, self::CONTEXT_DESERIALIZE + $attribute->serializationContext); } catch (UnsupportedFormatException $e) { throw new HttpException(Response::HTTP_UNSUPPORTED_MEDIA_TYPE, sprintf('Unsupported format: "%s".', $format), $e); } catch (NotEncodableValueException $e) { diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 326551d87b57e..1e6f69a048d06 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -24,6 +24,7 @@ use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Validator\Constraints as Assert; @@ -149,6 +150,46 @@ public function testQueryNullableValueArgument() $this->assertSame([null], $event->getArguments()); } + public function testMapQueryStringEmpty() + { + $payload = new RequestPayload(50); + $denormalizer = new RequestPayloadDenormalizer($payload); + $serializer = new Serializer([$denormalizer]); + $resolver = new RequestPayloadValueResolver($serializer); + $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + MapQueryString::class => new MapQueryString(mapEmpty: true), + ]); + $request = Request::create('/', 'GET'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, fn () => null, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertSame([$payload], $event->getArguments()); + } + + public function testMapRequestPayloadEmpty() + { + $payload = new RequestPayload(50); + $denormalizer = new RequestPayloadDenormalizer($payload); + $serializer = new Serializer([$denormalizer]); + $resolver = new RequestPayloadValueResolver($serializer); + $argument = new ArgumentMetadata('valid', RequestPayload::class, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(mapEmpty: true), + ]); + $request = Request::create('/', 'POST'); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, fn () => null, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertSame([$payload], $event->getArguments()); + } + public function testNullPayloadAndNotDefaultOrNullableArgument() { $validator = $this->createMock(ValidatorInterface::class); @@ -687,3 +728,25 @@ public function __construct(public readonly float $page) { } } + +class RequestPayloadDenormalizer implements DenormalizerInterface +{ + public function __construct(private RequestPayload $payload) + { + } + + public function denormalize(mixed $data, string $type, string $format = null, array $context = []): mixed + { + return $this->payload; + } + + public function supportsDenormalization(mixed $data, string $type, string $format = null, array $context = []): bool + { + return RequestPayload::class === $type; + } + + public function getSupportedTypes(string $format = null): array + { + return [RequestPayload::class => true]; + } +}