From 7fd5e773f30e24b294fa2942c3a5099ac78551a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20=C5=81ebkowski?= Date: Wed, 15 Oct 2025 15:33:40 +0200 Subject: [PATCH 1/2] Fix: deserializing nested objects Add ReflectionExtractor to get the type of nested fields from their types. This also breaks GET payloads, since all of their types are "string", because they are transported using a query string. Without the ReflectionExtractor, the discovered type was `null` and the value was coalesced to whatever the class had. With it, the correct type is discovered, but a mismatch happens, because all input types are "string". Adding `csv` format when deserializing GET payloads triggers the type juggling/coalescing inside of the ObjectNormalizer and everything starts working again --- .../Serializer/DeserializeParameterResolver.php | 8 ++++++++ src/ServiceFactory/SlimServiceFactory.php | 9 ++++++++- .../DeserializeParameterResolverTest.php | 14 +++++++++++++- tests/Http/Serializer/EchoController.php | 11 ++++++----- tests/Http/Serializer/SamplePostInput.php | 1 + 5 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/Http/Serializer/DeserializeParameterResolver.php b/src/Http/Serializer/DeserializeParameterResolver.php index b1281cb..8f276ff 100644 --- a/src/Http/Serializer/DeserializeParameterResolver.php +++ b/src/Http/Serializer/DeserializeParameterResolver.php @@ -7,6 +7,8 @@ use ReflectionFunctionAbstract; use ReflectionNamedType; use Slim\Exception\HttpBadRequestException; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\ExceptionInterface; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -50,10 +52,16 @@ public function getParameters( PayloadSource::Get => $request->getQueryParams(), }; + $format = match ($attribute->source) { + PayloadSource::Post => JsonEncoder::FORMAT, + PayloadSource::Get => CsvEncoder::FORMAT, + }; + try { $resolvedParameters[$index] = $this->serializer->denormalize( $data, $parameterClass, + format: $format, context: $attribute->context, ); } catch (NotNormalizableValueException $e) { diff --git a/src/ServiceFactory/SlimServiceFactory.php b/src/ServiceFactory/SlimServiceFactory.php index 9c9e49e..566caee 100644 --- a/src/ServiceFactory/SlimServiceFactory.php +++ b/src/ServiceFactory/SlimServiceFactory.php @@ -19,6 +19,8 @@ use Slim\Middleware\ErrorMiddleware; use Slim\Psr7\Factory\ResponseFactory; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -38,7 +40,12 @@ public function __invoke(ServicesBuilder $builder): iterable { yield Serializer::class => static fn () => new Serializer([ new ArrayDenormalizer(), new ObjectNormalizer( - propertyTypeExtractor: new PhpDocExtractor(), + propertyTypeExtractor: new PropertyInfoExtractor( + typeExtractors: [ + new PhpDocExtractor(), + new ReflectionExtractor(), + ], + ), ), ]); yield DenormalizerInterface::class => get(Serializer::class); diff --git a/tests/Http/Serializer/DeserializeParameterResolverTest.php b/tests/Http/Serializer/DeserializeParameterResolverTest.php index 83cf35d..6029e62 100644 --- a/tests/Http/Serializer/DeserializeParameterResolverTest.php +++ b/tests/Http/Serializer/DeserializeParameterResolverTest.php @@ -24,6 +24,9 @@ public function testDeserialize(): void { ['name' => 'cats'], ['name' => 'dogs'], ], + 'tag' => [ + 'name' => 'alpha', + ], ]; $get = [ 'page' => 3, @@ -33,8 +36,17 @@ public function testDeserialize(): void { ->createServerRequest('GET', '/?'.http_build_query($get)) ->withParsedBody($post); $response = $app->handle($request); + + $body = $response->getBody()->getContents(); + + self::assertSame( + 200, + $response->getStatusCode(), + $body, + ); + $actual = json_decode( - $response->getBody()->getContents(), + $body, associative: true, depth: 12, flags: JSON_THROW_ON_ERROR, diff --git a/tests/Http/Serializer/EchoController.php b/tests/Http/Serializer/EchoController.php index 5fa77f3..2f96d10 100644 --- a/tests/Http/Serializer/EchoController.php +++ b/tests/Http/Serializer/EchoController.php @@ -12,10 +12,11 @@ public function __invoke( #[Payload(source: PayloadSource::Get)] SampleGetInput $get, ResponseInterface $response, ): ResponseInterface { - return $response->withBody( - (new StreamFactory())->createStream( - json_encode(compact('post', 'get'), JSON_THROW_ON_ERROR), - ), - ); + $streamFactory = new StreamFactory(); + $payload = compact('post', 'get'); + $json = json_encode($payload, JSON_THROW_ON_ERROR); + $body = $streamFactory->createStream($json); + + return $response->withBody($body); } } diff --git a/tests/Http/Serializer/SamplePostInput.php b/tests/Http/Serializer/SamplePostInput.php index fe3879c..81a1649 100644 --- a/tests/Http/Serializer/SamplePostInput.php +++ b/tests/Http/Serializer/SamplePostInput.php @@ -8,4 +8,5 @@ final class SamplePostInput { public int $value; /** @var TagInput[] */ public array $tags; + public TagInput $tag; } From bea244b6cf635678e27b55f9934c5dd25296739a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20=C5=81ebkowski?= Date: Wed, 15 Oct 2025 16:00:43 +0200 Subject: [PATCH 2/2] add some more test cases --- tests/Http/Serializer/BooleansInput.php | 12 ++++++++++++ .../Serializer/DeserializeParameterResolverTest.php | 8 ++++++++ tests/Http/Serializer/SampleGetInput.php | 9 +++++++++ 3 files changed, 29 insertions(+) create mode 100644 tests/Http/Serializer/BooleansInput.php diff --git a/tests/Http/Serializer/BooleansInput.php b/tests/Http/Serializer/BooleansInput.php new file mode 100644 index 0000000..1021e40 --- /dev/null +++ b/tests/Http/Serializer/BooleansInput.php @@ -0,0 +1,12 @@ + 3, + 'lists' => ['alpha', 'bravo'], + 'arrays' => ['charlie' => 'delta'], + 'booleans' => [ + 'true' => true, + 'false' => false, + 'one' => 1, + 'zero' => 0, + ], ]; $request = (new ServerRequestFactory()) diff --git a/tests/Http/Serializer/SampleGetInput.php b/tests/Http/Serializer/SampleGetInput.php index b66f6b6..07d72db 100644 --- a/tests/Http/Serializer/SampleGetInput.php +++ b/tests/Http/Serializer/SampleGetInput.php @@ -6,4 +6,13 @@ final class SampleGetInput { public int $page = 1; public int $perPage = 100; + /** + * @var string[] + */ + public array $lists; + /** + * @var array + */ + public array $arrays; + public BooleansInput $booleans; }