From 928709b578ae06784333d043bc5c98bcfb5a6ef3 Mon Sep 17 00:00:00 2001 From: Jakov Knezovic Date: Fri, 27 Feb 2026 10:36:04 +0100 Subject: [PATCH 1/5] NGSTACK-1017 add feature node and update readme --- README.md | 13 +++++++++++++ src/DependencyInjection/Configuration.php | 4 ++++ 2 files changed, 17 insertions(+) diff --git a/README.md b/README.md index 01faaa5..6ca0158 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,10 @@ api_platform_extras: #Add @id as an optional property to all POST, PUT and PATCH schemas. jsonld_update_schema: false # NOT IMPLEMENTED YET + 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 jwt_refresh: @@ -35,6 +39,15 @@ 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. + +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/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() From 93a69b5b024e176461e72777fb46245f1f7d4595 Mon Sep 17 00:00:00 2001 From: Jakov Knezovic Date: Fri, 27 Feb 2026 14:24:29 +0100 Subject: [PATCH 2/5] NGSTACK-1017 implement hydra schema decorator for additional pagination numeric properties --- README.md | 1 - .../JsonSchema/SchemaFactoryDecorator.php | 127 ++++++++++++++++++ .../HydraPaginationEnrichmentCompilerPass.php | 37 +++++ src/NetgenApiPlatformExtrasBundle.php | 4 + 4 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php create mode 100644 src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php diff --git a/README.md b/README.md index 6ca0158..b5eebc8 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ api_platform_extras: nullable_required: false #Add @id as an optional property to all POST, PUT and PATCH schemas. jsonld_update_schema: false - # NOT IMPLEMENTED YET hydra_pagination_enrichment: #Adds numeric pagination fields to Hydra view keys (prefix depends on api_platform.serializer.hydra_prefix). enabled: false diff --git a/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php new file mode 100644 index 0000000..13f676d --- /dev/null +++ b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php @@ -0,0 +1,127 @@ + [ + '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; + } + + 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/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php b/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php new file mode 100644 index 0000000..c4bbce7 --- /dev/null +++ b/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php @@ -0,0 +1,37 @@ +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'); + } +} 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(), ); From 33c4557e05bd33fcd42575dd2c7053448d51bee0 Mon Sep 17 00:00:00 2001 From: Jakov Knezovic Date: Fri, 27 Feb 2026 15:46:11 +0100 Subject: [PATCH 3/5] NGSTACK-1017 implement hydra partial collection decorator for enrithching with numeric if not cursor pagination --- .../JsonSchema/SchemaFactoryDecorator.php | 7 ++ ...rtialCollectionViewNormalizerDecorator.php | 99 +++++++++++++++++++ .../HydraPaginationEnrichmentCompilerPass.php | 12 +++ 3 files changed, 118 insertions(+) create mode 100644 src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php diff --git a/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php index 13f676d..db74062 100644 --- a/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php +++ b/src/ApiPlatform/Hydra/JsonSchema/SchemaFactoryDecorator.php @@ -90,6 +90,13 @@ public function buildSchema(string $className, string $format = 'json', string $ 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)) { diff --git a/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php new file mode 100644 index 0000000..981117b --- /dev/null +++ b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php @@ -0,0 +1,99 @@ + $context */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array|\ArrayObject|bool|float|int|string|null + { + $normalized = $this->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'] ??= (int) min($currentPage + 1, $lastPage); + $normalized[$viewKey]['itemsPerPage'] ??= (int) $data->getItemsPerPage(); + + return $normalized; + } + + /** @param array $context */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->decorated->supportsNormalization($data, $format, $context); + } + + /** @return array|array */ + public function getSupportedTypes(?string $format): array + { + if (method_exists($this->decorated, 'getSupportedTypes')) { + /* @var array|array $supportedTypes */ + return $this->decorated->getSupportedTypes($format); + } + + return ['*' => false]; + } + + public function setNormalizer(NormalizerInterface $normalizer): void + { + if ($this->decorated instanceof NormalizerAwareInterface) { + $this->decorated->setNormalizer($normalizer); + } + } + + /** @return array|array */ + 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 index c4bbce7..1da4e85 100644 --- a/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php +++ b/src/DependencyInjection/CompilerPass/HydraPaginationEnrichmentCompilerPass.php @@ -5,6 +5,7 @@ namespace Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass; use Netgen\ApiPlatformExtras\ApiPlatform\Hydra\JsonSchema\SchemaFactoryDecorator; +use Netgen\ApiPlatformExtras\ApiPlatform\Hydra\Serializer\PartialCollectionViewNormalizerDecorator; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; @@ -33,5 +34,16 @@ public function process(ContainerBuilder $container): void 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'); } } From 864a8fed2dff0734b93e57bb0dcefdbc0a121399 Mon Sep 17 00:00:00 2001 From: Jakov Knezovic Date: Fri, 27 Feb 2026 15:48:22 +0100 Subject: [PATCH 4/5] NGSTACK-1017 add note to readme --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index b5eebc8..16c8e0a 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ 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: From 5b50a38b3809106a2501cc8e01d85712d8a42034 Mon Sep 17 00:00:00 2001 From: Jakov Knezovic Date: Fri, 27 Feb 2026 16:03:28 +0100 Subject: [PATCH 5/5] NGSTACK-1017 fix phpstan --- .../PartialCollectionViewNormalizerDecorator.php | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php index 981117b..2f51bb2 100644 --- a/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php +++ b/src/ApiPlatform/Hydra/Serializer/PartialCollectionViewNormalizerDecorator.php @@ -12,7 +12,6 @@ use function array_key_exists; use function is_array; use function max; -use function method_exists; use function min; final class PartialCollectionViewNormalizerDecorator implements NormalizerInterface, NormalizerAwareInterface @@ -21,7 +20,6 @@ public function __construct( private readonly NormalizerInterface $decorated, ) {} - /** @param array $context */ public function normalize(mixed $data, ?string $format = null, array $context = []): array|\ArrayObject|bool|float|int|string|null { $normalized = $this->decorated->normalize($data, $format, $context); @@ -45,27 +43,21 @@ public function normalize(mixed $data, ?string $format = null, array $context = $normalized[$viewKey]['lastPage'] ??= $lastPage; $normalized[$viewKey]['currentPage'] ??= $currentPage; $normalized[$viewKey]['previousPage'] ??= max(1, $currentPage - 1); - $normalized[$viewKey]['nextPage'] ??= (int) min($currentPage + 1, $lastPage); + $normalized[$viewKey]['nextPage'] ??= min($currentPage + 1, $lastPage); $normalized[$viewKey]['itemsPerPage'] ??= (int) $data->getItemsPerPage(); return $normalized; } - /** @param array $context */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $this->decorated->supportsNormalization($data, $format, $context); } - /** @return array|array */ + /** @return array */ public function getSupportedTypes(?string $format): array { - if (method_exists($this->decorated, 'getSupportedTypes')) { - /* @var array|array $supportedTypes */ - return $this->decorated->getSupportedTypes($format); - } - - return ['*' => false]; + return $this->decorated->getSupportedTypes($format); } public function setNormalizer(NormalizerInterface $normalizer): void @@ -75,7 +67,7 @@ public function setNormalizer(NormalizerInterface $normalizer): void } } - /** @return array|array */ + /** @param array $context */ private function isCursorPaginationEnabled(array $context): bool { $operation = $context['operation'] ?? null;