Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions src/Hydra/Collection.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,14 @@

/**
* @template T
*
* @internal
*/
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';
Expand All @@ -37,7 +35,7 @@ class Collection
public ?PartialCollectionView $view = null;

/**
* @var list<T>
* @var iterable<T>
*/
public iterable $member;
}
144 changes: 144 additions & 0 deletions src/Hydra/Serializer/CollectionObjectNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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<string, mixed> $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 = null !== $data->context
? ['@context' => $data->context]
: $this->addJsonLdContext($this->contextBuilder, $resourceClass, $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.
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;
}
}
204 changes: 204 additions & 0 deletions src/Hydra/Tests/Serializer/CollectionObjectNormalizerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 testNormalizeBuildsContextAndIdWhenNotSet(): 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);
}
}
Loading
Loading