From 3f721434062045c1bad7849d1b412d07aa2b98e4 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Sat, 23 Mar 2024 18:27:37 -0400 Subject: [PATCH] map a list of items with MapRequestPayload attribute --- .../Attribute/MapRequestPayload.php | 2 + src/Symfony/Component/HttpKernel/CHANGELOG.md | 1 + .../RequestPayloadValueResolver.php | 17 ++++- .../RequestPayloadValueResolverTest.php | 70 +++++++++++++++++++ 4 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php index 5ee2ffeb64e5c..cf086380c03f0 100644 --- a/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php +++ b/src/Symfony/Component/HttpKernel/Attribute/MapRequestPayload.php @@ -32,6 +32,7 @@ class MapRequestPayload extends ValueResolver * @param string|GroupSequence|array|null $validationGroups The validation groups to use when validating the query string mapping * @param class-string $resolver The class name of the resolver to use * @param int $validationFailedStatusCode The HTTP code to return if the validation fails + * @param class-string|string|null $type The element type for array deserialization */ public function __construct( public readonly array|string|null $acceptFormat = null, @@ -39,6 +40,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 readonly ?string $type = null, ) { parent::__construct($resolver); } diff --git a/src/Symfony/Component/HttpKernel/CHANGELOG.md b/src/Symfony/Component/HttpKernel/CHANGELOG.md index e9d4f2da90630..f639e2ada7517 100644 --- a/src/Symfony/Component/HttpKernel/CHANGELOG.md +++ b/src/Symfony/Component/HttpKernel/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Add `HttpException::fromStatusCode()` * Add `$validationFailedStatusCode` argument to `#[MapQueryParameter]` that allows setting a custom HTTP status code when validation fails * Add `NearMissValueResolverException` to let value resolvers report when an argument could be under their watch but failed to be resolved + * Add `$type` argument to `#[MapRequestPayload]` that allows mapping a list of items 7.0 --- diff --git a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php index 6274703a1731a..83e8abe7ffa93 100644 --- a/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php +++ b/src/Symfony/Component/HttpKernel/Controller/ArgumentResolver/RequestPayloadValueResolver.php @@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Serializer\Exception\NotEncodableValueException; @@ -78,6 +79,16 @@ public function resolve(Request $request, ArgumentMetadata $argument): iterable throw new \LogicException(sprintf('Mapping variadic argument "$%s" is not supported.', $argument->getName())); } + if ($attribute instanceof MapRequestPayload) { + if ('array' === $argument->getType()) { + if (!$attribute->type) { + throw new NearMissValueResolverException(sprintf('Please set the $type argument of the #[%s] attribute to the type of the objects in the expected array.', MapRequestPayload::class)); + } + } elseif ($attribute->type) { + throw new NearMissValueResolverException(sprintf('Please set its type to "array" when using argument $type of #[%s].', MapRequestPayload::class)); + } + } + $attribute->metadata = $argument; return [$attribute]; @@ -170,7 +181,7 @@ private function mapQueryString(Request $request, string $type, MapQueryString $ return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ['filter_bool' => true]); } - private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): ?object + private function mapRequestPayload(Request $request, string $type, MapRequestPayload $attribute): object|array|null { if (null === $format = $request->getContentTypeFormat()) { throw new UnsupportedMediaTypeHttpException('Unsupported format.'); @@ -180,6 +191,10 @@ private function mapRequestPayload(Request $request, string $type, MapRequestPay throw new UnsupportedMediaTypeHttpException(sprintf('Unsupported format, expects "%s", but "%s" given.', implode('", "', (array) $attribute->acceptFormat), $format)); } + if ('array' === $type && null !== $attribute->type) { + $type = $attribute->type.'[]'; + } + if ($data = $request->request->all()) { return $this->serializer->denormalize($data, $type, null, $attribute->serializationContext + self::CONTEXT_DENORMALIZE + ('form' === $format ? ['filter_bool' => true] : [])); } diff --git a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php index 4825ad890facd..b277650b44b45 100644 --- a/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php +++ b/src/Symfony/Component/HttpKernel/Tests/Controller/ArgumentResolver/RequestPayloadValueResolverTest.php @@ -20,11 +20,13 @@ use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\NearMissValueResolverException; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; @@ -421,6 +423,74 @@ public function testRequestInputValidationPassed() $this->assertEquals([$payload], $event->getArguments()); } + public function testRequestArrayDenormalization() + { + $input = [ + ['price' => '50'], + ['price' => '23'], + ]; + $payload = [ + new RequestPayload(50), + new RequestPayload(23), + ]; + + $serializer = new Serializer([new ArrayDenormalizer(), 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('prices', 'array', false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(type: RequestPayload::class), + ]); + $request = Request::create('/', 'POST', $input); + + $kernel = $this->createMock(HttpKernelInterface::class); + $arguments = $resolver->resolve($request, $argument); + $event = new ControllerArgumentsEvent($kernel, function () {}, $arguments, $request, HttpKernelInterface::MAIN_REQUEST); + + $resolver->onKernelControllerArguments($event); + + $this->assertEquals([$payload], $event->getArguments()); + } + + public function testItThrowsOnMissingAttributeType() + { + $serializer = new Serializer(); + $validator = $this->createMock(ValidatorInterface::class); + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('prices', 'array', false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(), + ]); + $request = Request::create('/', 'POST'); + $request->attributes->set('_controller', 'App\Controller\SomeController::someMethod'); + + $this->expectException(NearMissValueResolverException::class); + $this->expectExceptionMessage('Please set the $type argument of the #[Symfony\Component\HttpKernel\Attribute\MapRequestPayload] attribute to the type of the objects in the expected array.'); + $resolver->resolve($request, $argument); + } + + public function testItThrowsOnInvalidAttributeTypeUsage() + { + $serializer = new Serializer(); + $validator = $this->createMock(ValidatorInterface::class); + $resolver = new RequestPayloadValueResolver($serializer, $validator); + + $argument = new ArgumentMetadata('prices', null, false, false, null, false, [ + MapRequestPayload::class => new MapRequestPayload(type: RequestPayload::class), + ]); + $request = Request::create('/', 'POST'); + $request->attributes->set('_controller', 'App\Controller\SomeController::someMethod'); + + $this->expectException(NearMissValueResolverException::class); + $this->expectExceptionMessage('Please set its type to "array" when using argument $type of #[Symfony\Component\HttpKernel\Attribute\MapRequestPayload].'); + $resolver->resolve($request, $argument); + } + public function testItThrowsOnVariadicArgument() { $serializer = new Serializer();