From 92607e3e679a88d7444abdab25ed6463791a0c23 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 09:59:40 +0200 Subject: [PATCH 01/11] 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 9ebcd489bea..f8e84b6101e 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; @@ -46,6 +45,7 @@ */ final class DocumentationNormalizer implements NormalizerInterface { + use HydraOperationsTrait; use HydraPrefixTrait; public const FORMAT = 'jsonld'; @@ -248,106 +248,6 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource return $properties; } - /** - * Gets Hydra operations. - */ - private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array - { - $hydraOperations = []; - foreach ($resourceMetadata->getOperations() as $operation) { - if (true === $operation->getHideHydraOperation()) { - continue; - } - - if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { - continue; - } - - $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); - } - - return $hydraOperations; - } - - /** - * Gets and populates if applicable a Hydra operation. - */ - private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix): array - { - $method = $operation->getMethod() ?: 'GET'; - - $hydraOperation = $operation->getHydraContext() ?? []; - if ($operation->getDeprecationReason()) { - $hydraOperation['owl:deprecated'] = true; - } - - $shortName = $operation->getShortName(); - $inputMetadata = $operation->getInput() ?? []; - $outputMetadata = $operation->getOutput() ?? []; - - $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; - $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; - - if ('GET' === $method && $operation instanceof CollectionOperationInterface) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', - ]; - } elseif ('GET' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PATCH' === $method) { - $hydraOperation += [ - '@type' => $hydraPrefix.'Operation', - $hydraPrefix.'description' => "Updates the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - - if (null !== $inputClass) { - $possibleValue = []; - foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $possibleValue[] = $mimeType; - } - } - - $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; - } - } elseif ('POST' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], - $hydraPrefix.'description' => "Creates a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PUT' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], - $hydraPrefix.'description' => "Replaces the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('DELETE' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], - $hydraPrefix.'description' => "Deletes the $shortName resource.", - 'returns' => 'owl:Nothing', - ]; - } - - $hydraOperation[$hydraPrefix.'method'] ??= $method; - $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); - - ksort($hydraOperation); - - return $hydraOperation; - } - /** * Gets the range of the property. */ diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php new file mode 100644 index 00000000000..dfaa9f85bc5 --- /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 f94c8cc867ba340650b32d196edb5e13be33364a Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 10:02:01 +0200 Subject: [PATCH 02/11] 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 52ae847eb32..54a83136a94 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,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 0b2548a3ff0..52e8eea7e13 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -346,7 +346,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 10db1dde0dc..6ab9fb7c0f8 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -165,6 +165,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 b1d280c406f..2546e5c7242 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -46,6 +46,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 0d47ca31b71..568cc49568a 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -238,7 +238,8 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'use_symfony_listeners' => false, 'handle_symfony_errors' => false, 'serializer' => [ - 'hydra_prefix' => null, + 'hydra_prefix' => false, + 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [ From 8ca5d8edf0b5ebcd7a0215f1121de3bb4255010e Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 21 Apr 2026 10:26:06 +0200 Subject: [PATCH 03/11] 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 b32ab68d8f5..b2311eaca9d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -104,6 +104,9 @@ parameters: - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" + - + 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 e882d3aef05..c6dc72c996e 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -17,6 +17,7 @@ use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractCollectionNormalizer; @@ -31,6 +32,7 @@ */ final class CollectionNormalizer extends AbstractCollectionNormalizer { + use HydraOperationsTrait; use HydraPrefixTrait; use JsonLdContextTrait; @@ -42,7 +44,7 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer self::PRESERVE_COLLECTION_KEYS => false, ]; - public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = []) + public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); @@ -70,6 +72,18 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data[$hydraPrefix.'totalItems'] = \count($object); } + if (null !== $this->resourceMetadataCollectionFactory && ($context['hydra_operations'] ?? $this->defaultContext['hydra_operations'] ?? false)) { + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( + $resourceClass, + true, + $hydraPrefix + ); + + if (!empty($allHydraOperations)) { + $data[$hydraPrefix.'operation'] = $allHydraOperations; + } + } + return $data; } diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php index dfaa9f85bc5..0cb115b5566 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 58591f416de..585215630d6 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 e63202613f6..a7c90a05df7 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 152b7e9949b..7bd07b98dbb 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1023,7 +1023,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 1ce0d98c9d5..ab70db0573d 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -70,6 +70,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 d765b85a2ed..59833a5800a 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; @@ -98,4 +99,234 @@ public function testNormalize(): void ]; $this->assertEquals($expected, $normalizer->normalize($dummy)); } + + public function testNormalizeWithHydraOperations(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1989'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1989', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationsMultipleApiResource(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['patch' => (new Patch())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1990'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + [ + '@type' => 'Operation', + 'description' => 'Updates the Dummy resource.', + 'method' => 'PATCH', + 'returns' => 'Dummy', + 'title' => 'patchDummy', + 'expects' => 'Dummy', + 'expectsHeader' => [ + [ + 'headerName' => 'Content-Type', + 'possibleValue' => [], + ], + ], + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + (new ApiResource()) + ->withShortName('Dummy') + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), + ])); + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn($propertyNameCollection); + + $propertyMetadata = (new ApiProperty())->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1990'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', null, Argument::type('array'))->willReturn('hello'); + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Dummy::class)->willReturn('/contexts/Dummy'); + + $normalizer = new ItemNormalizer( + $resourceMetadataCollectionFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $contextBuilderProphecy->reveal(), + null, + null, + null, + ['hydra_prefix' => false, 'hydra_operations' => true] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:FindAction', + ], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } } From 12c2c6b3baaf28d805ff6de32e1894c85bf87290 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 16 May 2026 22:24:08 +0200 Subject: [PATCH 04/11] .#[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 00000000000..b0d48c4f79d --- /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 f8e84b6101e..af750e25ca9 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; @@ -49,6 +50,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, @@ -58,7 +62,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 0cb115b5566..cbf0af8378a 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 585215630d6..90fba410bcd 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 a7c90a05df7..290575d1a9d 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 7bd07b98dbb..e043e0dcb43 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1024,7 +1024,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 54a83136a94..52ae847eb32 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,7 +178,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 5d136533d83..8d8ee2608bc 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -982,6 +982,7 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, + protected ?array $hydraOperations = null, ) { parent::__construct( shortName: $shortName, @@ -1060,6 +1061,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 00000000000..f1c27c2643b --- /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 2409ea3dda7..76faa7bbdce 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 4bebfa435e7..1aa65494c4d 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -236,6 +236,33 @@ private function buildJsonldContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('jsonldContext'), $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 fe1595bf154..1e0d312ebab 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -350,3 +350,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 00000000000..278bb0a300b --- /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 f9eef140bc9..dd0214dd45d 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; @@ -37,6 +38,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\MutationDescription; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\PasswordResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\ResourceClassPropagation; @@ -409,4 +411,22 @@ public function testDuplicateOperationNameThrows(): void $factory->create(SameNameDifferentMethodOperations::class); } + + 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 52e8eea7e13..0b2548a3ff0 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -346,10 +346,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 6ab9fb7c0f8..10db1dde0dc 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -165,7 +165,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 ab70db0573d..58477d62a31 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -71,6 +71,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 2546e5c7242..b1d280c406f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -46,8 +46,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 59833a5800a..e4fb1fe07f2 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 568cc49568a..44cc6e47349 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -239,7 +239,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'handle_symfony_errors' => false, 'serializer' => [ 'hydra_prefix' => false, - 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [ From 3ec8cbc95dae0d97ecb4bec70ac6d9d51e413976 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Fri, 2 Oct 2026 17:40:15 +0200 Subject: [PATCH 05/11] Revert ".#[HydraOperation] attriute" This reverts commit 12c2c6b3baaf28d805ff6de32e1894c85bf87290. --- 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, 147 insertions(+), 728 deletions(-) delete mode 100644 docs/guides/declare-hydra-operations.php delete mode 100644 src/Metadata/HydraOperation.php delete 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 deleted file mode 100644 index b0d48c4f79d..00000000000 --- a/docs/guides/declare-hydra-operations.php +++ /dev/null @@ -1,68 +0,0 @@ - false, ]; - 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) + 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); @@ -73,17 +72,15 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data[$hydraPrefix.'totalItems'] = \count($object); } - if (null !== $this->resourceMetadataCollectionFactory) { - $hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes( + if (null !== $this->resourceMetadataCollectionFactory && ($context['hydra_operations'] ?? $this->defaultContext['hydra_operations'] ?? false)) { + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( $resourceClass, true, - null, - $context, $hydraPrefix ); - if (!empty($hydraOperationsFromAttributes)) { - $data[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; + if (!empty($allHydraOperations)) { + $data[$hydraPrefix.'operation'] = $allHydraOperations; } } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index af750e25ca9..f8e84b6101e 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -25,7 +25,6 @@ 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; @@ -50,9 +49,6 @@ 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, @@ -62,10 +58,7 @@ 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 cbf0af8378a..0cb115b5566 100644 --- a/src/Hydra/Serializer/HydraOperationsTrait.php +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -17,36 +17,27 @@ 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 HydraOperation attributes. + * Gets Hydra operations from all resource metadata. */ - private function getHydraOperationsFromAttributes(string $resourceClass, bool $collection, ?object $object, array $context, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + private function getHydraOperationsFromResourceMetadatas(string $resourceClass, bool $collection, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $allHydraOperations = []; $operationNames = []; foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resourceMetadata) { - $hydraOperations = $this->getHydraOperationsFromAttributesForResource( + $hydraOperations = $this->getHydraOperationsFromResourceMetadata( $collection, $resourceMetadata, $hydraPrefix, - $resourceClass, - $object, - $context, $operationNames ); @@ -59,117 +50,35 @@ private function getHydraOperationsFromAttributes(string $resourceClass, bool $c /** * Gets Hydra operations from a single resource metadata. */ - private function getHydraOperationsFromAttributesForResource(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, string $resourceClass, ?object $object, array $context, array &$operationNames): array + private function getHydraOperationsFromResourceMetadata(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix, array &$operationNames): array { $operations = []; - - 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; + $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; + } } - - $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; } @@ -203,22 +112,21 @@ 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; - $isCollection = $operation instanceof CollectionOperationInterface; - - $hydraOperation += ['@type' => 'PATCH' === $method ? $hydraPrefix.'Operation' : $this->defaultHydraOperationTypes($method, $hydraPrefix)]; - - if ('GET' === $method && $isCollection) { + 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, @@ -236,25 +144,28 @@ 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'] ??= $this->defaultHydraOperationTitle($method, $shortName, $isCollection); + $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); ksort($hydraOperation); diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index 90fba410bcd..585215630d6 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -18,11 +18,12 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\HydraOperation; +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\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractItemNormalizer; @@ -451,7 +452,7 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + public function testNormalizeResourceCollectionWithHydraOperations(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -474,16 +475,11 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $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') - ->withHydraOperations([$hydraOperations]), - (new ApiResource()) - ->withShortName('Foo') - ->withHydraOperations([$hydraOperations]), + ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo'), 'post' => (new Post())->withShortName('Foo')])), ])); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -496,7 +492,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false], + ['hydra_prefix' => false, 'hydra_operations' => true], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -515,11 +511,22 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes ], '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', @@ -529,7 +536,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiRes ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurity(): void + public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResource(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -556,9 +563,10 @@ public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurit $resourceMetadataCollectionFactoryProphecy->create(Foo::class)->willReturn(new ResourceMetadataCollection('Foo', [ (new ApiResource()) ->withShortName('Foo') - ->withHydraOperations([ - new HydraOperation(method: 'POST', collection: true), - ]), + ->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); @@ -571,7 +579,7 @@ public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurit $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false], + ['hydra_prefix' => false, 'hydra_operations' => true], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -593,87 +601,19 @@ public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurit [ '@type' => [ 'Operation', - 'schema:CreateAction', + 'schema:FindAction', ], - 'expects' => 'Foo', - 'method' => 'POST', - 'returns' => 'Foo', - 'title' => 'postFoo', + 'description' => 'Retrieves the collection of Foo resources.', + 'method' => 'GET', + 'returns' => 'Collection', + 'title' => 'getFooCollection', ], - ], - ], $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', @@ -683,7 +623,7 @@ public function testNormalizeResourceCollectionWithHydraOperationGrantedBySecuri ], $actual); } - public function testNormalizeResourceCollectionWithHydraOperationDeniedBySecurity(): void + public function testNormalizeResourceCollectionWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void { $fooOne = new Foo(); $fooOne->id = 1; @@ -710,135 +650,10 @@ public function testNormalizeResourceCollectionWithHydraOperationDeniedBySecurit $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(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', [ + ->withOperations(new Operations(['get' => (new GetCollection())->withShortName('Foo')])), (new ApiResource()) ->withShortName('Foo') - ->withHydraOperations([ - new HydraOperation(method: 'DELETE', collection: false), - ]), + ->withOperations(new Operations(['post' => (new GetCollection())->withShortName('Foo')])), ])); $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); @@ -851,7 +666,7 @@ public function testNormalizeResourceCollectionWithHydraOperationFilteredByColle $contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), - ['hydra_prefix' => false], + ['hydra_prefix' => false, 'hydra_operations' => true], $resourceMetadataCollectionFactoryProphecy->reveal() ); $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); @@ -869,6 +684,18 @@ public function testNormalizeResourceCollectionWithHydraOperationFilteredByColle $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 290575d1a9d..a7c90a05df7 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -53,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); } @@ -137,18 +140,16 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = $type; } - if ($isResourceClass && null !== $this->resourceMetadataCollectionFactory) { - $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); - $hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes( + if ($isResourceClass && ($context['hydra_operations'] ?? $this->itemNormalizerDefaultContext['hydra_operations'] ?? false)) { + $hydraPrefix = $this->getHydraPrefix($context + $this->itemNormalizerDefaultContext); + $allHydraOperations = $this->getHydraOperationsFromResourceMetadatas( $resourceClass, false, - $data, - $context, $hydraPrefix ); - if (!empty($hydraOperationsFromAttributes)) { - $metadata[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; + if (!empty($allHydraOperations)) { + $metadata[$hydraPrefix.'operation'] = $allHydraOperations; } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index e043e0dcb43..7bd07b98dbb 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1024,8 +1024,7 @@ public function register(): void $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), $defaultContext, - $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(ResourceAccessCheckerInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class) ), $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(ResourceClassResolverInterface::class), diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 52ae847eb32..54a83136a94 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,7 @@ 'serializer' => [ 'hydra_prefix' => false, + 'hydra_operations' => false, // 'datetime_format' => \DateTimeInterface::RFC3339, ], diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 8d8ee2608bc..5d136533d83 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -982,7 +982,6 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, - protected ?array $hydraOperations = null, ) { parent::__construct( shortName: $shortName, @@ -1061,25 +1060,6 @@ 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 deleted file mode 100644 index f1c27c2643b..00000000000 --- a/src/Metadata/HydraOperation.php +++ /dev/null @@ -1,88 +0,0 @@ - - * - * 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 76faa7bbdce..2409ea3dda7 100644 --- a/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AttributesResourceMetadataCollectionFactory.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Metadata\Resource\Factory; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; -use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; /** @@ -40,14 +39,8 @@ 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(); } @@ -55,10 +48,6 @@ 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 1aa65494c4d..4bebfa435e7 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -236,33 +236,6 @@ private function buildJsonldContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('jsonldContext'), $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 1e0d312ebab..fe1595bf154 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -350,4 +350,3 @@ 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 deleted file mode 100644 index 278bb0a300b..00000000000 --- a/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php +++ /dev/null @@ -1,25 +0,0 @@ - - * - * 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 dd0214dd45d..f9eef140bc9 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -22,7 +22,6 @@ 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; @@ -38,7 +37,6 @@ 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\MutationDescription; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\PasswordResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\ResourceClassPropagation; @@ -411,22 +409,4 @@ public function testDuplicateOperationNameThrows(): void $factory->create(SameNameDifferentMethodOperations::class); } - - 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 0b2548a3ff0..52e8eea7e13 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -346,7 +346,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 10db1dde0dc..6ab9fb7c0f8 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -165,6 +165,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->booleanNode('hydra_prefix')->defaultFalse()->info('Use the "hydra:" prefix.')->end() + ->booleanNode('hydra_operations')->defaultFalse()->info('Add the "operation" attribute to Hydra responses.')->end() ->end() ->end() ->end(); diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index 58477d62a31..ab70db0573d 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -71,7 +71,6 @@ 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 b1d280c406f..2546e5c7242 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -46,6 +46,8 @@ api_platform: Made with love enable_swagger: true enable_swagger_ui: true + serializer: + hydra_operations: false formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] diff --git a/tests/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index e4fb1fe07f2..59833a5800a 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -18,15 +18,14 @@ 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; @@ -101,23 +100,16 @@ public function testNormalize(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void + public function testNormalizeWithHydraOperations(): 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')])) - ->withHydraOperations([$hydraOperations]), - (new ApiResource()) - ->withShortName('Dummy') - ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])) - ->withHydraOperations([$hydraOperations]), + ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), ])); $propertyNameCollection = new PropertyNameCollection(['name']); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -128,7 +120,7 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation $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); @@ -153,23 +145,24 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation null, null, null, - ['hydra_prefix' => false] + ['hydra_prefix' => false, 'hydra_operations' => true] ); $normalizer->setSerializer($serializerProphecy->reveal()); $expected = [ '@context' => '/contexts/Dummy', - '@id' => '/dummies/1990', + '@id' => '/dummies/1989', '@type' => 'Dummy', 'operation' => [ [ '@type' => [ 'Operation', - 'schema:DeleteAction', + 'schema:FindAction', ], - 'method' => 'DELETE', - 'returns' => 'owl:Nothing', - 'title' => 'deleteDummy', + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', ], ], 'name' => 'hello', @@ -177,7 +170,7 @@ public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperation $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperationsWithoutSecurity(): void + public function testNormalizeWithHydraOperationsMultipleApiResource(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -186,10 +179,10 @@ public function testNormalizeWithHydraOperationsWithoutSecurity(): void $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'), - ]), + ->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); @@ -200,7 +193,7 @@ public function testNormalizeWithHydraOperationsWithoutSecurity(): 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); @@ -225,23 +218,38 @@ public function testNormalizeWithHydraOperationsWithoutSecurity(): void null, null, null, - ['hydra_prefix' => false] + ['hydra_prefix' => false, 'hydra_operations' => true] ); $normalizer->setSerializer($serializerProphecy->reveal()); $expected = [ '@context' => '/contexts/Dummy', - '@id' => '/dummies/1989', + '@id' => '/dummies/1990', '@type' => 'Dummy', 'operation' => [ [ '@type' => [ 'Operation', - 'schema:DeleteAction', + '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' => [], + ], ], - 'method' => 'DELETE', - 'returns' => 'owl:Nothing', - 'title' => 'deleteDummy', ], ], 'name' => 'hello', @@ -249,7 +257,7 @@ public function testNormalizeWithHydraOperationsWithoutSecurity(): void $this->assertEquals($expected, $normalizer->normalize($dummy)); } - public function testNormalizeWithHydraOperationGrantedBySecurity(): void + public function testNormalizeWithHydraOperationsMultipleApiResourceWithOperationInDuplicate(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -258,10 +266,10 @@ public function testNormalizeWithHydraOperationGrantedBySecurity(): void $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')"), - ]), + ->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); @@ -281,9 +289,6 @@ public function testNormalizeWithHydraOperationGrantedBySecurity(): void $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'); @@ -300,8 +305,7 @@ public function testNormalizeWithHydraOperationGrantedBySecurity(): void null, null, null, - ['hydra_prefix' => false], - $accessCheckerProphecy->reveal() + ['hydra_prefix' => false, 'hydra_operations' => true] ); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -313,80 +317,16 @@ public function testNormalizeWithHydraOperationGrantedBySecurity(): void [ '@type' => [ 'Operation', - 'schema:DeleteAction', + 'schema:FindAction', ], - 'method' => 'DELETE', - 'returns' => 'owl:Nothing', - 'title' => 'deleteDummy', + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', ], ], '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 44cc6e47349..568cc49568a 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -239,6 +239,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'handle_symfony_errors' => false, 'serializer' => [ 'hydra_prefix' => false, + 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [ From 2edd0f72f889d270648cdd0f45ae7a4f4de14912 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Fri, 2 Oct 2026 17:40:56 +0200 Subject: [PATCH 06/11] Revert "feat: add operation to hydra response" This reverts commit 8ca5d8edf0b5ebcd7a0215f1121de3bb4255010e. --- 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, 2 insertions(+), 571 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b2311eaca9d..b32ab68d8f5 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -104,9 +104,6 @@ parameters: - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" - - - 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 c6dc72c996e..e882d3aef05 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -17,7 +17,6 @@ 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; @@ -32,7 +31,6 @@ */ final class CollectionNormalizer extends AbstractCollectionNormalizer { - use HydraOperationsTrait; use HydraPrefixTrait; use JsonLdContextTrait; @@ -44,7 +42,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 = [], private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null) + public function __construct(private readonly ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, private readonly IriConverterInterface $iriConverter, array $defaultContext = []) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); @@ -72,18 +70,6 @@ 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 0cb115b5566..dfaa9f85bc5 100644 --- a/src/Hydra/Serializer/HydraOperationsTrait.php +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -25,53 +25,6 @@ */ 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 585215630d6..58591f416de 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -17,13 +17,7 @@ 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; @@ -451,251 +445,4 @@ 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 a7c90a05df7..e63202613f6 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -13,7 +13,6 @@ namespace ApiPlatform\JsonLd\Serializer; -use ApiPlatform\Hydra\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; use ApiPlatform\Metadata\HttpOperation; @@ -44,8 +43,6 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; - use HydraOperationsTrait; - use HydraPrefixTrait; use ItemNormalizerTrait { denormalize as private doDenormalize; } @@ -53,11 +50,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,19 +134,6 @@ 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 7bd07b98dbb..152b7e9949b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1023,8 +1023,7 @@ public function register(): void $app->make(ContextBuilderInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), - $defaultContext, - $app->make(ResourceMetadataCollectionFactoryInterface::class) + $defaultContext ), $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 ab70db0573d..1ce0d98c9d5 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -70,7 +70,6 @@ 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 59833a5800a..d765b85a2ed 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -20,7 +20,6 @@ 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; @@ -99,234 +98,4 @@ 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 0dad3403d657692810ff2f3f5a7df1ea412554e0 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Fri, 2 Oct 2026 17:41:05 +0200 Subject: [PATCH 07/11] Revert "feat: add `hydra_operations` option" This reverts commit f94c8cc867ba340650b32d196edb5e13be33364a. --- 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, 2 insertions(+), 10 deletions(-) diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 54a83136a94..52ae847eb32 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,7 +178,6 @@ '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 52e8eea7e13..0b2548a3ff0 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -346,10 +346,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 6ab9fb7c0f8..10db1dde0dc 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -165,7 +165,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/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 2546e5c7242..b1d280c406f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -46,8 +46,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/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 568cc49568a..0d47ca31b71 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -238,8 +238,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'use_symfony_listeners' => false, 'handle_symfony_errors' => false, 'serializer' => [ - 'hydra_prefix' => false, - 'hydra_operations' => false, + 'hydra_prefix' => null, ], 'enable_phpdoc_parser' => true, 'mcp' => [ From fa15e085bb5cffd0db77ea9008603664dd1b23d4 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 3 Oct 2026 17:54:01 +0200 Subject: [PATCH 08/11] feat(metadata): add HydraOperation to reference the operations exposed in hydra:operation --- src/Laravel/ApiPlatformDeferredProvider.php | 53 ++++---- src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/HttpOperation.php | 27 +++- src/Metadata/HydraOperation.php | 57 +++++++++ src/Metadata/Patch.php | 2 + src/Metadata/Put.php | 2 + ...tionsResourceMetadataCollectionFactory.php | 106 +++++++++++++++ ...sResourceMetadataCollectionFactoryTest.php | 121 ++++++++++++++++++ .../Resources/config/metadata/resource.php | 5 + 10 files changed, 349 insertions(+), 28 deletions(-) create mode 100644 src/Metadata/HydraOperation.php create mode 100644 src/Metadata/Resource/Factory/HydraOperationsResourceMetadataCollectionFactory.php create mode 100644 src/Metadata/Tests/Resource/Factory/HydraOperationsResourceMetadataCollectionFactoryTest.php diff --git a/src/Laravel/ApiPlatformDeferredProvider.php b/src/Laravel/ApiPlatformDeferredProvider.php index 03077001b1f..08d29fed9b9 100644 --- a/src/Laravel/ApiPlatformDeferredProvider.php +++ b/src/Laravel/ApiPlatformDeferredProvider.php @@ -63,6 +63,7 @@ use ApiPlatform\Metadata\Resource\Factory\ConcernsResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\FiltersResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\FormatsResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\HydraOperationsResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\InputOutputResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\LinkFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\LinkResourceMetadataCollectionFactory; @@ -232,39 +233,41 @@ public function register(): void new ParameterResourceMetadataCollectionFactory( $this->app->make(PropertyNameCollectionFactoryInterface::class), $this->app->make(PropertyMetadataFactoryInterface::class), - new AlternateUriResourceMetadataCollectionFactory( - new FiltersResourceMetadataCollectionFactory( - new FormatsResourceMetadataCollectionFactory( - new InputOutputResourceMetadataCollectionFactory( - new PhpDocResourceMetadataCollectionFactory( - new OperationNameResourceMetadataCollectionFactory( - new LinkResourceMetadataCollectionFactory( - $app->make(LinkFactoryInterface::class), - new UriTemplateResourceMetadataCollectionFactory( + new HydraOperationsResourceMetadataCollectionFactory( + new AlternateUriResourceMetadataCollectionFactory( + new FiltersResourceMetadataCollectionFactory( + new FormatsResourceMetadataCollectionFactory( + new InputOutputResourceMetadataCollectionFactory( + new PhpDocResourceMetadataCollectionFactory( + new OperationNameResourceMetadataCollectionFactory( + new LinkResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - $app->make(PathSegmentNameGeneratorInterface::class), - new NotExposedOperationResourceMetadataCollectionFactory( + new UriTemplateResourceMetadataCollectionFactory( $app->make(LinkFactoryInterface::class), - new AttributesResourceMetadataCollectionFactory( - new ConcernsResourceMetadataCollectionFactory( - null, + $app->make(PathSegmentNameGeneratorInterface::class), + new NotExposedOperationResourceMetadataCollectionFactory( + $app->make(LinkFactoryInterface::class), + new AttributesResourceMetadataCollectionFactory( + new ConcernsResourceMetadataCollectionFactory( + null, + $app->make(LoggerInterface::class), + $config->get('api-platform.defaults', []), + $config->get('api-platform.graphql.enabled'), + ), $app->make(LoggerInterface::class), $config->get('api-platform.defaults', []), $config->get('api-platform.graphql.enabled'), ), - $app->make(LoggerInterface::class), - $config->get('api-platform.defaults', []), - $config->get('api-platform.graphql.enabled'), - ), - ) - ), - $config->get('api-platform.graphql.enabled') + ) + ), + $config->get('api-platform.graphql.enabled') + ) ) ) - ) - ), - $formats, - $config->get('api-platform.patch_formats'), + ), + $formats, + $config->get('api-platform.patch_formats'), + ) ) ) ), diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 6bab9a22ae0..49648803060 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -103,6 +103,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + array|false|null $hydraOperations = null, ?bool $jsonStream = null, ?bool $throwOnNotFound = null, array $extraProperties = [], @@ -190,6 +191,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + hydraOperations: $hydraOperations, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 29bb2d569f9..000290f5ae4 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -103,6 +103,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + array|false|null $hydraOperations = null, ?bool $jsonStream = null, array $extraProperties = [], ?bool $throwOnNotFound = null, @@ -193,6 +194,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + hydraOperations: $hydraOperations, stateOptions: $stateOptions, map: $map ); diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index e0c5c956dd4..99f02bc181d 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -77,9 +77,10 @@ class HttpOperation extends Operation * field: string, * direction: string, * }>|null $paginationViaCursor {@see https://api-platform.com/docs/core/pagination/#cursor-based-pagination} - * @param array|null $normalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} - * @param array|null $denormalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} - * @param array|null $hydraContext {@see https://api-platform.com/docs/core/extending-jsonld-context/#hydra} + * @param array|null $normalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $denormalizationContext {@see https://api-platform.com/docs/core/serialization/#using-serialization-groups} + * @param array|null $hydraContext {@see https://api-platform.com/docs/core/extending-jsonld-context/#hydra} + * @param list|false|null $hydraOperations the operations exposed in the "hydra:operation" property of the JSON-LD responses, false to expose none * @param array{ * class?: string|null, * name?: string, @@ -174,6 +175,7 @@ public function __construct( protected ?array $errors = null, protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, + protected array|false|null $hydraOperations = null, ?string $shortName = null, ?string $class = null, @@ -656,6 +658,25 @@ public function withHydraContext(array $hydraContext): static return $self; } + /** + * @return list|false|null + */ + public function getHydraOperations(): array|false|null + { + return $this->hydraOperations; + } + + /** + * @param list|false $hydraOperations + */ + public function withHydraOperations(array|false $hydraOperations): static + { + $self = clone $this; + $self->hydraOperations = $hydraOperations; + + return $self; + } + public function getJsonldContext(): ?array { return $this->jsonldContext; diff --git a/src/Metadata/HydraOperation.php b/src/Metadata/HydraOperation.php new file mode 100644 index 00000000000..4d8c5ca83de --- /dev/null +++ b/src/Metadata/HydraOperation.php @@ -0,0 +1,57 @@ + + * + * 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; + +/** + * References an operation declared on the resource to expose it in the "hydra:operation" property of the JSON-LD responses. + * + * The operation is referenced either by its name, or by its method and URI template (the format suffix is ignored). + * Everything else (title, description, expected and returned types) is read from the referenced operation. + */ +final class HydraOperation +{ + /** + * @param string|null $method the HTTP method of the referenced operation + * @param string|null $uriTemplate the URI template of the referenced operation, defaults to the one of the operation declaring the reference + * @param string|null $name the name of the referenced operation + * @param string|\Stringable|null $security decides when the operation is exposed, defaults to the security of the referenced operation + */ + public function __construct( + private readonly ?string $method = null, + private readonly ?string $uriTemplate = null, + private readonly ?string $name = null, + private readonly string|\Stringable|null $security = null, + ) { + } + + public function getMethod(): ?string + { + return $this->method; + } + + public function getUriTemplate(): ?string + { + return $this->uriTemplate; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getSecurity(): ?string + { + return $this->security instanceof \Stringable ? (string) $this->security : $this->security; + } +} diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 4283817d7d4..94c5646a4e3 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -103,6 +103,7 @@ public function __construct( array|string|null $middleware = null, ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, + array|false|null $hydraOperations = null, ?bool $jsonStream = null, ?bool $throwOnNotFound = null, array $extraProperties = [], @@ -191,6 +192,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + hydraOperations: $hydraOperations, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 444f8fbcd5c..bde0bda6869 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -106,6 +106,7 @@ public function __construct( array $extraProperties = [], ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, + array|false|null $hydraOperations = null, private ?bool $allowCreate = null, ?bool $map = null, ) { @@ -192,6 +193,7 @@ class: $class, middleware: $middleware, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + hydraOperations: $hydraOperations, jsonStream: $jsonStream, throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, diff --git a/src/Metadata/Resource/Factory/HydraOperationsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/HydraOperationsResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..0b1d098af11 --- /dev/null +++ b/src/Metadata/Resource/Factory/HydraOperationsResourceMetadataCollectionFactory.php @@ -0,0 +1,106 @@ + + * + * 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\Resource\Factory; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\HydraOperation; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Util\UriTemplateHelper; + +/** + * Resolves the {@see HydraOperation} references to the operations declared on the resource. + */ +final class HydraOperationsResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public function __construct(private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null) + { + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = new ResourceMetadataCollection($resourceClass); + if ($this->decorated) { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + } + + foreach ($resourceMetadataCollection as $i => $resource) { + if (null === $operations = $resource->getOperations()) { + continue; + } + + foreach ($operations as $operationName => $operation) { + if (!\is_array($hydraOperations = $operation->getHydraOperations())) { + continue; + } + + foreach ($hydraOperations as $key => $hydraOperation) { + $hydraOperations[$key] = $this->resolve($resourceMetadataCollection, $hydraOperation, $operation); + } + + $operations->add($operationName, $operation->withHydraOperations($hydraOperations)); + } + + $resourceMetadataCollection[$i] = $resource->withOperations($operations); + } + + return $resourceMetadataCollection; + } + + /** + * Finds the referenced operation by name, or by method and URI template regardless of the format suffix. + */ + private function resolve(ResourceMetadataCollection $resourceMetadataCollection, HydraOperation $hydraOperation, HttpOperation $operation): HydraOperation + { + $name = $hydraOperation->getName(); + $method = null === $hydraOperation->getMethod() ? null : strtoupper($hydraOperation->getMethod()); + // Without a name, the reference targets the URI template of the operation declaring it by default + $uriTemplate = $hydraOperation->getUriTemplate() ?? (null === $name ? $operation->getUriTemplate() : null); + $match = $fallback = null; + + if (null !== $name || null !== $method) { + foreach ($resourceMetadataCollection as $resource) { + foreach ($resource->getOperations() ?? [] as $candidate) { + if ((null !== $name && $candidate->getName() !== $name) || (null !== $method && $candidate->getMethod() !== $method)) { + continue; + } + + if (null === $uriTemplate || $candidate->getUriTemplate() === $uriTemplate) { + $match = $candidate; + break 2; + } + + if (null === $fallback && null !== ($candidateUriTemplate = $candidate->getUriTemplate()) && UriTemplateHelper::withoutFormatSuffix($candidateUriTemplate) === UriTemplateHelper::withoutFormatSuffix($uriTemplate)) { + $fallback = $candidate; + } + } + } + } + + $match ??= $fallback; + if (null === $match) { + throw new RuntimeException(\sprintf('The Hydra operation "%s" referenced by the operation "%s" is not declared on the resource "%s".', $name ?? trim($method.' '.$uriTemplate), $operation->getName(), $operation->getClass())); + } + + return new HydraOperation( + method: $match->getMethod(), + uriTemplate: $match->getUriTemplate(), + name: $match->getName(), + security: $hydraOperation->getSecurity(), + ); + } +} diff --git a/src/Metadata/Tests/Resource/Factory/HydraOperationsResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/HydraOperationsResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..59f20c1f3f4 --- /dev/null +++ b/src/Metadata/Tests/Resource/Factory/HydraOperationsResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,121 @@ + + * + * 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\Resource\Factory; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\HydraOperation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\HydraOperationsResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\TestCase; + +final class HydraOperationsResourceMetadataCollectionFactoryTest extends TestCase +{ + public function testResolvesMethodAndUriTemplateRegardlessOfTheFormatSuffix(): void + { + $resourceMetadataCollection = $this->create([ + new Get(uriTemplate: '/companies{._format}', name: 'get', hydraOperations: [new HydraOperation(method: 'delete', uriTemplate: '/companies')]), + new Delete(uriTemplate: '/companies{._format}', name: 'delete'), + ]); + + $this->assertEquals([new HydraOperation(method: 'DELETE', uriTemplate: '/companies{._format}', name: 'delete')], $this->getHydraOperations($resourceMetadataCollection, 'get')); + } + + public function testExactUriTemplateWinsOverTheFormatAgnosticMatch(): void + { + $resourceMetadataCollection = $this->create([ + new Get(uriTemplate: '/companies', name: 'get', hydraOperations: [new HydraOperation(method: 'DELETE', uriTemplate: '/companies')]), + new Delete(uriTemplate: '/companies{._format}', name: 'lenient'), + new Delete(uriTemplate: '/companies', name: 'exact'), + ]); + + $this->assertEquals([new HydraOperation(method: 'DELETE', uriTemplate: '/companies', name: 'exact')], $this->getHydraOperations($resourceMetadataCollection, 'get')); + } + + public function testResolvesNameAcrossTheResourcesOfTheClassAndKeepsItsOwnSecurity(): void + { + $resourceMetadataCollection = $this->create( + [new Get(uriTemplate: '/companies/{id}{._format}', name: 'get', hydraOperations: [new HydraOperation(name: 'archive', security: "is_granted('ROLE_ADMIN')")])], + [new Patch(uriTemplate: '/companies/{id}/archive{._format}', name: 'archive', security: "is_granted('ROLE_USER')")], + ); + + $this->assertEquals([new HydraOperation(method: 'PATCH', uriTemplate: '/companies/{id}/archive{._format}', name: 'archive', security: "is_granted('ROLE_ADMIN')")], $this->getHydraOperations($resourceMetadataCollection, 'get')); + } + + public function testMethodDefaultsToTheUriTemplateOfTheDeclaringOperation(): void + { + $resourceMetadataCollection = $this->create([ + new GetCollection(uriTemplate: '/companies{._format}', name: 'get_collection', hydraOperations: [new HydraOperation(method: 'POST')]), + new Post(uriTemplate: '/admin/companies{._format}', name: 'admin_post'), + new Post(uriTemplate: '/companies{._format}', name: 'post'), + ]); + + $this->assertEquals([new HydraOperation(method: 'POST', uriTemplate: '/companies{._format}', name: 'post')], $this->getHydraOperations($resourceMetadataCollection, 'get_collection')); + } + + public function testLeavesUnsetAndDisabledHydraOperationsUntouched(): void + { + $resourceMetadataCollection = $this->create([ + new Get(uriTemplate: '/companies/{id}{._format}', name: 'get'), + new GetCollection(uriTemplate: '/companies{._format}', name: 'get_collection', hydraOperations: false), + ]); + + $this->assertNull($this->getHydraOperations($resourceMetadataCollection, 'get')); + $this->assertFalse($this->getHydraOperations($resourceMetadataCollection, 'get_collection')); + } + + public function testThrowsWhenTheReferencedOperationIsNotDeclared(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The Hydra operation "DELETE /companies/{id}" referenced by the operation "get" is not declared on the resource "'.Dummy::class.'".'); + + $this->create([ + new Get(uriTemplate: '/companies{._format}', name: 'get', hydraOperations: [new HydraOperation(method: 'DELETE', uriTemplate: '/companies/{id}')]), + new Delete(uriTemplate: '/companies{._format}', name: 'delete'), + ]); + } + + /** + * @param list ...$operations the operations of each resource + */ + private function create(array ...$operations): ResourceMetadataCollection + { + $decorated = $this->createStub(ResourceMetadataCollectionFactoryInterface::class); + $decorated->method('create')->willReturn(new ResourceMetadataCollection(Dummy::class, array_map( + static fn (array $resourceOperations): ApiResource => new ApiResource(class: Dummy::class, operations: array_map(static fn (HttpOperation $operation): HttpOperation => $operation->withClass(Dummy::class), $resourceOperations)), + $operations, + ))); + + return (new HydraOperationsResourceMetadataCollectionFactory($decorated))->create(Dummy::class); + } + + /** + * @return list|false|null + */ + private function getHydraOperations(ResourceMetadataCollection $resourceMetadataCollection, string $operationName): array|false|null + { + $operation = $resourceMetadataCollection->getOperation($operationName); + $this->assertInstanceOf(HttpOperation::class, $operation); + + return $operation->getHydraOperations(); + } +} diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.php b/src/Symfony/Bundle/Resources/config/metadata/resource.php index 137ad0515de..82e1429f85a 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ExtractorResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\FiltersResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\FormatsResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\HydraOperationsResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\InputOutputResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\LinkResourceMetadataCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\MainControllerResourceMetadataCollectionFactory; @@ -151,6 +152,10 @@ ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 200) ->args([service('api_platform.metadata.resource.metadata_collection_factory.alternate_uri.inner')]); + $services->set('api_platform.metadata.resource.metadata_collection_factory.hydra_operations', HydraOperationsResourceMetadataCollectionFactory::class) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 150) + ->args([service('api_platform.metadata.resource.metadata_collection_factory.hydra_operations.inner')]); + $services->set('api_platform.metadata.resource.metadata_collection_factory.parameter', ParameterResourceMetadataCollectionFactory::class) ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 1000) ->args([ From 2321a95c4451258cfc154431bc207be9bddcfdf1 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 3 Oct 2026 17:54:40 +0200 Subject: [PATCH 09/11] feat(hydra): expose the available operations in hydra:operation --- src/Hydra/Serializer/CollectionNormalizer.php | 15 +- .../Serializer/DocumentationNormalizer.php | 1 + .../Serializer/CollectionNormalizerTest.php | 61 +++++++ .../Serializer/HydraOperationsTrait.php | 63 ++++++- src/JsonLd/Serializer/ItemNormalizer.php | 31 ++++ src/Laravel/ApiPlatformProvider.php | 4 +- src/Laravel/config/api-platform.php | 1 + .../ApiPlatformExtension.php | 5 +- .../DependencyInjection/Configuration.php | 1 + src/Symfony/Bundle/Resources/config/hydra.php | 2 + tests/Fixtures/app/AppKernel.php | 1 + .../JsonLd/Serializer/ItemNormalizerTest.php | 159 ++++++++++++++++++ .../DependencyInjection/ConfigurationTest.php | 1 + 13 files changed, 334 insertions(+), 11 deletions(-) rename src/{Hydra => JsonLd}/Serializer/HydraOperationsTrait.php (61%) diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index e882d3aef05..5a08c712305 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -14,9 +14,14 @@ namespace ApiPlatform\Hydra\Serializer; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\AbstractCollectionNormalizer; @@ -31,6 +36,7 @@ */ final class CollectionNormalizer extends AbstractCollectionNormalizer { + use HydraOperationsTrait; use HydraPrefixTrait; use JsonLdContextTrait; @@ -42,11 +48,11 @@ 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 = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly ?ResourceAccessCheckerInterface $resourceAccessChecker = null) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); - parent::__construct($resourceClassResolver, ''); + parent::__construct($resourceClassResolver, '', $resourceMetadataFactory); } /** @@ -70,6 +76,11 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data[$hydraPrefix.'totalItems'] = \count($object); } + $operation = $context['operation'] ?? null; + if ($this->resourceMetadataFactory && $operation instanceof HttpOperation && $operation instanceof CollectionOperationInterface && $hydraOperations = $this->getExposedHydraOperations($operation, $this->resourceMetadataFactory, $this->resourceAccessChecker, $object, $context + $this->defaultContext, $hydraPrefix)) { + $data[$hydraPrefix.'operation'] = $hydraOperations; + } + return $data; } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index f8e84b6101e..337b2f8fd08 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Documentation\Documentation; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index 58591f416de..dc671d8eff5 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -17,7 +17,14 @@ use ApiPlatform\Hydra\Tests\Fixtures\Foo; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\IriConverterInterface; +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; @@ -445,4 +452,58 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void 'totalItems' => 2, ], $actual); } + + public function testNormalizeExposesTheCollectionOperationsByDefault(): void + { + $data = []; + $getCollection = new GetCollection(uriTemplate: '/foos{._format}', shortName: 'Foo', class: Foo::class, name: 'get_collection'); + + $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::class, [ + new ApiResource(shortName: 'Foo', class: Foo::class, operations: [ + $getCollection, + new Get(uriTemplate: '/foos/{id}{._format}', shortName: 'Foo', class: Foo::class, name: 'get'), + new Post(uriTemplate: '/foos{._format}', shortName: 'Foo', class: Foo::class, name: 'post', security: "is_granted('ROLE_ADMIN')"), + ]), + ])); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Foo::class, "is_granted('ROLE_ADMIN')", Argument::withEntry('object', $data))->willReturn(true)->shouldBeCalledOnce(); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal(), [], $resourceMetadataCollectionFactoryProphecy->reveal(), $accessCheckerProphecy->reveal()); + $normalizer->setNormalizer($this->prophesize(NormalizerInterface::class)->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'operation' => $getCollection, + 'resource_class' => Foo::class, + ContextBuilder::HYDRA_CONTEXT_HAS_PREFIX => false, + ]); + + $this->assertEquals([ + [ + '@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['operation']); + } } diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/JsonLd/Serializer/HydraOperationsTrait.php similarity index 61% rename from src/Hydra/Serializer/HydraOperationsTrait.php rename to src/JsonLd/Serializer/HydraOperationsTrait.php index dfaa9f85bc5..50a2bbac759 100644 --- a/src/Hydra/Serializer/HydraOperationsTrait.php +++ b/src/JsonLd/Serializer/HydraOperationsTrait.php @@ -11,23 +11,25 @@ declare(strict_types=1); -namespace ApiPlatform\Hydra\Serializer; +namespace ApiPlatform\JsonLd\Serializer; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\Util\UriTemplateHelper; /** - * Generates Hydra operations for JSON-LD responses. - * * @author Kévin Dunglas + * + * @internal */ trait HydraOperationsTrait { - /** - * Gets Hydra operations. - */ private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $hydraOperations = []; @@ -47,8 +49,55 @@ private function getHydraOperations(bool $collection, ApiResource $resourceMetad } /** - * Gets and populates if applicable a Hydra operation. + * Gets the Hydra operations exposed by a representation, given the operation identifying it: the ones referenced + * by its hydraOperations or, when enabled by default, all the operations sharing its IRI, filtered + * by their security. */ + private function getExposedHydraOperations(HttpOperation $operation, ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ?ResourceAccessCheckerInterface $resourceAccessChecker, mixed $object, array $context, string $hydraPrefix): array + { + $hydraOperations = $operation->getHydraOperations(); + if (false === $hydraOperations || (null === $hydraOperations && !($context['hydra_operations'] ?? true)) || null === $resourceClass = $operation->getClass()) { + return []; + } + + $resourceMetadataCollection = $resourceMetadataCollectionFactory->create($resourceClass); + $candidates = []; + + if (null === $hydraOperations) { + $iri = static fn (HttpOperation $httpOperation): ?string => null === ($uriTemplate = $httpOperation->getUriTemplate()) ? null : ($httpOperation->getRoutePrefix() ?? '').UriTemplateHelper::withoutFormatSuffix($uriTemplate); + $operationIri = $iri($operation); + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getOperations() ?? [] as $candidate) { + if (null !== $operationIri && $operationIri === $iri($candidate) && !$candidate instanceof NotExposed && !$candidate instanceof Error) { + $candidates[$candidate->getMethod()] ??= [$candidate, null]; + } + } + } + } else { + foreach ($hydraOperations as $hydraOperation) { + if (null !== $hydraOperation->getName() && ($candidate = $resourceMetadataCollection->getOperation($hydraOperation->getName())) instanceof HttpOperation) { + $candidates[] = [$candidate, $hydraOperation->getSecurity()]; + } + } + } + + $exposedOperations = []; + foreach ($candidates as [$candidate, $security]) { + $security ??= $candidate->getSecurity() ?? $candidate->getPolicy(); + try { + $granted = null === $security || $resourceAccessChecker?->isGranted($candidate->getClass(), $security, ['object' => $object, 'previous_object' => $object, 'request' => $context['request'] ?? null] + ($context['uri_variables'] ?? [])); + } catch (\Exception) { + $granted = false; + } + + if ($granted) { + $exposedOperations[] = $this->getHydraOperation($candidate, $candidate->getShortName(), $hydraPrefix); + } + } + + return $exposedOperations; + } + private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array { $method = $operation->getMethod() ?: 'GET'; diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e63202613f6..e1207e89e4e 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -15,6 +15,8 @@ use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -43,6 +45,8 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use HydraOperationsTrait; + use HydraPrefixTrait; use ItemNormalizerTrait { denormalize as private doDenormalize; } @@ -134,9 +138,36 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = $type; } + if ($isResourceClass && isset($metadata['@id']) && $this->resourceMetadataCollectionFactory && $operation = $this->getIriOperation($resourceClass, $context)) { + $hydraPrefix = $this->getHydraPrefix($context + $this->defaultContext); + if ($hydraOperations = $this->getExposedHydraOperations($operation, $this->resourceMetadataCollectionFactory, $this->resourceAccessChecker, $data, $context + $this->defaultContext, $hydraPrefix)) { + $metadata[$hydraPrefix.'operation'] = $hydraOperations; + } + } + return $metadata + $normalizedData; } + private function getIriOperation(string $resourceClass, array $context): ?HttpOperation + { + $operation = $context['operation'] ?? null; + if (isset($context['item_uri_template']) && $this->operationMetadataFactory) { + $operation = $this->operationMetadataFactory->create($context['item_uri_template']); + } + + if ($operation instanceof HttpOperation && !$operation instanceof CollectionOperationInterface && 'POST' !== $operation->getMethod()) { + return $operation; + } + + try { + $operation = $this->resourceMetadataCollectionFactory?->create($resourceClass)->getOperation(null, false, true); + } catch (OperationNotFoundException) { + return null; + } + + return $operation instanceof HttpOperation ? $operation : null; + } + /** * @return string|array|null */ diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 152b7e9949b..e043e0dcb43 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -1023,7 +1023,9 @@ public function register(): void $app->make(ContextBuilderInterface::class), $app->make(ResourceClassResolverInterface::class), $app->make(IriConverterInterface::class), - $defaultContext + $defaultContext, + $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 52ae847eb32..90801dce671 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -178,6 +178,7 @@ 'serializer' => [ 'hydra_prefix' => false, + 'hydra_operations' => true, // 'datetime_format' => \DateTimeInterface::RFC3339, ], diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0b2548a3ff0..52e8eea7e13 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -346,7 +346,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 10db1dde0dc..1c5a4329c01 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -165,6 +165,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->booleanNode('hydra_prefix')->defaultFalse()->info('Use the "hydra:" prefix.')->end() + ->booleanNode('hydra_operations')->defaultTrue()->info('Expose the operations sharing the IRI of a resource in the "hydra:operation" property of its JSON-LD representations, filtered by their security, unless the operation sets "hydraOperations".')->end() ->end() ->end() ->end(); diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index 1ce0d98c9d5..58477d62a31 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -70,6 +70,8 @@ service('api_platform.resource_class_resolver'), 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/AppKernel.php b/tests/Fixtures/app/AppKernel.php index e670b1c9de8..cfef98cfd98 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -290,6 +290,7 @@ class_exists(NativePasswordHasher::class) ? 'password_hashers' : 'encoders' => [ ], 'serializer' => [ 'hydra_prefix' => true, + 'hydra_operations' => false, ], ]); diff --git a/tests/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index d765b85a2ed..2bb65a7e6b6 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -17,17 +17,24 @@ use ApiPlatform\JsonLd\Serializer\ItemNormalizer; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; 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; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -98,4 +105,156 @@ public function testNormalize(): void ]; $this->assertEquals($expected, $normalizer->normalize($dummy)); } + + public function testNormalizeExposesTheReferencedHydraOperationsGrantedBySecurity(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, "is_granted('ROLE_ADMIN')", Argument::withEntry('object', $dummy))->willReturn(true)->shouldBeCalledOnce(); + + $normalizer = $this->createHydraOperationsNormalizer($dummy, [ + new Get(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get', hydraOperations: [new HydraOperation(method: 'DELETE', uriTemplate: '/dummies/{id}{._format}', name: 'delete')]), + new Delete(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'delete', security: "is_granted('ROLE_ADMIN')", hideHydraOperation: true), + ], [], $accessCheckerProphecy->reveal()); + + $this->assertEquals([ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => ['Operation', 'schema:DeleteAction'], + 'description' => 'Deletes the Dummy resource.', + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', + ], + ], + 'name' => 'hello', + ], $normalizer->normalize($dummy)); + } + + public function testNormalizeHidesTheHydraOperationsDeniedBySecurity(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, "is_granted('ROLE_ADMIN')", Argument::withEntry('object', $dummy))->willReturn(false)->shouldBeCalledOnce(); + + $normalizer = $this->createHydraOperationsNormalizer($dummy, [ + new Get(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get', hydraOperations: [new HydraOperation(method: 'DELETE', uriTemplate: '/dummies/{id}{._format}', name: 'delete', security: "is_granted('ROLE_ADMIN')")]), + new Delete(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'delete'), + ], [], $accessCheckerProphecy->reveal()); + + $this->assertArrayNotHasKey('operation', $normalizer->normalize($dummy)); + } + + public function testNormalizeExposesTheOperationsSharingTheIriByDefault(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, "is_granted('ROLE_ADMIN')", Argument::withEntry('object', $dummy))->willReturn(false); + + $normalizer = $this->createHydraOperationsNormalizer($dummy, [ + new GetCollection(uriTemplate: '/dummies{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get_collection'), + new Get(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get'), + new Post(uriTemplate: '/dummies{._format}', shortName: 'Dummy', class: Dummy::class, name: 'post'), + new Patch(uriTemplate: '/dummies/{id}', shortName: 'Dummy', class: Dummy::class, name: 'patch'), + new Delete(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'delete', security: "is_granted('ROLE_ADMIN')"), + ], [], $accessCheckerProphecy->reveal()); + + $this->assertEquals([ + [ + '@type' => ['Operation', 'schema:FindAction'], + 'description' => 'Retrieves a Dummy resource.', + 'method' => 'GET', + 'returns' => 'Dummy', + 'title' => 'getDummy', + ], + [ + '@type' => 'Operation', + 'description' => 'Updates the Dummy resource.', + 'expects' => 'Dummy', + 'expectsHeader' => [['headerName' => 'Content-Type', 'possibleValue' => []]], + 'method' => 'PATCH', + 'returns' => 'Dummy', + 'title' => 'patchDummy', + ], + ], $normalizer->normalize($dummy)['operation']); + } + + #[DataProvider('disabledHydraOperationsProvider')] + public function testNormalizeWithDisabledHydraOperations(?false $hydraOperations, array $defaultContext): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $normalizer = $this->createHydraOperationsNormalizer($dummy, [ + new Get(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get', hydraOperations: $hydraOperations), + new Delete(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'delete'), + ], $defaultContext); + + $this->assertArrayNotHasKey('operation', $normalizer->normalize($dummy)); + } + + public static function disabledHydraOperationsProvider(): iterable + { + yield 'disabled on the operation' => [false, []]; + yield 'disabled by the configuration' => [null, ['hydra_operations' => false]]; + } + + /** + * @param list $operations + */ + private function createHydraOperationsNormalizer(Dummy $dummy, array $operations, array $defaultContext, ?ResourceAccessCheckerInterface $resourceAccessChecker = null): ItemNormalizer + { + $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(shortName: 'Dummy', class: Dummy::class, operations: $operations), + ])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->willReturn(new PropertyNameCollection(['name'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withReadable(true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResource($dummy, UrlGeneratorInterface::ABS_PATH, null, Argument::any())->willReturn('/dummies/1'); + + $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->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] + $defaultContext, + $resourceAccessChecker, + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + return $normalizer; + } } diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 0d47ca31b71..685be68e436 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -239,6 +239,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'handle_symfony_errors' => false, 'serializer' => [ 'hydra_prefix' => null, + 'hydra_operations' => true, ], 'enable_phpdoc_parser' => true, 'mcp' => [ From 7c39db459d0dcef1082324dc372e647b30a6a344 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 3 Oct 2026 17:55:08 +0200 Subject: [PATCH 10/11] test(hydra): cover hydra:operation in JSON-LD responses --- .../ApiResource/HydraOperationsCompany.php | 66 +++++++++++++ .../Functional/JsonLd/HydraOperationsTest.php | 93 +++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/Fixtures/TestBundle/ApiResource/HydraOperationsCompany.php create mode 100644 tests/Functional/JsonLd/HydraOperationsTest.php diff --git a/tests/Fixtures/TestBundle/ApiResource/HydraOperationsCompany.php b/tests/Fixtures/TestBundle/ApiResource/HydraOperationsCompany.php new file mode 100644 index 00000000000..6a27395e9b3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/HydraOperationsCompany.php @@ -0,0 +1,66 @@ + + * + * 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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HydraOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; + +#[GetCollection( + uriTemplate: '/hydra_operations_companies', + normalizationContext: ['hydra_operations' => true], + provider: [self::class, 'provideCollection'], +)] +#[Post(uriTemplate: '/hydra_operations_companies', security: "is_granted('ROLE_ADMIN')", processor: [self::class, 'process'])] +#[Get( + uriTemplate: '/hydra_operations_companies/{id}{._format}', + provider: [self::class, 'provide'], + hydraOperations: [ + new HydraOperation(method: 'DELETE'), + new HydraOperation(name: 'archive_hydra_operations_company'), + ], +)] +#[Delete(uriTemplate: '/hydra_operations_companies/{id}', security: "is_granted('ROLE_ADMIN')", hideHydraOperation: true, provider: [self::class, 'provide'], processor: [self::class, 'process'])] +#[Patch(uriTemplate: '/hydra_operations_companies/{id}/archive', name: 'archive_hydra_operations_company', security: "is_granted('ROLE_USER') and object.owner == user.getUserIdentifier()", provider: [self::class, 'provide'], processor: [self::class, 'process'])] +class HydraOperationsCompany +{ + public function __construct( + #[ApiProperty(identifier: true)] public int $id, + public string $owner, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return self::provideCollection()[(int) $uriVariables['id'] - 1]; + } + + /** + * @return list + */ + public static function provideCollection(): array + { + return [new self(1, 'dunglas'), new self(2, 'admin')]; + } + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Functional/JsonLd/HydraOperationsTest.php b/tests/Functional/JsonLd/HydraOperationsTest.php new file mode 100644 index 00000000000..f4e5a3f0908 --- /dev/null +++ b/tests/Functional/JsonLd/HydraOperationsTest.php @@ -0,0 +1,93 @@ + + * + * 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\Tests\Functional\JsonLd; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HydraOperationsCompany; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class HydraOperationsTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HydraOperationsCompany::class]; + } + + public function testReferencedOperationsAreFilteredBySecurity(): void + { + $response = self::createClient()->request('GET', '/hydra_operations_companies/1', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseIsSuccessful(); + $this->assertArrayNotHasKey('hydra:operation', $response->toArray()); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + + $response = $client->request('GET', '/hydra_operations_companies/1', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertSame(['PATCH'], array_column($response->toArray()['hydra:operation'], 'hydra:method')); + + // The archive operation is only granted to the owner of the company + $response = $client->request('GET', '/hydra_operations_companies/2', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertArrayNotHasKey('hydra:operation', $response->toArray()); + } + + public function testOperationHiddenFromTheDocumentationIsExposedToAuthorizedUsers(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + + $hydraOperations = $client->request('GET', '/hydra_operations_companies/2', ['headers' => ['Accept' => 'application/ld+json']])->toArray()['hydra:operation']; + $this->assertSame(['DELETE', 'PATCH'], array_column($hydraOperations, 'hydra:method')); + $this->assertSame([ + '@type' => ['hydra:Operation', 'schema:DeleteAction'], + 'hydra:description' => 'Deletes the HydraOperationsCompany resource.', + 'hydra:method' => 'DELETE', + 'hydra:title' => 'deleteHydraOperationsCompany', + 'returns' => 'owl:Nothing', + ], $hydraOperations[0]); + + $supportedOperations = []; + foreach ($client->request('GET', '/docs.jsonld')->toArray()['hydra:supportedClass'] as $supportedClass) { + if ('HydraOperationsCompany' === $supportedClass['hydra:title']) { + $supportedOperations = $supportedClass['hydra:supportedOperation']; + } + } + + // hideHydraOperation only removes the DELETE operation from the documentation, the response reuses its JSON-LD + $this->assertSame(['GET', 'PATCH'], array_column($supportedOperations, 'hydra:method')); + $this->assertSame($supportedOperations[1], $hydraOperations[1]); + } + + public function testCollectionExposesTheOperationsSharingItsIri(): void + { + $response = self::createClient()->request('GET', '/hydra_operations_companies', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertSame(['GET'], array_column($response->toArray()['hydra:operation'], 'hydra:method')); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + + $data = $client->request('GET', '/hydra_operations_companies', ['headers' => ['Accept' => 'application/ld+json']])->toArray(); + $this->assertSame(['GET', 'POST'], array_column($data['hydra:operation'], 'hydra:method')); + // Members expose the operations referenced by the operation identifying them + $this->assertSame(['DELETE'], array_column($data['hydra:member'][0]['hydra:operation'], 'hydra:method')); + $this->assertSame(['DELETE', 'PATCH'], array_column($data['hydra:member'][1]['hydra:operation'], 'hydra:method')); + } +} From cbab334f220df9dac5e2be4ae5d5425401f9584b Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Sat, 3 Oct 2026 18:30:52 +0200 Subject: [PATCH 11/11] fix ci --- phpstan.neon.dist | 1 + src/Hydra/Serializer/CollectionNormalizer.php | 1 - src/Hydra/Serializer/DocumentationNormalizer.php | 1 - .../Serializer/HydraOperationsTrait.php | 4 ++-- .../Serializer/CollectionNormalizerTest.php | 5 +++++ src/JsonLd/Serializer/ItemNormalizer.php | 1 + tests/JsonLd/Serializer/ItemNormalizerTest.php | 16 ++++++++++++++++ 7 files changed, 25 insertions(+), 4 deletions(-) rename src/{JsonLd => Hydra}/Serializer/HydraOperationsTrait.php (99%) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index b32ab68d8f5..046b968a82a 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -100,6 +100,7 @@ parameters: - '#Access to an undefined property GraphQL\\Type\\Definition\\NamedType&GraphQL\\Type\\Definition\\Type::\$name\.#' - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\PropertyInfo\\\\\\\\PropertyInfoExtractor' and 'getType' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#" + - "#Call to function method_exists\\(\\) with 'ApiPlatform\\\\\\\\Metadata\\\\\\\\HttpOperation' and 'getHydraOperations' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index 5a08c712305..ae1dd651fd2 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Hydra\Serializer; use ApiPlatform\JsonLd\ContextBuilderInterface; -use ApiPlatform\JsonLd\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\JsonLd\Serializer\JsonLdContextTrait; use ApiPlatform\Metadata\CollectionOperationInterface; diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 337b2f8fd08..f8e84b6101e 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -16,7 +16,6 @@ use ApiPlatform\Documentation\Documentation; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; -use ApiPlatform\JsonLd\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\Serializer\HydraPrefixTrait; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; diff --git a/src/JsonLd/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php similarity index 99% rename from src/JsonLd/Serializer/HydraOperationsTrait.php rename to src/Hydra/Serializer/HydraOperationsTrait.php index 50a2bbac759..a523f8193c3 100644 --- a/src/JsonLd/Serializer/HydraOperationsTrait.php +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\JsonLd\Serializer; +namespace ApiPlatform\Hydra\Serializer; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\Metadata\ApiResource; @@ -86,7 +86,7 @@ private function getExposedHydraOperations(HttpOperation $operation, ResourceMet $security ??= $candidate->getSecurity() ?? $candidate->getPolicy(); try { $granted = null === $security || $resourceAccessChecker?->isGranted($candidate->getClass(), $security, ['object' => $object, 'previous_object' => $object, 'request' => $context['request'] ?? null] + ($context['uri_variables'] ?? [])); - } catch (\Exception) { + } catch (\Throwable) { $granted = false; } diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index dc671d8eff5..94b44a20567 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; @@ -455,6 +456,10 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void public function testNormalizeExposesTheCollectionOperationsByDefault(): void { + if (!method_exists(HttpOperation::class, 'getHydraOperations')) { + $this->markTestSkipped('api-platform/metadata without hydraOperations'); + } + $data = []; $getCollection = new GetCollection(uriTemplate: '/foos{._format}', shortName: 'Foo', class: Foo::class, name: 'get_collection'); diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e1207e89e4e..0325c4edb56 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\CollectionOperationInterface; diff --git a/tests/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index 2bb65a7e6b6..d793b18ce60 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -188,6 +188,22 @@ public function testNormalizeExposesTheOperationsSharingTheIriByDefault(): void ], $normalizer->normalize($dummy)['operation']); } + public function testNormalizeHidesTheHydraOperationsWhoseSecurityCannotBeEvaluated(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $accessCheckerProphecy = $this->prophesize(ResourceAccessCheckerInterface::class); + $accessCheckerProphecy->isGranted(Dummy::class, 'update', Argument::withEntry('object', $dummy))->willThrow(new \ArgumentCountError('Too few arguments to function DummyPolicy::update()')); + + $normalizer = $this->createHydraOperationsNormalizer($dummy, [ + new Get(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'get'), + new Patch(uriTemplate: '/dummies/{id}{._format}', shortName: 'Dummy', class: Dummy::class, name: 'patch', policy: 'update'), + ], [], $accessCheckerProphecy->reveal()); + + $this->assertSame(['GET'], array_column($normalizer->normalize($dummy)['operation'], 'method')); + } + #[DataProvider('disabledHydraOperationsProvider')] public function testNormalizeWithDisabledHydraOperations(?false $hydraOperations, array $defaultContext): void {