Skip to content

Commit 9d02040

Browse files
soyukaclaude
andcommitted
fix(jsonapi): prevent double unwrapping of data.attributes with input DTOs
| Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7794 | License | MIT | Doc PR | ∅ * When re-entering the serializer for input DTO denormalization, the data has already been unwrapped from the JSON:API `data.attributes` structure. * Without this guard, JsonApi\ItemNormalizer::denormalize() runs a second time on flat data, reads `$data['data']['attributes']` → null, and nulls every DTO property. * Skip JSON:API extraction when `api_platform_input` context flag is set. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 79b8f43 commit 9d02040

File tree

9 files changed

+339
-1
lines changed

9 files changed

+339
-1
lines changed

src/JsonApi/Serializer/ItemNormalizer.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form
169169
*/
170170
public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed
171171
{
172+
// When re-entering for input DTO denormalization, data has already been
173+
// unwrapped from the JSON:API structure by the first pass. Skip extraction.
174+
if (isset($context['api_platform_input'])) {
175+
return parent::denormalize($data, $class, $format, $context);
176+
}
177+
172178
// Avoid issues with proxies if we populated the object
173179
if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) {
174180
if (true !== ($context['api_allow_update'] ?? true)) {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\JsonApi\Tests\Fixtures;
15+
16+
final class InputDto
17+
{
18+
public string $title = '';
19+
public string $body = '';
20+
}

src/JsonApi/Tests/Serializer/ItemNormalizerTest.php

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter;
1818
use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference;
1919
use ApiPlatform\JsonApi\Tests\Fixtures\Dummy;
20+
use ApiPlatform\JsonApi\Tests\Fixtures\InputDto;
2021
use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy;
2122
use ApiPlatform\Metadata\ApiProperty;
2223
use ApiPlatform\Metadata\ApiResource;
@@ -35,9 +36,11 @@
3536
use Prophecy\PhpUnit\ProphecyTrait;
3637
use Symfony\Component\HttpFoundation\EventStreamResponse;
3738
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
39+
use Symfony\Component\PropertyAccess\PropertyAccessor;
3840
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
3941
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
4042
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
43+
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
4144
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
4245
use Symfony\Component\Serializer\Serializer;
4346
use Symfony\Component\Serializer\SerializerInterface;
@@ -577,4 +580,81 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void
577580
$this->assertArrayHasKey('relatedDummies', $result['data']['relationships']);
578581
$this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']);
579582
}
583+
584+
/**
585+
* Reproducer for https://github.com/api-platform/core/issues/7794.
586+
*
587+
* When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters
588+
* the serializer with the already-unwrapped (flat) data plus an 'api_platform_input'
589+
* context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second
590+
* time on the flat data, tries to read $data['data']['attributes'] and gets null,
591+
* which nulls every DTO property.
592+
*/
593+
public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void
594+
{
595+
$jsonApiData = [
596+
'data' => [
597+
'type' => 'dummy',
598+
'attributes' => [
599+
'title' => 'Hello',
600+
'body' => 'World',
601+
],
602+
],
603+
];
604+
605+
$propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class);
606+
$propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([]));
607+
$propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body']));
608+
609+
$propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class);
610+
$propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
611+
$propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true));
612+
613+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
614+
615+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
616+
$resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class);
617+
$resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true);
618+
$resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false);
619+
$resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class);
620+
621+
$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
622+
$resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [
623+
(new ApiResource())->withOperations(new Operations([new Get(name: 'get')])),
624+
]));
625+
626+
$normalizer = new ItemNormalizer(
627+
$propertyNameCollectionFactoryProphecy->reveal(),
628+
$propertyMetadataFactoryProphecy->reveal(),
629+
$iriConverterProphecy->reveal(),
630+
$resourceClassResolverProphecy->reveal(),
631+
new PropertyAccessor(),
632+
new ReservedAttributeNameConverter(),
633+
null,
634+
[],
635+
$resourceMetadataCollectionFactory->reveal(),
636+
);
637+
638+
// Create a mock serializer that simulates the real serializer chain:
639+
// when re-entering for the input DTO, it calls back into the normalizer.
640+
$serializerProphecy = $this->prophesize(SerializerInterface::class);
641+
$serializerProphecy->willImplement(DenormalizerInterface::class);
642+
$serializerProphecy->willImplement(NormalizerInterface::class);
643+
$serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array'))
644+
->will(static function ($args) use ($normalizer) {
645+
// This simulates the serializer re-entering the normalizer chain
646+
return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]);
647+
});
648+
649+
$normalizer->setSerializer($serializerProphecy->reveal());
650+
651+
$result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [
652+
'input' => ['class' => InputDto::class],
653+
'resource_class' => Dummy::class,
654+
]);
655+
656+
$this->assertInstanceOf(InputDto::class, $result);
657+
$this->assertSame('Hello', $result->title);
658+
$this->assertSame('World', $result->body);
659+
}
580660
}

