Skip to content

Commit d496c38

Browse files
authored
Merge pull request #9 from netgen/NGSTACK-1017-hydra-pagination-enrichment-feature
Ngstack 1017 hydra pagination enrichment feature
2 parents b3b618b + 5b50a38 commit d496c38

6 files changed

Lines changed: 295 additions & 0 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ api_platform_extras:
1616
nullable_required: false
1717
#Add @id as an optional property to all POST, PUT and PATCH schemas.
1818
jsonld_update_schema: false
19+
hydra_pagination_enrichment:
20+
#Adds numeric pagination fields to Hydra view keys (prefix depends on api_platform.serializer.hydra_prefix).
21+
enabled: false
1922
# NOT IMPLEMENTED YET
2023
simple_normalizer:
2124
enabled: false
@@ -35,6 +38,16 @@ api_platform_extras:
3538
3639
Enable features by setting the corresponding flag to true.
3740
41+
## Hydra Pagination Enrichment Feature
42+
43+
`hydra_pagination_enrichment` adds numeric pagination fields (`firstPage`, `lastPage`, `currentPage`, `previousPage`, `nextPage`, `itemsPerPage`) to Hydra collection view in both schema and response.
44+
- ! enrichment skipped if cursor pagination used
45+
46+
The Hydra key prefix is controlled by API Platform and is boolean:
47+
48+
- `api_platform.serializer.hydra_prefix: true` -> prefixed keys (for example `hydra:view`, `hydra:first`)
49+
- `api_platform.serializer.hydra_prefix: false` (default) -> unprefixed keys (`view`, `first`)
50+
3851
## JWT Refresh Feature
3952

