From 79b8f434f0c628ee2f6522365a2f1abc4b0bb34a Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Feb 2026 15:40:54 +0100 Subject: [PATCH 1/3] fix(serializer): report all missing constructor arguments in instantiateObject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Q | A | ------------- | --- | Branch? | 4.2 | Tickets | Fixes #7785 | License | MIT | Doc PR | ∅ * Add missing `continue` after collecting a missing constructor argument name, matching Symfony's AbstractNormalizer::instantiateObject() behavior. * Without it, the first missing arg initializes `not_normalizable_value_exceptions` in context, causing subsequent missing args to skip the collection — only the first missing field was reported in MissingConstructorArgumentsException. * Also set `api_platform_input` context flag when re-entering the serializer for input DTO denormalization, so downstream normalizers can detect re-entry. Co-Authored-By: Claude Opus 4.6 --- src/Serializer/AbstractItemNormalizer.php | 1 + .../Tests/AbstractItemNormalizerTest.php | 42 +++++++++++++++++++ ...mmyWithMultipleRequiredConstructorArgs.php | 27 ++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/Serializer/Tests/Fixtures/ApiResource/DummyWithMultipleRequiredConstructorArgs.php diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 15f74e62261..7b141132aaa 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -392,6 +392,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex } else { if (!isset($context['not_normalizable_value_exceptions'])) { $missingConstructorArguments[] = $constructorParameter->name; + continue; } $constructorParameterType = 'unknown'; diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 31802672ae5..cad427347d3 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DtoWithNullValue; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritance; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated; @@ -51,6 +52,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; @@ -1937,6 +1939,46 @@ public function testSupportsNormalizationWithApiPlatformOutputContext(): void 'api_platform_output_class' => 'SomeOtherClass', ])); } + + public function testDenormalizeReportsAllMissingConstructorArguments(): void + { + $data = []; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['title', 'rating', 'comment'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); + } else { + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + } + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, DummyWithMultipleRequiredConstructorArgs::class)->willReturn(DummyWithMultipleRequiredConstructorArgs::class); + $resourceClassResolverProphecy->isResourceClass(DummyWithMultipleRequiredConstructorArgs::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $propertyAccessorProphecy->reveal(), null, null, [], null, null) extends AbstractItemNormalizer {}; + $normalizer->setSerializer($serializerProphecy->reveal()); + + try { + $normalizer->denormalize($data, DummyWithMultipleRequiredConstructorArgs::class); + $this->fail('Expected MissingConstructorArgumentsException was not thrown'); + } catch (MissingConstructorArgumentsException $e) { + $this->assertCount(3, $e->getMissingConstructorArguments(), 'All three missing constructor arguments (title, rating, comment) should be reported, not just the first one.'); + $this->assertSame(['title', 'rating', 'comment'], $e->getMissingConstructorArguments()); + } + } } class ObjectWithBasicProperties diff --git a/src/Serializer/Tests/Fixtures/ApiResource/DummyWithMultipleRequiredConstructorArgs.php b/src/Serializer/Tests/Fixtures/ApiResource/DummyWithMultipleRequiredConstructorArgs.php new file mode 100644 index 00000000000..41dc14351d2 --- /dev/null +++ b/src/Serializer/Tests/Fixtures/ApiResource/DummyWithMultipleRequiredConstructorArgs.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Serializer\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource] +final class DummyWithMultipleRequiredConstructorArgs +{ + public function __construct( + public string $title, + public int $rating, + public string $comment, + ) { + } +} From ef9b0184a96ed5111dc16fe68f15aa861f06c2d6 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Feb 2026 15:41:06 +0100 Subject: [PATCH 2/3] fix(jsonapi): prevent double unwrapping of data.attributes with input DTOs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | 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 --- src/JsonApi/Serializer/ItemNormalizer.php | 6 ++ src/JsonApi/Tests/Fixtures/InputDto.php | 20 +++++ .../Tests/Serializer/ItemNormalizerTest.php | 85 +++++++++++++++++++ .../Tests/AbstractItemNormalizerTest.php | 2 +- .../ApiResource/JsonApiInputDto.php | 23 +++++ .../ApiResource/JsonApiInputResource.php | 49 +++++++++++ .../JsonApiRequiredFieldsInputDto.php | 30 +++++++ .../JsonApiRequiredFieldsResource.php | 49 +++++++++++ tests/Functional/JsonApiTest.php | 81 ++++++++++++++++++ 9 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/JsonApi/Tests/Fixtures/InputDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApiInputDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApiInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsInputDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsResource.php diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 3ecb096b0f7..f59a49b36fb 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -169,6 +169,12 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form */ public function denormalize(mixed $data, string $class, ?string $format = null, array $context = []): mixed { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $class, $format, $context); + } + // Avoid issues with proxies if we populated the object if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { if (true !== ($context['api_allow_update'] ?? true)) { diff --git a/src/JsonApi/Tests/Fixtures/InputDto.php b/src/JsonApi/Tests/Fixtures/InputDto.php new file mode 100644 index 00000000000..c6bcbe4832c --- /dev/null +++ b/src/JsonApi/Tests/Fixtures/InputDto.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Tests\Fixtures; + +final class InputDto +{ + public string $title = ''; + public string $body = ''; +} diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index a9af4cf4b5d..f11d7c31c14 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -17,6 +17,7 @@ use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference; use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\JsonApi\Tests\Fixtures\InputDto; use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; @@ -38,6 +39,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; @@ -577,4 +579,87 @@ public function testNormalizeWithNullToOneAndEmptyToManyRelationships(): void $this->assertArrayHasKey('relatedDummies', $result['data']['relationships']); $this->assertSame(['data' => []], $result['data']['relationships']['relatedDummies']); } + + /** + * Reproducer for https://github.com/api-platform/core/issues/7794. + * + * When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters + * the serializer with the already-unwrapped (flat) data plus an 'api_platform_input' + * context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second + * time on the flat data, tries to read $data['data']['attributes'] and gets null, + * which nulls every DTO property. + */ + public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void + { + $jsonApiData = [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'title' => 'Hello', + 'body' => 'World', + ], + ], + ]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([])); + $propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->setValue(Argument::type(InputDto::class), Argument::type('string'), Argument::any()) + ->will(static function ($args): void { + $args[0]->{$args[1]} = $args[2]; + }); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false); + $resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), + ])); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + null, + [], + $resourceMetadataCollectionFactory->reveal(), + ); + + // Create a mock serializer that simulates the real serializer chain: + // when re-entering for the input DTO, it calls back into the normalizer. + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array')) + ->will(static function ($args) use ($normalizer) { + // This simulates the serializer re-entering the normalizer chain + return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]); + }); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [ + 'input' => ['class' => InputDto::class], + 'resource_class' => Dummy::class, + ]); + + $this->assertInstanceOf(InputDto::class, $result); + $this->assertSame('Hello', $result->title); + $this->assertSame('World', $result->body); + } } diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index cad427347d3..49266814339 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -34,10 +34,10 @@ use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DtoWithNullValue; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; -use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritance; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceChild; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyTableInheritanceRelated; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\DummyWithMultipleRequiredConstructorArgs; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\NonCloneableDummy; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnly; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\PropertyCollectionIriOnlyRelation; diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiInputDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiInputDto.php new file mode 100644 index 00000000000..dc6de1d382d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiInputDto.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +/** + * Input DTO for JsonApiInputResource. + */ +final class JsonApiInputDto +{ + public string $title = ''; + public string $body = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiInputResource.php new file mode 100644 index 00000000000..20ecb6579c8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiInputResource.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +/** + * Reproducer for https://github.com/api-platform/core/issues/7794. + * + * When using input DTOs with JSON:API format, the JsonApi\ItemNormalizer must + * not unwrap the data twice. On re-entry for the input DTO, the data is already + * flat (attributes have been extracted from the JSON:API structure). + */ +#[Post( + uriTemplate: '/jsonapi_input_test', + formats: ['jsonapi' => ['application/vnd.api+json']], + input: JsonApiInputDto::class, + processor: [self::class, 'process'], +)] +class JsonApiInputResource +{ + public ?string $id = null; + public string $title = ''; + public string $body = ''; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self + { + \assert($data instanceof JsonApiInputDto); + + $resource = new self(); + $resource->id = '1'; + $resource->title = $data->title; + $resource->body = $data->body; + + return $resource; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsInputDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsInputDto.php new file mode 100644 index 00000000000..b0306d11460 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsInputDto.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +/** + * Input DTO with required constructor args — no defaults, not nullable. + * + * Used to reproduce the Sylius failures where only the first missing + * constructor argument is reported instead of all of them. + */ +final class JsonApiRequiredFieldsInputDto +{ + public function __construct( + public string $title, + public int $rating, + public string $comment, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsResource.php new file mode 100644 index 00000000000..a229a412317 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApiRequiredFieldsResource.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +/** + * Resource using an input DTO with required constructor arguments. + * + * Tests that all missing constructor arguments are reported (not just the first). + */ +#[Post( + uriTemplate: '/jsonapi_required_fields_test', + formats: ['jsonapi' => ['application/vnd.api+json']], + input: JsonApiRequiredFieldsInputDto::class, + processor: [self::class, 'process'], +)] +class JsonApiRequiredFieldsResource +{ + public ?string $id = null; + public string $title = ''; + public int $rating = 0; + public string $comment = ''; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): self + { + \assert($data instanceof JsonApiRequiredFieldsInputDto); + + $resource = new self(); + $resource->id = '1'; + $resource->title = $data->title; + $resource->rating = $data->rating; + $resource->comment = $data->comment; + + return $resource; + } +} diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php index 9b97c347a28..90eddbcbfb4 100644 --- a/tests/Functional/JsonApiTest.php +++ b/tests/Functional/JsonApiTest.php @@ -15,6 +15,8 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRequiredFieldsResource; use ApiPlatform\Tests\SetupClassResourcesTrait; class JsonApiTest extends ApiTestCase @@ -29,6 +31,8 @@ public static function getResources(): array { return [ JsonApiErrorTestResource::class, + JsonApiInputResource::class, + JsonApiRequiredFieldsResource::class, ]; } @@ -50,4 +54,81 @@ public function testError(): void ], ]); } + + /** + * Reproducer for https://github.com/api-platform/core/issues/7794. + * + * When using an input DTO with JSON:API format, the JsonApi\ItemNormalizer + * must not unwrap data.attributes twice. Without the fix, the second pass + * reads $data['data']['attributes'] from already-flat data and gets null, + * which nulls every DTO property. + */ + public function testPostWithInputDtoPreservesAttributes(): void + { + $response = self::createClient()->request('POST', '/jsonapi_input_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiInputResource', + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ]); + } + + /** + * Verify that a JSON:API POST with all required fields on an input DTO + * with constructor arguments works correctly end-to-end. + * + * Related to Sylius test failures caused by a missing `continue` in + * AbstractItemNormalizer::instantiateObject() — only the first missing + * constructor argument was reported instead of all of them. + */ + public function testPostWithRequiredConstructorArgsInputDto(): void + { + $response = self::createClient()->request('POST', '/jsonapi_required_fields_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiRequiredFieldsResource', + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ]); + } } From d29d1a5e45c539b22c39c6cc82559ea11adac7b8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 27 Feb 2026 17:02:33 +0100 Subject: [PATCH 3/3] chore: depend on last serializer --- src/JsonApi/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index c1fa5481aba..65ba9d597d1 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.2", "api-platform/json-schema": "^4.2", "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.4", + "api-platform/serializer": "^4.2.18", "api-platform/state": "^4.2.4", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0",