Skip to content

feat(hydra): normalize Hydra\Collection objects - #8539

Open
soyuka wants to merge 2 commits into
api-platform:mainfrom
soyuka:feat/hydra-collection-normalizer
Open

soyuka wants to merge 2 commits into
api-platform:mainfrom
soyuka:feat/hydra-collection-normalizer

Conversation

@soyuka

@soyuka soyuka commented Sep 17, 2026

Copy link
Copy Markdown
Member

A state provider can now return an ApiPlatform\Hydra\Collection instead of a bare iterable, and it is normalized correctly for the jsonld format.

Why

Hydra\Collection already exists but is only reachable from the JsonStreamer path (ApiPlatform\Hydra\State\JsonStreamerProcessor). On the Serializer path the object was claimed by ApiPlatform\JsonLd\Serializer\ObjectNormalizer (priority -995), which emitted its raw public properties instead of a Hydra collection.

Widening AbstractCollectionNormalizer::supportsNormalization() to accept the class is not an option: api-platform/hydra requires api-platform/serializer and not the reverse, so importing Hydra\Collection into src/Serializer would invert the split-package dependency direction.

What

  • New ApiPlatform\Hydra\Serializer\CollectionObjectNormalizer, registered at priority -984, which is strictly above the JSON-LD ObjectNormalizer that would otherwise claim the object. It is a standalone service and is not part of the PartialCollectionViewNormalizer / CollectionFiltersNormalizer decoration chain.
  • The object is authoritative. hydra:view and hydra:search are emitted only when the user sets them, and hydra:totalItems falls back to count() only when member is countable. No pagination or parameter introspection happens.
  • hydra:view and hydra:search reuse the exact shapes that PartialCollectionViewNormalizer and CollectionFiltersNormalizer already produce, so the output is indistinguishable from a normal collection.
  • Collection::$id and $context become nullable, and null means "generate it" while an explicit value wins. They previously held a 'VIRTUAL' placeholder, which exists only to keep the non-nullable string properties initialized for the JsonStreamer generated writer. That is a streamer implementation detail and never a "compute me" contract, so a normalizer must not read it.
  • Hydra\Collection is no longer @internal, since it becomes public API.
  • Its $member docblock goes from @var list<T> to @var iterable<T>. The native type is already iterable and JsonStreamerProcessor assigns paginators to it, so list<T> was wrong.

Example

#[GetCollection(uriTemplate: '/books', provider: [Book::class, 'provide'])]
class Book
{
    public static function provide(Operation $operation, array $uriVariables = [], array $context = []): Collection
    {
        $collection = new Collection();
        $collection->member = [new self('1', 'Hyperion'), new self('2', 'Endymion')];
        $collection->totalItems = 2;

        return $collection;
    }
}

JsonStreamer is unaffected

src/JsonLd/JsonStreamer/WritePropertyMetadataLoader.php generates @id, @type and @context as virtual PropertyMetadata bound to value transformers, and IriValueTransformer::transform() ignores the incoming value and recomputes from $options['_current_object']. Nullability of those two properties is therefore inert on the write path.

Verified rather than assumed: the streamed body for GET /json_stream_resources with application/ld+json is identical before and after the change, with @context = /contexts/JsonStreamResource, @id = /json_stream_resources and @type = Collection in both. tests/Functional/JsonStreamerTest.php only asserts key presence there, so a null would not have failed it; the values were compared directly.

Tests

src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php (9 cases) and tests/Functional/HydraCollectionObjectTest.php.

Both are green locally, together with tests/Functional/JsonStreamerTest.php. Broader regression scope left to CI.

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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant