|
17 | 17 | use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; |
18 | 18 | use ApiPlatform\JsonApi\Tests\Fixtures\CircularReference; |
19 | 19 | use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; |
| 20 | +use ApiPlatform\JsonApi\Tests\Fixtures\InputDto; |
20 | 21 | use ApiPlatform\JsonApi\Tests\Fixtures\RelatedDummy; |
21 | 22 | use ApiPlatform\Metadata\ApiProperty; |
22 | 23 | use ApiPlatform\Metadata\ApiResource; |
|
41 | 42 | use Symfony\Component\PropertyAccess\PropertyAccessorInterface; |
42 | 43 | use Symfony\Component\Serializer\Exception\NotNormalizableValueException; |
43 | 44 | use Symfony\Component\Serializer\Exception\UnexpectedValueException; |
| 45 | +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; |
44 | 46 | use Symfony\Component\Serializer\Normalizer\NormalizerInterface; |
45 | 47 | use Symfony\Component\Serializer\Serializer; |
46 | 48 | use Symfony\Component\Serializer\SerializerInterface; |
@@ -904,4 +906,87 @@ public function testDenormalizeRelationWithEntityIdentifier(): void |
904 | 906 |
|
905 | 907 | $this->assertInstanceOf(Dummy::class, $result); |
906 | 908 | } |
| 909 | + |
| 910 | + /** |
| 911 | + * Reproducer for https://github.com/api-platform/core/issues/7794. |
| 912 | + * |
| 913 | + * When a resource uses an input DTO, AbstractItemNormalizer::denormalize() re-enters |
| 914 | + * the serializer with the already-unwrapped (flat) data plus an 'api_platform_input' |
| 915 | + * context flag. Without the guard, JsonApi\ItemNormalizer::denormalize() runs a second |
| 916 | + * time on the flat data, tries to read $data['data']['attributes'] and gets null, |
| 917 | + * which nulls every DTO property. |
| 918 | + */ |
| 919 | + public function testDenormalizeInputDtoDoesNotDoubleUnwrapJsonApiStructure(): void |
| 920 | + { |
| 921 | + $jsonApiData = [ |
| 922 | + 'data' => [ |
| 923 | + 'type' => 'dummy', |
| 924 | + 'attributes' => [ |
| 925 | + 'title' => 'Hello', |
| 926 | + 'body' => 'World', |
| 927 | + ], |
| 928 | + ], |
| 929 | + ]; |
| 930 | + |
| 931 | + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); |
| 932 | + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection([])); |
| 933 | + $propertyNameCollectionFactoryProphecy->create(InputDto::class, Argument::any())->willReturn(new PropertyNameCollection(['title', 'body'])); |
| 934 | + |
| 935 | + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); |
| 936 | + $propertyMetadataFactoryProphecy->create(InputDto::class, 'title', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); |
| 937 | + $propertyMetadataFactoryProphecy->create(InputDto::class, 'body', Argument::any())->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); |
| 938 | + |
| 939 | + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); |
| 940 | + |
| 941 | + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); |
| 942 | + $propertyAccessorProphecy->setValue(Argument::type(InputDto::class), Argument::type('string'), Argument::any()) |
| 943 | + ->will(static function ($args): void { |
| 944 | + $args[0]->{$args[1]} = $args[2]; |
| 945 | + }); |
| 946 | + |
| 947 | + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); |
| 948 | + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); |
| 949 | + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); |
| 950 | + $resourceClassResolverProphecy->isResourceClass(InputDto::class)->willReturn(false); |
| 951 | + $resourceClassResolverProphecy->getResourceClass(null, InputDto::class)->willReturn(InputDto::class); |
| 952 | + |
| 953 | + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); |
| 954 | + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ |
| 955 | + (new ApiResource())->withOperations(new Operations([new Get(name: 'get')])), |
| 956 | + ])); |
| 957 | + |
| 958 | + $normalizer = new ItemNormalizer( |
| 959 | + $propertyNameCollectionFactoryProphecy->reveal(), |
| 960 | + $propertyMetadataFactoryProphecy->reveal(), |
| 961 | + $iriConverterProphecy->reveal(), |
| 962 | + $resourceClassResolverProphecy->reveal(), |
| 963 | + $propertyAccessorProphecy->reveal(), |
| 964 | + new ReservedAttributeNameConverter(), |
| 965 | + null, |
| 966 | + [], |
| 967 | + $resourceMetadataCollectionFactory->reveal(), |
| 968 | + ); |
| 969 | + |
| 970 | + // Create a mock serializer that simulates the real serializer chain: |
| 971 | + // when re-entering for the input DTO, it calls back into the normalizer. |
| 972 | + $serializerProphecy = $this->prophesize(SerializerInterface::class); |
| 973 | + $serializerProphecy->willImplement(DenormalizerInterface::class); |
| 974 | + $serializerProphecy->willImplement(NormalizerInterface::class); |
| 975 | + $serializerProphecy->denormalize(Argument::type('array'), InputDto::class, ItemNormalizer::FORMAT, Argument::type('array')) |
| 976 | + ->will(static function ($args) use ($normalizer) { |
| 977 | + // This simulates the serializer re-entering the normalizer chain |
| 978 | + return $normalizer->denormalize($args[0], $args[1], $args[2], $args[3]); |
| 979 | + }); |
| 980 | + |
| 981 | + $normalizer->setSerializer($serializerProphecy->reveal()); |
| 982 | + |
| 983 | + $result = $normalizer->denormalize($jsonApiData, Dummy::class, ItemNormalizer::FORMAT, [ |
| 984 | + 'input' => ['class' => InputDto::class], |
| 985 | + 'resource_class' => Dummy::class, |
| 986 | + ]); |
| 987 | + |
| 988 | + $this->assertInstanceOf(InputDto::class, $result); |
| 989 | + $this->assertSame('Hello', $result->title); |
| 990 | + $this->assertSame('World', $result->body); |
| 991 | + } |
907 | 992 | } |
0 commit comments