4053
`jwt_refresh` is active only when:
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Netgen\ApiPlatformExtras\ApiPlatform\Hydra\JsonSchema;
6+
7+
use ApiPlatform\JsonSchema\Schema;
8+
use ApiPlatform\JsonSchema\SchemaFactoryAwareInterface;
9+
use ApiPlatform\JsonSchema\SchemaFactoryInterface;
10+
use ApiPlatform\Metadata\Operation;
11+
12+
use function is_array;
13+
14+
final class SchemaFactoryDecorator implements SchemaFactoryInterface, SchemaFactoryAwareInterface
15+
{
16+
private const string HYDRA_COLLECTION_BASE_SCHEMA_NAME = 'HydraCollectionBaseSchema';
17+
18+
private const array HYDRA_VIEW_KEYS = ['hydra:view', 'view'];
19+
20+
private const array PAGINATION_PROPERTIES = [
21+
'firstPage' => [
22+
'type' => 'integer',
23+
'minimum' => 0,
24+
],
25+
'lastPage' => [
26+
'type' => 'integer',
27+
'minimum' => 0,
28+
],
29+
'currentPage' => [
30+
'type' => 'integer',
31+
'minimum' => 0,
32+
],
33+
'previousPage' => [
34+
'type' => 'integer',
35+
'minimum' => 0,
36+
],
37+
'nextPage' => [
38+
'type' => 'integer',
39+
'minimum' => 0,
40+
],
41+
'itemsPerPage' => [
42+
'type' => 'integer',
43+
'minimum' => 0,
44+
],
45+
];
46+
47+
private const array PAGINATION_EXAMPLE_VALUES = [
48+
'firstPage' => 1,
49+
'lastPage' => 10,
50+
'currentPage' => 1,
51+
'previousPage' => 1,
52+
'nextPage' => 2,
53+
'itemsPerPage' => 30,
54+
];
55+
56+
public function __construct(
57+
private readonly SchemaFactoryInterface $decorated,
58+
) {}
59+
60+
public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void
61+
{
62+
if ($this->decorated instanceof SchemaFactoryAwareInterface) {
63+
$this->decorated->setSchemaFactory($schemaFactory);
64+
}
65+
}
66+
67+
/** @param array<string, mixed>|null $serializerContext */
68+
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
69+
{
70+
$schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection);
71+
72+
if ('jsonld' !== $format) {
73+
return $schema;
74+
}
75+
76+
$definitions = $schema->getDefinitions();
77+
$collectionBaseSchema = $definitions[self::HYDRA_COLLECTION_BASE_SCHEMA_NAME] ?? null;
78+
79+
if (!is_array($collectionBaseSchema)) {
80+
return $schema;
81+
}
82+
83+
$allOf = $collectionBaseSchema['allOf'] ?? null;
84+
if (!is_array($allOf) || !isset($allOf[1]) || !is_array($allOf[1])) {
85+
return $schema;
86+
}
87+
88+
$properties = $allOf[1]['properties'] ?? null;
89+
if (!is_array($properties)) {
90+
return $schema;
91+
}
92+
93+
if (
94+
isset($properties['view']['properties']['firstPage'])
95+
|| isset($properties['hydra:view']['properties']['firstPage'])
96+
) {
97+
return $schema;
98+
}
99+
100+
foreach (self::HYDRA_VIEW_KEYS as $viewKey) {
101+
$viewSchema = $properties[$viewKey] ?? null;
102+
if (!is_array($viewSchema)) {
103+
continue;
104+
}
105+
106+
$viewProperties = $viewSchema['properties'] ?? [];
107+
if (!is_array($viewProperties)) {
108+
continue;
109+
}
110+
111+
foreach (self::PAGINATION_PROPERTIES as $propertyName => $propertySchema) {
112+
$viewProperties[$propertyName] ??= $propertySchema;
113+
}
114+
115+
$viewSchema['properties'] = $viewProperties;
116+
$viewExample = $viewSchema['example'] ?? [];
117+
if (is_array($viewExample)) {
118+
foreach (self::PAGINATION_EXAMPLE_VALUES as $propertyName => $propertyValue) {
119+
$viewExample[$propertyName] ??= $propertyValue;
120+
}
121+
122+
$viewSchema['example'] = $viewExample;
123+
}
124+
125+
$properties[$viewKey] = $viewSchema;
126+
}
127+
128+
$allOf[1]['properties'] = $properties;
129+
$collectionBaseSchema['allOf'] = $allOf;
130+
$definitions[self::HYDRA_COLLECTION_BASE_SCHEMA_NAME] = $collectionBaseSchema;
131+
132+
return $schema;
133+
}
134+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Netgen\ApiPlatformExtras\ApiPlatform\Hydra\Serializer;
6+
7+
use ApiPlatform\Metadata\HttpOperation;
8+
use ApiPlatform\State\Pagination\PaginatorInterface;
9+
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
10+
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
11+
12+
use function array_key_exists;
13+
use function is_array;
14+
use function max;
15+
use function min;
16+
17+
final class PartialCollectionViewNormalizerDecorator implements NormalizerInterface, NormalizerAwareInterface
18+
{
19+
public function __construct(
20+
private readonly NormalizerInterface $decorated,
21+
) {}
22+
23+
public function normalize(mixed $data, ?string $format = null, array $context = []): array|\ArrayObject|bool|float|int|string|null
24+
{
25+
$normalized = $this->decorated->normalize($data, $format, $context);
26+
if (
27+
!($data instanceof PaginatorInterface)
28+
|| !is_array($normalized)
29+
|| $this->isCursorPaginationEnabled($context)
30+
) {
31+
return $normalized;
32+
}
33+
34+
$viewKey = $this->getViewKey($normalized);
35+
if (null === $viewKey || !is_array($normalized[$viewKey])) {
36+
return $normalized;
37+
}
38+
39+
$currentPage = (int) $data->getCurrentPage();
40+
$lastPage = (int) $data->getLastPage();
41+
42+
$normalized[$viewKey]['firstPage'] ??= 1;
43+
$normalized[$viewKey]['lastPage'] ??= $lastPage;
44+
$normalized[$viewKey]['currentPage'] ??= $currentPage;
45+
$normalized[$viewKey]['previousPage'] ??= max(1, $currentPage - 1);
46+
$normalized[$viewKey]['nextPage'] ??= min($currentPage + 1, $lastPage);
47+
$normalized[$viewKey]['itemsPerPage'] ??= (int) $data->getItemsPerPage();
48+
49+
return $normalized;
50+
}
51+
52+
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
53+
{
54+
return $this->decorated->supportsNormalization($data, $format, $context);
55+
}
56+
57+
/** @return array<string, bool|null> */
58+
public function getSupportedTypes(?string $format): array
59+
{
60+
return $this->decorated->getSupportedTypes($format);
61+
}
62+
63+
public function setNormalizer(NormalizerInterface $normalizer): void
64+
{
65+
if ($this->decorated instanceof NormalizerAwareInterface) {
66+
$this->decorated->setNormalizer($normalizer);
67+
}
68+
}
69+
70+
/** @param array<string, mixed> $context */
71+
private function isCursorPaginationEnabled(array $context): bool
72+
{
73+
$operation = $context['operation'] ?? null;
74+
75+
return $operation instanceof HttpOperation && $operation->getPaginationViaCursor() !== null;
76+
}
77+
78+
/** @param array<string, mixed> $data */
79+
private function getViewKey(array $data): ?string
80+
{
81+
if (array_key_exists('hydra:view', $data)) {
82+
return 'hydra:view';
83+
}
84+
85+
if (array_key_exists('view', $data)) {
86+
return 'view';
87+
}
88+
89+
return null;
90+
}
91+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass;
6+
7+
use Netgen\ApiPlatformExtras\ApiPlatform\Hydra\JsonSchema\SchemaFactoryDecorator;
8+
use Netgen\ApiPlatformExtras\ApiPlatform\Hydra\Serializer\PartialCollectionViewNormalizerDecorator;
9+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
10+
use Symfony\Component\DependencyInjection\ContainerBuilder;
11+
use Symfony\Component\DependencyInjection\Definition;
12+
use Symfony\Component\DependencyInjection\Reference;
13+
14+
use function sprintf;
15+
16+
final class HydraPaginationEnrichmentCompilerPass implements CompilerPassInterface
17+
{
18+
private const string BASE_FEATURE_PATH = 'netgen_api_platform_extras.features.hydra_pagination_enrichment';
19+
20+
public function process(ContainerBuilder $container): void
21+
{
22+
$featureEnabledParameter = sprintf('%s.enabled', self::BASE_FEATURE_PATH);
23+
if (
24+
!$container->hasParameter($featureEnabledParameter)
25+
|| $container->getParameter($featureEnabledParameter) === false
26+
|| !$container->hasDefinition('api_platform.hydra.json_schema.schema_factory')
27+
) {
28+
return;
29+
}
30+
31+
$container
32+
->setDefinition('netgen.api_platform_extras.hydra.json_schema.schema_factory', new Definition(SchemaFactoryDecorator::class))
33+
->setArguments([
34+
new Reference('netgen.api_platform_extras.hydra.json_schema.schema_factory.inner'),
35+
])
36+
->setDecoratedService('api_platform.hydra.json_schema.schema_factory');
37+
38+
if (!$container->hasDefinition('api_platform.hydra.normalizer.partial_collection_view')) {
39+
return;
40+
}
41+
42+
$container
43+
->setDefinition('netgen.api_platform_extras.hydra.normalizer.partial_collection_view', new Definition(PartialCollectionViewNormalizerDecorator::class))
44+
->setArguments([
45+
new Reference('netgen.api_platform_extras.hydra.normalizer.partial_collection_view.inner'),
46+
])
47+
->setDecoratedService('api_platform.hydra.normalizer.partial_collection_view');
48+
}
49+
}

src/DependencyInjection/Configuration.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ public function getConfigTreeBuilder(): TreeBuilder
4141
->end()
4242
->end()
4343
->end()
44+
->arrayNode('hydra_pagination_enrichment')
45+
->info('Add numeric pagination fields to Hydra schema and collection response (hydra:view when api_platform.serializer.hydra_prefix=true, view when false).')
46+
->canBeEnabled()
47+
->end()
4448
->arrayNode('simple_normalizer')
4549
->canBeEnabled()
4650
->end()

src/NetgenApiPlatformExtrasBundle.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Netgen\ApiPlatformExtras;
66

7+
use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\HydraPaginationEnrichmentCompilerPass;
78
use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\IriTemplateGeneratorCompilerPass;
89
use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\JwtRefreshCompilerPass;
910
use Netgen\ApiPlatformExtras\DependencyInjection\CompilerPass\SchemaDecorationCompilerPass;
@@ -33,6 +34,9 @@ public function build(ContainerBuilder $container): void
3334
->addCompilerPass(
3435
new SchemaDecorationCompilerPass(),
3536
)
37+
->addCompilerPass(
38+
new HydraPaginationEnrichmentCompilerPass(),
39+
)
3640
->addCompilerPass(
3741
new JwtRefreshCompilerPass(),
3842
);

0 commit comments

Comments
 (0)