diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index e882d3aef05..c6dc72c996e 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -17,6 +17,7 @@ use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractCollectionNormalizer; @@ -31,6 +32,7 @@ */ final class CollectionNormalizer extends AbstractCollectionNormalizer { + use HydraOperationsTrait; use HydraPrefixTrait; use JsonLdContextTrait; @@ -42,7 +44,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer self::PRESERVE_COLLECTION_KEYS => false, ]; - public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = []) + public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); @@ -70,6 +72,18 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data[$hydraPrefix.'totalItems'] = \count($object); } + if (null !== $this->resourceMetadataCollectionFactory && ($context['hydra_operations'] ?? $this->defaultContext['hydra_operations'] ?? false)) { + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( + $resourceClass, + true, + $hydraPrefix + ); + + if (!empty($allHydraOperations)) { + $data[$hydraPrefix.'operation'] = $allHydraOperations; + } + } + return $data; } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 428c6da7a94..7cf342a1561 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\ErrorResource; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -48,6 +47,7 @@ */ final class DocumentationNormalizer implements NormalizerInterface { + use HydraOperationsTrait; use HydraPrefixTrait; public const FORMAT = 'jsonld'; @@ -254,106 +254,6 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource return $properties; } - /** - * Gets Hydra operations. - */ - private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array - { - $hydraOperations = []; - foreach ($resourceMetadata->getOperations() as $operation) { - if (true === $operation->getHideHydraOperation()) { - continue; - } - - if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { - continue; - } - - $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); - } - - return $hydraOperations; - } - - /** - * Gets and populates if applicable a Hydra operation. - */ - private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix): array - { - $method = $operation->getMethod() ?: 'GET'; - - $hydraOperation = $operation->getHydraContext() ?? []; - if ($operation->getDeprecationReason()) { - $hydraOperation['owl:deprecated'] = true; - } - - $shortName = $operation->getShortName(); - $inputMetadata = $operation->getInput() ?? []; - $outputMetadata = $operation->getOutput() ?? []; - - $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; - $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; - - if ('GET' === $method && $operation instanceof CollectionOperationInterface) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', - ]; - } elseif ('GET' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PATCH' === $method) { - $hydraOperation += [ - '@type' => $hydraPrefix.'Operation', - $hydraPrefix.'description' => "Updates the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - - if (null !== $inputClass) { - $possibleValue = []; - foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $possibleValue[] = $mimeType; - } - } - - $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; - } - } elseif ('POST' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], - $hydraPrefix.'description' => "Creates a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PUT' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], - $hydraPrefix.'description' => "Replaces the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('DELETE' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], - $hydraPrefix.'description' => "Deletes the $shortName resource.", - 'returns' => 'owl:Nothing', - ]; - } - - $hydraOperation[$hydraPrefix.'method'] ??= $method; - $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); - - ksort($hydraOperation); - - return $hydraOperation; - } - /** * Gets the range of the property. */ diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php new file mode 100644 index 00000000000..d86da4f24b9 --- /dev/null +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -0,0 +1,174 @@ + + * + * 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\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; + +/** + * Generates Hydra operations for JSON-LD responses. + * + * @author Kévin Dunglas + */ +trait HydraOperationsTrait +{ + /** + * Gets Hydra operations from all resource metadata. + */ + private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix): array + { + $allHydraOperations = []; + $operationNames = []; + + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { + $hydraOperations = $this->getHydraOperationsFromResourceMetadata( + $collection, + $resourceMetadata, + $hydraPrefix, + $operationNames + ); + + $allHydraOperations = array_merge($allHydraOperations, $hydraOperations); + } + + return $allHydraOperations; + } + + /** + * Gets Hydra operations from a single resource metadata. + */ + private function getHydraOperationsFromResourceMetadata(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, array &$operationNames): array + { + $operations = []; + $hydraOperations = $this->getHydraOperations( + $collection, + $resourceMetadata, + $hydraPrefix + ); + + if (!empty($hydraOperations)) { + foreach ($hydraOperations as $operation) { + $operationName = $operation['method']; + if (!\in_array($operationName, $operationNames, true)) { + $operationNames[] = $operationName; + $operations[] = $operation; + } + } + } + + return $operations; + } + + /** + * Gets Hydra operations. + */ + private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + { + $hydraOperations = []; + foreach ($resourceMetadata->getOperations() as $operation) { + if (true === $operation->getHideHydraOperation()) { + continue; + } + + if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { + continue; + } + + $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); + } + + return $hydraOperations; + } + + /** + * Gets and populates if applicable a Hydra operation. + */ + private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + { + $method = $operation->getMethod() ?: 'GET'; + + $hydraOperation = $operation->getHydraContext() ?? []; + if ($operation->getDeprecationReason()) { + $hydraOperation['owl:deprecated'] = true; + } + + $shortName = $operation->getShortName(); + $inputMetadata = $operation->getInput() ?? []; + $outputMetadata = $operation->getOutput() ?? []; + + $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; + $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; + + if ('GET' === $method && $operation instanceof CollectionOperationInterface) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', + ]; + } elseif ('GET' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PATCH' === $method) { + $hydraOperation += [ + '@type' => $hydraPrefix.'Operation', + $hydraPrefix.'description' => "Updates the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + + if (null !== $inputClass) { + $possibleValue = []; + foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $possibleValue[] = $mimeType; + } + } + + $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; + } + } elseif ('POST' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], + $hydraPrefix.'description' => "Creates a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PUT' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], + $hydraPrefix.'description' => "Replaces the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('DELETE' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], + $hydraPrefix.'description' => "Deletes the $shortName resource.", + 'returns' => 'owl:Nothing', + ]; + } + + $hydraOperation[$hydraPrefix.'method'] ??= $method; + $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); + + ksort($hydraOperation); + + return $hydraOperation; + } +} diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index b044c085ef2..ed0e4c4eb36 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -17,7 +17,13 @@ use ApiPlatform\Hydra\Tests\Fixtures\Foo; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; @@ -445,4 +451,251 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void 'totalItems' => 2, ], $actual); } + + public function testNormalizeResourceCollectionWithHydraOperations(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $data = [$fooOne]; + + $normalizedFooOne = [ + '@id' => '/foos/1', + '@type' => 'Foo', + 'bar' => 'baz', + ]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo'), 'post' => (new Post())->withShortName('Foo')])), + ])); + + $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); + + $normalizer = new CollectionNormalizer( + $contextBuilderProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + ['hydra_prefix' => false, 'hydra_operations' => true], + $resourceMetadataCollectionFactoryProphecy->reveal() + ); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'operation_name' => 'get', + 'resource_class' => Foo::class, + ]); + + $this->assertEquals([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'Collection', + 'member' => [ + $normalizedFooOne, + ], + 'totalItems' => 1, + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves the collection of Foo resources.', + 'method' => 'GET', + 'returns' => 'Collection', + 'title' => 'getFooCollection', + ], + [ + '@type' => [ + 'Operation', + 'schema:CreateAction', + ], + 'description' => 'Creates a Foo resource.', + 'expects' => 'Foo', + 'method' => 'POST', + 'returns' => 'Foo', + 'title' => 'postFoo', + ], + ], + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResource(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $data = [$fooOne]; + + $normalizedFooOne = [ + '@id' => '/foos/1', + '@type' => 'Foo', + 'bar' => 'baz', + ]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])), + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['post' => (new Post())->withShortName('Foo')])), + ])); + + $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); + + $normalizer = new CollectionNormalizer( + $contextBuilderProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + ['hydra_prefix' => false, 'hydra_operations' => true], + $resourceMetadataCollectionFactoryProphecy->reveal() + ); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'operation_name' => 'get', + 'resource_class' => Foo::class, + ]); + + $this->assertEquals([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'Collection', + 'member' => [ + $normalizedFooOne, + ], + 'totalItems' => 1, + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves the collection of Foo resources.', + 'method' => 'GET', + 'returns' => 'Collection', + 'title' => 'getFooCollection', + ], + [ + '@type' => [ + 'Operation', + 'schema:CreateAction', + ], + 'description' => 'Creates a Foo resource.', + 'expects' => 'Foo', + 'method' => 'POST', + 'returns' => 'Foo', + 'title' => 'postFoo', + ], + ], + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $data = [$fooOne]; + + $normalizedFooOne = [ + '@id' => '/foos/1', + '@type' => 'Foo', + 'bar' => 'baz', + ]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [ + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])), + (new ApiResource()) + ->withShortName('Foo') + ->withOperations(new Operations(['post' => (new GetCollection())->withShortName('Foo')])), + ])); + + $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); + + $normalizer = new CollectionNormalizer( + $contextBuilderProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + ['hydra_prefix' => false, 'hydra_operations' => true], + $resourceMetadataCollectionFactoryProphecy->reveal() + ); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'operation_name' => 'get', + 'resource_class' => Foo::class, + ]); + + $this->assertEquals([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'Collection', + 'member' => [ + $normalizedFooOne, + ], + 'totalItems' => 1, + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves the collection of Foo resources.', + 'method' => 'GET', + 'returns' => 'Collection', + 'title' => 'getFooCollection', + ], + ], + ], $actual); + } } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 7da1e54a213..036a5b23701 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -13,6 +13,7 @@ namespace ApiPlatform\JsonLd\Serializer; +use ApiPlatform\Hydra\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\Exception\ItemNotFoundException; @@ -45,6 +46,8 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use HydraOperationsTrait; + use HydraPrefixTrait; use JsonLdContextTrait; public const FORMAT = 'jsonld'; @@ -72,8 +75,11 @@ final class ItemNormalizer extends AbstractItemNormalizer '@vocab', ]; + private array $itemNormalizerDefaultContext = []; + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { + $this->itemNormalizerDefaultContext = $defaultContext; parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } @@ -184,6 +190,18 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } + if ($isResourceClass && ($context['hydra_operations'] ?? $this->itemNormalizerDefaultContext['hydra_operations'] ?? false)) { + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( + $resourceClass, + false, + $this->getHydraPrefix($context + $this->itemNormalizerDefaultContext) + ); + + if (!empty($allHydraOperations)) { + $metadata['operation'] = $allHydraOperations; + } + } + return $metadata + $normalizedData; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 8eac85f2780..2dc8f0e5825 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -919,7 +919,8 @@ public function register(): void $app->make(ContextBuilderInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), - $defaultContext + $defaultContext, + $app->make(ResourceMetadataCollectionFactoryInterface::class) ), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(ResourceClassResolverInterface::class), diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 7f6c7953c9d..94fc6771543 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -149,6 +149,7 @@ 'serializer' => [ 'hydra_prefix' => false, + 'hydra_operations' => false, // 'datetime_format' => \DateTimeInterface::RFC3339, ], diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e74..c36577e3a0b 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -332,7 +332,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setDefinition('serializer.normalizer.number', $numberNormalizerDefinition); } - $defaultContext = ['hydra_prefix' => $config['serializer']['hydra_prefix']] + ($container->hasParameter('serializer.default_context') ? $container->getParameter('serializer.default_context') : []); + $defaultContext = [ + 'hydra_prefix' => $config['serializer']['hydra_prefix'], + 'hydra_operations' => $config['serializer']['hydra_operations'], + ] + ($container->hasParameter('serializer.default_context') ? $container->getParameter('serializer.default_context') : []); $container->setParameter('api_platform.serializer.default_context', $defaultContext); if (!$container->hasParameter('serializer.default_context')) { diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fdb..7e0a59bde33 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -172,6 +172,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->booleanNode('hydra_prefix')->defaultFalse()->info('Use the "hydra:" prefix.')->end() + ->booleanNode('hydra_operations')->defaultFalse()->info('Add the "operation" attribute to Hydra responses.')->end() ->end() ->end() ->end(); diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index f015e531d7d..e3432b1589b 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -69,6 +69,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.iri_converter'), '%api_platform.serializer.default_context%', + service('api_platform.metadata.resource.metadata_collection_factory'), ]) ->tag('serializer.normalizer', ['priority' => -985]); diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e2637..ba86b4f4765 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -38,6 +38,8 @@ api_platform: Made with love enable_swagger: true enable_swagger_ui: true + serializer: + hydra_operations: false formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] diff --git a/tests/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index d765b85a2ed..59833a5800a 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -98,4 +99,234 @@ public function testNormalize(): void ]; $this->assertEquals($expected, $normalizer->normalize($dummy)); } + + public function testNormalizeWithHydraOperations(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1989'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1989', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationsMultipleApiResource(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['patch' => (new Patch())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1990'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + [ + '@type' => 'Operation', + 'description' => 'Updates the Dummy resource.', + 'method' => 'PATCH', + 'returns' => 'Dummy', + 'title' => 'patchDummy', + 'expects' => 'Dummy', + 'expectsHeader' => [ + [ + 'headerName' => 'Content-Type', + 'possibleValue' => [], + ], + ], + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1990'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } } diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb6654..3873f26d70c 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -242,7 +242,8 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm // TODO: remove in 5.0 'enable_link_security' => true, 'serializer' => [ - 'hydra_prefix' => null, + 'hydra_prefix' => false, + 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [