From 6debf8abb09b8d2a07661353e741d627c6b3911a Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 17 Sep 2026 16:05:59 +0200 Subject: [PATCH 1/2] feat(hydra): normalize Hydra\Collection objects A state provider can now return an ApiPlatform\Hydra\Collection instead of a bare iterable. A dedicated jsonld normalizer registered above the JSON-LD ObjectNormalizer reads the object's own fields; totalItems, view and search are emitted only when set. Collection is no longer @internal. --- src/Hydra/Collection.php | 4 +- .../Serializer/CollectionObjectNormalizer.php | 147 +++++++++++++ .../CollectionObjectNormalizerTest.php | 204 ++++++++++++++++++ src/Laravel/ApiPlatformProvider.php | 14 ++ src/Symfony/Bundle/Resources/config/hydra.php | 10 + .../HydraCollectionObject/HydraBook.php | 56 +++++ .../Functional/HydraCollectionObjectTest.php | 67 ++++++ 7 files changed, 499 insertions(+), 3 deletions(-) create mode 100644 src/Hydra/Serializer/CollectionObjectNormalizer.php create mode 100644 src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/HydraCollectionObject/HydraBook.php create mode 100644 tests/Functional/HydraCollectionObjectTest.php diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php index c4a98aa971e..ce79ee615b5 100644 --- a/src/Hydra/Collection.php +++ b/src/Hydra/Collection.php @@ -17,8 +17,6 @@ /** * @template T - * - * @internal */ class Collection { @@ -37,7 +35,7 @@ class Collection public ?PartialCollectionView $view = null; /** - * @var list + * @var iterable */ public iterable $member; } diff --git a/src/Hydra/Serializer/CollectionObjectNormalizer.php b/src/Hydra/Serializer/CollectionObjectNormalizer.php new file mode 100644 index 00000000000..1d5172dc66d --- /dev/null +++ b/src/Hydra/Serializer/CollectionObjectNormalizer.php @@ -0,0 +1,147 @@ + + * + * 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\Hydra\Serializer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; +use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\OperationContextTrait; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalizes an authoritative ApiPlatform\Hydra\Collection object: only the fields the user set are emitted. + */ +final class CollectionObjectNormalizer implements NormalizerInterface, NormalizerAwareInterface +{ + use ContextTrait; + use HydraPrefixTrait; + use JsonLdContextTrait; + use NormalizerAwareTrait; + use OperationContextTrait; + + public const FORMAT = 'jsonld'; + + /** + * @param array $defaultContext + */ + public function __construct( + private readonly ContextBuilderInterface $contextBuilder, + private readonly ResourceClassResolverInterface $resourceClassResolver, + private readonly IriConverterInterface $iriConverter, + private readonly array $defaultContext = [], + ) { + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && $data instanceof Collection; + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? [Collection::class => true] : []; + } + + /** + * @param Collection $data + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); + + if (!isset($context['resource_class'])) { + throw new UnexpectedValueException(\sprintf('The "resource_class" key must be present in the context to normalize an "%s" object.', Collection::class)); + } + + $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); + + $normalized = []; + if ('VIRTUAL' !== $data->context) { + $normalized['@context'] = $data->context; + } else { + $normalized = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); + } + + $normalized['@id'] = 'VIRTUAL' !== $data->id ? $data->id : $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + $normalized['@type'] = $hydraPrefix.$data->type; + + // "totalItems" is a non-nullable, uninitialized-by-default int: isset() is the only safe way to check it was set. + if (isset($data->totalItems)) { + $normalized[$hydraPrefix.'totalItems'] = $data->totalItems; + } elseif (is_countable($data->member)) { + $normalized[$hydraPrefix.'totalItems'] = \count($data->member); + } + + $collectionContext = $this->initContext($resourceClass, $context); + $collectionContext['api_collection_sub_level'] = true; + $childContext = $this->createOperationContext($collectionContext, $resourceClass); + + $members = []; + foreach ($data->member as $item) { + $members[] = $this->normalizer->normalize($item, $format, $childContext + ['jsonld_has_context' => true]); + } + $normalized[$hydraPrefix.'member'] = $members; + + if (null !== $data->view) { + $view = [ + '@id' => $data->view->id, + '@type' => $hydraPrefix.'PartialCollectionView', + ]; + + if (null !== $data->view->first) { + $view[$hydraPrefix.'first'] = $data->view->first; + $view[$hydraPrefix.'last'] = $data->view->last; + } + + if (null !== $data->view->previous) { + $view[$hydraPrefix.'previous'] = $data->view->previous; + } + + if (null !== $data->view->next) { + $view[$hydraPrefix.'next'] = $data->view->next; + } + + $normalized[$hydraPrefix.'view'] = $view; + } + + if (null !== $data->search) { + $mapping = []; + foreach ($data->search->mapping as $m) { + $mapping[] = [ + '@type' => 'IriTemplateMapping', + 'variable' => $m->variable, + 'property' => $m->property, + 'required' => $m->required, + ]; + } + + $normalized[$hydraPrefix.'search'] = [ + '@type' => $hydraPrefix.'IriTemplate', + $hydraPrefix.'template' => $data->search->template, + $hydraPrefix.'variableRepresentation' => $data->search->variableRepresentation, + $hydraPrefix.'mapping' => $mapping, + ]; + } + + return $normalized; + } +} diff --git a/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php new file mode 100644 index 00000000000..8ed54bbfd55 --- /dev/null +++ b/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php @@ -0,0 +1,204 @@ + + * + * 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\Hydra\Tests\Serializer; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\IriTemplate; +use ApiPlatform\Hydra\IriTemplateMapping; +use ApiPlatform\Hydra\PartialCollectionView; +use ApiPlatform\Hydra\Serializer\CollectionObjectNormalizer; +use ApiPlatform\Hydra\Tests\Fixtures\Foo; +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class CollectionObjectNormalizerTest extends TestCase +{ + public function testSupportsNormalization(): void + { + $normalizer = $this->createNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new Collection(), CollectionObjectNormalizer::FORMAT)); + $this->assertFalse($normalizer->supportsNormalization(new Collection(), 'json')); + $this->assertFalse($normalizer->supportsNormalization([], CollectionObjectNormalizer::FORMAT)); + } + + public function testGetSupportedTypes(): void + { + $normalizer = $this->createNormalizer(); + + $this->assertSame([Collection::class => true], $normalizer->getSupportedTypes(CollectionObjectNormalizer::FORMAT)); + $this->assertSame([], $normalizer->getSupportedTypes('json')); + } + + public function testNormalizeUsesContextBuilderAndIriConverterWhenVirtual(): void + { + $collection = new Collection(); + $collection->member = []; + + $contextBuilder = $this->createMock(ContextBuilderInterface::class); + $contextBuilder->expects($this->once())->method('getResourceContextUri')->with(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->with(null, Foo::class)->willReturn(Foo::class); + + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->once())->method('getIriFromResource')->with(Foo::class, UrlGeneratorInterface::ABS_PATH, null, $this->anything())->willReturn('/foos'); + + $normalizer = new CollectionObjectNormalizer($contextBuilder, $resourceClassResolver, $iriConverter); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, ['resource_class' => Foo::class]); + + $this->assertSame('/contexts/Foo', $result['@context']); + $this->assertSame('/foos', $result['@id']); + $this->assertSame('hydra:Collection', $result['@type']); + $this->assertSame([], $result['hydra:member']); + } + + public function testNormalizeKeepsExplicitContextAndId(): void + { + $collection = new Collection(); + $collection->context = '/contexts/Custom'; + $collection->id = '/custom_id'; + $collection->member = []; + + $contextBuilder = $this->createMock(ContextBuilderInterface::class); + $contextBuilder->expects($this->never())->method('getResourceContextUri'); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturn(Foo::class); + + $iriConverter = $this->createMock(IriConverterInterface::class); + $iriConverter->expects($this->never())->method('getIriFromResource'); + + $normalizer = new CollectionObjectNormalizer($contextBuilder, $resourceClassResolver, $iriConverter); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, ['resource_class' => Foo::class]); + + $this->assertSame('/contexts/Custom', $result['@context']); + $this->assertSame('/custom_id', $result['@id']); + } + + public function testNormalizeCountsCountableMemberWhenTotalItemsUninitialized(): void + { + $foo1 = new Foo(); + $foo2 = new Foo(); + + $collection = new Collection(); + $collection->member = [$foo1, $foo2]; + + $normalizer = $this->createNormalizer(); + $itemNormalizer = $this->createStub(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn(['@id' => '/foos/1']); + $normalizer->setNormalizer($itemNormalizer); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, ['resource_class' => Foo::class]); + + $this->assertSame(2, $result['hydra:totalItems']); + } + + public function testNormalizeOmitsTotalItemsForNonCountableMember(): void + { + $collection = new Collection(); + $collection->member = (static function (): \Generator { + yield new Foo(); + })(); + + $normalizer = $this->createNormalizer(); + $itemNormalizer = $this->createStub(NormalizerInterface::class); + $itemNormalizer->method('normalize')->willReturn(['@id' => '/foos/1']); + $normalizer->setNormalizer($itemNormalizer); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, ['resource_class' => Foo::class]); + + $this->assertArrayNotHasKey('hydra:totalItems', $result); + } + + public function testNormalizeSearch(): void + { + $collection = new Collection(); + $collection->member = []; + $collection->search = new IriTemplate( + 'BasicRepresentation', + [ + new IriTemplateMapping('foo', 'foo', true), + new IriTemplateMapping('bar', 'bar'), + ], + '/foos{?foo}', + ); + + $normalizer = $this->createNormalizer(); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, ['resource_class' => Foo::class]); + + $this->assertSame([ + '@type' => 'hydra:IriTemplate', + 'hydra:template' => '/foos{?foo}', + 'hydra:variableRepresentation' => 'BasicRepresentation', + 'hydra:mapping' => [ + ['@type' => 'IriTemplateMapping', 'variable' => 'foo', 'property' => 'foo', 'required' => true], + ['@type' => 'IriTemplateMapping', 'variable' => 'bar', 'property' => 'bar', 'required' => false], + ], + ], $result['hydra:search']); + } + + public function testNormalizeWithCustomHydraPrefix(): void + { + $collection = new Collection(); + $collection->member = []; + $collection->view = new PartialCollectionView('/foos?page=1', first: '/foos?page=1', last: '/foos?page=1'); + + $normalizer = $this->createNormalizer(); + + $result = $normalizer->normalize($collection, CollectionObjectNormalizer::FORMAT, [ + 'resource_class' => Foo::class, + ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false, + ]); + + $this->assertSame('Collection', $result['@type']); + $this->assertArrayHasKey('totalItems', $result); + $this->assertArrayHasKey('member', $result); + $this->assertArrayHasKey('view', $result); + $this->assertSame('PartialCollectionView', $result['view']['@type']); + $this->assertArrayNotHasKey('hydra:view', $result); + } + + public function testNormalizeWithoutResourceClassThrows(): void + { + $normalizer = $this->createNormalizer(); + + $this->expectException(UnexpectedValueException::class); + + $normalizer->normalize(new Collection(), CollectionObjectNormalizer::FORMAT, []); + } + + private function createNormalizer(): CollectionObjectNormalizer + { + $contextBuilder = $this->createStub(ContextBuilderInterface::class); + $contextBuilder->method('getResourceContextUri')->willReturn('/contexts/Foo'); + + $resourceClassResolver = $this->createStub(ResourceClassResolverInterface::class); + $resourceClassResolver->method('getResourceClass')->willReturn(Foo::class); + + $iriConverter = $this->createStub(IriConverterInterface::class); + $iriConverter->method('getIriFromResource')->willReturn('/foos'); + + return new CollectionObjectNormalizer($contextBuilder, $resourceClassResolver, $iriConverter); + } +} diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 58317cf0884..50c41508044 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -54,6 +54,7 @@ use ApiPlatform\Hydra\JsonSchema\SchemaFactory as HydraSchemaFactory; use ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer as HydraCollectionFiltersNormalizer; use ApiPlatform\Hydra\Serializer\CollectionNormalizer as HydraCollectionNormalizer; +use ApiPlatform\Hydra\Serializer\CollectionObjectNormalizer as HydraCollectionObjectNormalizer; use ApiPlatform\Hydra\Serializer\DocumentationNormalizer as HydraDocumentationNormalizer; use ApiPlatform\Hydra\Serializer\EntrypointNormalizer as HydraEntrypointNormalizer; use ApiPlatform\Hydra\Serializer\HydraPrefixNameConverter; @@ -1017,6 +1018,18 @@ public function register(): void ); }); + $this->app->singleton(HydraCollectionObjectNormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new HydraCollectionObjectNormalizer( + $app->make(ContextBuilderInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(IriConverterInterface::class), + $defaultContext + ); + }); + $this->app->singleton(ReservedAttributeNameConverter::class, static function (Application $app) { return new ReservedAttributeNameConverter($app->make(NameConverterInterface::class)); }); @@ -1109,6 +1122,7 @@ public function register(): void $list = new \SplPriorityQueue(); $list->insert($app->make(HydraEntrypointNormalizer::class), -800); $list->insert($app->make(HydraPartialCollectionViewNormalizer::class), -800); + $list->insert($app->make(HydraCollectionObjectNormalizer::class), -984); $list->insert($app->make(HalCollectionNormalizer::class), -800); $list->insert($app->make(HalEntrypointNormalizer::class), -985); $list->insert($app->make(HalObjectNormalizer::class), -995); diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index f015e531d7d..1ce0d98c9d5 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -16,6 +16,7 @@ use ApiPlatform\Hydra\JsonSchema\SchemaFactory; use ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer; use ApiPlatform\Hydra\Serializer\CollectionNormalizer; +use ApiPlatform\Hydra\Serializer\CollectionObjectNormalizer; use ApiPlatform\Hydra\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\Hydra\Serializer\DocumentationNormalizer; use ApiPlatform\Hydra\Serializer\EntrypointNormalizer; @@ -72,6 +73,15 @@ ]) ->tag('serializer.normalizer', ['priority' => -985]); + $services->set('api_platform.hydra.normalizer.collection_object', CollectionObjectNormalizer::class) + ->args([ + service('api_platform.jsonld.context_builder'), + service('api_platform.resource_class_resolver'), + service('api_platform.iri_converter'), + '%api_platform.serializer.default_context%', + ]) + ->tag('serializer.normalizer', ['priority' => -984]); + $services->set('api_platform.hydra.normalizer.partial_collection_view', PartialCollectionViewNormalizer::class) ->decorate('api_platform.hydra.normalizer.collection', null, 0) ->args([ diff --git a/tests/Fixtures/TestBundle/ApiResource/HydraCollectionObject/HydraBook.php b/tests/Fixtures/TestBundle/ApiResource/HydraCollectionObject/HydraBook.php new file mode 100644 index 00000000000..d0f91eb1e54 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/HydraCollectionObject/HydraBook.php @@ -0,0 +1,56 @@ + + * + * 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\HydraCollectionObject; + +use ApiPlatform\Hydra\Collection; +use ApiPlatform\Hydra\PartialCollectionView; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new GetCollection(uriTemplate: '/hydra_collection_objects', provider: [self::class, 'provide']), + new Get(uriTemplate: '/hydra_collection_objects/{id}', uriVariables: ['id']), + ] +)] +class HydraBook +{ + public function __construct( + public string $id = '', + public string $title = '', + ) { + } + + /** + * @return Collection + */ + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): Collection + { + $collection = new Collection(); + $collection->member = [ + new self(id: '1', title: 'Hyperion'), + new self(id: '2', title: 'Endymion'), + ]; + $collection->totalItems = 2; + $collection->view = new PartialCollectionView( + '/hydra_collection_objects?page=1', + first: '/hydra_collection_objects?page=1', + last: '/hydra_collection_objects?page=1', + ); + + return $collection; + } +} diff --git a/tests/Functional/HydraCollectionObjectTest.php b/tests/Functional/HydraCollectionObjectTest.php new file mode 100644 index 00000000000..71f0c83532f --- /dev/null +++ b/tests/Functional/HydraCollectionObjectTest.php @@ -0,0 +1,67 @@ + + * + * 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\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HydraCollectionObject\HydraBook; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraCollectionObjectTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HydraBook::class]; + } + + public function testProviderReturningHydraCollectionObject(): void + { + $response = self::createClient()->request('GET', '/hydra_collection_objects', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + + $this->assertJsonContains([ + '@context' => '/contexts/HydraBook', + '@id' => '/hydra_collection_objects', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 2, + 'hydra:member' => [ + [ + '@id' => '/hydra_collection_objects/1', + '@type' => 'HydraBook', + 'title' => 'Hyperion', + ], + [ + '@id' => '/hydra_collection_objects/2', + '@type' => 'HydraBook', + 'title' => 'Endymion', + ], + ], + 'hydra:view' => [ + '@id' => '/hydra_collection_objects?page=1', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => '/hydra_collection_objects?page=1', + 'hydra:last' => '/hydra_collection_objects?page=1', + ], + ]); + + $this->assertArrayNotHasKey('hydra:search', $response->toArray()); + } +} From 6a082fb7c79cb952e8beb962c60b614aca875072 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 17 Sep 2026 16:20:38 +0200 Subject: [PATCH 2/2] fix(hydra): generate @id and @context when null Collection::$id and $context become nullable: null means "generate it", an explicit value wins. The VIRTUAL placeholder is a JsonStreamer implementation detail, never a "compute me" contract for normalizers. --- src/Hydra/Collection.php | 4 ++-- src/Hydra/Serializer/CollectionObjectNormalizer.php | 11 ++++------- .../Serializer/CollectionObjectNormalizerTest.php | 2 +- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Hydra/Collection.php b/src/Hydra/Collection.php index ce79ee615b5..2aaedb3ffe1 100644 --- a/src/Hydra/Collection.php +++ b/src/Hydra/Collection.php @@ -21,10 +21,10 @@ class Collection { #[StreamedName('@context')] - public string $context = 'VIRTUAL'; + public ?string $context = null; #[StreamedName('@id')] - public string $id = 'VIRTUAL'; + public ?string $id = null; #[StreamedName('@type')] public string $type = 'Collection'; diff --git a/src/Hydra/Serializer/CollectionObjectNormalizer.php b/src/Hydra/Serializer/CollectionObjectNormalizer.php index 1d5172dc66d..8a911997aa6 100644 --- a/src/Hydra/Serializer/CollectionObjectNormalizer.php +++ b/src/Hydra/Serializer/CollectionObjectNormalizer.php @@ -74,14 +74,11 @@ public function normalize(mixed $data, ?string $format = null, array $context = $resourceClass = $this->resourceClassResolver->getResourceClass(null, $context['resource_class']); - $normalized = []; - if ('VIRTUAL' !== $data->context) { - $normalized['@context'] = $data->context; - } else { - $normalized = $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - } + $normalized = null !== $data->context + ? ['@context' => $data->context] + : $this->addJsonLdContext($this->contextBuilder, $resourceClass, $context); - $normalized['@id'] = 'VIRTUAL' !== $data->id ? $data->id : $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); + $normalized['@id'] = $data->id ?? $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $context['operation'] ?? null, $context); $normalized['@type'] = $hydraPrefix.$data->type; // "totalItems" is a non-nullable, uninitialized-by-default int: isset() is the only safe way to check it was set. diff --git a/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php index 8ed54bbfd55..4621aac2437 100644 --- a/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php @@ -47,7 +47,7 @@ public function testGetSupportedTypes(): void $this->assertSame([], $normalizer->getSupportedTypes('json')); } - public function testNormalizeUsesContextBuilderAndIriConverterWhenVirtual(): void + public function testNormalizeBuildsContextAndIdWhenNotSet(): void { $collection = new Collection(); $collection->member = [];