From 63d77278aace2ae5336c3e2c96e3d3e2aef002a4 Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Thu, 11 Apr 2024 15:51:41 +0700 Subject: [PATCH] feat(paginator): add CursorPaginator --- ...orBasedPartialCollectionViewNormalizer.php | 131 ++++++++++++++++++ src/OpenApi/Factory/OpenApiFactory.php | 2 +- .../Pagination/CursorPaginatorInterface.php | 34 +++++ src/Symfony/Bundle/Resources/config/hydra.xml | 7 + src/Util/IriHelper.php | 2 +- 5 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php create mode 100644 src/State/Pagination/CursorPaginatorInterface.php diff --git a/src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php b/src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php new file mode 100644 index 00000000000..84bd9d6b22f --- /dev/null +++ b/src/Hydra/Serializer/CursorBasedPartialCollectionViewNormalizer.php @@ -0,0 +1,131 @@ + + * + * 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 + */ +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(), + ]; + } + + 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); + } + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index b059d9c183d..c6fe679352b 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -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 = [ diff --git a/src/State/Pagination/CursorPaginatorInterface.php b/src/State/Pagination/CursorPaginatorInterface.php new file mode 100644 index 00000000000..b98edc7e15f --- /dev/null +++ b/src/State/Pagination/CursorPaginatorInterface.php @@ -0,0 +1,34 @@ + + * + * 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 + * + * @template T of object + * + * @extends \Traversable + */ +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; +} diff --git a/src/Symfony/Bundle/Resources/config/hydra.xml b/src/Symfony/Bundle/Resources/config/hydra.xml index 8a61c1c0893..eeeeb030653 100644 --- a/src/Symfony/Bundle/Resources/config/hydra.xml +++ b/src/Symfony/Bundle/Resources/config/hydra.xml @@ -64,6 +64,13 @@ %api_platform.url_generation_strategy% + + + %api_platform.collection.pagination.page_parameter_name% + + %api_platform.url_generation_strategy% + + diff --git a/src/Util/IriHelper.php b/src/Util/IriHelper.php index d4c8915e6ac..eaa1877314f 100644 --- a/src/Util/IriHelper.php +++ b/src/Util/IriHelper.php @@ -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, null|float|string $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string { if (null !== $page && null !== $pageParameterName) { $parameters[$pageParameterName] = $page;