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 = []) + 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); @@ -70,6 +73,20 @@ protected function getPaginationData(iterable $object, array $context = []): arr $data[$hydraPrefix.'totalItems'] = \count($object); } + if (null !== $this->resourceMetadataCollectionFactory) { + $hydraOperationsFromAttributes = $this->getHydraOperationsFromAttributes( + $resourceClass, + true, + null, + $context, + $hydraPrefix + ); + + if (!empty($hydraOperationsFromAttributes)) { + $data[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; + } + } + return $data; } diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 14fbee9c03d..c4e1cefabed 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -21,11 +21,11 @@ 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; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; @@ -48,9 +48,13 @@ */ final class DocumentationNormalizer implements NormalizerInterface { + use HydraOperationsTrait; use HydraPrefixTrait; public const FORMAT = 'jsonld'; + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory; + private ?ResourceAccessCheckerInterface $resourceAccessChecker; + public function __construct( private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -60,7 +64,10 @@ public function __construct( private readonly ?NameConverterInterface $nameConverter = null, private readonly ?array $defaultContext = [], private readonly ?bool $entrypointEnabled = true, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; } /** @@ -250,106 +257,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..cbf0af8378a --- /dev/null +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -0,0 +1,263 @@ + + * + * 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; +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. + */ + 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->getHydraOperationsFromAttributesForResource( + $collection, + $resourceMetadata, + $hydraPrefix, + $resourceClass, + $object, + $context, + $operationNames + ); + + $allHydraOperations = array_merge($allHydraOperations, $hydraOperations); + } + + return $allHydraOperations; + } + + /** + * 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 + { + $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; + } + + $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) { + 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; + + $isCollection = $operation instanceof CollectionOperationInterface; + + $hydraOperation += ['@type' => 'PATCH' === $method ? $hydraPrefix.'Operation' : $this->defaultHydraOperationTypes($method, $hydraPrefix)]; + + if ('GET' === $method && $isCollection) { + $hydraOperation += [ + $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', + ]; + } elseif ('GET' === $method) { + $hydraOperation += [ + $hydraPrefix.'description' => "Retrieves a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PATCH' === $method) { + $hydraOperation += [ + $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 += [ + $hydraPrefix.'description' => "Creates a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PUT' === $method) { + $hydraOperation += [ + $hydraPrefix.'description' => "Replaces the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('DELETE' === $method) { + $hydraOperation += [ + $hydraPrefix.'description' => "Deletes the $shortName resource.", + 'returns' => 'owl:Nothing', + ]; + } + + $hydraOperation[$hydraPrefix.'method'] ??= $method; + $hydraOperation[$hydraPrefix.'title'] ??= $this->defaultHydraOperationTitle($method, $shortName, $isCollection); + + ksort($hydraOperation); + + return $hydraOperation; + } +} diff --git a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php index b044c085ef2..42866beb293 100644 --- a/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/CollectionNormalizerTest.php @@ -17,7 +17,12 @@ use ApiPlatform\Hydra\Tests\Fixtures\Foo; use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\IriConverterInterface; +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 +450,425 @@ public function testNormalizeResourceCollectionWithoutPrefix(): void 'totalItems' => 2, ], $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'); + + $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]), + ])); + + $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, + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:CreateAction', + ], + 'expects' => 'Foo', + 'method' => 'POST', + 'returns' => 'Foo', + 'title' => 'postFoo', + ], + ], + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationsWithoutSecurity(): 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), + ]), + ])); + + $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, + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:CreateAction', + ], + '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', + ], + 'expects' => 'Foo', + 'method' => 'POST', + 'returns' => 'Foo', + 'title' => 'postFoo', + ], + ], + ], $actual); + } + + public function testNormalizeResourceCollectionWithHydraOperationDeniedBySecurity(): 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(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') + ->withHydraOperations([ + new HydraOperation(method: 'DELETE', collection: 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() + ); + $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); + } } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 2c93881c19d..17bfe5c2c07 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; } @@ -134,6 +137,21 @@ 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( + $resourceClass, + false, + $data, + $context, + $hydraPrefix + ); + + if (!empty($hydraOperationsFromAttributes)) { + $metadata[$hydraPrefix.'operation'] = $hydraOperationsFromAttributes; + } + } + return $metadata + $normalizedData; } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 12c176f54b5..9c291fb398c 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -953,7 +953,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/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index eabfdda8fc0..65fd4599291 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -973,6 +973,7 @@ public function __construct( protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, + protected ?array $hydraOperations = null, ) { parent::__construct( shortName: $shortName, @@ -1050,6 +1051,25 @@ public function withMcp(array $mcp): static return $self; } + /** + * @return array|null + */ + public function getHydraOperations(): ?array + { + return $this->hydraOperations; + } + + /** + * @param array $hydraOperations + */ + public function withHydraOperations(array $hydraOperations): static + { + $self = clone $this; + $self->hydraOperations = $hydraOperations; + + return $self; + } + /** * @return Operations|null */ diff --git a/src/Metadata/HydraOperation.php b/src/Metadata/HydraOperation.php new file mode 100644 index 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 b15d99a4508..832f1ff1ae4 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -230,6 +230,33 @@ private function buildHydraContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('hydraContext'), $values); } + private function buildHydraOperations(\SimpleXMLElement $resource, ?array $values): void + { + if (null === $values) { + return; + } + + $node = $resource->addChild('hydraOperations'); + foreach ($values as $operation) { + $child = $node->addChild('hydraOperation'); + foreach ($operation as $key => $value) { + if (\is_string($value) || null === $value || is_numeric($value) || \is_bool($value)) { + $child->addAttribute($key, $this->parse($value)); + continue; + } + + if (\is_array($value)) { + $method = 'build'.ucfirst($key); + if (method_exists($this, $method)) { + $this->{$method}($child, $value); + continue; + } + $this->buildValues($child->addChild($key), $value); + } + } + } + } + private function buildOpenapi(\SimpleXMLElement $resource, array $values): void { $node = $resource->openapi ?? $resource->addChild('openapi'); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 30c16895a07..a23ceff0783 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -345,3 +345,4 @@ resources: 'Lorem ipsum': 'Dolor sit amet' map: null mcp: null + hydraOperations: null diff --git a/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php b/src/Metadata/Tests/Fixtures/ApiResource/HydraOperationResource.php new file mode 100644 index 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 3f744979a03..dfdf91d6ead 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\QueryCollection; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\HydraOperation; use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; @@ -33,6 +34,7 @@ use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResources; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\ExtraPropertiesResource; +use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\HydraOperationResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\PasswordResource; use ApiPlatform\Metadata\Tests\Fixtures\ApiResource\WithParameter; use ApiPlatform\Metadata\Tests\Fixtures\State\AttributeResourceProcessor; @@ -312,4 +314,22 @@ public function testWithParameters(): void $parameters = $metadataCollection->getOperation('collection')->getParameters(); $this->assertCount(3, $parameters); } + + public function testHydraOperationsFromAttributes(): void + { + $factory = new AttributesResourceMetadataCollectionFactory(); + + $collection = $factory->create(HydraOperationResource::class); + + $this->assertCount(1, $collection); + $hydraOperations = $collection[0]->getHydraOperations(); + $this->assertNotNull($hydraOperations); + $this->assertCount(2, $hydraOperations); + $this->assertContainsOnlyInstancesOf(HydraOperation::class, $hydraOperations); + $this->assertSame('DELETE', $hydraOperations[0]->getMethod()); + $this->assertSame("is_granted('ROLE_ADMIN')", $hydraOperations[0]->getSecurity()); + $this->assertFalse($hydraOperations[0]->getCollection()); + $this->assertSame('PUT', $hydraOperations[1]->getMethod()); + $this->assertTrue($hydraOperations[1]->getCollection()); + } } diff --git a/src/Symfony/Bundle/Resources/config/hydra.php b/src/Symfony/Bundle/Resources/config/hydra.php index f015e531d7d..afecf0ce54f 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.php +++ b/src/Symfony/Bundle/Resources/config/hydra.php @@ -69,6 +69,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/JsonLd/Serializer/ItemNormalizerTest.php b/tests/JsonLd/Serializer/ItemNormalizerTest.php index d765b85a2ed..e4fb1fe07f2 100644 --- a/tests/JsonLd/Serializer/ItemNormalizerTest.php +++ b/tests/JsonLd/Serializer/ItemNormalizerTest.php @@ -18,6 +18,7 @@ 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\Property\Factory\PropertyMetadataFactoryInterface; @@ -25,6 +26,7 @@ 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; @@ -98,4 +100,293 @@ public function testNormalize(): void ]; $this->assertEquals($expected, $normalizer->normalize($dummy)); } + + 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')])) + ->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); + $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] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:DeleteAction', + ], + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationsWithoutSecurity(): 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'), + ]), + ])); + $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] + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1989', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:DeleteAction', + ], + 'method' => 'DELETE', + 'returns' => 'owl:Nothing', + 'title' => 'deleteDummy', + ], + ], + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy)); + } + + public function testNormalizeWithHydraOperationGrantedBySecurity(): 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(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], + $accessCheckerProphecy->reveal() + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1990', + '@type' => 'Dummy', + 'operation' => [ + [ + '@type' => [ + 'Operation', + 'schema:DeleteAction', + ], + '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 9004ecb6654..2f24321d9e0 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -242,7 +242,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm // TODO: remove in 5.0 'enable_link_security' => true, 'serializer' => [ - 'hydra_prefix' => null, + 'hydra_prefix' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [