Skip to content

Commit d499145

Browse files
committed
feat: add operation to hydra response
1 parent a0ca31a commit d499145

File tree

8 files changed

+572
-2
lines changed

8 files changed

+572
-2
lines changed

phpstan.neon.dist

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ parameters:
9898
message: '#^Service "[^"]+" is private.$#'
9999
path: src
100100

101+
-
102+
message: '#Access to an undefined property .*DocumentationNormalizer::\$resourceMetadataCollectionFactory#'
103+
path: src/Hydra/Serializer/DocumentationNormalizer.php
101104

102105
# Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130
103106
- '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#'

src/Hydra/Serializer/CollectionNormalizer.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait;
1818
use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait;
1919
use ApiPlatform\Metadata\IriConverterInterface;
20+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
2021
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2122
use ApiPlatform\Metadata\UrlGeneratorInterface;
2223
use ApiPlatform\Serializer\AbstractCollectionNormalizer;
@@ -31,6 +32,7 @@
3132
*/
3233
final class CollectionNormalizer extends AbstractCollectionNormalizer
3334
{
35+
use HydraOperationsTrait;
3436
use HydraPrefixTrait;
3537
use JsonLdContextTrait;
3638

@@ -42,7 +44,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer
4244
self::PRESERVE_COLLECTION_KEYS => false,
4345
];
4446

45-
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [])
47+
public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null)
4648
{
4749
$this->defaultContext = array_merge($this->defaultContext, $defaultContext);
4850

@@ -70,6 +72,18 @@ protected function getPaginationData(iterable $object, array $context = []): arr
7072
$data[$hydraPrefix.'totalItems'] = \count($object);
7173
}
7274

75+
if (null !== $this->resourceMetadataCollectionFactory && ($context['hydra_operations'] ?? $this->defaultContext['hydra_operations'] ?? false)) {
76+
$allHydraOperations = $this->getHydraOperationsFromResourceMetadatas(
77+
$resourceClass,
78+
true,
79+
$hydraPrefix
80+
);
81+
82+
if (!empty($allHydraOperations)) {
83+
$data[$hydraPrefix.'operation'] = $allHydraOperations;
84+
}
85+
}
86+
7387
return $data;
7488
}
7589

src/Hydra/Serializer/HydraOperationsTrait.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,53 @@
2525
*/
2626
trait HydraOperationsTrait
2727
{
28+
/**
29+
* Gets Hydra operations from all resource metadata.
30+
*/
31+
private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array
32+
{
33+
$allHydraOperations = [];
34+
$operationNames = [];
35+
36+
foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) {
37+
$hydraOperations = $this->getHydraOperationsFromResourceMetadata(
38+
$collection,
39+
$resourceMetadata,
40+
$hydraPrefix,
41+
$operationNames
42+
);
43+
44+
$allHydraOperations = array_merge($allHydraOperations, $hydraOperations);
45+
}
46+
47+
return $allHydraOperations;
48+
}
49+
50+
/**
51+
* Gets Hydra operations from a single resource metadata.
52+
*/
53+
private function getHydraOperationsFromResourceMetadata(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, array &$operationNames): array
54+
{
55+
$operations = [];
56+
$hydraOperations = $this->getHydraOperations(
57+
$collection,
58+
$resourceMetadata,
59+
$hydraPrefix
60+
);
61+
62+
if (!empty($hydraOperations)) {
63+
foreach ($hydraOperations as $operation) {
64+
$operationName = $operation[$hydraPrefix.'method'];
65+
if (!\in_array($operationName, $operationNames, true)) {
66+
$operationNames[] = $operationName;
67+
$operations[] = $operation;
68+
}
69+
}
70+
}
71+
72+
return $operations;
73+
}
74+
2875
/**
2976
* Gets Hydra operations.
3077
*/

