diff --git a/features/serializer/empty_array_as_object.feature b/features/serializer/empty_array_as_object.feature new file mode 100644 index 00000000000..c0cc8548178 --- /dev/null +++ b/features/serializer/empty_array_as_object.feature @@ -0,0 +1,33 @@ +Feature: Serialize empty array as object + In order to have a coherent JSON representation + As a developer + I should be able to serialize some empty array properties as objects + + @createSchema + Scenario: Get a resource with empty array properties as objects + When I add "Content-Type" header equal to "application/ld+json" + And I send a "GET" request to "/empty_array_as_objects/5" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/EmptyArrayAsObject", + "@id": "/empty_array_as_objects/6", + "@type": "EmptyArrayAsObject", + "id": 6, + "emptyArray": [], + "emptyArrayAsObject": {}, + "arrayObjectAsArray": [], + "arrayObject": {}, + "stringArray": [ + "foo", + "bar" + ], + "objectArray": { + "foo": 67, + "bar": "baz" + } + } + """ diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 393eea32716..9bda570cd7a 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -56,7 +56,7 @@ public function __construct(private readonly ContextBuilderInterface $contextBui */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return self::FORMAT === $format && is_iterable($data); + return self::FORMAT === $format && is_iterable($data) && isset($context['resource_class']) && !isset($context['api_sub_level']); } /** @@ -66,10 +66,6 @@ public function supportsNormalization(mixed $data, string $format = null, array */ public function normalize(mixed $object, string $format = null, array $context = []): array { - if (!isset($context['resource_class']) || isset($context['api_sub_level'])) { - return $this->normalizeRawCollection($object, $format, $context); - } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); $context = $this->initContext($resourceClass, $context); $data = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); @@ -106,19 +102,6 @@ public function normalize(mixed $object, string $format = null, array $context = public function hasCacheableSupportsMethod(): bool { - return true; - } - - /** - * Normalizes a raw collection (not API resources). - */ - private function normalizeRawCollection(iterable $object, ?string $format, array $context): array - { - $data = []; - foreach ($object as $index => $obj) { - $data[$index] = $this->normalizer->normalize($obj, $format, $context); - } - - return $data; + return false; } } diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index b9fda314646..c2f8c63d879 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -50,12 +50,12 @@ public function __construct(protected ResourceClassResolverInterface $resourceCl */ public function supportsNormalization(mixed $data, string $format = null, array $context = []): bool { - return static::FORMAT === $format && is_iterable($data); + return static::FORMAT === $format && is_iterable($data) && isset($context['resource_class']) && !isset($context['api_sub_level']); } public function hasCacheableSupportsMethod(): bool { - return true; + return false; } /** @@ -65,10 +65,6 @@ public function hasCacheableSupportsMethod(): bool */ public function normalize(mixed $object, string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - if (!isset($context['resource_class']) || isset($context['api_sub_level'])) { - return $this->normalizeRawCollection($object, $format, $context); - } - $resourceClass = $this->resourceClassResolver->getResourceClass($object, $context['resource_class']); $context = $this->initContext($resourceClass, $context); $data = []; @@ -86,19 +82,6 @@ public function normalize(mixed $object, string $format = null, array $context = return array_merge_recursive($data, $paginationData, $itemsData); } - /** - * Normalizes a raw collection (not API resources). - */ - protected function normalizeRawCollection(iterable $object, string $format = null, array $context = []): array - { - $data = []; - foreach ($object as $index => $obj) { - $data[$index] = $this->normalizer->normalize($obj, $format, $context); - } - - return $data; - } - /** * Gets the pagination configuration. */ diff --git a/tests/Fixtures/TestBundle/Model/EmptyArrayAsObject.php b/tests/Fixtures/TestBundle/Model/EmptyArrayAsObject.php new file mode 100644 index 00000000000..1ee54afc389 --- /dev/null +++ b/tests/Fixtures/TestBundle/Model/EmptyArrayAsObject.php @@ -0,0 +1,47 @@ + + * + * 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\Model; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Tests\Fixtures\TestBundle\State\EmptyArrayAsObjectProvider; +use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +#[ApiResource(operations: [new Get()], provider: EmptyArrayAsObjectProvider::class)] +class EmptyArrayAsObject +{ + public int $id = 6; + + public array $emptyArray = []; + + #[Context([Serializer::EMPTY_ARRAY_AS_OBJECT => true, AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true])] + public array $emptyArrayAsObject = []; + + public \ArrayObject $arrayObjectAsArray; + + #[Context([AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true])] + public \ArrayObject $arrayObject; + + public array $stringArray = ['foo', 'bar']; + + public array $objectArray = ['foo' => 67, 'bar' => 'baz']; + + public function __construct() + { + $this->arrayObjectAsArray = new \ArrayObject(); + $this->arrayObject = new \ArrayObject(); + } +} diff --git a/tests/Fixtures/TestBundle/State/EmptyArrayAsObjectProvider.php b/tests/Fixtures/TestBundle/State/EmptyArrayAsObjectProvider.php new file mode 100644 index 00000000000..fce37317a3d --- /dev/null +++ b/tests/Fixtures/TestBundle/State/EmptyArrayAsObjectProvider.php @@ -0,0 +1,26 @@ + + * + * 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\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\EmptyArrayAsObject; + +final class EmptyArrayAsObjectProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): EmptyArrayAsObject + { + return new EmptyArrayAsObject(); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 5d2abc1a643..96a0a3ae8a2 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -137,6 +137,11 @@ services: tags: - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\EmptyArrayAsObjectProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\EmptyArrayAsObjectProvider' + tags: + - { name: 'api_platform.state_provider' } + ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor' tags: diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php index c54a6398241..6626a8a57a5 100644 --- a/tests/Hal/Serializer/CollectionNormalizerTest.php +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -39,26 +39,13 @@ public function testSupportsNormalize(): void $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); - $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); - $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); - $this->assertFalse($normalizer->supportsNormalization([], 'xml')); - $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml')); - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); - } - - public function testNormalizeApiSubLevel(): void - { - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass()->shouldNotBeCalled(); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $itemNormalizer = $this->prophesize(NormalizerInterface::class); - $itemNormalizer->normalize('bar', null, ['api_sub_level' => true])->willReturn(22); - - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); - $normalizer->setNormalizer($itemNormalizer->reveal()); - - $this->assertEquals(['foo' => 22], $normalizer->normalize(['foo' => 'bar'], null, ['api_sub_level' => true])); + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo', 'api_sub_level' => true])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, [])); + $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->hasCacheableSupportsMethod()); } public function testNormalizePaginator(): void diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index d7a879edcd7..dfe602f1e73 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -16,14 +16,12 @@ use ApiPlatform\Api\IriConverterInterface; use ApiPlatform\Api\ResourceClassResolverInterface; use ApiPlatform\Api\UrlGeneratorInterface; -use ApiPlatform\Exception\InvalidArgumentException; use ApiPlatform\Hydra\Serializer\CollectionNormalizer; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\State\Pagination\PaginatorInterface; use ApiPlatform\State\Pagination\PartialPaginatorInterface; use ApiPlatform\Tests\Fixtures\Foo; -use ApiPlatform\Tests\Fixtures\NotAResource; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -48,11 +46,13 @@ public function testSupportsNormalize(): void $normalizer = new CollectionNormalizer($contextBuilder->reveal(), $resourceClassResolverProphecy->reveal(), $iriConvert->reveal()); - $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); - $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); - $this->assertFalse($normalizer->supportsNormalization([], 'xml')); - $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml')); - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo', 'api_sub_level' => true])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, [])); + $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->hasCacheableSupportsMethod()); } public function testNormalizeResourceCollection(): void @@ -118,141 +118,6 @@ public function testNormalizeResourceCollection(): void ], $actual); } - public function testNormalizeNonResourceCollection(): void - { - $notAResourceA = new NotAResource('A', 'buzz'); - $notAResourceB = new NotAResource('B', 'bzzt'); - - $data = [$notAResourceA, $notAResourceB]; - - $normalizedNotAResourceA = [ - 'foo' => 'A', - 'bar' => 'buzz', - ]; - - $normalizedNotAResourceB = [ - 'foo' => 'B', - 'bar' => 'bzzt', - ]; - - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass($data, null)->willThrow(InvalidArgumentException::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - $delegateNormalizerProphecy->normalize($notAResourceA, CollectionNormalizer::FORMAT, Argument::any())->willReturn($normalizedNotAResourceA); - $delegateNormalizerProphecy->normalize($notAResourceB, CollectionNormalizer::FORMAT, Argument::any())->willReturn($normalizedNotAResourceB); - - $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); - $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); - - $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ - ]); - - $this->assertEquals([ - $normalizedNotAResourceA, - $normalizedNotAResourceB, - ], $actual); - } - - public function testNormalizeSubLevelResourceCollection(): void - { - $fooOne = new Foo(); - $fooOne->id = 1; - $fooOne->bar = 'baz'; - - $fooThree = new Foo(); - $fooThree->id = 3; - $fooThree->bar = 'bzz'; - - $data = [$fooOne, $fooThree]; - - $normalizedFooOne = [ - '@id' => '/foos/1', - '@type' => 'Foo', - 'bar' => 'baz', - ]; - - $normalizedFooThree = [ - '@id' => '/foos/3', - '@type' => 'Foo', - 'bar' => 'bzz', - ]; - - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - $delegateNormalizerProphecy->normalize($fooOne, CollectionNormalizer::FORMAT, Argument::allOf( - Argument::withEntry('resource_class', Foo::class), - Argument::withEntry('api_sub_level', true) - ))->willReturn($normalizedFooOne); - $delegateNormalizerProphecy->normalize($fooThree, CollectionNormalizer::FORMAT, Argument::allOf( - Argument::withEntry('resource_class', Foo::class), - Argument::withEntry('api_sub_level', true) - ))->willReturn($normalizedFooThree); - - $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); - $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); - - $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ - 'operation_name' => 'get', - 'resource_class' => Foo::class, - 'api_sub_level' => true, - ]); - - $this->assertEquals([ - $normalizedFooOne, - $normalizedFooThree, - ], $actual); - } - - public function testNormalizeSubLevelNonResourceCollection(): void - { - $notAResourceA = new NotAResource('A', 'buzz'); - $notAResourceB = new NotAResource('B', 'bzzt'); - - $data = [$notAResourceA, $notAResourceB]; - - $normalizedNotAResourceA = [ - 'foo' => 'A', - 'bar' => 'buzz', - ]; - - $normalizedNotAResourceB = [ - 'foo' => 'B', - 'bar' => 'bzzt', - ]; - - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); - $delegateNormalizerProphecy->normalize($notAResourceA, CollectionNormalizer::FORMAT, Argument::any())->willReturn($normalizedNotAResourceA); - $delegateNormalizerProphecy->normalize($notAResourceB, CollectionNormalizer::FORMAT, Argument::any())->willReturn($normalizedNotAResourceB); - - $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); - $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); - - $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ - 'api_sub_level' => true, - ]); - - $this->assertEquals([ - $normalizedNotAResourceA, - $normalizedNotAResourceB, - ], $actual); - } - public function testNormalizePaginator(): void { $this->assertEquals( diff --git a/tests/JsonApi/Serializer/CollectionNormalizerTest.php b/tests/JsonApi/Serializer/CollectionNormalizerTest.php index 538ea74c1f2..dfd7ed01e5c 100644 --- a/tests/JsonApi/Serializer/CollectionNormalizerTest.php +++ b/tests/JsonApi/Serializer/CollectionNormalizerTest.php @@ -41,11 +41,13 @@ public function testSupportsNormalize(): void $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); - $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); - $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); - $this->assertFalse($normalizer->supportsNormalization([], 'xml')); - $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml')); - $this->assertTrue($normalizer->hasCacheableSupportsMethod()); + $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, ['resource_class' => 'Foo', 'api_sub_level' => true])); + $this->assertFalse($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT, [])); + $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT, ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization([], 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->supportsNormalization(new \ArrayObject(), 'xml', ['resource_class' => 'Foo'])); + $this->assertFalse($normalizer->hasCacheableSupportsMethod()); } public function testNormalizePaginator(): void