diff --git a/README.md b/README.md index 01faaa5..16c8e0a 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ api_platform_extras: nullable_required: false #Add @id as an optional property to all POST, PUT and PATCH schemas. jsonld_update_schema: false + hydra_pagination_enrichment: + #Adds numeric pagination fields to Hydra view keys (prefix depends on api_platform.serializer.hydra_prefix). + enabled: false # NOT IMPLEMENTED YET simple_normalizer: enabled: false @@ -35,6 +38,16 @@ api_platform_extras: Enable features by setting the corresponding flag to true. +## Hydra Pagination Enrichment Feature + +`hydra_pagination_enrichment` adds numeric pagination fields (`firstPage`, `lastPage`, `currentPage`, `previousPage`, `nextPage`, `itemsPerPage`) to Hydra collection view in both schema and response. +- ! enrichment skipped if cursor pagination used + +The Hydra key prefix is controlled by API Platform and is boolean: + +- `api_platform.serializer.hydra_prefix: true` -> prefixed keys (for example `hydra:view`, `hydra:first`) +- `api_platform.serializer.hydra_prefix: false` (default) -> unprefixed keys (`view`, `first`) + ## JWT Refresh Feature `jwt_refresh` is active only when: diff --git a/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php new file mode 100644 index 0000000..db74062 --- /dev/null +++ b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php @@ -0,0 +1,134 @@ + [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'lastPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'currentPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'previousPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'nextPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + 'itemsPerPage' => [ + 'type' => 'integer', + 'minimum' => 0, + ], + ]; + + private const array PAGINATION_EXAMPLE_VALUES = [ + 'firstPage' => 1, + 'lastPage' => 10, + 'currentPage' => 1, + 'previousPage' => 1, + 'nextPage' => 2, + 'itemsPerPage' => 30, + ]; + + public function __construct( + private readonly SchemaFactoryInterface $decorated, + ) {} + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + if ($this->decorated instanceof SchemaFactoryAwareInterface) { + $this->decorated->setSchemaFactory($schemaFactory); + } + } + + /** @param array|null $serializerContext */ + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + if ('jsonld' !== $format) { + return $schema; + } + + $definitions = $schema->getDefinitions(); + $collectionBaseSchema = $definitions[self::HYDRA_COLLECTION_BASE_SCHEMA_NAME] ?? null; + + if (!is_array($collectionBaseSchema)) { + return $schema; + } + + $allOf = $collectionBaseSchema['allOf'] ?? null; + if (!is_array($allOf) || !isset($allOf[1]) || !is_array($allOf[1])) { + return $schema; + } + + $properties = $allOf[1]['properties'] ?? null; + if (!is_array($properties)) { + return $schema; + } + + if ( + isset($properties['view']['properties']['firstPage']) + || isset($properties['hydra:view']['properties']['firstPage']) + ) { + return $schema; + } + + foreach (self::HYDRA_VIEW_KEYS as $viewKey) { + $viewSchema = $properties[$viewKey] ?? null; + if (!is_array($viewSchema)) { + continue; + } + + $viewProperties = $viewSchema['properties'] ?? []; + if (!is_array($viewProperties)) { + continue; + } + + foreach (self::PAGINATION_PROPERTIES as $propertyName => $propertySchema) { + $viewProperties[$propertyName] ??= $propertySchema; + } + + $viewSchema['properties'] = $viewProperties; + $viewExample = $viewSchema['example'] ?? []; + if (is_array($viewExample)) { + foreach (self::PAGINATION_EXAMPLE_VALUES as $propertyName => $propertyValue) { + $viewExample[$propertyName] ??= $propertyValue; + } + + $viewSchema['example'] = $viewExample; + } + + $properties[$viewKey] = $viewSchema; + } + + $allOf[1]['properties'] = $properties; + $collectionBaseSchema['allOf'] = $allOf; + $definitions[self::HYDRA_COLLECTION_BASE_SCHEMA_NAME] = $collectionBaseSchema; + + return $schema; + } +} diff --git a/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php new file mode 100644 index 0000000..2f51bb2 --- /dev/null +++ b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php @@ -0,0 +1,91 @@ +decorated->normalize($data, $format, $context); + if ( + !($data instanceof PaginatorInterface) + || !is_array($normalized) + || $this->isCursorPaginationEnabled($context) + ) { + return $normalized; + } + + $viewKey = $this->getViewKey($normalized); + if (null === $viewKey || !is_array($normalized[$viewKey])) { + return $normalized; + } + + $currentPage = (int) $data->getCurrentPage(); + $lastPage = (int) $data->getLastPage(); + + $normalized[$viewKey]['firstPage'] ??= 1; + $normalized[$viewKey]['lastPage'] ??= $lastPage; + $normalized[$viewKey]['currentPage'] ??= $currentPage; + $normalized[$viewKey]['previousPage'] ??= max(1, $currentPage - 1); + $normalized[$viewKey]['nextPage'] ??= min($currentPage + 1, $lastPage); + $normalized[$viewKey]['itemsPerPage'] ??= (int) $data->getItemsPerPage(); + + return $normalized; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + /** @return array */ + public function getSupportedTypes(?string $format): array + { + return $this->decorated->getSupportedTypes($format); + } + + public function setNormalizer(NormalizerInterface $normalizer): void + { + if ($this->decorated instanceof NormalizerAwareInterface) { + $this->decorated->setNormalizer($normalizer); + } + } + + /** @param array $context */ + private function isCursorPaginationEnabled(array $context): bool + { + $operation = $context['operation'] ?? null; + + return $operation instanceof HttpOperation && $operation->getPaginationViaCursor() !== null; + } + + /** @param array $data */ + private function getViewKey(array $data): ?string + { + if (array_key_exists('hydra:view', $data)) { + return 'hydra:view'; + } + + if (array_key_exists('view', $data)) { + return 'view'; + } + + return null; + } +} diff --git a/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php b/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php new file mode 100644 index 0000000..1da4e85 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php @@ -0,0 +1,49 @@ +hasParameter($featureEnabledParameter) + || $container->getParameter($featureEnabledParameter) === false + || !$container->hasDefinition('api_platform.hydra.json_schema.schema_factory') + ) { + return; + } + + $container + ->setDefinition('netgen.api_platform_extras.hydra.json_schema.schema_factory', new Definition(SchemaFactoryDecorator::class)) + ->setArguments([ + new Reference('netgen.api_platform_extras.hydra.json_schema.schema_factory.inner'), + ]) + ->setDecoratedService('api_platform.hydra.json_schema.schema_factory'); + + if (!$container->hasDefinition('api_platform.hydra.normalizer.partial_collection_view')) { + return; + } + + $container + ->setDefinition('netgen.api_platform_extras.hydra.normalizer.partial_collection_view', new Definition(PartialCollectionViewNormalizerDecorator::class)) + ->setArguments([ + new Reference('netgen.api_platform_extras.hydra.normalizer.partial_collection_view.inner'), + ]) + ->setDecoratedService('api_platform.hydra.normalizer.partial_collection_view'); + } +} diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 2436b6f..4212107 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -41,6 +41,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('hydra_pagination_enrichment') + ->info('Add numeric pagination fields to Hydra schema and collection response (hydra:view when api_platform.serializer.hydra_prefix=true, view when false).') + ->canBeEnabled() + ->end() ->arrayNode('simple_normalizer') ->canBeEnabled() ->end() diff --git a/src/NetgenApiPlatformExtrasBundle.php b/src/NetgenApiPlatformExtrasBundle.php index d68f9b2..8f6321d 100644 --- a/src/NetgenApiPlatformExtrasBundle.php +++ b/src/NetgenApiPlatformExtrasBundle.php @@ -4,6 +4,7 @@ namespace Netgen\ApiPlatformExtras; +use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\HydraPaginationEnrichmentCompilerPass; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\IriTemplateGeneratorCompilerPass; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\JwtRefreshCompilerPass; use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\SchemaDecorationCompilerPass; @@ -33,6 +34,9 @@ public function build(ContainerBuilder $container): void ->addCompilerPass( new SchemaDecorationCompilerPass(), ) + ->addCompilerPass( + new HydraPaginationEnrichmentCompilerPass(), + ) ->addCompilerPass( new JwtRefreshCompilerPass(), );