diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 428c6da7a9..7cf342a156 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\ErrorResource; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; @@ -48,6 +47,7 @@ */ final class DocumentationNormalizer implements NormalizerInterface { + use HydraOperationsTrait; use HydraPrefixTrait; public const FORMAT = 'jsonld'; @@ -254,106 +254,6 @@ private function getHydraProperties(string $resourceClass, ApiResource $resource return $properties; } - /** - * Gets Hydra operations. - */ - private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array - { - $hydraOperations = []; - foreach ($resourceMetadata->getOperations() as $operation) { - if (true === $operation->getHideHydraOperation()) { - continue; - } - - if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { - continue; - } - - $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); - } - - return $hydraOperations; - } - - /** - * Gets and populates if applicable a Hydra operation. - */ - private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix): array - { - $method = $operation->getMethod() ?: 'GET'; - - $hydraOperation = $operation->getHydraContext() ?? []; - if ($operation->getDeprecationReason()) { - $hydraOperation['owl:deprecated'] = true; - } - - $shortName = $operation->getShortName(); - $inputMetadata = $operation->getInput() ?? []; - $outputMetadata = $operation->getOutput() ?? []; - - $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; - $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; - - if ('GET' === $method && $operation instanceof CollectionOperationInterface) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', - ]; - } elseif ('GET' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], - $hydraPrefix.'description' => "Retrieves a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PATCH' === $method) { - $hydraOperation += [ - '@type' => $hydraPrefix.'Operation', - $hydraPrefix.'description' => "Updates the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - - if (null !== $inputClass) { - $possibleValue = []; - foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $possibleValue[] = $mimeType; - } - } - - $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; - } - } elseif ('POST' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], - $hydraPrefix.'description' => "Creates a $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('PUT' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], - $hydraPrefix.'description' => "Replaces the $shortName resource.", - 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, - 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, - ]; - } elseif ('DELETE' === $method) { - $hydraOperation += [ - '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], - $hydraPrefix.'description' => "Deletes the $shortName resource.", - 'returns' => 'owl:Nothing', - ]; - } - - $hydraOperation[$hydraPrefix.'method'] ??= $method; - $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); - - ksort($hydraOperation); - - return $hydraOperation; - } - /** * Gets the range of the property. */ diff --git a/src/Hydra/Serializer/HydraOperationsTrait.php b/src/Hydra/Serializer/HydraOperationsTrait.php new file mode 100644 index 0000000000..dfaa9f85bc --- /dev/null +++ b/src/Hydra/Serializer/HydraOperationsTrait.php @@ -0,0 +1,127 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Hydra\Serializer; + +use ApiPlatform\JsonLd\ContextBuilder; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; + +/** + * Generates Hydra operations for JSON-LD responses. + * + * @author Kévin Dunglas + */ +trait HydraOperationsTrait +{ + /** + * Gets Hydra operations. + */ + private function getHydraOperations(bool $collection, ApiResource $resourceMetadata, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + { + $hydraOperations = []; + foreach ($resourceMetadata->getOperations() as $operation) { + if (true === $operation->getHideHydraOperation()) { + continue; + } + + if (('POST' === $operation->getMethod() || $operation instanceof CollectionOperationInterface) !== $collection) { + continue; + } + + $hydraOperations[] = $this->getHydraOperation($operation, $operation->getShortName(), $hydraPrefix); + } + + return $hydraOperations; + } + + /** + * Gets and populates if applicable a Hydra operation. + */ + private function getHydraOperation(HttpOperation $operation, string $prefixedShortName, string $hydraPrefix = ContextBuilder::HYDRA_PREFIX): array + { + $method = $operation->getMethod() ?: 'GET'; + + $hydraOperation = $operation->getHydraContext() ?? []; + if ($operation->getDeprecationReason()) { + $hydraOperation['owl:deprecated'] = true; + } + + $shortName = $operation->getShortName(); + $inputMetadata = $operation->getInput() ?? []; + $outputMetadata = $operation->getOutput() ?? []; + + $inputClass = \array_key_exists('class', $inputMetadata) ? $inputMetadata['class'] : false; + $outputClass = \array_key_exists('class', $outputMetadata) ? $outputMetadata['class'] : false; + + if ('GET' === $method && $operation instanceof CollectionOperationInterface) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves the collection of $shortName resources.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $hydraPrefix.'Collection', + ]; + } elseif ('GET' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:FindAction'], + $hydraPrefix.'description' => "Retrieves a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PATCH' === $method) { + $hydraOperation += [ + '@type' => $hydraPrefix.'Operation', + $hydraPrefix.'description' => "Updates the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + + if (null !== $inputClass) { + $possibleValue = []; + foreach ($operation->getInputFormats() ?? [] as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $possibleValue[] = $mimeType; + } + } + + $hydraOperation['expectsHeader'] = [['headerName' => 'Content-Type', 'possibleValue' => $possibleValue]]; + } + } elseif ('POST' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:CreateAction'], + $hydraPrefix.'description' => "Creates a $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('PUT' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:ReplaceAction'], + $hydraPrefix.'description' => "Replaces the $shortName resource.", + 'returns' => null === $outputClass ? 'owl:Nothing' : $prefixedShortName, + 'expects' => null === $inputClass ? 'owl:Nothing' : $prefixedShortName, + ]; + } elseif ('DELETE' === $method) { + $hydraOperation += [ + '@type' => [$hydraPrefix.'Operation', 'schema:DeleteAction'], + $hydraPrefix.'description' => "Deletes the $shortName resource.", + 'returns' => 'owl:Nothing', + ]; + } + + $hydraOperation[$hydraPrefix.'method'] ??= $method; + $hydraOperation[$hydraPrefix.'title'] ??= strtolower($method).$shortName.($operation instanceof CollectionOperationInterface ? 'Collection' : ''); + + ksort($hydraOperation); + + return $hydraOperation; + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 7da1e54a21..51b6b9f8fa 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -13,8 +13,10 @@ namespace ApiPlatform\JsonLd\Serializer; +use ApiPlatform\Hydra\Serializer\HydraOperationsTrait; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\Metadata\ErrorResourceInterface; use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; @@ -45,6 +47,8 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use HydraOperationsTrait; + use HydraPrefixTrait; use JsonLdContextTrait; public const FORMAT = 'jsonld'; @@ -72,8 +76,11 @@ final class ItemNormalizer extends AbstractItemNormalizer '@vocab', ]; + private array $itemNormalizerDefaultContext = []; + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { + $this->itemNormalizerDefaultContext = $defaultContext; parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); } @@ -184,6 +191,21 @@ public function normalize(mixed $data, ?string $format = null, array $context = $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } + if ($isResourceClass && !is_a($resourceClass, ErrorResourceInterface::class, true)) { + $showOperations = $context['hydra_operations'] ?? false; + + if ($showOperations) { + $hydraOperations = $this->getHydraOperations( + false, + $this->resourceMetadataCollectionFactory->create($resourceClass)[0], + $this->getHydraPrefix($context + $this->itemNormalizerDefaultContext) + ); + if (!empty($hydraOperations)) { + $metadata['operation'] = $hydraOperations; + } + } + } + return $metadata + $normalizedData; } diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 7f6c7953c9..94fc677154 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -149,6 +149,7 @@ 'serializer' => [ 'hydra_prefix' => false, + 'hydra_operations' => false, // 'datetime_format' => \DateTimeInterface::RFC3339, ], diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e7..c36577e3a0 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -332,7 +332,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setDefinition('serializer.normalizer.number', $numberNormalizerDefinition); } - $defaultContext = ['hydra_prefix' => $config['serializer']['hydra_prefix']] + ($container->hasParameter('serializer.default_context') ? $container->getParameter('serializer.default_context') : []); + $defaultContext = [ + 'hydra_prefix' => $config['serializer']['hydra_prefix'], + 'hydra_operations' => $config['serializer']['hydra_operations'], + ] + ($container->hasParameter('serializer.default_context') ? $container->getParameter('serializer.default_context') : []); $container->setParameter('api_platform.serializer.default_context', $defaultContext); if (!$container->hasParameter('serializer.default_context')) { diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fd..f5953a9696 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -172,6 +172,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->booleanNode('hydra_prefix')->defaultFalse()->info('Use the "hydra:" prefix.')->end() + ->booleanNode('hydra_operations')->defaultFalse()->info('Add the "operation" field to Hydra responses. Disabled by default to avoid breaking changes.')->end() ->end() ->end() ->end(); diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e263..ba86b4f476 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -38,6 +38,8 @@ api_platform: Made with love enable_swagger: true enable_swagger_ui: true + serializer: + hydra_operations: false formats: jsonld: ['application/ld+json'] jsonhal: ['application/hal+json'] diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb665..3873f26d70 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -242,7 +242,8 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm // TODO: remove in 5.0 'enable_link_security' => true, 'serializer' => [ - 'hydra_prefix' => null, + 'hydra_prefix' => false, + 'hydra_operations' => false, ], 'enable_phpdoc_parser' => true, 'mcp' => [