From 302ca61dd6929015ee2a12d2cba925eca0db359a Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 09:59:40 +0200 Subject: [PATCH 1/4] refactor(hydra): move DocumentationNormalizer Hydra operations methods to HydraOperationsTrait --- .../Serializer/DocumentationNormalizer.php | 102 +------------- src/Hydra/Serializer/HydraOperationsTrait.php | 127 ++++++++++++++++++ 2 files changed, 128 insertions(+), 101 deletions(-) create mode 100644 src/Hydra/Serializer/HydraOperationsTrait.php diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 14fbee9c03..39e394db80 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'; @@ -250,106 +250,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 0000000000..dfaa9f85bc --- /dev/null +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -0,0 +1,127 @@ + + * + * 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. + */ + 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; + } +} From d23c5ae4028dd68919ce2106ad0fe21d0cd9f74a Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 10:02:01 +0200 Subject: [PATCH 2/4] feat: add `hydra_operations` option --- src/Laravel/config/api-platform.php | 1 + .../Bundle/DependencyInjection/ApiPlatformExtension.php | 5 ++++- src/Symfony/Bundle/DependencyInjection/Configuration.php | 1 + tests/Fixtures/app/config/config_common.yml | 2 ++ .../Symfony/Bundle/DependencyInjection/ConfigurationTest.php | 3 ++- 5 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 2db701be66..f97dde2c97 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -153,6 +153,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 0164d273aa..e6e8357b68 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -336,7 +336,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 911de422fd..7e0a59bde3 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/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 77b2d7cbc1..1434cbe369 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/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb665..3873f26d70 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' => [ From 87ae0f7055dcfdb09e6f9a5ca168d07fd399cfb6 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 10:26:06 +0200 Subject: [PATCH 3/4] feat: add operation to hydra response --- phpstan.neon.dist | 3 + src/Hydra/Serializer/CollectionNormalizer.php | 16 +- src/Hydra/Serializer/HydraOperationsTrait.php | 47 ++++ .../Serializer/CollectionNormalizerTest.php | 253 ++++++++++++++++++ src/JsonLd/Serializer/ItemNormalizer.php | 19 ++ src/Laravel/ApiPlatformProvider.php | 3 +- src/Symfony/Bundle/Resources/config/hydra.php | 1 + .../JsonLd/Serializer/ItemNormalizerTest.php | 231 ++++++++++++++++ 8 files changed, 571 insertions(+), 2 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a221b59557..7f5d91bd5a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -99,6 +99,9 @@ parameters: message: '#^Service "[^"]+" is private.$#' path: src + - + message: '#Access to an undefined property .*DocumentationNormalizer::\$resourceMetadataCollectionFactory#' + path: src/Hydra/Serializer/DocumentationNormalizer.php # Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130 - '#^Call to (static )?method PHPUnit\\Framework\\Assert::.* will always evaluate to true\.$#' diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index e882d3aef0..c6dc72c996 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/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php index dfaa9f85bc..0cb115b556 100644 --- a/src/Hydra/Serializer/HydraOperationsTrait.php +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -25,6 +25,53 @@ */ trait HydraOperationsTrait { + /** + * Gets Hydra operations from all resource metadata. + */ + private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): 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[$hydraPrefix.'method']; + if (!\in_array($operationName, $operationNames, true)) { + $operationNames[] = $operationName; + $operations[] = $operation; + } + } + } + + return $operations; + } + /** * Gets Hydra operations. */ diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index b044c085ef..ed0e4c4eb3 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 2c93881c19..c06b5e6ccd 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\HttpOperation; @@ -43,6 +44,8 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use HydraOperationsTrait; + use HydraPrefixTrait; use ItemNormalizerTrait { denormalize as private doDenormalize; } @@ -50,8 +53,11 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonld'; + 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); } @@ -134,6 +140,19 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = $type; } + if ($isResourceClass && ($context['hydra_operations'] ?? $this->itemNormalizerDefaultContext['hydra_operations'] ?? false)) { + $hydraPrefix = $this->getHydraPrefix($context + $this->itemNormalizerDefaultContext); + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( + $resourceClass, + false, + $hydraPrefix + ); + + if (!empty($allHydraOperations)) { + $metadata[$hydraPrefix.'operation'] = $allHydraOperations; + } + } + return $metadata + $normalizedData; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 12c176f54b..40f2ca655a 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -953,7 +953,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/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index f015e531d7..e3432b1589 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/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index d765b85a2e..59833a5800 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)); + } } From 986443aafe769ad9b9946e0be164a9621e41143e Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 16 May 2026 22:24:08 +0200 Subject: [PATCH 4/4] #[HydraOperation] attriute --- docs/guides/declare-hydra-operations.php | 68 +++++ phpstan.neon.dist | 4 - src/Hydra/Serializer/CollectionNormalizer.php | 13 +- .../Serializer/DocumentationNormalizer.php | 7 + src/Hydra/Serializer/HydraOperationsTrait.php | 141 ++++++++-- .../Serializer/CollectionNormalizerTest.php | 263 +++++++++++++++--- src/JsonLd/Serializer/ItemNormalizer.php | 15 +- src/Laravel/ApiPlatformProvider.php | 3 +- src/Laravel/config/api-platform.php | 1 - src/Metadata/ApiResource.php | 20 ++ src/Metadata/HydraOperation.php | 88 ++++++ ...butesResourceMetadataCollectionFactory.php | 11 + .../Extractor/Adapter/XmlResourceAdapter.php | 27 ++ .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ApiResource/HydraOperationResource.php | 25 ++ ...sResourceMetadataCollectionFactoryTest.php | 20 ++ .../ApiPlatformExtension.php | 5 +- .../DependencyInjection/Configuration.php | 1 - src/Symfony/Bundle/Resources/config/hydra.php | 1 + tests/Fixtures/app/config/config_common.yml | 2 - .../JsonLd/Serializer/ItemNormalizerTest.php | 158 +++++++---- .../DependencyInjection/ConfigurationTest.php | 1 - 22 files changed, 728 insertions(+), 147 deletions(-) create mode 100644 docs/guides/declare-hydra-operations.php create mode 100644 src/Metadata/HydraOperation.php create mode 100644 src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php diff --git a/docs/guides/declare-hydra-operations.php b/docs/guides/declare-hydra-operations.php new file mode 100644 index 0000000000..b0d48c4f79 --- /dev/null +++ b/docs/guides/declare-hydra-operations.php @@ -0,0 +1,68 @@ + false, ]; - public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) + public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); @@ -72,15 +73,17 @@ 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( + if (null !== $this->resourceMetadataCollectionFactory) { + $hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes( $resourceClass, true, + null, + $context, $hydraPrefix ); - if (!empty($allHydraOperations)) { - $data[$hydraPrefix.'operation'] = $allHydraOperations; + if (!empty($hydraOperationsFromAttributes)) { + $data[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; } } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 39e394db80..c4e1cefabe 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -25,6 +25,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; @@ -51,6 +52,9 @@ final class DocumentationNormalizer implements NormalizerInterface use HydraPrefixTrait; public const FORMAT = 'jsonld'; + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory; + private ?ResourceAccessCheckerInterface $resourceAccessChecker; + public function __construct( private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -60,7 +64,10 @@ public function __construct( private readonly ?NameConverterInterface $nameConverter = null, private readonly ?array $defaultContext = [], private readonly ?bool $entrypointEnabled = true, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; } /** diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php index 0cb115b556..cbf0af8378 100644 --- a/src/Hydra/Serializer/HydraOperationsTrait.php +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -17,27 +17,36 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\HydraOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; /** * Generates Hydra operations for JSON-LD responses. * * @author Kévin Dunglas + * + * @property ResourceMetadataCollectionFactoryInterface|null $resourceMetadataCollectionFactory + * @property ResourceAccessCheckerInterface|null $resourceAccessChecker */ trait HydraOperationsTrait { /** - * Gets Hydra operations from all resource metadata. + * Gets Hydra operations from all HydraOperation attributes. */ - private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + private function getHydraOperationsFromAttributes(string $resourceClass, bool $collection, ?object $object, array $context, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $allHydraOperations = []; $operationNames = []; foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { - $hydraOperations = $this->getHydraOperationsFromResourceMetadata( + $hydraOperations = $this->getHydraOperationsFromAttributesForResource( $collection, $resourceMetadata, $hydraPrefix, + $resourceClass, + $object, + $context, $operationNames ); @@ -50,35 +59,117 @@ private function getHydraOperationsFromResourceMetadatas(string $resourceClass, /** * Gets Hydra operations from a single resource metadata. */ - private function getHydraOperationsFromResourceMetadata(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, array &$operationNames): array + private function getHydraOperationsFromAttributesForResource(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, string $resourceClass, ?object $object, array $context, array &$operationNames): array { $operations = []; - $hydraOperations = $this->getHydraOperations( - $collection, - $resourceMetadata, - $hydraPrefix - ); - - if (!empty($hydraOperations)) { - foreach ($hydraOperations as $operation) { - $operationName = $operation[$hydraPrefix.'method']; - if (!\in_array($operationName, $operationNames, true)) { - $operationNames[] = $operationName; - $operations[] = $operation; - } + + foreach ($resourceMetadata->getHydraOperations() ?? [] as $hydraOperation) { + if ($hydraOperation->getCollection() !== $collection) { + continue; + } + + $method = $hydraOperation->getMethod(); + if (\in_array($method, $operationNames, true)) { + continue; + } + + if (!$this->isHydraOperationGranted($hydraOperation, $resourceClass, $object, $context)) { + continue; } + + $operationNames[] = $method; + $operations[] = $this->normalizeHydraOperationAttribute($hydraOperation, $resourceMetadata->getShortName(), $hydraPrefix); } return $operations; } + private function isHydraOperationGranted(HydraOperation $hydraOperation, string $resourceClass, ?object $object, array $context): bool + { + if (null === $expression = $hydraOperation->getSecurity()) { + return true; + } + + if (null === $this->resourceAccessChecker) { + return false; + } + + $extraVariables = ['object' => $object]; + if (isset($context['request'])) { + $extraVariables['request'] = $context['request']; + } + + return $this->resourceAccessChecker->isGranted($resourceClass, $expression, $extraVariables); + } + + /** + * Normalizes a HydraOperation attribute into a JSON-LD array. + */ + private function normalizeHydraOperationAttribute(HydraOperation $hydraOperation, ?string $shortName, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + { + $method = $hydraOperation->getMethod(); + $output = $hydraOperation->getExtraProperties(); + + $output['@type'] = $hydraOperation->getTypes() ?? $this->defaultHydraOperationTypes($method, $hydraPrefix); + + if (null !== ($description = $hydraOperation->getDescription())) { + $output[$hydraPrefix.'description'] = $description; + } + + if (null !== ($expects = $hydraOperation->getExpects())) { + $output['expects'] = $expects; + } elseif (\in_array($method, ['POST', 'PUT', 'PATCH'], true) && null !== $shortName) { + $output['expects'] = $shortName; + } + + if (null !== ($returns = $hydraOperation->getReturns())) { + $output['returns'] = $returns; + } elseif ('DELETE' === $method) { + $output['returns'] = 'owl:Nothing'; + } elseif (null !== $shortName) { + $output['returns'] = $shortName; + } + + $output[$hydraPrefix.'method'] = $method; + $output[$hydraPrefix.'title'] = $hydraOperation->getTitle() + ?? $this->defaultHydraOperationTitle($method, $shortName, $hydraOperation->getCollection() && 'GET' === $method); + + if (null === $output[$hydraPrefix.'title']) { + unset($output[$hydraPrefix.'title']); + } + + ksort($output); + + return $output; + } + + private function defaultHydraOperationTypes(string $method, string $hydraPrefix): array|string + { + return match ($method) { + 'GET' => [$hydraPrefix.'Operation', 'schema:FindAction'], + 'POST' => [$hydraPrefix.'Operation', 'schema:CreateAction'], + 'PUT' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], + 'DELETE' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], + default => $hydraPrefix.'Operation', + }; + } + + private function defaultHydraOperationTitle(string $method, ?string $shortName, bool $isCollection): ?string + { + if (null === $shortName) { + return null; + } + + return strtolower($method).$shortName.($isCollection ? 'Collection' : ''); + } + /** * Gets Hydra operations. */ private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $hydraOperations = []; - foreach ($resourceMetadata->getOperations() as $operation) { + foreach ($resourceMetadata->getOperations() ?? [] as $operation) { if (true === $operation->getHideHydraOperation()) { continue; } @@ -112,21 +203,22 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; - if ('GET' === $method && $operation instanceof CollectionOperationInterface) { + $isCollection = $operation instanceof CollectionOperationInterface; + + $hydraOperation += ['@type' => 'PATCH' === $method ? $hydraPrefix.'Operation' : $this->defaultHydraOperationTypes($method, $hydraPrefix)]; + + if ('GET' === $method && $isCollection) { $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, @@ -144,28 +236,25 @@ private function getHydraOperation(HttpOperation $operation, string $prefixedSho } } 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' : ''); + $hydraOperation[$hydraPrefix.'title'] ??= $this->defaultHydraOperationTitle($method, $shortName, $isCollection); ksort($hydraOperation); diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index ed0e4c4eb3..42866beb29 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -18,12 +18,11 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HydraOperation; 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\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; @@ -452,7 +451,7 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperations(): void + public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -475,11 +474,16 @@ public function testNormalizeResourceCollectionWithHydraOperations(): void $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource(Foo::class, UrlGeneratorInterface::ABS_PATH, Argument::any(), Argument::any())->willReturn('/foos'); + $hydraOperations = new HydraOperation(method: 'POST', collection: true); + $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')])), + ->withHydraOperations([$hydraOperations]), + (new ApiResource()) + ->withShortName('Foo') + ->withHydraOperations([$hydraOperations]), ])); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -492,7 +496,7 @@ public function testNormalizeResourceCollectionWithHydraOperations(): void $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false, 'hydra_operations' => true], + ['hydra_prefix' => false], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -511,22 +515,11 @@ public function testNormalizeResourceCollectionWithHydraOperations(): void ], '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', @@ -536,7 +529,7 @@ public function testNormalizeResourceCollectionWithHydraOperations(): void ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResource(): void + public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurity(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -563,10 +556,9 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $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')])), + ->withHydraOperations([ + new HydraOperation(method: 'POST', collection: true), + ]), ])); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -579,7 +571,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false, 'hydra_operations' => true], + ['hydra_prefix' => false], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -601,19 +593,87 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes [ '@type' => [ 'Operation', - 'schema:FindAction', + 'schema:CreateAction', ], - 'description' => 'Retrieves the collection of Foo resources.', - 'method' => 'GET', - 'returns' => 'Collection', - 'title' => 'getFooCollection', + 'expects' => 'Foo', + 'method' => 'POST', + 'returns' => 'Foo', + 'title' => 'postFoo', ], + ], + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationGrantedBySecurity(): 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') + ->withHydraOperations([ + new HydraOperation(method: 'POST', collection: true, security: "is_granted('ROLE_ADMIN')"), + ]), + ])); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Foo::class, "is_granted('ROLE_ADMIN')", Argument::any())->willReturn(true); + + $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], + $resourceMetadataCollectionFactoryProphecy->reveal(), + $accessCheckerProphecy->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:CreateAction', ], - 'description' => 'Creates a Foo resource.', 'expects' => 'Foo', 'method' => 'POST', 'returns' => 'Foo', @@ -623,7 +683,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + public function testNormalizeResourceCollectionWithHydraOperationDeniedBySecurity(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -650,10 +710,135 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [ (new ApiResource()) ->withShortName('Foo') - ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])), + ->withHydraOperations([ + new HydraOperation(method: 'POST', collection: true, security: "is_granted('ROLE_ADMIN')"), + ]), + ])); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Foo::class, "is_granted('ROLE_ADMIN')", Argument::any())->willReturn(false); + + $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], + $resourceMetadataCollectionFactoryProphecy->reveal(), + $accessCheckerProphecy->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, + ], $actual); + } + + public function testNormalizeResourceCollectionWithoutHydraOperations(): 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'), + ])); + + $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], + $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, + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationFilteredByCollection(): 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(['post' => (new GetCollection())->withShortName('Foo')])), + ->withHydraOperations([ + new HydraOperation(method: 'DELETE', collection: false), + ]), ])); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -666,7 +851,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false, 'hydra_operations' => true], + ['hydra_prefix' => false], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -684,18 +869,6 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $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 c06b5e6ccd..17bfe5c2c0 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -53,11 +53,8 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonld'; - 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); } @@ -140,16 +137,18 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = $type; } - if ($isResourceClass && ($context['hydra_operations'] ?? $this->itemNormalizerDefaultContext['hydra_operations'] ?? false)) { - $hydraPrefix = $this->getHydraPrefix($context + $this->itemNormalizerDefaultContext); - $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( + if ($isResourceClass && null !== $this->resourceMetadataCollectionFactory) { + $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); + $hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes( $resourceClass, false, + $data, + $context, $hydraPrefix ); - if (!empty($allHydraOperations)) { - $metadata[$hydraPrefix.'operation'] = $allHydraOperations; + if (!empty($hydraOperationsFromAttributes)) { + $metadata[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 40f2ca655a..9c291fb398 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -954,7 +954,8 @@ public function register(): void $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), $defaultContext, - $app->make(ResourceMetadataCollectionFactoryInterface::class) + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::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 f97dde2c97..2db701be66 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -153,7 +153,6 @@ 'serializer' => [ 'hydra_prefix' => false, - 'hydra_operations' => false, // 'datetime_format' => \DateTimeInterface::RFC3339, ], diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index eabfdda8fc..65fd459929 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -973,6 +973,7 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, + protected ?array $hydraOperations = null, ) { parent::__construct( shortName: $shortName, @@ -1050,6 +1051,25 @@ public function withMcp(array $mcp): static return $self; } + /** + * @return array|null + */ + public function getHydraOperations(): ?array + { + return $this->hydraOperations; + } + + /** + * @param array $hydraOperations + */ + public function withHydraOperations(array $hydraOperations): static + { + $self = clone $this; + $self->hydraOperations = $hydraOperations; + + return $self; + } + /** * @return Operations|null */ diff --git a/src/Metadata/HydraOperation.php b/src/Metadata/HydraOperation.php new file mode 100644 index 0000000000..f1c27c2643 --- /dev/null +++ b/src/Metadata/HydraOperation.php @@ -0,0 +1,88 @@ + + * + * 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\Metadata; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] +final class HydraOperation +{ + /** + * @param string $method HTTP method (GET, POST, PUT, PATCH, DELETE) + * @param array|null $types Hydra/schema.org types (e.g. ['Operation', 'schema:DeleteAction']). When null, a sensible default is derived from $method. + * @param string|\Stringable|null $security ExpressionLanguage expression evaluated at serialization time. The expression has access to `object`, `user`, `request`, `auth_checker`. When the expression evaluates to false, the operation is omitted from the response. + * @param bool $collection Whether the operation applies to the collection (true) or to an item (false) + */ + public function __construct( + private readonly string $method, + private readonly bool $collection = false, + private readonly string|\Stringable|null $security = null, + private readonly ?string $title = null, + private readonly ?string $description = null, + private readonly ?array $types = null, + private readonly ?string $expects = null, + private readonly ?string $returns = null, + private readonly array $extraProperties = [], + ) { + } + + public function getMethod(): string + { + return $this->method; + } + + public function getCollection(): bool + { + return $this->collection; + } + + public function getSecurity(): ?string + { + return $this->security instanceof \Stringable ? (string) $this->security : $this->security; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function getDescription(): ?string + { + return $this->description; + } + + /** + * @return array|null + */ + public function getTypes(): ?array + { + return $this->types; + } + + public function getExpects(): ?string + { + return $this->expects; + } + + public function getReturns(): ?string + { + return $this->returns; + } + + /** + * @return array + */ + public function getExtraProperties(): array + { + return $this->extraProperties; + } +} diff --git a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php index 2409ea3dda..76faa7bbdc 100644 --- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; +use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; /** @@ -39,8 +40,14 @@ public function create(string $resourceClass): ResourceMetadataCollection } $metadataCollection = []; + $hydraOperations = []; foreach ($reflectionClass->getAttributes() as $attribute) { $name = $attribute->getName(); + if (HydraOperation::class === $name) { + $hydraOperations[] = $attribute->newInstance(); + continue; + } + if ($this->isResourceMetadata($name)) { $metadataCollection[] = $attribute->newInstance(); } @@ -48,6 +55,10 @@ public function create(string $resourceClass): ResourceMetadataCollection $resultCollection = new ResourceMetadataCollection($resourceClass); foreach ($this->buildResourceOperations($metadataCollection, $resourceClass, iterator_to_array($resourceMetadataCollection)) as $resource) { + if ($hydraOperations) { + $resource = $resource->withHydraOperations($hydraOperations); + } + $resultCollection[] = $resource; } diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index b15d99a450..832f1ff1ae 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -230,6 +230,33 @@ private function buildHydraContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('hydraContext'), $values); } + private function buildHydraOperations(\SimpleXMLElement $resource, ?array $values): void + { + if (null === $values) { + return; + } + + $node = $resource->addChild('hydraOperations'); + foreach ($values as $operation) { + $child = $node->addChild('hydraOperation'); + foreach ($operation as $key => $value) { + if (\is_string($value) || null === $value || is_numeric($value) || \is_bool($value)) { + $child->addAttribute($key, $this->parse($value)); + continue; + } + + if (\is_array($value)) { + $method = 'build'.ucfirst($key); + if (method_exists($this, $method)) { + $this->{$method}($child, $value); + continue; + } + $this->buildValues($child->addChild($key), $value); + } + } + } + } + private function buildOpenapi(\SimpleXMLElement $resource, array $values): void { $node = $resource->openapi ?? $resource->addChild('openapi'); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 30c16895a0..a23ceff078 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -345,3 +345,4 @@ resources: 'Lorem ipsum': 'Dolor sit amet' map: null mcp: null + hydraOperations: null diff --git a/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php b/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php new file mode 100644 index 0000000000..278bb0a300 --- /dev/null +++ b/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php @@ -0,0 +1,25 @@ + + * + * 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\Metadata\Tests\Fixtures\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\HydraOperation; + +#[ApiResource] +#[HydraOperation(method: 'DELETE', security: "is_granted('ROLE_ADMIN')")] +#[HydraOperation(method: 'PUT', collection: true, title: 'Bulk replace')] +class HydraOperationResource +{ + public int $id = 0; +} diff --git a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php index 3f744979a0..dfdf91d6ea 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; @@ -33,6 +34,7 @@ use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResources; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\ExtraPropertiesResource; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\HydraOperationResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\PasswordResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter; use ApiPlatform\Metadata\Tests\Fixtures\State\AttributeResourceProcessor; @@ -312,4 +314,22 @@ public function testWithParameters(): void $parameters = $metadataCollection->getOperation('collection')->getParameters(); $this->assertCount(3, $parameters); } + + public function testHydraOperationsFromAttributes(): void + { + $factory = new AttributesResourceMetadataCollectionFactory(); + + $collection = $factory->create(HydraOperationResource::class); + + $this->assertCount(1, $collection); + $hydraOperations = $collection[0]->getHydraOperations(); + $this->assertNotNull($hydraOperations); + $this->assertCount(2, $hydraOperations); + $this->assertContainsOnlyInstancesOf(HydraOperation::class, $hydraOperations); + $this->assertSame('DELETE', $hydraOperations[0]->getMethod()); + $this->assertSame("is_granted('ROLE_ADMIN')", $hydraOperations[0]->getSecurity()); + $this->assertFalse($hydraOperations[0]->getCollection()); + $this->assertSame('PUT', $hydraOperations[1]->getMethod()); + $this->assertTrue($hydraOperations[1]->getCollection()); + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index e6e8357b68..0164d273aa 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -336,10 +336,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setDefinition('serializer.normalizer.number', $numberNormalizerDefinition); } - $defaultContext = [ - 'hydra_prefix' => $config['serializer']['hydra_prefix'], - 'hydra_operations' => $config['serializer']['hydra_operations'], - ] + ($container->hasParameter('serializer.default_context') ? $container->getParameter('serializer.default_context') : []); + $defaultContext = ['hydra_prefix' => $config['serializer']['hydra_prefix']] + ($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 7e0a59bde3..911de422fd 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -172,7 +172,6 @@ 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 e3432b1589..afecf0ce54 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -70,6 +70,7 @@ service('api_platform.iri_converter'), '%api_platform.serializer.default_context%', service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), ]) ->tag('serializer.normalizer', ['priority' => -985]); diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1434cbe369..77b2d7cbc1 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -38,8 +38,6 @@ 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 59833a5800..e4fb1fe07f 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -18,14 +18,15 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\HydraOperation; 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; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -100,16 +101,23 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperations(): void + public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void { $dummy = new Dummy(); $dummy->setName('hello'); + $hydraOperations = new HydraOperation(method: 'DELETE'); + $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')])), + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])) + ->withHydraOperations([$hydraOperations]), + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])) + ->withHydraOperations([$hydraOperations]), ])); $propertyNameCollection = new PropertyNameCollection(['name']); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -120,7 +128,7 @@ public function testNormalizeWithHydraOperations(): void $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'); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1990'); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); @@ -145,24 +153,23 @@ public function testNormalizeWithHydraOperations(): void null, null, null, - ['hydra_prefix' => false, 'hydra_operations' => true] + ['hydra_prefix' => false] ); $normalizer->setSerializer($serializerProphecy->reveal()); $expected = [ '@context' => '/contexts/Dummy', - '@id' => '/dummies/1989', + '@id' => '/dummies/1990', '@type' => 'Dummy', 'operation' => [ [ '@type' => [ 'Operation', - 'schema:FindAction', + 'schema:DeleteAction', ], - 'description' => 'Retrieves a Dummy resource.', - 'method' => 'GET', - 'returns' => 'Dummy', - 'title' => 'getDummy', + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', ], ], 'name' => 'hello', @@ -170,7 +177,7 @@ public function testNormalizeWithHydraOperations(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperationsMultipleApiResource(): void + public function testNormalizeWithHydraOperationsWithoutSecurity(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -179,10 +186,10 @@ public function testNormalizeWithHydraOperationsMultipleApiResource(): void $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')])), + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])) + ->withHydraOperations([ + new HydraOperation(method: 'DELETE'), + ]), ])); $propertyNameCollection = new PropertyNameCollection(['name']); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -193,7 +200,7 @@ public function testNormalizeWithHydraOperationsMultipleApiResource(): void $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'); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1989'); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); @@ -218,38 +225,23 @@ public function testNormalizeWithHydraOperationsMultipleApiResource(): void null, null, null, - ['hydra_prefix' => false, 'hydra_operations' => true] + ['hydra_prefix' => false] ); $normalizer->setSerializer($serializerProphecy->reveal()); $expected = [ '@context' => '/contexts/Dummy', - '@id' => '/dummies/1990', + '@id' => '/dummies/1989', '@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' => [], - ], + 'schema:DeleteAction', ], + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', ], ], 'name' => 'hello', @@ -257,7 +249,7 @@ public function testNormalizeWithHydraOperationsMultipleApiResource(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + public function testNormalizeWithHydraOperationGrantedBySecurity(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -266,10 +258,10 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation $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')])), + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])) + ->withHydraOperations([ + new HydraOperation(method: 'DELETE', security: "is_granted('ROLE_ADMIN')"), + ]), ])); $propertyNameCollection = new PropertyNameCollection(['name']); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -289,6 +281,9 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, "is_granted('ROLE_ADMIN')", Argument::any())->willReturn(true); + $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); @@ -305,7 +300,8 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation null, null, null, - ['hydra_prefix' => false, 'hydra_operations' => true] + ['hydra_prefix' => false], + $accessCheckerProphecy->reveal() ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -317,16 +313,80 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation [ '@type' => [ 'Operation', - 'schema:FindAction', + 'schema:DeleteAction', ], - 'description' => 'Retrieves a Dummy resource.', - 'method' => 'GET', - 'returns' => 'Dummy', - 'title' => 'getDummy', + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', ], ], 'name' => 'hello', ]; $this->assertEquals($expected, $normalizer->normalize($dummy)); } + + public function testNormalizeWithHydraOperationDeniedBySecurity(): 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')])) + ->withHydraOperations([ + new HydraOperation(method: 'DELETE', security: "is_granted('ROLE_ADMIN')"), + ]), + ])); + $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); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, "is_granted('ROLE_ADMIN')", Argument::any())->willReturn(false); + + $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], + $accessCheckerProphecy->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + '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 3873f26d70..2f24321d9e 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -243,7 +243,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enable_link_security' => true, 'serializer' => [ 'hydra_prefix' => false, - 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [