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 e882d3aef05..ae1dd651fd2 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -16,7 +16,11 @@ use ApiPlatform\JsonLd\ContextBuilderInterface; 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 +35,7 @@ */ final class CollectionNormalizer extends AbstractCollectionNormalizer { + use HydraOperationsTrait; use HydraPrefixTrait; use JsonLdContextTrait; @@ -42,11 +47,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 +75,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 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..a523f8193c3 --- /dev/null +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -0,0 +1,176 @@ + + * + * 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\Error; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\Util\UriTemplateHelper; + +/** + * @author Kévin Dunglas + * + * @internal + */ +trait HydraOperationsTrait +{ + 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 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 (\Throwable) { + $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'; + + $hydraOperation = $operation->getHydraContext() ?? []; + if ($operation->getDeprecationReason()) { + $hydraOperation['owl:deprecated'] = true; + } + + $shortName = $operation->getShortName(); + $inputMetadata = $operation->getInput() ?? []; + $outputMetadata = $operation->getOutput() ?? []; + + $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; + $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; + + if ('GET' === $method && $operation instanceof CollectionOperationInterface) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', + ]; + } elseif ('GET' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PATCH' === $method) { + $hydraOperation += [ + '@type' => $hydraPrefix.'Operation', + $hydraPrefix.'description' => "Updates the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + + if (null !== $inputClass) { + $possibleValue = []; + foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $possibleValue[] = $mimeType; + } + } + + $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; + } + } elseif ('POST' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], + $hydraPrefix.'description' => "Creates a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PUT' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], + $hydraPrefix.'description' => "Replaces the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('DELETE' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], + $hydraPrefix.'description' => "Deletes the $shortName resource.", + 'returns' => 'owl:Nothing', + ]; + } + + $hydraOperation[$hydraPrefix.'method'] ??= $method; + $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); + + ksort($hydraOperation); + + return $hydraOperation; + } +} diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index 58591f416de..94b44a20567 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -17,7 +17,15 @@ 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\HttpOperation; 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 +453,62 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void 'totalItems' => 2, ], $actual); } + + 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'); + + $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/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index e63202613f6..0325c4edb56 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -13,8 +13,11 @@ namespace ApiPlatform\JsonLd\Serializer; +use ApiPlatform\Hydra\Serializer\HydraOperationsTrait; 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 +46,8 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use HydraOperationsTrait; + use HydraPrefixTrait; use ItemNormalizerTrait { denormalize as private doDenormalize; } @@ -134,9 +139,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/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/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/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/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/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([ 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/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/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')); + } +} diff --git a/tests/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index d765b85a2ed..d793b18ce60 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,172 @@ 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']); + } + + 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 + { + $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' => [