diff --git a/composer.json b/composer.json index 6847605..a5ad723 100644 --- a/composer.json +++ b/composer.json @@ -36,6 +36,7 @@ "doctrine/persistence": "^3.1", "ekino/phpstan-banned-code": "^1.0 || ^2.0", "fakerphp/faker": "^1.23", + "graviton/link-header-rel-parser": "^1.0", "phpstan/phpstan": "^1.10.66 || ^1.11", "phpstan/phpstan-deprecation-rules": "^1.1", "phpstan/phpstan-phpunit": "^1.3", diff --git a/packages/rekapager-bundle/config/services.php b/packages/rekapager-bundle/config/services.php index 5ae9221..e03be54 100644 --- a/packages/rekapager-bundle/config/services.php +++ b/packages/rekapager-bundle/config/services.php @@ -15,6 +15,7 @@ use Rekalogika\Rekapager\Bundle\Contracts\PageUrlGeneratorFactoryInterface; use Rekalogika\Rekapager\Bundle\Implementation\SymfonyPageUrlGeneratorFactory; use Rekalogika\Rekapager\Bundle\PagerFactory; +use Rekalogika\Rekapager\Bundle\RekapagerLinkProcessor; use Rekalogika\Rekapager\Bundle\Twig\RekapagerExtension; use Rekalogika\Rekapager\Bundle\Twig\RekapagerRuntime; use Rekalogika\Rekapager\Bundle\Twig\TwigPagerRenderer; @@ -71,6 +72,13 @@ '$defaultUrlReferenceType' => '%rekalogika.rekapager.config.default_url_reference_type%', ]); + $services + ->set(RekapagerLinkProcessor::class) + ->decorate('api_platform.state_processor.respond', priority: 410) + ->args([ + service('.inner'), + ]); + $services ->set(TwigPagerRenderer::class) ->args([ diff --git a/packages/rekapager-bundle/src/RekapagerLinkProcessor.php b/packages/rekapager-bundle/src/RekapagerLinkProcessor.php new file mode 100644 index 0000000..c25515b --- /dev/null +++ b/packages/rekapager-bundle/src/RekapagerLinkProcessor.php @@ -0,0 +1,101 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Rekapager\Bundle; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; +use Psr\Link\EvolvableLinkProviderInterface; +use Rekalogika\Rekapager\Contracts\PagerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\WebLink\GenericLinkProvider; +use Symfony\Component\WebLink\Link; + +/** + * @template T1 + * @template T2 + * + * @implements ProcessorInterface + */ +class RekapagerLinkProcessor implements ProcessorInterface +{ + /** + * @param ProcessorInterface $decorated + */ + public function __construct( + private readonly ProcessorInterface $decorated, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if ( + !($request = $context['request'] ?? null) + || !$request instanceof Request + || !$operation instanceof HttpOperation + || $this->isPreflightRequest($request) + ) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + $linkProvider = $request->attributes->get('_api_platform_links') ?? new GenericLinkProvider(); + + if (!$linkProvider instanceof EvolvableLinkProviderInterface) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + /** @var mixed */ + $requestData = $request->attributes->get('data'); + + if (!$requestData instanceof PagerInterface) { + /** @psalm-suppress MixedArgumentTypeCoercion */ + /** @psalm-suppress MixedArgument */ + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + if (null !== $first = $requestData->getFirstPage()?->getUrl()) { + $linkProvider = $linkProvider->withLink(new Link('first', $first)); + } + + if (null !== $prev = $requestData->getPreviousPage()?->getUrl()) { + $linkProvider = $linkProvider->withLink(new Link('prev', $prev)); + } + + if (null !== $next = $requestData->getNextPage()?->getUrl()) { + $linkProvider = $linkProvider->withLink(new Link('next', $next)); + } + + if (null !== $last = $requestData->getLastPage()?->getUrl()) { + $linkProvider = $linkProvider->withLink(new Link('last', $last)); + } + + $request->attributes->set('_api_platform_links', $linkProvider); + + /** + * @psalm-suppress MixedArgumentTypeCoercion + * @psalm-suppress InvalidArgument + * @phpstan-ignore argument.type + */ + return $this->decorated->process($data, $operation, $uriVariables, $context); + } + + /** + * @see ApiPlatform\State\Util\CorsTrait::isPreflightRequest() + */ + private function isPreflightRequest(Request $request): bool + { + return $request->isMethod('OPTIONS') && $request->headers->has('Access-Control-Request-Method'); + } +} diff --git a/tests/src/IntegrationTests/ApiPlatform/ApiTest.php b/tests/src/IntegrationTests/ApiPlatform/ApiTest.php index c11936a..9a8c283 100644 --- a/tests/src/IntegrationTests/ApiPlatform/ApiTest.php +++ b/tests/src/IntegrationTests/ApiPlatform/ApiTest.php @@ -14,6 +14,7 @@ namespace Rekalogika\Rekapager\Tests\IntegrationTests\ApiPlatform; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use Graviton\LinkHeaderParser\LinkHeader; class ApiTest extends ApiTestCase { @@ -75,6 +76,16 @@ public function testApiWithCustomProvider(): void $lastPage = $response->toArray()['hydra:view']['hydra:last'] ?? null; self::assertIsString($lastPage); + $headers = $response->getHeaders(); + $link = $headers['link'][0] ?? null; + self::assertNotNull($link); + $linkHeader = LinkHeader::fromString($link); + + self::assertEquals($firstPage, $linkHeader->getRel('first')?->getUri()); + self::assertEquals($previousPage, $linkHeader->getRel('prev')?->getUri()); + self::assertEquals($nextPage, $linkHeader->getRel('next')?->getUri()); + self::assertEquals($lastPage, $linkHeader->getRel('last')?->getUri()); + // test last page $response = $client->request('GET', $lastPage); @@ -104,5 +115,15 @@ public function testApiWithCustomProvider(): void /** @var ?string */ $lastPage = $response->toArray()['hydra:view']['hydra:last'] ?? null; self::assertNull($lastPage); + + $headers = $response->getHeaders(); + $link = $headers['link'][0] ?? null; + self::assertNotNull($link); + $linkHeader = LinkHeader::fromString($link); + + self::assertEquals($firstPage, $linkHeader->getRel('first')?->getUri()); + self::assertEquals($previousPage, $linkHeader->getRel('prev')?->getUri()); + self::assertEquals($nextPage, $linkHeader->getRel('next')?->getUri()); + self::assertEquals($lastPage, $linkHeader->getRel('last')?->getUri()); } }