src/Serializer/Tests/AbstractItemNormalizerTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@
3434
use ApiPlatform\Serializer\AbstractItemNormalizer;
3535
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DtoWithNullValue;
3636
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy;
37-
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs;
3837
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritance;
3938
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild;
4039
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated;
40+
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs;
4141
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy;
4242
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly;
4343
use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation;
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
/**
17+
* Input DTO for JsonApiInputResource.
18+
*/
19+
final class JsonApiInputDto
20+
{
21+
public string $title = '';
22+
public string $body = '';
23+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\Post;
18+
19+
/**
20+
* Reproducer for https://github.com/api-platform/core/issues/7794.
21+
*
22+
* When using input DTOs with JSON:API format, the JsonApi\ItemNormalizer must
23+
* not unwrap the data twice. On re-entry for the input DTO, the data is already
24+
* flat (attributes have been extracted from the JSON:API structure).
25+
*/
26+
#[Post(
27+
uriTemplate: '/jsonapi_input_test',
28+
formats: ['jsonapi' => ['application/vnd.api+json']],
29+
input: JsonApiInputDto::class,
30+
processor: [self::class, 'process'],
31+
)]
32+
class JsonApiInputResource
33+
{
34+
public ?string $id = null;
35+
public string $title = '';
36+
public string $body = '';
37+
38+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self
39+
{
40+
\assert($data instanceof JsonApiInputDto);
41+
42+
$resource = new self();
43+
$resource->id = '1';
44+
$resource->title = $data->title;
45+
$resource->body = $data->body;
46+
47+
return $resource;
48+
}
49+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
/**
17+
* Input DTO with required constructor args — no defaults, not nullable.
18+
*
19+
* Used to reproduce the Sylius failures where only the first missing
20+
* constructor argument is reported instead of all of them.
21+
*/
22+
final class JsonApiRequiredFieldsInputDto
23+
{
24+
public function __construct(
25+
public string $title,
26+
public int $rating,
27+
public string $comment,
28+
) {
29+
}
30+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource;
15+
16+
use ApiPlatform\Metadata\Operation;
17+
use ApiPlatform\Metadata\Post;
18+
19+
/**
20+
* Resource using an input DTO with required constructor arguments.
21+
*
22+
* Tests that all missing constructor arguments are reported (not just the first).
23+
*/
24+
#[Post(
25+
uriTemplate: '/jsonapi_required_fields_test',
26+
formats: ['jsonapi' => ['application/vnd.api+json']],
27+
input: JsonApiRequiredFieldsInputDto::class,
28+
processor: [self::class, 'process'],
29+
)]
30+
class JsonApiRequiredFieldsResource
31+
{
32+
public ?string $id = null;
33+
public string $title = '';
34+
public int $rating = 0;
35+
public string $comment = '';
36+
37+
public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self
38+
{
39+
\assert($data instanceof JsonApiRequiredFieldsInputDto);
40+
41+
$resource = new self();
42+
$resource->id = '1';
43+
$resource->title = $data->title;
44+
$resource->rating = $data->rating;
45+
$resource->comment = $data->comment;
46+
47+
return $resource;
48+
}
49+
}

0 commit comments

Comments
 (0)