src/Hydra/Tests/Serializer/CollectionNormalizerTest.php

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@
1717
use ApiPlatform\Hydra\Tests\Fixtures\Foo;
1818
use ApiPlatform\JsonLd\ContextBuilder;
1919
use ApiPlatform\JsonLd\ContextBuilderInterface;
20+
use ApiPlatform\Metadata\ApiResource;
21+
use ApiPlatform\Metadata\GetCollection;
2022
use ApiPlatform\Metadata\IriConverterInterface;
23+
use ApiPlatform\Metadata\Operations;
24+
use ApiPlatform\Metadata\Post;
25+
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
26+
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
2127
use ApiPlatform\Metadata\ResourceClassResolverInterface;
2228
use ApiPlatform\Metadata\UrlGeneratorInterface;
2329
use ApiPlatform\Serializer\AbstractItemNormalizer;
@@ -445,4 +451,251 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void
445451
'totalItems' => 2,
446452
], $actual);
447453
}
454+
455+
public function testNormalizeResourceCollectionWithHydraOperations(): void
456+
{
457+
$fooOne = new Foo();
458+
$fooOne->id = 1;
459+
$fooOne->bar = 'baz';
460+
461+
$data = [$fooOne];
462+
463+
$normalizedFooOne = [
464+
'@id' => '/foos/1',
465+
'@type' => 'Foo',
466+
'bar' => 'baz',
467+
];
468+
469+
$contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class);
470+
$contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo');
471+
472+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
473+
$resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class);
474+
475+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
476+
$iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos');
477+
478+
$resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
479+
$resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [
480+
(new ApiResource())
481+
->withShortName('Foo')
482+
->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo'), 'post' => (new Post())->withShortName('Foo')])),
483+
]));
484+
485+
$delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class);
486+
$delegateNormalizerProphecy->normalize($fooOne, CollectionNormalizer::FORMAT, Argument::allOf(
487+
Argument::withEntry('resource_class', Foo::class),
488+
Argument::withEntry('api_sub_level', true)
489+
))->willReturn($normalizedFooOne);
490+
491+
$normalizer = new CollectionNormalizer(
492+
$contextBuilderProphecy->reveal(),
493+
$resourceClassResolverProphecy->reveal(),
494+
$iriConverterProphecy->reveal(),
495+
['hydra_prefix' => false, 'hydra_operations' => true],
496+
$resourceMetadataCollectionFactoryProphecy->reveal()
497+
);
498+
$normalizer->setNormalizer($delegateNormalizerProphecy->reveal());
499+
500+
$actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [
501+
'operation_name' => 'get',
502+
'resource_class' => Foo::class,
503+
]);
504+
505+
$this->assertEquals([
506+
'@context' => '/contexts/Foo',
507+
'@id' => '/foos',
508+
'@type' => 'Collection',
509+
'member' => [
510+
$normalizedFooOne,
511+
],
512+
'totalItems' => 1,
513+
'operation' => [
514+
[
515+
'@type' => [
516+
'Operation',
517+
'schema:FindAction',
518+
],
519+
'description' => 'Retrieves the collection of Foo resources.',
520+
'method' => 'GET',
521+
'returns' => 'Collection',
522+
'title' => 'getFooCollection',
523+
],
524+
[
525+
'@type' => [
526+
'Operation',
527+
'schema:CreateAction',
528+
],
529+
'description' => 'Creates a Foo resource.',
530+
'expects' => 'Foo',
531+
'method' => 'POST',
532+
'returns' => 'Foo',
533+
'title' => 'postFoo',
534+
],
535+
],
536+
], $actual);
537+
}
538+
539+
public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResource(): void
540+
{
541+
$fooOne = new Foo();
542+
$fooOne->id = 1;
543+
$fooOne->bar = 'baz';
544+
545+
$data = [$fooOne];
546+
547+
$normalizedFooOne = [
548+
'@id' => '/foos/1',
549+
'@type' => 'Foo',
550+
'bar' => 'baz',
551+
];
552+
553+
$contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class);
554+
$contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo');
555+
556+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
557+
$resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class);
558+
559+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
560+
$iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos');
561+
562+
$resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
563+
$resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [
564+
(new ApiResource())
565+
->withShortName('Foo')
566+
->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])),
567+
(new ApiResource())
568+
->withShortName('Foo')
569+
->withOperations(new Operations(['post' => (new Post())->withShortName('Foo')])),
570+
]));
571+
572+
$delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class);
573+
$delegateNormalizerProphecy->normalize($fooOne, CollectionNormalizer::FORMAT, Argument::allOf(
574+
Argument::withEntry('resource_class', Foo::class),
575+
Argument::withEntry('api_sub_level', true)
576+
))->willReturn($normalizedFooOne);
577+
578+
$normalizer = new CollectionNormalizer(
579+
$contextBuilderProphecy->reveal(),
580+
$resourceClassResolverProphecy->reveal(),
581+
$iriConverterProphecy->reveal(),
582+
['hydra_prefix' => false, 'hydra_operations' => true],
583+
$resourceMetadataCollectionFactoryProphecy->reveal()
584+
);
585+
$normalizer->setNormalizer($delegateNormalizerProphecy->reveal());
586+
587+
$actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [
588+
'operation_name' => 'get',
589+
'resource_class' => Foo::class,
590+
]);
591+
592+
$this->assertEquals([
593+
'@context' => '/contexts/Foo',
594+
'@id' => '/foos',
595+
'@type' => 'Collection',
596+
'member' => [
597+
$normalizedFooOne,
598+
],
599+
'totalItems' => 1,
600+
'operation' => [
601+
[
602+
'@type' => [
603+
'Operation',
604+
'schema:FindAction',
605+
],
606+
'description' => 'Retrieves the collection of Foo resources.',
607+
'method' => 'GET',
608+
'returns' => 'Collection',
609+
'title' => 'getFooCollection',
610+
],
611+
[
612+
'@type' => [
613+
'Operation',
614+
'schema:CreateAction',
615+
],
616+
'description' => 'Creates a Foo resource.',
617+
'expects' => 'Foo',
618+
'method' => 'POST',
619+
'returns' => 'Foo',
620+
'title' => 'postFoo',
621+
],
622+
],
623+
], $actual);
624+
}
625+
626+
public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void
627+
{
628+
$fooOne = new Foo();
629+
$fooOne->id = 1;
630+
$fooOne->bar = 'baz';
631+
632+
$data = [$fooOne];
633+
634+
$normalizedFooOne = [
635+
'@id' => '/foos/1',
636+
'@type' => 'Foo',
637+
'bar' => 'baz',
638+
];
639+
640+
$contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class);
641+
$contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo');
642+
643+
$resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class);
644+
$resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class);
645+
646+
$iriConverterProphecy = $this->prophesize(IriConverterInterface::class);
647+
$iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos');
648+
649+
$resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
650+
$resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [
651+
(new ApiResource())
652+
->withShortName('Foo')
653+
->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])),
654+
(new ApiResource())
655+
->withShortName('Foo')
656+
->withOperations(new Operations(['post' => (new GetCollection())->withShortName('Foo')])),
657+
]));
658+
659+
$delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class);
660+
$delegateNormalizerProphecy->normalize($fooOne, CollectionNormalizer::FORMAT, Argument::allOf(
661+
Argument::withEntry('resource_class', Foo::class),
662+
Argument::withEntry('api_sub_level', true)
663+
))->willReturn($normalizedFooOne);
664+
665+
$normalizer = new CollectionNormalizer(
666+
$contextBuilderProphecy->reveal(),
667+
$resourceClassResolverProphecy->reveal(),
668+
$iriConverterProphecy->reveal(),
669+
['hydra_prefix' => false, 'hydra_operations' => true],
670+
$resourceMetadataCollectionFactoryProphecy->reveal()
671+
);
672+
$normalizer->setNormalizer($delegateNormalizerProphecy->reveal());
673+
674+
$actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [
675+
'operation_name' => 'get',
676+
'resource_class' => Foo::class,
677+
]);
678+
679+
$this->assertEquals([
680+
'@context' => '/contexts/Foo',
681+
'@id' => '/foos',
682+
'@type' => 'Collection',
683+
'member' => [
684+
$normalizedFooOne,
685+
],
686+
'totalItems' => 1,
687+
'operation' => [
688+
[
689+
'@type' => [
690+
'Operation',
691+
'schema:FindAction',
692+
],
693+
'description' => 'Retrieves the collection of Foo resources.',
694+
'method' => 'GET',
695+
'returns' => 'Collection',
696+
'title' => 'getFooCollection',
697+
],
698+
],
699+
], $actual);
700+
}
448701
}

0 commit comments

Comments
 (0)