From 936f5d24feba9c8f71e18f0f830c9d8ebae3fffa Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 13:53:32 -0400 Subject: [PATCH 1/5] Add signed cursor encoding and position based route generation --- composer.json | 6 +- config/pagerfanta.php | 44 +++++++ config/twig.php | 1 + phpstan-baseline.neon | 8 +- src/Cursor/SignedCursorEncoder.php | 68 ++++++++++ .../BabDevPagerfantaExtension.php | 3 +- .../RegisterPagerfantaViewsPass.php | 36 ++++++ src/DependencyInjection/Configuration.php | 4 + .../RequestAwareRouteGeneratorFactory.php | 45 ++++++- .../RouterAwarePositionRouteGenerator.php | 64 ++++++++++ src/Twig/UndefinedCallableHandler.php | 1 + tests/Cursor/SignedCursorEncoderTest.php | 120 ++++++++++++++++++ .../BabDevPagerfantaExtensionTest.php | 44 +++++++ .../RegisterPagerfantaViewsPassTest.php | 39 ++++++ .../DependencyInjection/ConfigurationTest.php | 15 +++ .../RequestAwareRouteGeneratorFactoryTest.php | 36 ++++++ .../RouterAwarePositionRouteGeneratorTest.php | 103 +++++++++++++++ .../Twig/TwigUndefinedCallableHandlerTest.php | 1 + tests/View/TwigViewIntegrationTest.php | 45 ++++++- 19 files changed, 670 insertions(+), 13 deletions(-) create mode 100644 src/Cursor/SignedCursorEncoder.php create mode 100644 src/RouteGenerator/RouterAwarePositionRouteGenerator.php create mode 100644 tests/Cursor/SignedCursorEncoderTest.php create mode 100644 tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php diff --git a/composer.json b/composer.json index 8159b5b..3d73911 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ "license": "MIT", "require": { "php": "^8.1", - "pagerfanta/core": "^3.7 || ^4.0", + "pagerfanta/core": "^4.10", "psr/container": "^1.0 || ^2.0", "symfony/config": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.3 || ^8.0", @@ -19,7 +19,7 @@ "jms/serializer": "^3.18", "jms/serializer-bundle": "^4.2 || ^5.0", "matthiasnoback/symfony-dependency-injection-test": "^6.2", - "pagerfanta/twig": "^3.7 || ^4.0", + "pagerfanta/twig": "^4.10", "phpstan/extension-installer": "^1.3", "phpstan/phpstan": "2.2.16", "phpstan/phpstan-phpunit": "2.0.19", @@ -34,7 +34,7 @@ "conflict": { "jms/serializer": "<3.18", "jms/serializer-bundle": "<4.2", - "pagerfanta/twig": "<3.7", + "pagerfanta/twig": "<4.10", "symfony/serializer": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", "symfony/translation": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", "symfony/twig-bridge": "<5.4 || >=6.0,<6.4 || >=7.0,<7.3", diff --git a/config/pagerfanta.php b/config/pagerfanta.php index 820efd5..0db72fd 100644 --- a/config/pagerfanta.php +++ b/config/pagerfanta.php @@ -2,12 +2,24 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory; +use Pagerfanta\Cursor\Base64JsonCursorEncoder; +use Pagerfanta\Cursor\CursorEncoderInterface; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\View\DefaultView; use Pagerfanta\View\Foundation6View; use Pagerfanta\View\SemanticUiView; +use Pagerfanta\View\SequentialView; +use Pagerfanta\View\Template\DefaultTemplate; +use Pagerfanta\View\Template\Foundation6Template; +use Pagerfanta\View\Template\SemanticUiTemplate; +use Pagerfanta\View\Template\TwitterBootstrap3Template; +use Pagerfanta\View\Template\TwitterBootstrap4Template; +use Pagerfanta\View\Template\TwitterBootstrap5Template; +use Pagerfanta\View\Template\TwitterBootstrapTemplate; use Pagerfanta\View\TwitterBootstrap3View; use Pagerfanta\View\TwitterBootstrap4View; use Pagerfanta\View\TwitterBootstrap5View; @@ -17,14 +29,28 @@ return static function (ContainerConfigurator $container): void { $services = $container->services(); + $services->set('pagerfanta.cursor_encoder.base64_json', Base64JsonCursorEncoder::class); + + $services->set('pagerfanta.cursor_encoder.signed', SignedCursorEncoder::class) + ->args([ + service('pagerfanta.cursor_encoder.base64_json'), + param('kernel.secret'), + ]) + ; + + $services->alias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed'); + $services->alias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder'); + $services->set('pagerfanta.route_generator_factory', RequestAwareRouteGeneratorFactory::class) ->args([ service('router'), service('request_stack'), service('property_accessor'), + service('pagerfanta.cursor_encoder'), ]) ; $services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); + $services->alias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); $services->set('pagerfanta.view.default', DefaultView::class) ->tag('pagerfanta.view', ['alias' => 'default']) @@ -54,6 +80,24 @@ ->tag('pagerfanta.view', ['alias' => 'twitter_bootstrap5']) ; + foreach ([ + 'default' => DefaultTemplate::class, + 'foundation6' => Foundation6Template::class, + 'semantic_ui' => SemanticUiTemplate::class, + 'twitter_bootstrap' => TwitterBootstrapTemplate::class, + 'twitter_bootstrap3' => TwitterBootstrap3Template::class, + 'twitter_bootstrap4' => TwitterBootstrap4Template::class, + 'twitter_bootstrap5' => TwitterBootstrap5Template::class, + ] as $name => $templateClass) { + $services->set(\sprintf('pagerfanta.view.%s_sequential', $name), SequentialView::class) + ->args([ + inline_service($templateClass), + \sprintf('%s_sequential', $name), + ]) + ->tag('pagerfanta.view', ['alias' => \sprintf('%s_sequential', $name)]) + ; + } + $services->set('pagerfanta.view_factory', ContainerBackedImmutableViewFactory::class) ->args([ abstract_arg('service locator'), diff --git a/config/twig.php b/config/twig.php index 4e189fd..b529eb4 100644 --- a/config/twig.php +++ b/config/twig.php @@ -19,6 +19,7 @@ abstract_arg('default view'), service('pagerfanta.view_factory'), service('pagerfanta.route_generator_factory'), + abstract_arg('default sequential view'), ]) ->tag('twig.runtime') ; diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 37391ce..31a5844 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -31,7 +31,13 @@ parameters: path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php - - message: '#^Parameter \#3 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, non\-empty\-array\ given\.$#' + message: '#^Parameter \#3 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php + + - + message: '#^Parameter \#4 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwarePositionRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, cursorParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#' identifier: argument.type count: 1 path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php diff --git a/src/Cursor/SignedCursorEncoder.php b/src/Cursor/SignedCursorEncoder.php new file mode 100644 index 0000000..93770ce --- /dev/null +++ b/src/Cursor/SignedCursorEncoder.php @@ -0,0 +1,68 @@ +encoder->encode($cursor); + + return $payload.self::SEPARATOR.$this->sign($payload); + } + + public function decode(string $encoded): Cursor + { + // The signature never contains the separator, so the payload may contain it + $position = strrpos($encoded, self::SEPARATOR); + + if (false === $position) { + throw new InvalidCursorException('The cursor is not signed.'); + } + + $payload = substr($encoded, 0, $position); + $signature = substr($encoded, $position + 1); + + if (!hash_equals($this->sign($payload), $signature)) { + throw new InvalidCursorException('The cursor signature is not valid.'); + } + + return $this->encoder->decode($payload); + } + + private function sign(string $payload): string + { + return rtrim(strtr(base64_encode(hash_hmac('sha256', self::CONTEXT.'|'.$payload, $this->secret, true)), '+/', '-_'), '='); + } +} diff --git a/src/DependencyInjection/BabDevPagerfantaExtension.php b/src/DependencyInjection/BabDevPagerfantaExtension.php index 504075c..25ef669 100644 --- a/src/DependencyInjection/BabDevPagerfantaExtension.php +++ b/src/DependencyInjection/BabDevPagerfantaExtension.php @@ -37,7 +37,8 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container if (ContainerBuilder::willBeAvailable('pagerfanta/twig', PagerfantaExtension::class, ['babdev/pagerfanta-bundle'])) { $container->getDefinition('pagerfanta.twig_runtime') - ->replaceArgument(0, $mergedConfig['default_view']); + ->replaceArgument(0, $mergedConfig['default_view']) + ->replaceArgument(3, $mergedConfig['default_sequential_view']); $container->getDefinition('pagerfanta.view.twig') ->replaceArgument(1, $mergedConfig['default_twig_template']); diff --git a/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php b/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php index cac514f..32fc8c7 100644 --- a/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php +++ b/src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php @@ -19,6 +19,8 @@ public function process(ContainerBuilder $container): void return; } + $this->configureDefaultSequentialView($container); + $definition = $container->getDefinition('pagerfanta.view_factory'); if (ContainerBackedImmutableViewFactory::class === $definition->getClass()) { @@ -47,4 +49,38 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('set', [$alias, new Reference($serviceId)]); } } + + /** + * Defaults the sequential view of the Twig runtime to the sequential variant of the default view, when one exists. + */ + private function configureDefaultSequentialView(ContainerBuilder $container): void + { + if (!$container->hasDefinition('pagerfanta.twig_runtime')) { + return; + } + + $runtime = $container->getDefinition('pagerfanta.twig_runtime'); + + if (null !== $runtime->getArgument(3)) { + return; + } + + $defaultView = $runtime->getArgument(0); + + if (!\is_string($defaultView)) { + return; + } + + $sequentialView = \sprintf('%s_sequential', $defaultView); + + foreach ($container->findTaggedServiceIds('pagerfanta.view') as $serviceId => $arguments) { + $attributes = $arguments[0] ?? []; + + if ($sequentialView === (\is_array($attributes) && isset($attributes['alias']) ? $attributes['alias'] : $serviceId)) { + $runtime->replaceArgument(3, $sequentialView); + + return; + } + } + } } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index ee62d35..a42e833 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -21,6 +21,10 @@ public function getConfigTreeBuilder(): TreeBuilder $treeBuilder->getRootNode() ->children() ->scalarNode('default_view')->defaultValue('default')->end() + ->scalarNode('default_sequential_view') + ->info('The view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view), defaults to the "_sequential" view when one exists') + ->defaultNull() + ->end() ->scalarNode('default_twig_template')->defaultValue('@BabDevPagerfanta/default.html.twig')->end() ->arrayNode('exceptions_strategy') ->addDefaultsIfNotSet() diff --git a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php index 7ed11ff..f4b2d96 100644 --- a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php +++ b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php @@ -2,7 +2,11 @@ namespace BabDev\PagerfantaBundle\RouteGenerator; +use Pagerfanta\Cursor\Base64JsonCursorEncoder; +use Pagerfanta\Cursor\CursorEncoderInterface; use Pagerfanta\Exception\RuntimeException; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; use Symfony\Component\HttpFoundation\Request; @@ -10,21 +14,52 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; -final class RequestAwareRouteGeneratorFactory implements RouteGeneratorFactoryInterface +/** + * Creates route generators for the current route, unless another route is set in the options. + */ +final class RequestAwareRouteGeneratorFactory implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface { public function __construct( private readonly UrlGeneratorInterface $router, private readonly RequestStack $requestStack, - private readonly PropertyAccessorInterface $propertyAccessor + private readonly PropertyAccessorInterface $propertyAccessor, + private readonly CursorEncoderInterface $cursorEncoder = new Base64JsonCursorEncoder(), ) {} public function create(array $options = []): RouteGeneratorInterface + { + return new RouterAwareRouteGenerator( + $this->router, + $this->propertyAccessor, + $this->resolveOptions($options), + ); + } + + public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface + { + return new RouterAwarePositionRouteGenerator( + $this->router, + $this->propertyAccessor, + $this->cursorEncoder, + $this->resolveOptions($options), + ); + } + + /** + * @param array $options + * + * @return array + * + * @throws RuntimeException if the route cannot be resolved from the current request + */ + private function resolveOptions(array $options): array { $options = array_replace( [ 'routeName' => null, 'routeParams' => [], 'pageParameter' => '[page]', + 'cursorParameter' => '[cursor]', 'omitFirstPage' => false, ], $options @@ -49,11 +84,7 @@ public function create(array $options = []): RouteGeneratorInterface $options['routeParams'] = array_merge($defaultRouteParams, $options['routeParams']); } - return new RouterAwareRouteGenerator( - $this->router, - $this->propertyAccessor, - $options, - ); + return $options; } private function getRequest(): ?Request diff --git a/src/RouteGenerator/RouterAwarePositionRouteGenerator.php b/src/RouteGenerator/RouterAwarePositionRouteGenerator.php new file mode 100644 index 0000000..a5a8ae0 --- /dev/null +++ b/src/RouteGenerator/RouterAwarePositionRouteGenerator.php @@ -0,0 +1,64 @@ +, referenceType?: UrlGeneratorInterface::*} + */ +final class RouterAwarePositionRouteGenerator implements PositionRouteGeneratorInterface +{ + /** + * @phpstan-param RouteGeneratorOptions $options + * + * @throws InvalidArgumentException if the route name is not set in the options + */ + public function __construct( + private readonly UrlGeneratorInterface $router, + private readonly PropertyAccessorInterface $propertyAccessor, + private readonly CursorEncoderInterface $cursorEncoder, + private readonly array $options, + ) { + if (!isset($options['routeName'])) { + throw new InvalidArgumentException(\sprintf('The "%s" class options requires a "routeName" parameter to be set.', self::class)); + } + } + + /** + * @throws InvalidArgumentException if the position is not supported + */ + public function __invoke(Position $position): string + { + $pagePropertyPath = new PropertyPath($this->options['pageParameter'] ?? '[page]'); + $cursorPropertyPath = new PropertyPath($this->options['cursorParameter'] ?? '[cursor]'); + $routeParams = $this->options['routeParams'] ?? []; + + if ($position instanceof PagePosition) { + $omitFirstPage = $this->options['omitFirstPage'] ?? false; + + $this->propertyAccessor->setValue($routeParams, $pagePropertyPath, $omitFirstPage && 1 === $position->page ? null : $position->page); + $this->propertyAccessor->setValue($routeParams, $cursorPropertyPath, null); + } elseif ($position instanceof CursorPosition) { + $this->propertyAccessor->setValue($routeParams, $cursorPropertyPath, $this->cursorEncoder->encode($position->cursor)); + $this->propertyAccessor->setValue($routeParams, $pagePropertyPath, null); + } else { + throw new InvalidArgumentException(\sprintf('The "%s" route generator does not support "%s" positions.', self::class, get_debug_type($position))); + } + + return $this->router->generate($this->options['routeName'], $routeParams, $this->options['referenceType'] ?? UrlGeneratorInterface::ABSOLUTE_PATH); + } +} diff --git a/src/Twig/UndefinedCallableHandler.php b/src/Twig/UndefinedCallableHandler.php index 9d40989..e0228cc 100644 --- a/src/Twig/UndefinedCallableHandler.php +++ b/src/Twig/UndefinedCallableHandler.php @@ -12,6 +12,7 @@ final class UndefinedCallableHandler private const SUPPORTED_FUNCTIONS = [ 'pagerfanta', 'pagerfanta_page_url', + 'pagerfanta_position_url', ]; /** diff --git a/tests/Cursor/SignedCursorEncoderTest.php b/tests/Cursor/SignedCursorEncoderTest.php new file mode 100644 index 0000000..e38c8d5 --- /dev/null +++ b/tests/Cursor/SignedCursorEncoderTest.php @@ -0,0 +1,120 @@ + '2026-09-28 12:00:00', 'p.id' => 42], Direction::Previous); + + $encoder = $this->createEncoder(); + + self::assertEquals($cursor, $encoder->decode($encoder->encode($cursor))); + } + + public function testTheEncodedCursorIsTheDecoratedEncodingWithASignature(): void + { + $cursor = new Cursor(['p.id' => 42]); + + $encoded = $this->createEncoder()->encode($cursor); + + self::assertStringStartsWith((new Base64JsonCursorEncoder())->encode($cursor).'.', $encoded); + self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/', $encoded, 'The encoded cursor is URL-safe'); + } + + public function testThePayloadMayContainTheSeparator(): void + { + $decorated = new class implements CursorEncoderInterface { + public function encode(Cursor $cursor): string + { + return 'with.separators.'.$cursor->fields['id']; + } + + public function decode(string $encoded): Cursor + { + return new Cursor(['id' => (int) substr($encoded, \strlen('with.separators.'))]); + } + }; + + $encoder = new SignedCursorEncoder($decorated, 'secret'); + + self::assertEquals(new Cursor(['id' => 42]), $encoder->decode($encoder->encode(new Cursor(['id' => 42])))); + } + + public static function dataTamperedCursors(): \Generator + { + $encoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'); + $encoded = $encoder->encode(new Cursor(['p.id' => 42])); + [$payload, $signature] = explode('.', $encoded); + + $tamperedPayload = (new Base64JsonCursorEncoder())->encode(new Cursor(['p.id' => 43])); + + yield 'unsigned cursor' => [$payload]; + yield 'empty signature' => [$payload.'.']; + yield 'altered payload' => [$tamperedPayload.'.'.$signature]; + yield 'altered signature' => [$payload.'.'.strrev($signature)]; + yield 'truncated signature' => [substr($encoded, 0, -1)]; + yield 'signed with another secret' => [(new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'another secret'))->encode(new Cursor(['p.id' => 42]))]; + yield 'empty string' => ['']; + } + + /** + * @dataProvider dataTamperedCursors + */ + #[DataProvider('dataTamperedCursors')] + public function testATamperedCursorIsRejectedBeforeItIsDecoded(string $encoded): void + { + $decorated = $this->createMock(CursorEncoderInterface::class); + $decorated->method('encode')->willReturnCallback(static fn (Cursor $cursor): string => (new Base64JsonCursorEncoder())->encode($cursor)); + $decorated->expects(self::never())->method('decode'); + + $this->expectException(InvalidCursorException::class); + + (new SignedCursorEncoder($decorated, 'secret'))->decode($encoded); + } + + public function testAnInvalidCursorWithAValidSignatureIsRejectedByTheDecoratedEncoder(): void + { + // The decorated encoder produces a payload which it cannot decode, so the payload is correctly signed but not a valid cursor + $decorated = new class implements CursorEncoderInterface { + public function encode(Cursor $cursor): string + { + return 'not-a-cursor'; + } + + public function decode(string $encoded): Cursor + { + return (new Base64JsonCursorEncoder())->decode($encoded); + } + }; + + $encoder = new SignedCursorEncoder($decorated, 'secret'); + + $this->expectException(InvalidCursorException::class); + + $encoder->decode($encoder->encode(new Cursor(['id' => 1]))); + } + + public function testTheSecretMustNotBeEmpty(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createEncoder(''); + } +} diff --git a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php index 057eeb1..f3302ab 100644 --- a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php +++ b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php @@ -9,8 +9,12 @@ use JMS\SerializerBundle\JMSSerializerBundle; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\DefinitionDecoratesConstraint; +use Pagerfanta\Cursor\CursorEncoderInterface; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; +use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\Twig\Extension\PagerfantaExtension; use Pagerfanta\View\ViewFactoryInterface; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Bundle\TwigBundle\DependencyInjection\TwigExtension; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\HttpKernel\KernelEvents; @@ -29,6 +33,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsNot $this->load(); $this->assertContainerBuilderHasAlias(ViewFactoryInterface::class, 'pagerfanta.view_factory'); + $this->assertCursorAndRouteGenerationServicesAreRegistered(); $listeners = [ 'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found', @@ -138,6 +143,9 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsIns $this->assertContainerBuilderHasService($twigService); } + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 0, 'default'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, null); + $twigConfig = $this->container->getExtensionConfig('twig'); self::assertArrayHasKey(0, $twigConfig); @@ -232,4 +240,40 @@ protected function getContainerExtensions(): array new BabDevPagerfantaExtension(), ]; } + + public function testTheDefaultSequentialViewIsGivenToTheTwigRuntime(): void + { + if (!class_exists(PagerfantaExtension::class)) { + self::markTestSkipped('Test requires Twig'); + } + + $this->container->setParameter( + 'kernel.bundles', + [ + 'BabDevPagerfantaBundle' => BabDevPagerfantaBundle::class, + 'TwigBundle' => TwigBundle::class, + ], + ); + + $this->load(['default_sequential_view' => 'twitter_bootstrap5_sequential']); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'twitter_bootstrap5_sequential'); + } + + private function assertCursorAndRouteGenerationServicesAreRegistered(): void + { + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.cursor_encoder.signed', 0, new Reference('pagerfanta.cursor_encoder.base64_json')); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.cursor_encoder.signed', 1, '%kernel.secret%'); + $this->assertContainerBuilderHasAlias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed'); + $this->assertContainerBuilderHasAlias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder'); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder')); + $this->assertContainerBuilderHasAlias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); + $this->assertContainerBuilderHasAlias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); + + foreach (['default', 'foundation6', 'semantic_ui', 'twitter_bootstrap', 'twitter_bootstrap3', 'twitter_bootstrap4', 'twitter_bootstrap5'] as $name) { + $this->assertContainerBuilderHasServiceDefinitionWithArgument(\sprintf('pagerfanta.view.%s_sequential', $name), 1, \sprintf('%s_sequential', $name)); + $this->assertContainerBuilderHasServiceDefinitionWithTag(\sprintf('pagerfanta.view.%s_sequential', $name), 'pagerfanta.view', ['alias' => \sprintf('%s_sequential', $name)]); + } + } } diff --git a/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php b/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php index adbd2a5..ac557f9 100644 --- a/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php +++ b/tests/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPassTest.php @@ -5,7 +5,9 @@ use BabDev\PagerfantaBundle\DependencyInjection\CompilerPass\RegisterPagerfantaViewsPass; use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractCompilerPassTestCase; +use Pagerfanta\Twig\Extension\PagerfantaRuntime; use Pagerfanta\View\DefaultView; +use Pagerfanta\View\SequentialView; use Pagerfanta\View\ViewFactory; use Symfony\Component\DependencyInjection\Argument\AbstractArgument; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; @@ -54,6 +56,43 @@ public function testViewsAreAddedToTheContainerBackedViewFactory(): void ); } + public function testTheDefaultSequentialViewIsTheSequentialVariantOfTheDefaultView(): void + { + $this->registerService('pagerfanta.view_factory', ViewFactory::class); + $this->registerService('pagerfanta.view.twitter_bootstrap5_sequential', SequentialView::class) + ->addTag('pagerfanta.view', ['alias' => 'twitter_bootstrap5_sequential']); + $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class) + ->setArguments(['twitter_bootstrap5', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), null]); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'twitter_bootstrap5_sequential'); + } + + public function testTheDefaultSequentialViewIsNotSetWhenTheDefaultViewHasNoSequentialVariant(): void + { + $this->registerService('pagerfanta.view_factory', ViewFactory::class); + $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class) + ->setArguments(['twig', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), null]); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, null); + } + + public function testAConfiguredDefaultSequentialViewIsKept(): void + { + $this->registerService('pagerfanta.view_factory', ViewFactory::class); + $this->registerService('pagerfanta.view.default_sequential', SequentialView::class) + ->addTag('pagerfanta.view', ['alias' => 'default_sequential']); + $this->registerService('pagerfanta.twig_runtime', PagerfantaRuntime::class) + ->setArguments(['default', new Reference('pagerfanta.view_factory'), new Reference('pagerfanta.route_generator_factory'), 'custom_sequential']); + + $this->compile(); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, 'custom_sequential'); + } + protected function registerCompilerPass(ContainerBuilder $container): void { $container->addCompilerPass(new RegisterPagerfantaViewsPass()); diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index e0396d0..4ce4214 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -29,6 +29,20 @@ public function testConfigWithCustomDefaultView(): void ); } + public function testConfigWithCustomDefaultSequentialView(): void + { + $extraConfig = [ + 'default_sequential_view' => 'twitter_bootstrap5_sequential', + ]; + + $config = (new Processor())->processConfiguration(new Configuration(), [$extraConfig]); + + self::assertEquals( + array_merge(self::getBundleDefaultConfig(), $extraConfig), + $config, + ); + } + public function testConfigWithCustomDefaultTwigTemplate(): void { $extraConfig = [ @@ -64,6 +78,7 @@ protected static function getBundleDefaultConfig(): array { return [ 'default_view' => 'default', + 'default_sequential_view' => null, 'default_twig_template' => '@BabDevPagerfanta/default.html.twig', 'exceptions_strategy' => [ 'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND, diff --git a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php index a4e4959..100d112 100644 --- a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php +++ b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php @@ -3,14 +3,23 @@ namespace BabDev\PagerfantaBundle\Tests\RouteGenerator; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; +use Pagerfanta\Cursor\Base64JsonCursorEncoder; +use Pagerfanta\Cursor\Cursor; use Pagerfanta\Exception\RuntimeException; +use Pagerfanta\Position\CursorPosition; +use Pagerfanta\Position\PagePosition; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Routing\Generator\UrlGenerator; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; final class RequestAwareRouteGeneratorFactoryTest extends TestCase { @@ -100,4 +109,31 @@ private function createFactory(): RequestAwareRouteGeneratorFactory $this->propertyAccessor ); } + + public function testAPositionRouteGeneratorIsCreatedForTheCurrentRequest(): void + { + $routeCollection = new RouteCollection(); + $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view')); + + $request = Request::create('/pagerfanta-view', 'GET', ['page' => '3', 'cursor' => 'stale', 'hello' => 'world']); + $request->attributes->set('_route', 'pagerfanta_view'); + $request->attributes->set('_route_params', []); + + $this->requestStack->push($request); + + $generator = (new RequestAwareRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder()))->createPositionRouteGenerator(); + + $cursor = new Cursor(['p.id' => 42]); + + // The parameters from the request keep their position + self::assertSame('/pagerfanta-view?page=4&hello=world', $generator(new PagePosition(4))); + self::assertSame('/pagerfanta-view?cursor='.(new Base64JsonCursorEncoder())->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor))); + } + + public function testAPositionRouteGeneratorIsNotCreatedWhenARequestIsNotActive(): void + { + $this->expectException(RuntimeException::class); + + $this->createFactory()->createPositionRouteGenerator(); + } } diff --git a/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php b/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php new file mode 100644 index 0000000..d1bdae5 --- /dev/null +++ b/tests/RouteGenerator/RouterAwarePositionRouteGeneratorTest.php @@ -0,0 +1,103 @@ +, referenceType?: UrlGeneratorInterface::*} $options + */ + private function createGenerator(array $options = []): RouterAwarePositionRouteGenerator + { + $routeCollection = new RouteCollection(); + $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view')); + + return new RouterAwarePositionRouteGenerator( + new UrlGenerator($routeCollection, new RequestContext()), + PropertyAccess::createPropertyAccessor(), + new Base64JsonCursorEncoder(), + ['routeName' => 'pagerfanta_view', ...$options], + ); + } + + private function encode(Cursor $cursor): string + { + return (new Base64JsonCursorEncoder())->encode($cursor); + } + + public function testARouteIsGeneratedForAPage(): void + { + self::assertSame('/pagerfanta-view?page=1', $this->createGenerator()(new PagePosition(1))); + } + + public function testARouteIsGeneratedForTheFirstPageWithTheFirstPageOmitted(): void + { + $generator = $this->createGenerator(['omitFirstPage' => true]); + + self::assertSame('/pagerfanta-view', $generator(new PagePosition(1))); + self::assertSame('/pagerfanta-view?page=2', $generator(new PagePosition(2))); + } + + public function testARouteIsGeneratedForACursor(): void + { + $cursor = new Cursor(['p.id' => 42]); + + self::assertSame('/pagerfanta-view?cursor='.$this->encode($cursor), $this->createGenerator()(new CursorPosition($cursor))); + } + + public function testARouteIsGeneratedForACursorWithACustomCursorParameter(): void + { + $cursor = new Cursor(['p.id' => 42]); + + self::assertSame('/pagerfanta-view?after='.$this->encode($cursor), $this->createGenerator(['cursorParameter' => '[after]'])(new CursorPosition($cursor))); + } + + public function testTheCursorParameterIsRemovedForAPageAndThePageParameterIsRemovedForACursor(): void + { + $cursor = new Cursor(['p.id' => 42]); + + $generator = $this->createGenerator(['routeParams' => ['page' => 3, 'cursor' => 'stale', 'hello' => 'world']]); + + self::assertSame('/pagerfanta-view?page=2&hello=world', $generator(new PagePosition(2))); + self::assertSame('/pagerfanta-view?cursor='.$this->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor))); + } + + public function testARouteIsGeneratedWithAnAbsoluteUrl(): void + { + self::assertSame('http://localhost/pagerfanta-view?page=2', $this->createGenerator(['referenceType' => UrlGeneratorInterface::ABSOLUTE_URL])(new PagePosition(2))); + } + + public function testAnUnsupportedPositionIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + $this->createGenerator()(new class implements Position {}); + } + + public function testARouteIsNotGeneratedWhenTheRouteNameParameterIsMissing(): void + { + $this->expectException(InvalidArgumentException::class); + + new RouterAwarePositionRouteGenerator( + new UrlGenerator(new RouteCollection(), new RequestContext()), + PropertyAccess::createPropertyAccessor(), + new Base64JsonCursorEncoder(), + [], // @phpstan-ignore argument.type + ); + } +} diff --git a/tests/Twig/TwigUndefinedCallableHandlerTest.php b/tests/Twig/TwigUndefinedCallableHandlerTest.php index 521eb35..88b2fb3 100644 --- a/tests/Twig/TwigUndefinedCallableHandlerTest.php +++ b/tests/Twig/TwigUndefinedCallableHandlerTest.php @@ -20,6 +20,7 @@ public static function dataSupportedFunctions(): \Generator { yield '"pagerfanta" function' => ['pagerfanta']; yield '"pagerfanta_page_url" function' => ['pagerfanta_page_url']; + yield '"pagerfanta_position_url" function' => ['pagerfanta_position_url']; } /** diff --git a/tests/View/TwigViewIntegrationTest.php b/tests/View/TwigViewIntegrationTest.php index 2d45db9..2172277 100644 --- a/tests/View/TwigViewIntegrationTest.php +++ b/tests/View/TwigViewIntegrationTest.php @@ -2,9 +2,17 @@ namespace BabDev\PagerfantaBundle\Tests\View; +use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; +use Pagerfanta\Adapter\CallbackCursorAdapter; +use Pagerfanta\Adapter\CursorSlice; use Pagerfanta\Adapter\FixedAdapter; +use Pagerfanta\Cursor\Base64JsonCursorEncoder; +use Pagerfanta\Cursor\Cursor; +use Pagerfanta\Cursor\Direction; +use Pagerfanta\CursorPagerfanta; use Pagerfanta\Pagerfanta; +use Pagerfanta\Position\CursorPosition; use Pagerfanta\Twig\Extension\PagerfantaExtension; use Pagerfanta\Twig\Extension\PagerfantaRuntime; use Pagerfanta\Twig\View\TwigView; @@ -43,6 +51,8 @@ final class TwigViewIntegrationTest extends TestCase public PropertyAccessorInterface $propertyAccessor; + public SignedCursorEncoder $cursorEncoder; + public Environment $twig; public static function setUpBeforeClass(): void @@ -72,6 +82,7 @@ protected function setUp(): void $this->router = $this->createRouter(); $this->requestStack = new RequestStack(); $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + $this->cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'); } protected function tearDown(): void @@ -415,6 +426,37 @@ public function testPagerfantaRenderingWithEmptyOptions(): void ); } + public function testACursorPagerIsRenderedWithSignedCursors(): void + { + $request = Request::create('/', 'GET', ['cursor' => 'current', 'hello' => 'world']); + $request->attributes->set('_route', 'pagerfanta_view'); + $request->attributes->set('_route_params', []); + + $this->requestStack->push($request); + + $previous = new Cursor(['id' => 11], Direction::Previous); + $next = new Cursor(['id' => 20]); + + $pager = new CursorPagerfanta( + new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice(range(11, 20), $previous, $next), true), + 10, + new CursorPosition(new Cursor(['id' => 10])), + ); + + $output = $this->twig->render('integration.html.twig', ['pager' => $pager, 'options' => ['template' => '@BabDevPagerfanta/twitter_bootstrap5.html.twig']]); + + $this->assertViewOutputMatches( + $output, + \sprintf( + '', + $this->cursorEncoder->encode($previous), + $this->cursorEncoder->encode($next), + ), + ); + + self::assertMatchesRegularExpression('/cursor=[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}&/', $output, 'The cursors are signed'); + } + private function createRouter(): UrlGeneratorInterface { $routeCollection = new RouteCollection(); @@ -445,7 +487,8 @@ public function load($class) $routeGeneratorFactory = new RequestAwareRouteGeneratorFactory( $this->testCase->router, $this->testCase->requestStack, - $this->testCase->propertyAccessor + $this->testCase->propertyAccessor, + $this->testCase->cursorEncoder, ); return new PagerfantaRuntime( From dfaf1d35499685a5db66c62a9b2701ae58f8fc3c Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 14:06:44 -0400 Subject: [PATCH 2/5] Add a position resolver and convert invalid cursors to a bad request --- config/pagerfanta.php | 9 ++ phpstan-baseline.neon | 6 + .../BabDevPagerfantaExtension.php | 13 ++ src/DependencyInjection/Configuration.php | 5 + ...nvertInvalidCursorToBadRequestListener.php | 19 +++ src/Position/PositionResolver.php | 110 ++++++++++++++ .../BabDevPagerfantaExtensionTest.php | 9 ++ .../DependencyInjection/ConfigurationTest.php | 2 + ...tInvalidCursorToBadRequestListenerTest.php | 47 ++++++ tests/Position/PositionResolverTest.php | 142 ++++++++++++++++++ 10 files changed, 362 insertions(+) create mode 100644 src/EventListener/ConvertInvalidCursorToBadRequestListener.php create mode 100644 src/Position/PositionResolver.php create mode 100644 tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php create mode 100644 tests/Position/PositionResolverTest.php diff --git a/config/pagerfanta.php b/config/pagerfanta.php index 0db72fd..0e6c435 100644 --- a/config/pagerfanta.php +++ b/config/pagerfanta.php @@ -3,6 +3,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder; +use BabDev\PagerfantaBundle\Position\PositionResolver; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory; use Pagerfanta\Cursor\Base64JsonCursorEncoder; @@ -41,6 +42,14 @@ $services->alias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed'); $services->alias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder'); + $services->set('pagerfanta.position_resolver', PositionResolver::class) + ->args([ + service('property_accessor'), + service('pagerfanta.cursor_encoder'), + ]) + ; + $services->alias(PositionResolver::class, 'pagerfanta.position_resolver'); + $services->set('pagerfanta.route_generator_factory', RequestAwareRouteGeneratorFactory::class) ->args([ service('router'), diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 31a5844..4caeca4 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Cannot access offset ''invalid_cursor'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/DependencyInjection/BabDevPagerfantaExtension.php + - message: '#^Cannot access offset ''not_valid_current_page'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible diff --git a/src/DependencyInjection/BabDevPagerfantaExtension.php b/src/DependencyInjection/BabDevPagerfantaExtension.php index 25ef669..4894996 100644 --- a/src/DependencyInjection/BabDevPagerfantaExtension.php +++ b/src/DependencyInjection/BabDevPagerfantaExtension.php @@ -2,6 +2,7 @@ namespace BabDev\PagerfantaBundle\DependencyInjection; +use BabDev\PagerfantaBundle\EventListener\ConvertInvalidCursorToBadRequestListener; use BabDev\PagerfantaBundle\EventListener\ConvertNotValidCurrentPageToNotFoundListener; use BabDev\PagerfantaBundle\EventListener\ConvertNotValidMaxPerPageToNotFoundListener; use BabDev\PagerfantaBundle\Serializer\Normalizer\LegacyPagerfantaNormalizer; @@ -90,6 +91,18 @@ protected function loadInternal(array $mergedConfig, ContainerBuilder $container ], ); } + + if (Configuration::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST === $mergedConfig['exceptions_strategy']['invalid_cursor']) { + $container->register('pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ConvertInvalidCursorToBadRequestListener::class) + ->addTag( + 'kernel.event_listener', + [ + 'event' => KernelEvents::EXCEPTION, + 'method' => 'onKernelException', + 'priority' => 512, + ], + ); + } } public function prepend(ContainerBuilder $container): void diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index a42e833..c3e2ad7 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -8,6 +8,7 @@ final class Configuration implements ConfigurationInterface { public const EXCEPTION_STRATEGY_CUSTOM = 'custom'; + public const EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST = 'to_http_bad_request'; public const EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND = 'to_http_not_found'; /** @@ -37,6 +38,10 @@ public function getConfigTreeBuilder(): TreeBuilder ->defaultValue(self::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND) ->values([self::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND, self::EXCEPTION_STRATEGY_CUSTOM]) ->end() + ->enumNode('invalid_cursor') + ->defaultValue(self::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST) + ->values([self::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST, self::EXCEPTION_STRATEGY_CUSTOM]) + ->end() ->end() ->end() ->end(); diff --git a/src/EventListener/ConvertInvalidCursorToBadRequestListener.php b/src/EventListener/ConvertInvalidCursorToBadRequestListener.php new file mode 100644 index 0000000..bddd16f --- /dev/null +++ b/src/EventListener/ConvertInvalidCursorToBadRequestListener.php @@ -0,0 +1,19 @@ +getThrowable(); + + if ($throwable instanceof InvalidCursorException) { + $event->setThrowable(new BadRequestHttpException('Invalid Cursor', $throwable)); + } + } +} diff --git a/src/Position/PositionResolver.php b/src/Position/PositionResolver.php new file mode 100644 index 0000000..faa71c0 --- /dev/null +++ b/src/Position/PositionResolver.php @@ -0,0 +1,110 @@ +resolveCursorPosition($request, $options) ?? $this->resolvePagePosition($request, $options); + } + + /** + * Resolves the position for an offset pager, the cursor parameter is ignored. + * + * @phpstan-param PositionResolverOptions $options + * + * @throws NotValidCurrentPageException if the page is not a positive integer + */ + public function resolvePagePosition(Request $request, array $options = []): ?PagePosition + { + $parameter = $options['pageParameter'] ?? '[page]'; + $page = $this->readParameter($request, $parameter); + + if (null === $page || '' === $page) { + return null; + } + + if (\is_string($page) && ctype_digit($page)) { + $page = (int) $page; + } + + if (!\is_int($page)) { + throw new NotValidCurrentPageException(\sprintf('The "%s" parameter must be a positive integer.', $parameter)); + } + + if ($page < 1) { + throw new LessThan1CurrentPageException(); + } + + return new PagePosition($page); + } + + /** + * Resolves the position for a cursor pager, the page parameter is ignored. + * + * @phpstan-param PositionResolverOptions $options + * + * @throws InvalidCursorException if the cursor is not valid + */ + public function resolveCursorPosition(Request $request, array $options = []): ?CursorPosition + { + $parameter = $options['cursorParameter'] ?? '[cursor]'; + $cursor = $this->readParameter($request, $parameter); + + if (null === $cursor || '' === $cursor) { + return null; + } + + if (!\is_string($cursor)) { + throw new InvalidCursorException(\sprintf('The "%s" parameter must be a string.', $parameter)); + } + + return new CursorPosition($this->cursorEncoder->decode($cursor)); + } + + private function readParameter(Request $request, string $parameter): mixed + { + $routeParams = $request->attributes->get('_route_params', []); + + $parameters = array_merge($request->query->all(), \is_array($routeParams) ? $routeParams : []); + $propertyPath = new PropertyPath($parameter); + + if (!$this->propertyAccessor->isReadable($parameters, $propertyPath)) { + return null; + } + + return $this->propertyAccessor->getValue($parameters, $propertyPath); + } +} diff --git a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php index f3302ab..16ea1f7 100644 --- a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php +++ b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php @@ -5,6 +5,7 @@ use BabDev\PagerfantaBundle\BabDevPagerfantaBundle; use BabDev\PagerfantaBundle\DependencyInjection\BabDevPagerfantaExtension; use BabDev\PagerfantaBundle\DependencyInjection\Configuration; +use BabDev\PagerfantaBundle\Position\PositionResolver; use Composer\InstalledVersions; use JMS\SerializerBundle\JMSSerializerBundle; use Matthias\SymfonyDependencyInjectionTest\PhpUnit\AbstractExtensionTestCase; @@ -38,6 +39,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsNot $listeners = [ 'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found', 'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found', + 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ]; foreach ($listeners as $listener) { @@ -119,6 +121,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsIns $listeners = [ 'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found', 'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found', + 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ]; foreach ($listeners as $listener) { @@ -186,6 +189,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenJMSSerializerBu $listeners = [ 'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found', 'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found', + 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ]; foreach ($listeners as $listener) { @@ -217,6 +221,7 @@ public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionSt 'exceptions_strategy' => [ 'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM, 'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM, + 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_CUSTOM, ], ]; @@ -227,6 +232,7 @@ public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionSt $listeners = [ 'pagerfanta.event_listener.convert_not_valid_max_per_page_to_not_found', 'pagerfanta.event_listener.convert_not_valid_current_page_to_not_found', + 'pagerfanta.event_listener.convert_invalid_cursor_to_bad_request', ]; foreach ($listeners as $listener) { @@ -267,6 +273,9 @@ private function assertCursorAndRouteGenerationServicesAreRegistered(): void $this->assertContainerBuilderHasAlias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed'); $this->assertContainerBuilderHasAlias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_resolver', 1, new Reference('pagerfanta.cursor_encoder')); + $this->assertContainerBuilderHasAlias(PositionResolver::class, 'pagerfanta.position_resolver'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder')); $this->assertContainerBuilderHasAlias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); $this->assertContainerBuilderHasAlias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php index 4ce4214..e030b8c 100644 --- a/tests/DependencyInjection/ConfigurationTest.php +++ b/tests/DependencyInjection/ConfigurationTest.php @@ -63,6 +63,7 @@ public function testConfigWithCustomExceptionsStrategy(): void 'exceptions_strategy' => [ 'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM, 'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_CUSTOM, + 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_CUSTOM, ], ]; @@ -83,6 +84,7 @@ protected static function getBundleDefaultConfig(): array 'exceptions_strategy' => [ 'out_of_range_page' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND, 'not_valid_current_page' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_NOT_FOUND, + 'invalid_cursor' => Configuration::EXCEPTION_STRATEGY_TO_HTTP_BAD_REQUEST, ], ]; } diff --git a/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php b/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php new file mode 100644 index 0000000..46c61dd --- /dev/null +++ b/tests/EventListener/ConvertInvalidCursorToBadRequestListenerTest.php @@ -0,0 +1,47 @@ +createStub(HttpKernelInterface::class), + Request::create('/'), + HttpKernelInterface::MAIN_REQUEST, + $exception + ); + + (new ConvertInvalidCursorToBadRequestListener())->onKernelException($event); + + self::assertInstanceOf(BadRequestHttpException::class, $event->getThrowable()); + self::assertSame($exception, $event->getThrowable()->getPrevious()); + } + + public function testListenerDoesNotConvertUnknownExceptionForEvent(): void + { + $exception = new \RuntimeException(); + + $event = new ExceptionEvent( + $this->createStub(HttpKernelInterface::class), + Request::create('/'), + HttpKernelInterface::MAIN_REQUEST, + $exception + ); + + (new ConvertInvalidCursorToBadRequestListener())->onKernelException($event); + + self::assertSame($exception, $event->getThrowable()); + } +} diff --git a/tests/Position/PositionResolverTest.php b/tests/Position/PositionResolverTest.php new file mode 100644 index 0000000..8ab44ed --- /dev/null +++ b/tests/Position/PositionResolverTest.php @@ -0,0 +1,142 @@ +cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'); + $this->resolver = new PositionResolver(PropertyAccess::createPropertyAccessor(), $this->cursorEncoder); + } + + public function testNoPositionIsResolvedForTheFirstPage(): void + { + $request = Request::create('/'); + + self::assertNull($this->resolver->resolve($request)); + self::assertNull($this->resolver->resolvePagePosition($request)); + self::assertNull($this->resolver->resolveCursorPosition($request)); + } + + public function testNoPositionIsResolvedForEmptyParameters(): void + { + self::assertNull($this->resolver->resolve(Request::create('/', 'GET', ['page' => '', 'cursor' => '']))); + } + + public function testAPagePositionIsResolvedFromTheQuery(): void + { + $request = Request::create('/', 'GET', ['page' => '3']); + + self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition($request)); + self::assertEquals(new PagePosition(3), $this->resolver->resolve($request)); + } + + public function testAPagePositionIsResolvedFromTheRouteParameters(): void + { + $request = Request::create('/posts/3'); + $request->attributes->set('_route_params', ['page' => 3]); + + self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition($request)); + } + + public function testAPagePositionIsResolvedFromACustomParameter(): void + { + $request = Request::create('/', 'GET', ['filters' => ['page' => '2']]); + + self::assertEquals(new PagePosition(2), $this->resolver->resolvePagePosition($request, ['pageParameter' => '[filters][page]'])); + } + + public static function dataInvalidPages(): \Generator + { + yield 'not a number' => ['abc', NotValidCurrentPageException::class]; + yield 'decimal' => ['1.5', NotValidCurrentPageException::class]; + yield 'negative' => ['-1', NotValidCurrentPageException::class]; + yield 'array' => [['1'], NotValidCurrentPageException::class]; + yield 'zero' => ['0', LessThan1CurrentPageException::class]; + } + + /** + * @param class-string<\Throwable> $exception + * + * @dataProvider dataInvalidPages + */ + #[DataProvider('dataInvalidPages')] + public function testAnInvalidPageIsRejected(mixed $page, string $exception): void + { + $this->expectException($exception); + + $this->resolver->resolvePagePosition(Request::create('/', 'GET', ['page' => $page])); + } + + public function testACursorPositionIsResolvedFromTheQuery(): void + { + $cursor = new Cursor(['p.id' => 42], Direction::Previous); + $request = Request::create('/', 'GET', ['cursor' => $this->cursorEncoder->encode($cursor)]); + + self::assertEquals(new CursorPosition($cursor), $this->resolver->resolveCursorPosition($request)); + self::assertEquals(new CursorPosition($cursor), $this->resolver->resolve($request)); + } + + public function testACursorPositionIsResolvedFromACustomParameter(): void + { + $cursor = new Cursor(['p.id' => 42]); + $request = Request::create('/', 'GET', ['after' => $this->cursorEncoder->encode($cursor)]); + + self::assertEquals(new CursorPosition($cursor), $this->resolver->resolveCursorPosition($request, ['cursorParameter' => '[after]'])); + } + + public static function dataInvalidCursors(): \Generator + { + $encoded = (new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'))->encode(new Cursor(['p.id' => 42])); + + yield 'unsigned' => [(new Base64JsonCursorEncoder())->encode(new Cursor(['p.id' => 42]))]; + yield 'tampered' => [substr($encoded, 0, -2).'xx']; + yield 'garbage' => ['not a cursor']; + yield 'array' => [[$encoded]]; + } + + /** + * @dataProvider dataInvalidCursors + */ + #[DataProvider('dataInvalidCursors')] + public function testAnInvalidCursorIsRejected(mixed $cursor): void + { + $this->expectException(InvalidCursorException::class); + + $this->resolver->resolveCursorPosition(Request::create('/', 'GET', ['cursor' => $cursor])); + } + + public function testACursorTakesPrecedenceOverAPageWhenResolvingEitherPosition(): void + { + $cursor = new Cursor(['p.id' => 42]); + $request = Request::create('/', 'GET', ['page' => '3', 'cursor' => $this->cursorEncoder->encode($cursor)]); + + self::assertEquals(new CursorPosition($cursor), $this->resolver->resolve($request)); + } + + public function testTheOtherParameterIsIgnoredWhenResolvingASpecificPosition(): void + { + self::assertEquals(new PagePosition(3), $this->resolver->resolvePagePosition(Request::create('/', 'GET', ['page' => '3', 'cursor' => 'tampered']))); + self::assertNull($this->resolver->resolveCursorPosition(Request::create('/', 'GET', ['page' => 'abc']))); + } +} From d10799ebdd4f170d222ae0a3f531ba64f9ff83d1 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 14:36:22 -0400 Subject: [PATCH 3/5] Add serializers for cursor pagers --- config/jms_serializer.php | 8 + config/serializer.php | 8 + src/Serializer/Handler/CursorPagerHandler.php | 94 ++++++++ .../Normalizer/CursorPagerNormalizer.php | 94 ++++++++ .../BabDevPagerfantaExtensionTest.php | 6 + .../Handler/CursorPagerHandlerTest.php | 183 +++++++++++++++ .../Normalizer/CursorPagerNormalizerTest.php | 212 ++++++++++++++++++ 7 files changed, 605 insertions(+) create mode 100644 src/Serializer/Handler/CursorPagerHandler.php create mode 100644 src/Serializer/Normalizer/CursorPagerNormalizer.php create mode 100644 tests/Serializer/Handler/CursorPagerHandlerTest.php create mode 100644 tests/Serializer/Normalizer/CursorPagerNormalizerTest.php diff --git a/config/jms_serializer.php b/config/jms_serializer.php index ce30513..e2afa4b 100644 --- a/config/jms_serializer.php +++ b/config/jms_serializer.php @@ -2,6 +2,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use BabDev\PagerfantaBundle\Serializer\Handler\CursorPagerHandler; use BabDev\PagerfantaBundle\Serializer\Handler\PagerfantaHandler; return static function (ContainerConfigurator $container): void { @@ -10,4 +11,11 @@ $services->set('pagerfanta.serializer.handler', PagerfantaHandler::class) ->tag('jms_serializer.subscribing_handler') ; + + $services->set('pagerfanta.serializer.cursor_handler', CursorPagerHandler::class) + ->args([ + service('pagerfanta.cursor_encoder'), + ]) + ->tag('jms_serializer.subscribing_handler') + ; }; diff --git a/config/serializer.php b/config/serializer.php index afba863..9e2275d 100644 --- a/config/serializer.php +++ b/config/serializer.php @@ -2,6 +2,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use BabDev\PagerfantaBundle\Serializer\Normalizer\CursorPagerNormalizer; use BabDev\PagerfantaBundle\Serializer\Normalizer\PagerfantaNormalizer; return static function (ContainerConfigurator $container): void { @@ -10,4 +11,11 @@ $services->set('pagerfanta.serializer.normalizer', PagerfantaNormalizer::class) ->tag('serializer.normalizer') ; + + $services->set('pagerfanta.serializer.cursor_normalizer', CursorPagerNormalizer::class) + ->args([ + service('pagerfanta.cursor_encoder'), + ]) + ->tag('serializer.normalizer') + ; }; diff --git a/src/Serializer/Handler/CursorPagerHandler.php b/src/Serializer/Handler/CursorPagerHandler.php new file mode 100644 index 0000000..a7c6293 --- /dev/null +++ b/src/Serializer/Handler/CursorPagerHandler.php @@ -0,0 +1,94 @@ + GraphNavigatorInterface::DIRECTION_SERIALIZATION, + 'format' => 'json', + 'type' => $type, + 'method' => 'serializeToJson', + ]; + } + + return $methods; + } + + /** + * @param CursorPagerInterface $pager + * @param array{name: string, params: array} $type + * + * @return array|\ArrayObject|null + * + * @throws LogicException when the handler is not called in an expected context + */ + public function serializeToJson(JsonSerializationVisitor $visitor, CursorPagerInterface $pager, array $type, SerializationContext $context) + { + $items = $pager->getCurrentPageResults(); + + if ($context->hasAttribute(self::PRESERVE_KEYS_KEY)) { + $preserveKeys = $context->getAttribute(self::PRESERVE_KEYS_KEY); + + if (!\is_bool($preserveKeys) && null !== $preserveKeys) { + throw new LogicException(\sprintf('The "%s" context key must be a boolean value or null, "%s" given.', self::PRESERVE_KEYS_KEY, get_debug_type($preserveKeys))); + } + + if (null !== $preserveKeys) { + // When requiring PHP 8.2, this `is_array()` check can be removed + if (\is_array($items)) { + $items = new \ArrayIterator($items); + } + + $items = iterator_to_array($items, $preserveKeys); + } + } + + $pagination = [ + 'per_page' => $pager->getMaxPerPage(), + 'has_previous_page' => $pager->hasPreviousPage(), + 'has_next_page' => $pager->hasNextPage(), + 'previous_cursor' => $pager->hasPreviousPage() ? $this->cursorEncoder->encode($pager->getPreviousPosition()->cursor) : null, + 'next_cursor' => $pager->hasNextPage() ? $this->cursorEncoder->encode($pager->getNextPosition()->cursor) : null, + ]; + + if ($pager instanceof CountablePagerInterface) { + $pagination['total_items'] = $pager->getNbResults(); + } + + return $visitor->visitArray( + [ + 'items' => $items, + 'pagination' => $pagination, + ], + $type, + ); + } +} diff --git a/src/Serializer/Normalizer/CursorPagerNormalizer.php b/src/Serializer/Normalizer/CursorPagerNormalizer.php new file mode 100644 index 0000000..0f625ac --- /dev/null +++ b/src/Serializer/Normalizer/CursorPagerNormalizer.php @@ -0,0 +1,94 @@ +getCurrentPageResults(); + + if (\array_key_exists(self::PRESERVE_KEYS_KEY, $context)) { + $preserveKeys = $context[self::PRESERVE_KEYS_KEY]; + + if (!\is_bool($preserveKeys) && null !== $preserveKeys) { + throw new LogicException(\sprintf('The "%s" context key must be a boolean value or null, "%s" given.', self::PRESERVE_KEYS_KEY, get_debug_type($preserveKeys))); + } + + if (null !== $preserveKeys) { + // When requiring PHP 8.2, this `is_array()` check can be removed + if (\is_array($items)) { + $items = new \ArrayIterator($items); + } + + $items = iterator_to_array($items, $preserveKeys); + } + } + + $pagination = [ + 'per_page' => $object->getMaxPerPage(), + 'has_previous_page' => $object->hasPreviousPage(), + 'has_next_page' => $object->hasNextPage(), + 'previous_cursor' => $object->hasPreviousPage() ? $this->cursorEncoder->encode($object->getPreviousPosition()->cursor) : null, + 'next_cursor' => $object->hasNextPage() ? $this->cursorEncoder->encode($object->getNextPosition()->cursor) : null, + ]; + + if ($object instanceof CountablePagerInterface) { + $pagination['total_items'] = $object->getNbResults(); + } + + return [ + 'items' => $this->normalizer->normalize($items, $format, $context), + 'pagination' => $pagination, + ]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof CursorPagerInterface; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [ + CursorPagerInterface::class => true, + CursorPagerfanta::class => true, + CountableCursorPagerfanta::class => true, + ]; + } +} diff --git a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php index 16ea1f7..bcd123d 100644 --- a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php +++ b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php @@ -206,6 +206,9 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenJMSSerializerBu $this->assertContainerBuilderHasService('pagerfanta.serializer.handler'); $this->assertContainerBuilderHasService('pagerfanta.serializer.normalizer'); + + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.serializer.cursor_handler', 0, new Reference('pagerfanta.cursor_encoder')); + $this->assertContainerBuilderHasServiceDefinitionWithTag('pagerfanta.serializer.cursor_handler', 'jms_serializer.subscribing_handler'); } public function testContainerIsLoadedWhenBundleIsConfiguredWithCustomExceptionStrategies(): void @@ -273,6 +276,9 @@ private function assertCursorAndRouteGenerationServicesAreRegistered(): void $this->assertContainerBuilderHasAlias('pagerfanta.cursor_encoder', 'pagerfanta.cursor_encoder.signed'); $this->assertContainerBuilderHasAlias(CursorEncoderInterface::class, 'pagerfanta.cursor_encoder'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.serializer.cursor_normalizer', 0, new Reference('pagerfanta.cursor_encoder')); + $this->assertContainerBuilderHasServiceDefinitionWithTag('pagerfanta.serializer.cursor_normalizer', 'serializer.normalizer'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_resolver', 1, new Reference('pagerfanta.cursor_encoder')); $this->assertContainerBuilderHasAlias(PositionResolver::class, 'pagerfanta.position_resolver'); diff --git a/tests/Serializer/Handler/CursorPagerHandlerTest.php b/tests/Serializer/Handler/CursorPagerHandlerTest.php new file mode 100644 index 0000000..c0f1d19 --- /dev/null +++ b/tests/Serializer/Handler/CursorPagerHandlerTest.php @@ -0,0 +1,183 @@ +cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'); + } + + /** + * @return ArrayCursorAdapter + */ + private function createAdapter(): ArrayCursorAdapter + { + return new ArrayCursorAdapter(range(1, 7), static fn (int $item): array => ['id' => $item]); + } + + public function testACountablePagerIsSerializedWithTheTotal(): void + { + $pager = new CountableCursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 3]))); + + self::assertJsonStringEqualsJsonString( + json_encode([ + 'items' => [4, 5, 6], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => true, + 'has_next_page' => true, + 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4], Direction::Previous)), + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 6])), + 'total_items' => 7, + ], + ], \JSON_THROW_ON_ERROR), + $this->createSerializer()->serialize($pager, 'json'), + ); + } + + public function testAPagerWhichCannotCountIsSerializedWithoutTheTotal(): void + { + $pager = new CursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 6]))); + + // The JMS Serializer omits null values unless the context enables serializing them + self::assertJsonStringEqualsJsonString( + json_encode([ + 'items' => [7], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => true, + 'has_next_page' => false, + 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 7], Direction::Previous)), + ], + ], \JSON_THROW_ON_ERROR), + $this->createSerializer()->serialize($pager, 'json'), + ); + } + + public function testTheMissingCursorsAreSerializedAsNullWhenTheContextSerializesNulls(): void + { + $pager = new CursorPagerfanta($this->createAdapter(), 3); + + self::assertJsonStringEqualsJsonString( + json_encode([ + 'items' => [1, 2, 3], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => false, + 'has_next_page' => true, + 'previous_cursor' => null, + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 3])), + ], + ], \JSON_THROW_ON_ERROR), + $this->createSerializer()->serialize($pager, 'json', SerializationContext::create()->setSerializeNull(true)), + ); + } + + public function testAForwardOnlyPagerHasNoPreviousCursor(): void + { + $adapter = new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([4], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 4]))); + + self::assertJsonStringEqualsJsonString( + json_encode([ + 'items' => [4], + 'pagination' => [ + 'per_page' => 1, + 'has_previous_page' => false, + 'has_next_page' => true, + 'previous_cursor' => null, + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4])), + ], + ], \JSON_THROW_ON_ERROR), + $this->createSerializer()->serialize(new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 3]))), 'json', SerializationContext::create()->setSerializeNull(true)), + ); + } + + /** + * @return \Generator, array, string}> + */ + public static function dataSerializeWithPreserveKeysContext(): \Generator + { + yield 'Context not set' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [], '{"items":{"0":"item1","2":"item2","4":"item3"},"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}']; + + yield 'Context with preserve keys disabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], ['pagerfanta_preserve_keys' => false], '{"items":["item1","item2","item3"],"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}']; + + yield 'Context with preserve keys enabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], ['pagerfanta_preserve_keys' => true], '{"items":{"0":"item1","2":"item2","4":"item3"},"pagination":{"per_page":10,"has_previous_page":false,"has_next_page":false}}']; + } + + /** + * @param array $data + * @param array $context + * + * @dataProvider dataSerializeWithPreserveKeysContext + */ + #[DataProvider('dataSerializeWithPreserveKeysContext')] + public function testSerializeToJsonWithPreserveKeysContext(array $data, array $context, string $expectedJson): void + { + // @phpstan-ignore argument.type + $pager = new CursorPagerfanta(new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice($data))); + + $serializationContext = new SerializationContext(); + + foreach ($context as $key => $value) { + $serializationContext->setAttribute($key, $value); + } + + self::assertJsonStringEqualsJsonString( + $expectedJson, + $this->createSerializer()->serialize($pager, 'json', $serializationContext), + ); + } + + public function testSerializeRejectsInvalidPreserveKeysContext(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "pagerfanta_preserve_keys" context key must be a boolean value or null, "string" given.'); + + $serializationContext = new SerializationContext(); + $serializationContext->setAttribute('pagerfanta_preserve_keys', 'invalid'); + + $this->createSerializer()->serialize(new CursorPagerfanta($this->createAdapter()), 'json', $serializationContext); + } + + private function createSerializer(): SerializerInterface + { + $registry = new HandlerRegistry(); + $registry->registerSubscribingHandler(new CursorPagerHandler($this->cursorEncoder)); + + return SerializerBuilder::create($registry, new EventDispatcher())->build(); + } +} diff --git a/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php b/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php new file mode 100644 index 0000000..5bc0cfe --- /dev/null +++ b/tests/Serializer/Normalizer/CursorPagerNormalizerTest.php @@ -0,0 +1,212 @@ +cursorEncoder = new SignedCursorEncoder(new Base64JsonCursorEncoder(), 'secret'); + } + + private function createSerializer(): Serializer + { + return new Serializer([new CursorPagerNormalizer($this->cursorEncoder)]); + } + + /** + * @return ArrayCursorAdapter + */ + private function createAdapter(): ArrayCursorAdapter + { + return new ArrayCursorAdapter(array_map(static fn (int $id): array => ['id' => $id], range(1, 7)), static fn (array $item): array => ['id' => $item['id']]); + } + + public function testACountablePagerIsNormalizedWithTheTotal(): void + { + $pager = new CountableCursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 3]))); + + self::assertSame( + [ + 'items' => [['id' => 4], ['id' => 5], ['id' => 6]], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => true, + 'has_next_page' => true, + 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4], Direction::Previous)), + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 6])), + 'total_items' => 7, + ], + ], + $this->createSerializer()->normalize($pager), + ); + } + + public function testAPagerWhichCannotCountIsNormalizedWithoutTheTotal(): void + { + $pager = new CursorPagerfanta($this->createAdapter(), 3); + + self::assertSame( + [ + 'items' => [['id' => 1], ['id' => 2], ['id' => 3]], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => false, + 'has_next_page' => true, + 'previous_cursor' => null, + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 3])), + ], + ], + $this->createSerializer()->normalize($pager), + ); + } + + public function testTheLastPageHasNoNextCursor(): void + { + self::assertSame( + [ + 'items' => [['id' => 7]], + 'pagination' => [ + 'per_page' => 3, + 'has_previous_page' => true, + 'has_next_page' => false, + 'previous_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 7], Direction::Previous)), + 'next_cursor' => null, + ], + ], + $this->createSerializer()->normalize(new CursorPagerfanta($this->createAdapter(), 3, new CursorPosition(new Cursor(['id' => 6])))), + ); + } + + public function testAForwardOnlyPagerHasNoPreviousCursor(): void + { + $adapter = new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice([4], new Cursor(['id' => 4], Direction::Previous), new Cursor(['id' => 4]))); + + self::assertSame( + [ + 'items' => [4], + 'pagination' => [ + 'per_page' => 1, + 'has_previous_page' => false, + 'has_next_page' => true, + 'previous_cursor' => null, + 'next_cursor' => $this->cursorEncoder->encode(new Cursor(['id' => 4])), + ], + ], + $this->createSerializer()->normalize(new CursorPagerfanta($adapter, 1, new CursorPosition(new Cursor(['id' => 3])))), + ); + } + + public function testTheCursorsAreSignedWithTheBundleEncoder(): void + { + $normalized = $this->createSerializer()->normalize(new CursorPagerfanta($this->createAdapter(), 3)); + + self::assertIsArray($normalized); + self::assertIsArray($normalized['pagination']); + + $nextCursor = $normalized['pagination']['next_cursor']; + + self::assertIsString($nextCursor); + self::assertMatchesRegularExpression('/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{43}$/', $nextCursor); + self::assertEquals(new Cursor(['id' => 3]), $this->cursorEncoder->decode($nextCursor)); + } + + /** + * Creates a pager whose adapter returns keyed items, which the cursor adapters are not required to reindex. + * + * @param array $items + * + * @return CursorPagerfanta + */ + private function createPagerWithKeyedItems(array $items): CursorPagerfanta + { + // @phpstan-ignore argument.type + return new CursorPagerfanta(new CallbackCursorAdapter(static fn (?Cursor $cursor, int $limit): CursorSlice => new CursorSlice($items))); + } + + /** + * @return \Generator, array, array}> + */ + public static function dataNormalizeWithPreserveKeysContext(): \Generator + { + yield 'Context not set' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [], [0 => 'item1', 2 => 'item2', 4 => 'item3']]; + + yield 'Context with preserve keys disabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => false], ['item1', 'item2', 'item3']]; + + yield 'Context with preserve keys enabled' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => true], [0 => 'item1', 2 => 'item2', 4 => 'item3']]; + + yield 'Context with preserve keys unset' => [[0 => 'item1', 2 => 'item2', 4 => 'item3'], [CursorPagerNormalizer::PRESERVE_KEYS_KEY => null], [0 => 'item1', 2 => 'item2', 4 => 'item3']]; + } + + /** + * @param array $data + * @param array $context + * @param array $expectedItems + * + * @dataProvider dataNormalizeWithPreserveKeysContext + */ + #[DataProvider('dataNormalizeWithPreserveKeysContext')] + public function testNormalizeWithPreserveKeysContext(array $data, array $context, array $expectedItems): void + { + self::assertSame( + [ + 'items' => $expectedItems, + 'pagination' => [ + 'per_page' => 10, + 'has_previous_page' => false, + 'has_next_page' => false, + 'previous_cursor' => null, + 'next_cursor' => null, + ], + ], + $this->createSerializer()->normalize($this->createPagerWithKeyedItems($data), null, $context), + ); + } + + public function testNormalizeRejectsInvalidPreserveKeysContext(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The "pagerfanta_preserve_keys" context key must be a boolean value or null, "string" given.'); + + (new CursorPagerNormalizer($this->cursorEncoder))->normalize(new CursorPagerfanta($this->createAdapter()), null, [CursorPagerNormalizer::PRESERVE_KEYS_KEY => 'invalid']); + } + + public function testOnlyCursorPagersAreSupported(): void + { + $normalizer = new CursorPagerNormalizer($this->cursorEncoder); + + self::assertTrue($normalizer->supportsNormalization(new CursorPagerfanta($this->createAdapter()))); + self::assertFalse($normalizer->supportsNormalization(new Pagerfanta(new NullAdapter(5)))); + self::assertArrayHasKey(CursorPagerInterface::class, $normalizer->getSupportedTypes(null)); + } + + public function testNormalizeOnlyAcceptsCursorPagers(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The object must be an instance of "%s".', CursorPagerInterface::class)); + + (new CursorPagerNormalizer($this->cursorEncoder))->normalize(new \stdClass()); + } +} From 88d8a07ae2105b2ac6ef03b72fa9c9f0ffb089c6 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 15:02:57 -0400 Subject: [PATCH 4/5] Document cursor pagination support --- docs/configuring-the-bundle.md | 20 +++++- docs/cursor-pagination.md | 108 ++++++++++++++++++++++++++++ docs/default-configuration.md | 6 ++ docs/generating-paginated-routes.md | 25 +++++++ docs/index.md | 1 + docs/rendering-pagerfantas.md | 6 ++ docs/serializer.md | 34 +++++++++ docs/views.md | 22 ++++++ 8 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 docs/cursor-pagination.md diff --git a/docs/configuring-the-bundle.md b/docs/configuring-the-bundle.md index b85f027..b7943cb 100644 --- a/docs/configuring-the-bundle.md +++ b/docs/configuring-the-bundle.md @@ -10,6 +10,21 @@ babdev_pagerfanta: default_view: my_view ``` +## Default Sequential View + +
The default sequential view was introduced in PagerfantaBundle 4.7.
+ +Views with numbered page links can only render offset pagers. When the default view (or the view given to the `pagerfanta()` Twig function) cannot render a pager, such as a [cursor pager](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination), the Twig function renders it with the default sequential view instead. + +The default sequential view can be set with the `default_sequential_view` configuration node. When not set, the sequential variant of the default view is used if one exists (i.e. `twitter_bootstrap5_sequential` for the `twitter_bootstrap5` view). The Twig view can render any pager, so it does not need a default sequential view. + +```yaml +# config/packages/babdev_pagerfanta.yaml +babdev_pagerfanta: + default_view: twitter_bootstrap5 + default_sequential_view: twitter_bootstrap5_sequential +``` + ## Default Twig Template The default Twig template for Twig views in your application can be set with the `default_twig_template` configuration node. This defaults to "`@BabDevPagerfanta/default.html.twig`". @@ -23,7 +38,7 @@ babdev_pagerfanta: ## Exception Strategies -By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change. +By default, the bundle converts `Pagerfanta\Exception\NotValidCurrentPageException` and `Pagerfanta\Exception\NotValidMaxPerPageException` exceptions into 404 responses, and `Pagerfanta\Exception\InvalidCursorException` exceptions into 400 responses. If you would like to disable or change this behavior, you can change the strategies using the `exceptions_strategy` node by setting the value to "custom" for each behavior you want to change. ```yaml # config/packages/babdev_pagerfanta.yaml @@ -31,4 +46,7 @@ babdev_pagerfanta: exceptions_strategy: out_of_range_page: custom # Disables converting `Pagerfanta\Exception\NotValidMaxPerPageException` to a 404 response not_valid_current_page: to_http_not_found # Default behavior converting `Pagerfanta\Exception\NotValidCurrentPageException` to a 404 response + invalid_cursor: to_http_bad_request # Default behavior converting `Pagerfanta\Exception\InvalidCursorException` to a 400 response ``` + +
The invalid_cursor exception strategy was introduced in PagerfantaBundle 4.7.
diff --git a/docs/cursor-pagination.md b/docs/cursor-pagination.md new file mode 100644 index 0000000..c42480e --- /dev/null +++ b/docs/cursor-pagination.md @@ -0,0 +1,108 @@ +# Cursor Pagination + +
Cursor pagination support was introduced in PagerfantaBundle 4.7.
+ +The bundle integrates the [cursor pagination](/open-source/packages/pagerfanta/docs/4.x/cursor-pagination) support from Pagerfanta with your Symfony application: + +- Cursors in URLs are signed, so they cannot be tampered with +- The position of the current page is resolved from the request with the `BabDev\PagerfantaBundle\Position\PositionResolver` +- Invalid cursors are converted into 400 responses +- Cursor pagers are rendered with the [sequential views](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views) and serialized with their cursors + +## Resolving The Current Page + +The `BabDev\PagerfantaBundle\Position\PositionResolver` service reads the position of the current page from the request. It has a method for each kind of pager, which ignores the parameters for the other kind: + +- `resolveCursorPosition()` returns the `Pagerfanta\Position\CursorPosition` from the `cursor` parameter, or null for the first page +- `resolvePagePosition()` returns the `Pagerfanta\Position\PagePosition` from the `page` parameter, or null for the first page +- `resolve()` returns either position, preferring the cursor when both parameters are given + +Below is an example of paginating blog posts with a cursor pager. + +```php +createQueryBuilder('p') + ->orderBy('p.publishedAt', 'DESC') + ->addOrderBy('p.id', 'DESC'); + + $pager = CursorPagerfantaFactory::create( + new CursorQueryAdapter($queryBuilder), + 10, + $positionResolver->resolveCursorPosition($request), + ); + + return $this->render( + 'blog/list.html.twig', + [ + 'pager' => $pager, + ], + ); + } +} +``` + +The parameters are read from the query string and the route parameters of the request. If your application uses different parameters, set the `cursorParameter` and `pageParameter` options, which use the same format as the [route generator options](/open-source/packages/pagerfantabundle/docs/4.x/generating-paginated-routes#position-route-generator-options). + +```php +$position = $positionResolver->resolveCursorPosition($request, ['cursorParameter' => '[after]']); +``` + +When the page parameter is not a positive integer, a `Pagerfanta\Exception\NotValidCurrentPageException` is thrown, which is handled by the `not_valid_current_page` [exception strategy](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies). + +## Signed Cursors + +The bundle encodes cursors with the `BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder`, which appends a HMAC-SHA256 signature using the `kernel.secret` parameter to the cursors encoded by the `Pagerfanta\Cursor\Base64JsonCursorEncoder`. The signature is verified before a cursor is decoded, so a cursor which has been altered (or was not created by your application) is rejected with a `Pagerfanta\Exception\InvalidCursorException` and never reaches your query. + +The encoder is available as the `pagerfanta.cursor_encoder` service, and can be autowired with the `Pagerfanta\Cursor\CursorEncoderInterface`. It is used by the position resolver, the route generators, and the serializers. + +
Changing the kernel.secret parameter invalidates all previously generated cursors, clients using a cursor from before the change will receive a 400 response.
+ +To use another encoder, such as one which encrypts the cursors, point the `pagerfanta.cursor_encoder` alias to your service. + +```yaml +# config/services.yaml +services: + pagerfanta.cursor_encoder: + alias: App\Pagination\EncryptedCursorEncoder +``` + +## Invalid Cursors + +By default, the bundle converts a `Pagerfanta\Exception\InvalidCursorException` into a 400 response. This exception is thrown for cursors which cannot be decoded, fail signature verification, or do not match the sort fields of the adapter. See the [exception strategies](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#exception-strategies) to change this behavior. + +## Rendering Cursor Pagers + +Cursor pagers are rendered with the `pagerfanta()` Twig function, the same as offset pagers. As cursor pagers can only link to the previous and next pages, they are rendered with a [sequential view](/open-source/packages/pagerfantabundle/docs/4.x/views#sequential-views). + +```twig +{{ pagerfanta(pager) }} +``` + +The URLs are generated for the current route, setting the signed cursor to the `cursor` parameter and removing the `page` parameter. The `pagerfanta_position_url()` Twig function generates the URL for a single position, such as a "Load more" link. + +```twig +{% if pager.hasNextPage() %} + Load more +{% endif %} +``` + +## APIs + +Cursor pagers are [serialized](/open-source/packages/pagerfantabundle/docs/4.x/serializer#cursor-pagers) with the signed cursors for the previous and next pages, which clients pass back in the `cursor` parameter to request those pages. diff --git a/docs/default-configuration.md b/docs/default-configuration.md index 57d769c..22d9499 100644 --- a/docs/default-configuration.md +++ b/docs/default-configuration.md @@ -5,6 +5,9 @@ babdev_pagerfanta: # The default Pagerfanta view to use in your application default_view: default + # The view to render pagers which the default view cannot render (i.e. cursor pagers with a numbered view), defaults to the "_sequential" view when one exists + default_sequential_view: null + # The default Twig template to use when using the Twig Pagerfanta view default_twig_template: '@BabDevPagerfanta/default.html.twig' @@ -14,4 +17,7 @@ babdev_pagerfanta: # The exception strategy if the current page is not an allowed value in a paginated list; valid options are "custom" or "to_http_not_found" not_valid_current_page: to_http_not_found + + # The exception strategy if a cursor cannot be decoded or fails validation (i.e. a tampered cursor); valid options are "custom" or "to_http_bad_request" + invalid_cursor: to_http_bad_request ``` diff --git a/docs/generating-paginated-routes.md b/docs/generating-paginated-routes.md index 329b0ab..c10031d 100644 --- a/docs/generating-paginated-routes.md +++ b/docs/generating-paginated-routes.md @@ -2,6 +2,8 @@ When rendering a Pagerfanta view, a route generator callable is required to generate the URLs for each item in the pagination list. The route generator can be customized for use within your application if you need to adjust the routing logic. +
The page number based route generators (the RouteGeneratorInterface and RouteGeneratorFactoryInterface, and the RouterAwareRouteGenerator) are deprecated since Pagerfanta 4.10, use the position route generators instead. The bundle's route generator factory supports both, so no changes are needed when using the bundle's services.
+ The route generators are defined by two interfaces, with their default implementations noted below: - `Pagerfanta\RouteGenerator\RouteGeneratorInterface` - The class type that is used to generate routes @@ -22,3 +24,26 @@ The following options may be passed through a route generator factory when using - `omitFirstPage` - Defaults to `false`, a boolean value indicating whether the first page should omit the pagination parameter (if true, `?page=1` will not be part of the paginated URL for page 1 of your list) - `routeParams` - Defaults to an empty array, an array of additional parameters to pass to the router for generating the URL - `referenceType` - Defaults to `Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_PATH`, allows specifying the `$referenceType` parameter when calling `Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate()` + +## Position Route Generators + +
Position route generators were introduced in PagerfantaBundle 4.7.
+ +Pagerfanta describes the pages a pager links to with [positions](/open-source/packages/pagerfanta/docs/4.x/route-generator#position-route-generators), which support both offset and [cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) pagination. + +The `BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory` also implements the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`, and creates a `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` which uses the Symfony Routing component to generate the URLs for positions: + +- For a page position, the page number is set to the page parameter and the cursor parameter is removed +- For a cursor position, the [signed cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination#signed-cursors) is set to the cursor parameter and the page parameter is removed + +The factory is also available with the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` alias. + +### Position Route Generator Options + +The `RouterAwarePositionRouteGenerator` supports all of the [`RouterAwareRouteGenerator` options](#routerawareroutegenerator-options), and the following option: + +- `cursorParameter` - Defaults to "`[cursor]`", specifies the name of the routing parameter to use for the cursor, note that the cursor parameter *MUST* be wrapped in brackets (i.e. `[after]`) for the route generator to correctly function + +```twig +{{ pagerfanta(pager, {'cursorParameter': '[after]'}) }} +``` diff --git a/docs/index.md b/docs/index.md index d482772..f19fdeb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -3,6 +3,7 @@ - [Default Configuration](/open-source/packages/pagerfantabundle/docs/4.x/default-configuration) - [Rendering Pagerfantas](/open-source/packages/pagerfantabundle/docs/4.x/rendering-pagerfantas) - [Generating Paginated Routes](/open-source/packages/pagerfantabundle/docs/4.x/generating-paginated-routes) +- [Cursor Pagination](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) - [Available Views](/open-source/packages/pagerfantabundle/docs/4.x/views) - [Adding Views](/open-source/packages/pagerfantabundle/docs/4.x/adding-views) - [Retrieving Views](/open-source/packages/pagerfantabundle/docs/4.x/retrieving-views) diff --git a/docs/rendering-pagerfantas.md b/docs/rendering-pagerfantas.md index d37089c..764296c 100644 --- a/docs/rendering-pagerfantas.md +++ b/docs/rendering-pagerfantas.md @@ -62,3 +62,9 @@ If you are using a parameter other than `page` for pagination, you can set the p Note that the page parameter *MUST* be wrapped in brackets (i.e. `[other_page]`) for the route generator to correctly function. See the [Pagerfanta documentation](/open-source/packages/pagerfanta/docs) for the list of supported options. + +## Cursor Pagers + +
Rendering cursor pagers was introduced in PagerfantaBundle 4.7.
+ +Cursor pagers are rendered with the `pagerfanta()` function in the same way, and are linked with signed cursors in the `cursor` parameter. See the [cursor pagination documentation](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) for details. diff --git a/docs/serializer.md b/docs/serializer.md index f4e29ab..36c2799 100644 --- a/docs/serializer.md +++ b/docs/serializer.md @@ -56,6 +56,40 @@ Below is an example of how a `Pagerfanta\Pagerfanta` instance is serialized into } ``` +## Cursor Pagers + +
Serializing cursor pagers was introduced in PagerfantaBundle 4.7.
+ +[Cursor pagers](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) are serialized with the [signed cursors](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination#signed-cursors) for the previous and next pages, which are null when there is no page in that direction. Clients pass these cursors back in the `cursor` parameter to request the other pages. + +The total number of items is only included for pagers which can count their results (implementing `Pagerfanta\CountablePagerInterface`), as counting the results is often expensive and cursor pagers do not need the total. + +```json +{ + "items": [ + { + "id": 4 + }, + { + "id": 5 + }, + { + "id": 6 + } + ], + "pagination": { + "per_page": 3, + "has_previous_page": true, + "has_next_page": true, + "previous_cursor": "eyJmIjp7ImlkIjo0fSwiZCI6InAifQ.3TwY0stlypgZh7JF3MaG4-U73b75wQ5vAjc7czLn25M", + "next_cursor": "eyJmIjp7ImlkIjo2fSwiZCI6Im4ifQ.FQMxPOBSKzLVFP20bHe62gJloQtIqkt4pQdnUc0Xk1s", + "total_items": 35 + } +} +``` + +
The JMS Serializer omits null values unless the serialization context enables serializing them, so the previous_cursor and next_cursor keys are omitted when there is no page in that direction unless SerializationContext::setSerializeNull(true) is used.
+ ## Serialization Context Configuration ### Preserving Array Keys diff --git a/docs/views.md b/docs/views.md index 5ce3703..83a2f68 100644 --- a/docs/views.md +++ b/docs/views.md @@ -16,6 +16,26 @@ The below table lists the view names and the corresponding class. | `twitter_bootstrap4` | `Pagerfanta\View\TwitterBootstrap4View` | | `twitter_bootstrap5` | `Pagerfanta\View\TwitterBootstrap5View` | +## Sequential Views + +
The sequential views were introduced in PagerfantaBundle 4.7.
+ +The views above render numbered page links, so they can only render offset pagers. The [sequential views](/open-source/packages/pagerfanta/docs/4.x/views#sequential-views) render only the links to the previous and next pages, so they can render any pager, including [cursor pagers](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination). + +A sequential view is available for each of the default views, all using the `Pagerfanta\View\SequentialView` class. + +| View Name | Template Class Name | +|---------------------------------|------------------------------------------------------| +| `default_sequential` | `Pagerfanta\View\Template\DefaultTemplate` | +| `foundation6_sequential` | `Pagerfanta\View\Template\Foundation6Template` | +| `semantic_ui_sequential` | `Pagerfanta\View\Template\SemanticUiTemplate` | +| `twitter_bootstrap_sequential` | `Pagerfanta\View\Template\TwitterBootstrapTemplate` | +| `twitter_bootstrap3_sequential` | `Pagerfanta\View\Template\TwitterBootstrap3Template` | +| `twitter_bootstrap4_sequential` | `Pagerfanta\View\Template\TwitterBootstrap4Template` | +| `twitter_bootstrap5_sequential` | `Pagerfanta\View\Template\TwitterBootstrap5Template` | + +When the `pagerfanta()` Twig function is given a pager which its view cannot render, the pager is rendered with the [default sequential view](/open-source/packages/pagerfantabundle/docs/4.x/configuring-the-bundle#default-sequential-view). + ## Twig View This bundle provides a Pagerfanta view which renders a Twig template. If you have not already, you will need to install the `pagerfanta/twig` package. @@ -35,6 +55,8 @@ The below table lists the available templates and the CSS framework they corresp The labels of the "Previous" and "Next" buttons are localizable in the Twig templates. +The Twig view can render any pager. Offset pagers are rendered with numbered page links, and all other pagers (such as cursor pagers) are rendered with only the previous and next links using the same templates. + See the [Pagerfanta documentation](/open-source/packages/pagerfanta/docs/views) for more information about building a Twig template. ## Default View CSS From e64a0b91735126d8fffa766913406200e8d77669 Mon Sep 17 00:00:00 2001 From: Michael Babker Date: Mon, 28 Sep 2026 15:45:42 -0400 Subject: [PATCH 5/5] Deprecate the page number based route generator factory and generator --- composer.json | 1 + config/pagerfanta.php | 17 ++- config/twig.php | 3 +- docs/generating-paginated-routes.md | 56 +++++---- phpstan-baseline.neon | 8 +- ...uestAwarePositionRouteGeneratorFactory.php | 36 ++++++ .../RequestAwareRouteGeneratorFactory.php | 67 ++-------- .../ResolvesRouteGeneratorOptions.php | 66 ++++++++++ .../RouterAwareRouteGenerator.php | 4 + tests/CapturesDeprecations.php | 35 ++++++ .../BabDevPagerfantaExtensionTest.php | 7 +- ...AwarePositionRouteGeneratorFactoryTest.php | 119 ++++++++++++++++++ .../RequestAwareRouteGeneratorFactoryTest.php | 40 +++--- .../RouterAwareRouteGeneratorTest.php | 15 +++ tests/View/TwigViewIntegrationTest.php | 6 +- 15 files changed, 363 insertions(+), 117 deletions(-) create mode 100644 src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php create mode 100644 src/RouteGenerator/ResolvesRouteGeneratorOptions.php create mode 100644 tests/CapturesDeprecations.php create mode 100644 tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php diff --git a/composer.json b/composer.json index 3d73911..532e472 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "psr/container": "^1.0 || ^2.0", "symfony/config": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/dependency-injection": "^5.4 || ^6.4 || ^7.3 || ^8.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", "symfony/http-foundation": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/http-kernel": "^5.4 || ^6.4 || ^7.3 || ^8.0", "symfony/property-access": "^5.4 || ^6.4 || ^7.3 || ^8.0", diff --git a/config/pagerfanta.php b/config/pagerfanta.php index 0e6c435..360e21c 100644 --- a/config/pagerfanta.php +++ b/config/pagerfanta.php @@ -4,6 +4,7 @@ use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder; use BabDev\PagerfantaBundle\Position\PositionResolver; +use BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; use BabDev\PagerfantaBundle\View\ContainerBackedImmutableViewFactory; use Pagerfanta\Cursor\Base64JsonCursorEncoder; @@ -57,9 +58,21 @@ service('property_accessor'), service('pagerfanta.cursor_encoder'), ]) + ->deprecate('babdev/pagerfanta-bundle', '4.7', 'The "%service_id%" service is deprecated, use the "pagerfanta.position_route_generator_factory" service instead.') ; - $services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); - $services->alias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); + $services->alias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory') + ->deprecate('babdev/pagerfanta-bundle', '4.7', \sprintf('The "%%alias_id%%" alias is deprecated, use the "%s" alias instead.', PositionRouteGeneratorFactoryInterface::class)) + ; + + $services->set('pagerfanta.position_route_generator_factory', RequestAwarePositionRouteGeneratorFactory::class) + ->args([ + service('router'), + service('request_stack'), + service('property_accessor'), + service('pagerfanta.cursor_encoder'), + ]) + ; + $services->alias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.position_route_generator_factory'); $services->set('pagerfanta.view.default', DefaultView::class) ->tag('pagerfanta.view', ['alias' => 'default']) diff --git a/config/twig.php b/config/twig.php index b529eb4..23ed181 100644 --- a/config/twig.php +++ b/config/twig.php @@ -3,6 +3,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use BabDev\PagerfantaBundle\Twig\UndefinedCallableHandler; +use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\Twig\Extension\PagerfantaExtension; use Pagerfanta\Twig\Extension\PagerfantaRuntime; use Pagerfanta\Twig\View\TwigView; @@ -18,7 +19,7 @@ ->args([ abstract_arg('default view'), service('pagerfanta.view_factory'), - service('pagerfanta.route_generator_factory'), + service(PositionRouteGeneratorFactoryInterface::class), abstract_arg('default sequential view'), ]) ->tag('twig.runtime') diff --git a/docs/generating-paginated-routes.md b/docs/generating-paginated-routes.md index c10031d..0599c02 100644 --- a/docs/generating-paginated-routes.md +++ b/docs/generating-paginated-routes.md @@ -2,48 +2,52 @@ When rendering a Pagerfanta view, a route generator callable is required to generate the URLs for each item in the pagination list. The route generator can be customized for use within your application if you need to adjust the routing logic. -
The page number based route generators (the RouteGeneratorInterface and RouteGeneratorFactoryInterface, and the RouterAwareRouteGenerator) are deprecated since Pagerfanta 4.10, use the position route generators instead. The bundle's route generator factory supports both, so no changes are needed when using the bundle's services.
- -The route generators are defined by two interfaces, with their default implementations noted below: - -- `Pagerfanta\RouteGenerator\RouteGeneratorInterface` - The class type that is used to generate routes - - `BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator` is used by default, which uses the Symfony Routing component to generate routes -- `Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface` - A factory service that is used to generate a `RouteGeneratorInterface` at runtime - - `BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory` is used by default, which uses the `Symfony\Component\HttpFoundation\Request` object to attempt to set the default route name and route parameters, this creates a `RouterAwareRouteGenerator` - -The Twig integration uses a `RouteGeneratorFactoryInterface` instance to create the route generator used when rendering a Pagerfanta view. - -The `pagerfanta.route_generator_factory` service is available for use in your application if you need to create a route generator. You may use a compiler pass to change this service to any class meeting the interface requirements. - -## `RouterAwareRouteGenerator` Options - -The following options may be passed through a route generator factory when using the `BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator` in order to customize the generated URLs: - -- `routeName` - Required option (generated by the `RequestAwareRouteGeneratorFactory` if not given), the name of the route to use for the paginated URLs -- `pageParameter` - Defaults to "`[page]`", specifies the name of the routing parameter to use for the page, note that the page parameter *MUST* be wrapped in brackets (i.e. `[other_page]`) for the route generator to correctly function -- `omitFirstPage` - Defaults to `false`, a boolean value indicating whether the first page should omit the pagination parameter (if true, `?page=1` will not be part of the paginated URL for page 1 of your list) -- `routeParams` - Defaults to an empty array, an array of additional parameters to pass to the router for generating the URL -- `referenceType` - Defaults to `Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_PATH`, allows specifying the `$referenceType` parameter when calling `Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate()` - ## Position Route Generators
Position route generators were introduced in PagerfantaBundle 4.7.
Pagerfanta describes the pages a pager links to with [positions](/open-source/packages/pagerfanta/docs/4.x/route-generator#position-route-generators), which support both offset and [cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination) pagination. -The `BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory` also implements the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`, and creates a `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` which uses the Symfony Routing component to generate the URLs for positions: +The route generators are defined by two interfaces, with their default implementations noted below: + +- `Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface` - The class type that is used to generate routes + - `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` is used by default, which uses the Symfony Routing component to generate routes +- `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` - A factory service that is used to generate a `PositionRouteGeneratorInterface` at runtime + - `BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory` is used by default, which uses the `Symfony\Component\HttpFoundation\Request` object to attempt to set the default route name and route parameters, this creates a `RouterAwarePositionRouteGenerator` + +The `RouterAwarePositionRouteGenerator` generates the URLs as follows: - For a page position, the page number is set to the page parameter and the cursor parameter is removed - For a cursor position, the [signed cursor](/open-source/packages/pagerfantabundle/docs/4.x/cursor-pagination#signed-cursors) is set to the cursor parameter and the page parameter is removed -The factory is also available with the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` alias. +The Twig integration uses the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface` service to create the route generator used when rendering a Pagerfanta view. + +The `pagerfanta.position_route_generator_factory` service is available for use in your application if you need to create a route generator, and is aliased to the `Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface`. To use another factory, including with the Twig integration, point this alias to your service. + +```yaml +# config/services.yaml +services: + Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface: + alias: App\Pagination\RouteGeneratorFactory +``` ### Position Route Generator Options -The `RouterAwarePositionRouteGenerator` supports all of the [`RouterAwareRouteGenerator` options](#routerawareroutegenerator-options), and the following option: +The following options may be passed through a route generator factory when using the `BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator` in order to customize the generated URLs: +- `routeName` - Required option (generated by the `RequestAwarePositionRouteGeneratorFactory` if not given), the name of the route to use for the paginated URLs +- `pageParameter` - Defaults to "`[page]`", specifies the name of the routing parameter to use for the page, note that the page parameter *MUST* be wrapped in brackets (i.e. `[other_page]`) for the route generator to correctly function - `cursorParameter` - Defaults to "`[cursor]`", specifies the name of the routing parameter to use for the cursor, note that the cursor parameter *MUST* be wrapped in brackets (i.e. `[after]`) for the route generator to correctly function +- `omitFirstPage` - Defaults to `false`, a boolean value indicating whether the first page should omit the pagination parameter (if true, `?page=1` will not be part of the paginated URL for page 1 of your list) +- `routeParams` - Defaults to an empty array, an array of additional parameters to pass to the router for generating the URL +- `referenceType` - Defaults to `Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_PATH`, allows specifying the `$referenceType` parameter when calling `Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate()` ```twig {{ pagerfanta(pager, {'cursorParameter': '[after]'}) }} ``` + +## Page Number Based Route Generators + +
The page number based route generators are deprecated since PagerfantaBundle 4.7 (following their deprecation in Pagerfanta 4.10), and will be removed in PagerfantaBundle 5.0. This includes the BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator and BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory classes, the pagerfanta.route_generator_factory service, and the Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface alias. Use the position route generators instead, which accept the same options.
+ +Before PagerfantaBundle 4.7, the route generators were defined by the `Pagerfanta\RouteGenerator\RouteGeneratorInterface` and `Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface` interfaces, which generate routes using page numbers. The `pagerfanta.route_generator_factory` service (a `RequestAwareRouteGeneratorFactory` creating `RouterAwareRouteGenerator` instances) remains available until PagerfantaBundle 5.0, and supports all of the position route generator options except the `cursorParameter` option. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 4caeca4..4aaa302 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -30,12 +30,6 @@ parameters: count: 2 path: src/DependencyInjection/CompilerPass/RegisterPagerfantaViewsPass.php - - - message: '#^Parameter \#2 \.\.\.\$arrays of function array_merge expects array, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php - - message: '#^Parameter \#3 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#' identifier: argument.type @@ -46,7 +40,7 @@ parameters: message: '#^Parameter \#4 \$options of class BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwarePositionRouteGenerator constructor expects array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, cursorParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}, array\ given\.$#' identifier: argument.type count: 1 - path: src/RouteGenerator/RequestAwareRouteGeneratorFactory.php + path: src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php - message: '#^Default value of the parameter \#3 \$options \(array\{\}\) of method BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator\:\:__construct\(\) is incompatible with type array\{routeName\: non\-empty\-string, pageParameter\?\: non\-empty\-string, omitFirstPage\?\: bool, routeParams\?\: array\, referenceType\?\: 0\|1\|2\|3\}\.$#' diff --git a/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php b/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php new file mode 100644 index 0000000..c09815b --- /dev/null +++ b/src/RouteGenerator/RequestAwarePositionRouteGeneratorFactory.php @@ -0,0 +1,36 @@ +router, + $this->propertyAccessor, + $this->cursorEncoder, + $this->resolveOptions($options), + ); + } +} diff --git a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php index f4b2d96..4276376 100644 --- a/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php +++ b/src/RouteGenerator/RequestAwareRouteGeneratorFactory.php @@ -4,27 +4,34 @@ use Pagerfanta\Cursor\Base64JsonCursorEncoder; use Pagerfanta\Cursor\CursorEncoderInterface; -use Pagerfanta\Exception\RuntimeException; use Pagerfanta\RouteGenerator\PositionRouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\PositionRouteGeneratorInterface; use Pagerfanta\RouteGenerator\RouteGeneratorFactoryInterface; use Pagerfanta\RouteGenerator\RouteGeneratorInterface; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** * Creates route generators for the current route, unless another route is set in the options. + * + * @deprecated since PagerfantaBundle 4.7, use the {@see RequestAwarePositionRouteGeneratorFactory} instead */ final class RequestAwareRouteGeneratorFactory implements RouteGeneratorFactoryInterface, PositionRouteGeneratorFactoryInterface { + use ResolvesRouteGeneratorOptions; + + /** + * @param CursorEncoderInterface $cursorEncoder The encoder for the cursors in the generated URLs, the bundle configures an encoder which signs the cursors + */ public function __construct( private readonly UrlGeneratorInterface $router, private readonly RequestStack $requestStack, private readonly PropertyAccessorInterface $propertyAccessor, private readonly CursorEncoderInterface $cursorEncoder = new Base64JsonCursorEncoder(), - ) {} + ) { + trigger_deprecation('babdev/pagerfanta-bundle', '4.7', 'The "%s" class is deprecated, use the "%s" class instead.', self::class, RequestAwarePositionRouteGeneratorFactory::class); + } public function create(array $options = []): RouteGeneratorInterface { @@ -37,58 +44,6 @@ public function create(array $options = []): RouteGeneratorInterface public function createPositionRouteGenerator(array $options = []): PositionRouteGeneratorInterface { - return new RouterAwarePositionRouteGenerator( - $this->router, - $this->propertyAccessor, - $this->cursorEncoder, - $this->resolveOptions($options), - ); - } - - /** - * @param array $options - * - * @return array - * - * @throws RuntimeException if the route cannot be resolved from the current request - */ - private function resolveOptions(array $options): array - { - $options = array_replace( - [ - 'routeName' => null, - 'routeParams' => [], - 'pageParameter' => '[page]', - 'cursorParameter' => '[cursor]', - 'omitFirstPage' => false, - ], - $options - ); - - if (null === $options['routeName']) { - $request = $this->getRequest(); - - if (null === $request) { - throw new RuntimeException('The request aware route generator can not be used when there is not an active request.'); - } - - if (null !== $this->requestStack->getParentRequest()) { - throw new RuntimeException('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.'); - } - - $options['routeName'] = $request->attributes->get('_route'); - - // Make sure we read the route parameters from the passed option array - $defaultRouteParams = array_merge($request->query->all(), $request->attributes->get('_route_params', [])); - - $options['routeParams'] = array_merge($defaultRouteParams, $options['routeParams']); - } - - return $options; - } - - private function getRequest(): ?Request - { - return $this->requestStack->getCurrentRequest(); + return (new RequestAwarePositionRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor, $this->cursorEncoder))->createPositionRouteGenerator($options); } } diff --git a/src/RouteGenerator/ResolvesRouteGeneratorOptions.php b/src/RouteGenerator/ResolvesRouteGeneratorOptions.php new file mode 100644 index 0000000..b5659e0 --- /dev/null +++ b/src/RouteGenerator/ResolvesRouteGeneratorOptions.php @@ -0,0 +1,66 @@ + $options + * + * @return array + * + * @throws InvalidArgumentException if the route parameters option is not an array + * @throws RuntimeException if the route cannot be resolved from the current request + */ + private function resolveOptions(array $options): array + { + $options = array_replace( + [ + 'routeName' => null, + 'routeParams' => [], + 'pageParameter' => '[page]', + 'cursorParameter' => '[cursor]', + 'omitFirstPage' => false, + ], + $options + ); + + if (null === $options['routeName']) { + $request = $this->requestStack->getCurrentRequest(); + + if (null === $request) { + throw new RuntimeException('The request aware route generator can not be used when there is not an active request.'); + } + + if (null !== $this->requestStack->getParentRequest()) { + throw new RuntimeException('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.'); + } + + $options['routeName'] = $request->attributes->get('_route'); + + if (!\is_array($options['routeParams'])) { + throw new InvalidArgumentException(\sprintf('The "routeParams" option must be an array, "%s" given.', get_debug_type($options['routeParams']))); + } + + $requestRouteParams = $request->attributes->get('_route_params', []); + + // Make sure we read the route parameters from the passed option array + $defaultRouteParams = array_merge($request->query->all(), \is_array($requestRouteParams) ? $requestRouteParams : []); + + $options['routeParams'] = array_merge($defaultRouteParams, $options['routeParams']); + } + + return $options; + } +} diff --git a/src/RouteGenerator/RouterAwareRouteGenerator.php b/src/RouteGenerator/RouterAwareRouteGenerator.php index a72ae2f..a9560ff 100644 --- a/src/RouteGenerator/RouterAwareRouteGenerator.php +++ b/src/RouteGenerator/RouterAwareRouteGenerator.php @@ -9,6 +9,8 @@ use Symfony\Component\Routing\Generator\UrlGeneratorInterface; /** + * @deprecated since PagerfantaBundle 4.7, use the {@see RouterAwarePositionRouteGenerator} instead + * * @phpstan-type RouteGeneratorOptions array{routeName: non-empty-string, pageParameter?: non-empty-string, omitFirstPage?: bool, routeParams?: array, referenceType?: UrlGeneratorInterface::*} */ final class RouterAwareRouteGenerator implements RouteGeneratorInterface @@ -21,6 +23,8 @@ public function __construct( private readonly PropertyAccessorInterface $propertyAccessor, private readonly array $options = [], ) { + trigger_deprecation('babdev/pagerfanta-bundle', '4.7', 'The "%s" class is deprecated, use the "%s" class instead.', self::class, RouterAwarePositionRouteGenerator::class); + // Check missing options if (!isset($options['routeName'])) { throw new InvalidArgumentException(\sprintf('The "%s" class options requires a "routeName" parameter to be set.', self::class)); diff --git a/tests/CapturesDeprecations.php b/tests/CapturesDeprecations.php new file mode 100644 index 0000000..7e6d208 --- /dev/null +++ b/tests/CapturesDeprecations.php @@ -0,0 +1,35 @@ + + */ + private function captureDeprecations(callable $callback): array + { + $deprecations = []; + + set_error_handler( + static function (int $level, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, + \E_USER_DEPRECATED + ); + + try { + $callback(); + } finally { + restore_error_handler(); + } + + return $deprecations; + } +} diff --git a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php index bcd123d..9b23369 100644 --- a/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php +++ b/tests/DependencyInjection/BabDevPagerfantaExtensionTest.php @@ -147,6 +147,7 @@ public function testContainerIsLoadedWithDefaultConfigurationWhenTwigBundleIsIns } $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 0, 'default'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 2, new Reference(PositionRouteGeneratorFactoryInterface::class)); $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.twig_runtime', 3, null); $twigConfig = $this->container->getExtensionConfig('twig'); @@ -282,9 +283,13 @@ private function assertCursorAndRouteGenerationServicesAreRegistered(): void $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_resolver', 1, new Reference('pagerfanta.cursor_encoder')); $this->assertContainerBuilderHasAlias(PositionResolver::class, 'pagerfanta.position_resolver'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.position_route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder')); + $this->assertContainerBuilderHasAlias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.position_route_generator_factory'); + $this->assertContainerBuilderHasServiceDefinitionWithArgument('pagerfanta.route_generator_factory', 3, new Reference('pagerfanta.cursor_encoder')); + self::assertTrue($this->container->getDefinition('pagerfanta.route_generator_factory')->isDeprecated()); $this->assertContainerBuilderHasAlias(RouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); - $this->assertContainerBuilderHasAlias(PositionRouteGeneratorFactoryInterface::class, 'pagerfanta.route_generator_factory'); + self::assertTrue($this->container->getAlias(RouteGeneratorFactoryInterface::class)->isDeprecated()); foreach (['default', 'foundation6', 'semantic_ui', 'twitter_bootstrap', 'twitter_bootstrap3', 'twitter_bootstrap4', 'twitter_bootstrap5'] as $name) { $this->assertContainerBuilderHasServiceDefinitionWithArgument(\sprintf('pagerfanta.view.%s_sequential', $name), 1, \sprintf('%s_sequential', $name)); diff --git a/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php b/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php new file mode 100644 index 0000000..c995429 --- /dev/null +++ b/tests/RouteGenerator/RequestAwarePositionRouteGeneratorFactoryTest.php @@ -0,0 +1,119 @@ +requestStack = new RequestStack(); + } + + protected function tearDown(): void + { + do { + $request = $this->requestStack->pop(); + } while (null !== $request); + } + + private function createFactory(): RequestAwarePositionRouteGeneratorFactory + { + $routeCollection = new RouteCollection(); + $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view')); + $routeCollection->add('other_view', new Route('/other-view')); + + return new RequestAwarePositionRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder()); + } + + private function pushRequest(): void + { + $request = Request::create('/pagerfanta-view', 'GET', ['page' => '3', 'cursor' => 'stale', 'hello' => 'world']); + $request->attributes->set('_route', 'pagerfanta_view'); + $request->attributes->set('_route_params', []); + + $this->requestStack->push($request); + } + + public function testTheFactoryIsNotDeprecated(): void + { + self::assertSame([], $this->captureDeprecations(fn () => $this->createFactory())); + } + + public function testAGeneratorIsCreatedForTheCurrentRequest(): void + { + $this->pushRequest(); + + $generator = $this->createFactory()->createPositionRouteGenerator(); + + $cursor = new Cursor(['p.id' => 42]); + + // The parameters from the request keep their position + self::assertSame('/pagerfanta-view?page=4&hello=world', $generator(new PagePosition(4))); + self::assertSame('/pagerfanta-view?cursor='.(new Base64JsonCursorEncoder())->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor))); + } + + public function testTheRouteParametersFromTheOptionsOverrideTheRequestParameters(): void + { + $this->pushRequest(); + + self::assertSame('/pagerfanta-view?page=2&hello=there', $this->createFactory()->createPositionRouteGenerator(['routeParams' => ['hello' => 'there']])(new PagePosition(2))); + } + + public function testAGeneratorIsCreatedForAGivenRouteDuringASubrequest(): void + { + $this->pushRequest(); + $this->requestStack->push(Request::create('/_internal')); + + self::assertSame('/other-view?page=2', $this->createFactory()->createPositionRouteGenerator(['routeName' => 'other_view'])(new PagePosition(2))); + } + + public function testAGeneratorIsNotCreatedWhenARouteNameIsNotGivenDuringASubrequest(): void + { + $this->pushRequest(); + $this->requestStack->push(Request::create('/_internal')); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The request aware route generator can not guess the route when used in a sub-request, pass the "routeName" option to use this generator.'); + + $this->createFactory()->createPositionRouteGenerator(); + } + + public function testAGeneratorIsNotCreatedWhenARequestIsNotActive(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The request aware route generator can not be used when there is not an active request.'); + + $this->createFactory()->createPositionRouteGenerator(); + } + + public function testTheRouteParametersOptionMustBeAnArray(): void + { + $this->pushRequest(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The "routeParams" option must be an array, "string" given.'); + + $this->createFactory()->createPositionRouteGenerator(['routeParams' => 'hello=world']); + } +} diff --git a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php index 100d112..d60f129 100644 --- a/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php +++ b/tests/RouteGenerator/RequestAwareRouteGeneratorFactoryTest.php @@ -3,12 +3,13 @@ namespace BabDev\PagerfantaBundle\Tests\RouteGenerator; use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; +use BabDev\PagerfantaBundle\RouteGenerator\RouterAwarePositionRouteGenerator; +use BabDev\PagerfantaBundle\Tests\CapturesDeprecations; use Pagerfanta\Cursor\Base64JsonCursorEncoder; -use Pagerfanta\Cursor\Cursor; use Pagerfanta\Exception\RuntimeException; -use Pagerfanta\Position\CursorPosition; use Pagerfanta\Position\PagePosition; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -21,8 +22,14 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +/** + * @group legacy + */ +#[Group('legacy')] final class RequestAwareRouteGeneratorFactoryTest extends TestCase { + use CapturesDeprecations; + private MockObject&UrlGeneratorInterface $router; private RequestStack $requestStack; @@ -110,30 +117,21 @@ private function createFactory(): RequestAwareRouteGeneratorFactory ); } - public function testAPositionRouteGeneratorIsCreatedForTheCurrentRequest(): void + public function testTheFactoryIsDeprecated(): void { - $routeCollection = new RouteCollection(); - $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view')); - - $request = Request::create('/pagerfanta-view', 'GET', ['page' => '3', 'cursor' => 'stale', 'hello' => 'world']); - $request->attributes->set('_route', 'pagerfanta_view'); - $request->attributes->set('_route_params', []); - - $this->requestStack->push($request); - - $generator = (new RequestAwareRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder()))->createPositionRouteGenerator(); + $deprecations = $this->captureDeprecations(fn () => $this->createFactory()); - $cursor = new Cursor(['p.id' => 42]); - - // The parameters from the request keep their position - self::assertSame('/pagerfanta-view?page=4&hello=world', $generator(new PagePosition(4))); - self::assertSame('/pagerfanta-view?cursor='.(new Base64JsonCursorEncoder())->encode($cursor).'&hello=world', $generator(new CursorPosition($cursor))); + self::assertSame(['Since babdev/pagerfanta-bundle 4.7: The "BabDev\\PagerfantaBundle\\RouteGenerator\\RequestAwareRouteGeneratorFactory" class is deprecated, use the "BabDev\\PagerfantaBundle\\RouteGenerator\\RequestAwarePositionRouteGeneratorFactory" class instead.'], $deprecations); } - public function testAPositionRouteGeneratorIsNotCreatedWhenARequestIsNotActive(): void + public function testThePositionRouteGeneratorIsCreatedByTheReplacementFactory(): void { - $this->expectException(RuntimeException::class); + $routeCollection = new RouteCollection(); + $routeCollection->add('pagerfanta_view', new Route('/pagerfanta-view')); + + $generator = (new RequestAwareRouteGeneratorFactory(new UrlGenerator($routeCollection, new RequestContext()), $this->requestStack, PropertyAccess::createPropertyAccessor(), new Base64JsonCursorEncoder()))->createPositionRouteGenerator(['routeName' => 'pagerfanta_view']); - $this->createFactory()->createPositionRouteGenerator(); + self::assertInstanceOf(RouterAwarePositionRouteGenerator::class, $generator); + self::assertSame('/pagerfanta-view?page=2', $generator(new PagePosition(2))); } } diff --git a/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php b/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php index 02c7964..001c787 100644 --- a/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php +++ b/tests/RouteGenerator/RouterAwareRouteGeneratorTest.php @@ -3,7 +3,9 @@ namespace BabDev\PagerfantaBundle\Tests\RouteGenerator; use BabDev\PagerfantaBundle\RouteGenerator\RouterAwareRouteGenerator; +use BabDev\PagerfantaBundle\Tests\CapturesDeprecations; use Pagerfanta\Exception\InvalidArgumentException; +use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -13,8 +15,14 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; +/** + * @group legacy + */ +#[Group('legacy')] final class RouterAwareRouteGeneratorTest extends TestCase { + use CapturesDeprecations; + private function createRouter(): UrlGeneratorInterface { $routeCollection = new RouteCollection(); @@ -95,4 +103,11 @@ public function testARouteIsNotGeneratedWhenTheRouteNameParameterIsMissing(): vo $generator(1); } + + public function testTheGeneratorIsDeprecated(): void + { + $deprecations = $this->captureDeprecations(fn () => new RouterAwareRouteGenerator($this->createRouter(), $this->createPropertyAccessor(), ['routeName' => 'pagerfanta_view'])); + + self::assertSame(['Since babdev/pagerfanta-bundle 4.7: The "BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwareRouteGenerator" class is deprecated, use the "BabDev\\PagerfantaBundle\\RouteGenerator\\RouterAwarePositionRouteGenerator" class instead.'], $deprecations); + } } diff --git a/tests/View/TwigViewIntegrationTest.php b/tests/View/TwigViewIntegrationTest.php index 2172277..211112a 100644 --- a/tests/View/TwigViewIntegrationTest.php +++ b/tests/View/TwigViewIntegrationTest.php @@ -3,7 +3,7 @@ namespace BabDev\PagerfantaBundle\Tests\View; use BabDev\PagerfantaBundle\Cursor\SignedCursorEncoder; -use BabDev\PagerfantaBundle\RouteGenerator\RequestAwareRouteGeneratorFactory; +use BabDev\PagerfantaBundle\RouteGenerator\RequestAwarePositionRouteGeneratorFactory; use Pagerfanta\Adapter\CallbackCursorAdapter; use Pagerfanta\Adapter\CursorSlice; use Pagerfanta\Adapter\FixedAdapter; @@ -421,7 +421,7 @@ public function testPagerfantaRenderingWithEmptyOptions(): void self::assertNotEmpty( (new TwigView($this->twig))->render( $this->createPagerfanta(), - (new RequestAwareRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor))->create(), + (new RequestAwarePositionRouteGeneratorFactory($this->router, $this->requestStack, $this->propertyAccessor))->createPositionRouteGenerator(), ) ); } @@ -484,7 +484,7 @@ public function load($class) $viewFactory = new ViewFactory(); $viewFactory->set('twig', new TwigView($this->testCase->twig)); - $routeGeneratorFactory = new RequestAwareRouteGeneratorFactory( + $routeGeneratorFactory = new RequestAwarePositionRouteGeneratorFactory( $this->testCase->router, $this->testCase->requestStack, $this->testCase->propertyAccessor,