Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[RFC] feat(paginator): add CursorPaginatorInterface #6305

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion features/openapi/docs.feature
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ Feature: Documentation support
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].name" should be equal to "page"
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].in" should be equal to "query"
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].required" should be false
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "integer"
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "string"

And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].name" should be equal to "itemsPerPage"
And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].in" should be equal to "query"
Expand Down
131 changes: 131 additions & 0 deletions src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\Serializer\CacheableSupportsMethodInterface;
use ApiPlatform\State\Pagination\CursorPaginatorInterface;
use ApiPlatform\Util\IriHelper;
use Symfony\Component\Serializer\Exception\UnexpectedValueException;
use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface as BaseCacheableSupportsMethodInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\Serializer\Serializer;

/**
* Adds a view key to the result of a paginated Hydra collection, if the
* collection is a CursorPaginatorInterface.
*
* @author Priyadi Iman Nurcahyo <priyadi@rekalogika.com>
*/
final class CursorBasedPartialCollectionViewNormalizer implements NormalizerInterface, NormalizerAwareInterface, CacheableSupportsMethodInterface
{
public function __construct(private readonly NormalizerInterface $collectionNormalizer, private readonly string $pageParameterName = 'page', private readonly ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH)
{
}

/**
* {@inheritdoc}
*/
public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null
{
$data = $this->collectionNormalizer->normalize($object, $format, $context);

if (!$object instanceof CursorPaginatorInterface || isset($context['api_sub_level'])) {
return $data;
}

if (!\is_array($data)) {
throw new UnexpectedValueException('Expected data to be an array');
}

// (same TODO message retained from PartialCollectionViewNormalizer)
// TODO: This needs to be changed as well as I wrote in the CollectionFiltersNormalizer
// We should not rely on the request_uri but instead rely on the UriTemplate
// This needs that we implement the RFC and that we do more parsing before calling the serialization (MainController)
$parsed = IriHelper::parseIri($context['uri'] ?? $context['request_uri'] ?? '/', $this->pageParameterName);

$operation = $context['operation'] ?? null;
if (!$operation && $this->resourceMetadataFactory && isset($context['resource_class'])) {
$operation = $this->resourceMetadataFactory->create($context['resource_class'])->getOperation($context['operation_name'] ?? null);
}

$data['hydra:view'] = ['@type' => 'hydra:PartialCollectionView'];

$data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $object->getCurrentPageCursor(), $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);

if (($firstPageCursor = $object->getFirstPageCursor()) !== null) {
$data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $firstPageCursor, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

if (($lastPageCursor = $object->getLastPageCursor()) !== null) {
$data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPageCursor, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

if (($nextPageCursor = $object->getNextPageCursor()) !== null) {
$data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $nextPageCursor, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

if (($previousPageCursor = $object->getPreviousPageCursor()) !== null) {
$data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $previousPageCursor, $operation?->getUrlGenerationStrategy() ?? $this->urlGenerationStrategy);
}

return $data;
}

/**
* {@inheritdoc}
*/
public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool
{
return $this->collectionNormalizer->supportsNormalization($data, $format, $context);
}

public function getSupportedTypes($format): array
{
// @deprecated remove condition when support for symfony versions under 6.3 is dropped
if (!method_exists($this->collectionNormalizer, 'getSupportedTypes')) {
return [
'*' => $this->collectionNormalizer instanceof CacheableSupportsMethodInterface && $this->collectionNormalizer->hasCacheableSupportsMethod(),

Check failure on line 101 in src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php

View workflow job for this annotation

GitHub Actions / PHPStan (PHP 8.3)

Call to an undefined method ApiPlatform\Serializer\CacheableSupportsMethodInterface&Symfony\Component\Serializer\Normalizer\NormalizerInterface::hasCacheableSupportsMethod().
];
}

return $this->collectionNormalizer->getSupportedTypes($format);
}

public function hasCacheableSupportsMethod(): bool
{
if (method_exists(Serializer::class, 'getSupportedTypes')) {
trigger_deprecation(
'api-platform/core',
'3.1',
'The "%s()" method is deprecated, use "getSupportedTypes()" instead.',
__METHOD__
);
}

return $this->collectionNormalizer instanceof BaseCacheableSupportsMethodInterface && $this->collectionNormalizer->hasCacheableSupportsMethod();
}

/**
* {@inheritdoc}
*/
public function setNormalizer(NormalizerInterface $normalizer): void
{
if ($this->collectionNormalizer instanceof NormalizerAwareInterface) {
$this->collectionNormalizer->setNormalizer($normalizer);
}
}
}
2 changes: 1 addition & 1 deletion src/OpenApi/Factory/OpenApiFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -663,7 +663,7 @@ private function getPaginationParameters(CollectionOperationInterface|HttpOperat
$parameters = [];

if ($operation->getPaginationEnabled() ?? $this->paginationOptions->isPaginationEnabled()) {
$parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]);
$parameters[] = new Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page identifier', false, false, true, ['type' => 'string']);

if ($operation->getPaginationClientItemsPerPage() ?? $this->paginationOptions->getClientItemsPerPage()) {
$schema = [
Expand Down
24 changes: 10 additions & 14 deletions src/OpenApi/Tests/Factory/OpenApiFactoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ public function testInvoke(): void
new Parameter(
name: 'page',
in: 'query',
description: 'Test modified collection page number',
description: 'Test modified collection page identifier',
required: false,
allowEmptyValue: true,
schema: ['type' => 'integer', 'default' => 1],
schema: ['type' => 'string'],
),
],
))->withOperation($baseOperation),
Expand Down Expand Up @@ -566,9 +566,8 @@ public function testInvoke(): void
'Retrieves the collection of Dummy resources.',
null,
[
new Parameter('page', 'query', 'Test modified collection page number', false, false, true, [
'type' => 'integer',
'default' => 1,
new Parameter('page', 'query', 'Test modified collection page identifier', false, false, true, [
'type' => 'string',
]),
new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [
'type' => 'integer',
Expand Down Expand Up @@ -767,9 +766,8 @@ public function testInvoke(): void
'Retrieves the collection of Dummy resources.',
null,
[
new Parameter('page', 'query', 'The collection page number', false, false, true, [
'type' => 'integer',
'default' => 1,
new Parameter('page', 'query', 'The collection page identifier', false, false, true, [
'type' => 'string',
]),
new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [
'type' => 'integer',
Expand Down Expand Up @@ -812,9 +810,8 @@ public function testInvoke(): void
'Retrieves the collection of Dummy resources.',
null,
[
new Parameter('page', 'query', 'The collection page number', false, false, true, [
'type' => 'integer',
'default' => 1,
new Parameter('page', 'query', 'The collection page identifier', false, false, true, [
'type' => 'string',
]),
new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [
'type' => 'integer',
Expand Down Expand Up @@ -959,9 +956,8 @@ public function testInvoke(): void
'Retrieves the collection of Dummy resources.',
null,
[
new Parameter('page', 'query', 'The collection page number', false, false, true, [
'type' => 'integer',
'default' => 1,
new Parameter('page', 'query', 'The collection page identifier', false, false, true, [
'type' => 'string',
]),
new Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [
'type' => 'integer',
Expand Down
34 changes: 34 additions & 0 deletions src/State/Pagination/CursorPaginatorInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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\State\Pagination;

/**
* @author Priyadi Iman Nurcahyo <priyadi@rekalogika.com>
*
* @template T of object
*
* @extends \Traversable<T>
*/
interface CursorPaginatorInterface extends \Countable, \Traversable
{
public function getCurrentPageCursor(): ?string;

public function getNextPageCursor(): ?string;

public function getPreviousPageCursor(): ?string;

public function getFirstPageCursor(): ?string;

public function getLastPageCursor(): ?string;
}
7 changes: 7 additions & 0 deletions src/Symfony/Bundle/Resources/config/hydra.xml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@
<argument>%api_platform.url_generation_strategy%</argument>
</service>

<service id="api_platform.hydra.normalizer.cursor_based_partial_collection_view" class="ApiPlatform\Hydra\Serializer\CursorBasedPartialCollectionViewNormalizer" decorates="api_platform.hydra.normalizer.collection" public="false">
<argument type="service" id="api_platform.hydra.normalizer.cursor_based_partial_collection_view.inner" />
<argument>%api_platform.collection.pagination.page_parameter_name%</argument>
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
<argument>%api_platform.url_generation_strategy%</argument>
</service>

<service id="api_platform.hydra.normalizer.collection_filters" class="ApiPlatform\Hydra\Serializer\CollectionFiltersNormalizer" decorates="api_platform.hydra.normalizer.collection" public="false">
<argument type="service" id="api_platform.hydra.normalizer.collection_filters.inner" />
<argument type="service" id="api_platform.metadata.resource.metadata_collection_factory" />
Expand Down
2 changes: 1 addition & 1 deletion src/Util/IriHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public static function parseIri(string $iri, string $pageParameterName): array
/**
* Gets a collection IRI for the given parameters.
*/
public static function createIri(array $parts, array $parameters, ?string $pageParameterName = null, ?float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string
public static function createIri(array $parts, array $parameters, ?string $pageParameterName = null, float|string|null $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string
{
if (null !== $page && null !== $pageParameterName) {
$parameters[$pageParameterName] = $page;
Expand Down
Loading