From 93555126daa057b1606117ac889da88070b1ee94 Mon Sep 17 00:00:00 2001 From: Nayte Date: Sat, 21 Mar 2026 10:46:29 +0100 Subject: [PATCH 1/3] feat(state): content-range response for paginated collections --- src/State/Util/HttpResponseHeadersTrait.php | 40 +++ tests/State/ContentRangeHeaderTest.php | 270 ++++++++++++++++++++ 2 files changed, 310 insertions(+) create mode 100644 tests/State/ContentRangeHeaderTest.php diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index a608706fa0..90fcf24b6f 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\State\Util; +use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Error; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; @@ -26,6 +27,8 @@ use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Metadata\Util\CloneTrait; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface; @@ -145,9 +148,46 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $this->addLinkedDataPlatformHeaders($headers, $operation); } + if ($operation instanceof CollectionOperationInterface && $originalData instanceof PartialPaginatorInterface) { + $headers['Accept-Ranges'] = self::extractRangeUnit($operation); + + if ('HEAD' !== $method) { + $this->addContentRangeHeader($headers, $operation, $originalData); + } + } + return $headers; } + private function addContentRangeHeader(array &$headers, HttpOperation $operation, PartialPaginatorInterface $paginator): void + { + $unit = self::extractRangeUnit($operation); + $currentCount = $paginator->count(); + $rangeStart = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage()); + + if ($paginator instanceof PaginatorInterface) { + $totalItems = (int) $paginator->getTotalItems(); + $headers['Content-Range'] = 0 === $currentCount + ? \sprintf('%s */%d', $unit, $totalItems) + : \sprintf('%s %d-%d/%d', $unit, $rangeStart, $rangeStart + $currentCount - 1, $totalItems); + } elseif (0 < $currentCount) { + $headers['Content-Range'] = \sprintf('%s %d-%d/*', $unit, $rangeStart, $rangeStart + $currentCount - 1); + } + } + + private static function extractRangeUnit(HttpOperation $operation): string + { + if ($uriTemplate = $operation->getUriTemplate()) { + $path = strtok($uriTemplate, '{'); + $segments = array_filter(explode('/', trim($path, '/'))); + if ($last = end($segments)) { + return strtolower($last); + } + } + + return strtolower($operation->getShortName() ?? 'items') ?: 'items'; + } + private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void { if (!$this->resourceMetadataCollectionFactory) { diff --git a/tests/State/ContentRangeHeaderTest.php b/tests/State/ContentRangeHeaderTest.php new file mode 100644 index 0000000000..691d62134c --- /dev/null +++ b/tests/State/ContentRangeHeaderTest.php @@ -0,0 +1,270 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; +use ApiPlatform\State\Processor\RespondProcessor; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpFoundation\Request; + +class ContentRangeHeaderTest extends TestCase +{ + use ProphecyTrait; + + public function testContentRangeForPartialCollection(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testContentRangeForPageThree(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(3.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books 60-89/201', $response->headers->get('Content-Range')); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testContentRangeForFullCollection(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(3); + $paginator->getTotalItems()->willReturn(3.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books 0-2/3', $response->headers->get('Content-Range')); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testContentRangeForPartialPaginatorUnknownTotal(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PartialPaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books 0-29/*', $response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertSame(200, $response->getStatusCode()); + } + + public function testContentRangeForEmptyPageKnownTotal(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(0); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books */201', $response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + } + + public function testNoContentRangeForEmptyPageUnknownTotal(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PartialPaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertNull($response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + } + + public function testContentRangeDoesNotAffectStatusCode(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); + } + + public function testNoContentRangeForNonCollectionOperation(): void + { + $operation = new Get(shortName: 'Book'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertNull($response->headers->get('Content-Range')); + $this->assertNull($response->headers->get('Accept-Ranges')); + } + + public function testContentRangeWithNoShortNameFallsBackToItems(): void + { + $operation = new GetCollection(shortName: null); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('items 0-29/201', $response->headers->get('Content-Range')); + $this->assertSame('items', $response->headers->get('Accept-Ranges')); + } + + public function testHeadRequestOmitsContentRangeWithoutCountingCollection(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->shouldNotBeCalled(); + $paginator->getItemsPerPage()->shouldNotBeCalled(); + $paginator->count()->shouldNotBeCalled(); + $paginator->getTotalItems()->shouldNotBeCalled(); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('', $operation, context: [ + 'request' => Request::create('/books', 'HEAD'), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertNull($response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertEmpty($response->getContent()); + } + + public function testStatus206WhenOperationStatusIsPartialContent(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(1.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + } + + public function testStatus206ForPageTwo(): void + { + $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206); + + $paginator = $this->prophesize(PaginatorInterface::class); + $paginator->getCurrentPage()->willReturn(2.0); + $paginator->getItemsPerPage()->willReturn(30.0); + $paginator->count()->willReturn(30); + $paginator->getTotalItems()->willReturn(201.0); + + $respondProcessor = new RespondProcessor(); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => new Request(), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('books 30-59/201', $response->headers->get('Content-Range')); + } +} From 39293df497b92a821ab50efc18320adc96988adc Mon Sep 17 00:00:00 2001 From: Nayte Date: Sat, 21 Mar 2026 10:46:31 +0100 Subject: [PATCH 2/3] feat(state): range request for paginated collections --- src/State/Provider/RangeHeaderProvider.php | 109 +++++++++++ .../Resources/config/state/provider.php | 8 + tests/State/RangeHeaderProviderTest.php | 169 ++++++++++++++++++ 3 files changed, 286 insertions(+) create mode 100644 src/State/Provider/RangeHeaderProvider.php create mode 100644 tests/State/RangeHeaderProviderTest.php diff --git a/src/State/Provider/RangeHeaderProvider.php b/src/State/Provider/RangeHeaderProvider.php new file mode 100644 index 0000000000..19932b598b --- /dev/null +++ b/src/State/Provider/RangeHeaderProvider.php @@ -0,0 +1,109 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\State\Provider; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * Parses the Range request header and converts it to pagination filters. + * + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.2 + * + * @author Julien Robic + */ +final class RangeHeaderProvider implements ProviderInterface +{ + public function __construct( + private readonly ProviderInterface $decorated, + private readonly Pagination $pagination, + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $request = $context['request'] ?? null; + + if ( + !$request + || !$operation instanceof CollectionOperationInterface + || !$operation instanceof HttpOperation + || !\in_array($request->getMethod(), ['GET', 'HEAD'], true) + || !$request->headers->has('Range') + ) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $rangeHeader = $request->headers->get('Range'); + + if (!preg_match('/^([a-z]+)=(\d+)-(\d+)$/i', $rangeHeader, $matches)) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + [, $unit, $startStr, $endStr] = $matches; + $expectedUnit = self::extractRangeUnit($operation); + + if (strtolower($unit) !== $expectedUnit) { + return $this->decorated->provide($operation, $uriVariables, $context); + } + + $start = (int) $startStr; + $end = (int) $endStr; + + if ($start > $end) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range start must not exceed end.'); + } + + $itemsPerPage = $end - $start + 1; + + if (0 !== $start % $itemsPerPage) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range must be aligned to page boundaries.'); + } + + $page = (int) ($start / $itemsPerPage) + 1; + + $options = $this->pagination->getOptions(); + $filters = $request->attributes->get('_api_filters', []); + $filters[$options['page_parameter_name']] = $page; + $filters[$options['items_per_page_parameter_name']] = $itemsPerPage; + $request->attributes->set('_api_filters', $filters); + + $operation = $operation->withStatus(Response::HTTP_PARTIAL_CONTENT); + $request->attributes->set('_api_operation', $operation); + + return $this->decorated->provide($operation, $uriVariables, $context); + } + + /** + * Extracts the range unit from the operation's uriTemplate (e.g., "/books{._format}" → "books"). + * Falls back to lowercase shortName, then "items". + */ + private static function extractRangeUnit(HttpOperation $operation): string + { + if ($uriTemplate = $operation->getUriTemplate()) { + $path = strtok($uriTemplate, '{'); + $segments = array_filter(explode('/', trim($path, '/'))); + if ($last = end($segments)) { + return strtolower($last); + } + } + + return strtolower($operation->getShortName() ?? 'items') ?: 'items'; + } +} diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index 50f32a83fb..89a1403262 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -17,6 +17,7 @@ use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; +use ApiPlatform\State\Provider\RangeHeaderProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\Symfony\EventListener\ErrorListener; use ApiPlatform\Validator\DenormalizationViolationFactory; @@ -50,6 +51,13 @@ $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.range_header', RangeHeaderProvider::class) + ->decorate('api_platform.state_provider.read', null, 1) + ->args([ + service('api_platform.state_provider.range_header.inner'), + service('api_platform.pagination'), + ]); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->decorate('api_platform.state_provider.main', null, 300) ->args([ diff --git a/tests/State/RangeHeaderProviderTest.php b/tests/State/RangeHeaderProviderTest.php new file mode 100644 index 0000000000..604a75f495 --- /dev/null +++ b/tests/State/RangeHeaderProviderTest.php @@ -0,0 +1,169 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Provider\RangeHeaderProvider; +use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\HttpException; + +class RangeHeaderProviderTest extends TestCase +{ + private function createProvider(?ProviderInterface $decorated = null): RangeHeaderProvider + { + $decorated ??= $this->createStub(ProviderInterface::class); + $pagination = new Pagination(); + + return new RangeHeaderProvider($decorated, $pagination); + } + + public function testDelegatesWhenNoRangeHeader(): void + { + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn([]); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $result = $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => new Request()]); + + $this->assertSame([], $result); + } + + public function testDelegatesWhenNotCollectionOperation(): void + { + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn(null); + + $request = new Request(); + $request->headers->set('Range', 'books=0-29'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new Get(shortName: 'Book'), [], ['request' => $request]); + } + + public function testDelegatesWhenNotGetOrHead(): void + { + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn(null); + + $request = Request::create('/books', 'POST'); + $request->headers->set('Range', 'books=0-29'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + } + + public function testIgnoresUnparseableRangeFormat(): void + { + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn([]); + + $request = new Request(); + $request->headers->set('Range', 'invalid-format'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + } + + public function testIgnoresWrongUnit(): void + { + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->willReturn([]); + + $request = new Request(); + $request->headers->set('Range', 'items=0-29'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + } + + public function testHeadRequestWithRangeHeaderSetsFilters(): void + { + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn([]); + + $request = Request::create('/books', 'HEAD'); + $request->headers->set('Range', 'books=0-29'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + + $filters = $request->attributes->get('_api_filters'); + $this->assertSame(1, $filters['page']); + $this->assertSame(30, $filters['itemsPerPage']); + + $operation = $request->attributes->get('_api_operation'); + $this->assertSame(206, $operation->getStatus()); + } + + public function testValidRangeSetsFiltersAndStatus206(): void + { + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn([]); + + $request = new Request(); + $request->headers->set('Range', 'books=0-29'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + + $filters = $request->attributes->get('_api_filters'); + $this->assertSame(1, $filters['page']); + $this->assertSame(30, $filters['itemsPerPage']); + + $operation = $request->attributes->get('_api_operation'); + $this->assertSame(206, $operation->getStatus()); + } + + public function testValidRangePageTwo(): void + { + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn([]); + + $request = new Request(); + $request->headers->set('Range', 'books=30-59'); + + $provider = new RangeHeaderProvider($decorated, new Pagination()); + $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + + $filters = $request->attributes->get('_api_filters'); + $this->assertSame(2, $filters['page']); + $this->assertSame(30, $filters['itemsPerPage']); + } + + public function testStartGreaterThanEndThrows416(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Range start must not exceed end.'); + + $request = new Request(); + $request->headers->set('Range', 'books=50-20'); + + $this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + } + + public function testNonPageAlignedRangeThrows416(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Range must be aligned to page boundaries.'); + + $request = new Request(); + $request->headers->set('Range', 'books=10-25'); + + $this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + } +} From 89356d2d58e7e0f0ef4a5d6f90a46f4176e08e72 Mon Sep 17 00:00:00 2001 From: Nayte Date: Sun, 13 Sep 2026 10:56:55 +0200 Subject: [PATCH 3/3] fix(state): address the range request review --- src/Laravel/ApiPlatformProvider.php | 7 +- .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/GetCollection.php | 4 +- src/Metadata/HttpOperation.php | 14 + .../Tests/Extractor/XmlExtractorTest.php | 2 + .../Tests/Extractor/YamlExtractorTest.php | 2 + src/State/Provider/RangeHeaderProvider.php | 103 ++++--- src/State/Util/HttpResponseHeadersTrait.php | 42 +-- .../Resources/config/state/provider.php | 2 +- .../Resources/config/symfony/events.php | 8 + .../RangeRequest/RangeRequestResource.php | 76 +++++ tests/Functional/RangeRequestTest.php | 161 ++++++++++ tests/State/ContentRangeHeaderTest.php | 276 +++++------------- tests/State/RangeHeaderProviderTest.php | 273 +++++++++++------ 16 files changed, 613 insertions(+), 360 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/RangeRequest/RangeRequestResource.php create mode 100644 tests/Functional/RangeRequestTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 58317cf088..1d65448725 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -177,6 +177,7 @@ use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ObjectMapperProvider; use ApiPlatform\State\Provider\ParameterProvider; +use ApiPlatform\State\Provider\RangeHeaderProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; @@ -451,12 +452,16 @@ public function register(): void return new ReadProvider($app->make(CallableProvider::class)); }); + $this->app->singleton(RangeHeaderProvider::class, static function (Application $app) { + return new RangeHeaderProvider($app->make(ReadProvider::class), $app->make(Pagination::class)); + }); + $this->app->singleton(SwaggerUiProvider::class, static function (Application $app) { /** @var ConfigRepository */ $config = $app['config']; return new SwaggerUiProvider( - decorated: $app->make(ReadProvider::class), + decorated: $app->make(RangeHeaderProvider::class), openApiFactory: $app->make(OpenApiFactoryInterface::class), swaggerUiEnabled: $config->get('api-platform.swagger_ui.enabled', false), scalarEnabled: $config->get('api-platform.scalar.enabled', false), diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 1c04a62c5c..6d911cba37 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -429,6 +429,7 @@ private function buildOperations(\SimpleXMLElement $resource, array $root): ?arr 'queryParameterValidate' => $this->phpize($operation, 'queryParameterValidate', 'bool'), 'priority' => $this->phpize($operation, 'priority', 'integer'), 'routePriority' => $this->phpize($operation, 'routePriority', 'integer'), + 'rangeUnit' => $this->phpize($operation, 'rangeUnit', 'string'), 'name' => $this->phpize($operation, 'name', 'string'), 'routeName' => $this->phpize($operation, 'routeName', 'string'), ]); diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index f73d3a6290..466a2cff5e 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -366,6 +366,7 @@ private function buildOperations(array $resource, array $root): ?array 'hideHydraOperation' => $this->phpize($resource, 'hideHydraOperation', 'bool'), 'priority' => $this->phpize($operation, 'priority', 'integer'), 'routePriority' => $this->phpize($operation, 'routePriority', 'integer'), + 'rangeUnit' => $this->phpize($operation, 'rangeUnit', 'string'), 'name' => $this->phpize($operation, 'name', 'string'), 'class' => (string) $class, ]); diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 295dd49599..caccaeb859 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -47,6 +47,7 @@ + diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 9810442359..3cf6702c61 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -107,6 +107,7 @@ public function __construct( ?bool $throwOnNotFound = null, private ?string $itemUriTemplate = null, ?bool $map = null, + ?string $rangeUnit = null, ) { parent::__construct( uriTemplate: $uriTemplate, @@ -192,7 +193,8 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, stateOptions: $stateOptions, - map: $map + map: $map, + rangeUnit: $rangeUnit ); } diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index a7fb0d0f49..059b7f336b 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -226,6 +226,7 @@ public function __construct( ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, + protected ?string $rangeUnit = null, ) { $this->formats = (null === $formats || \is_array($formats)) ? $formats : [$formats]; $this->inputFormats = (null === $inputFormats || \is_array($inputFormats)) ? $inputFormats : [$inputFormats]; @@ -517,6 +518,19 @@ public function withAcceptPatch(string $acceptPatch): static return $self; } + public function getRangeUnit(): ?string + { + return $this->rangeUnit; + } + + public function withRangeUnit(?string $rangeUnit): static + { + $self = clone $this; + $self->rangeUnit = $rangeUnit; + + return $self; + } + public function getStatus(): ?int { return $this->status; diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index 14e94d18a0..529d8b12e8 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -280,6 +280,7 @@ public function testValidXML(): void 'method' => null, 'priority' => null, 'routePriority' => null, + 'rangeUnit' => null, 'processor' => null, 'provider' => null, 'itemUriTemplate' => null, @@ -390,6 +391,7 @@ public function testValidXML(): void 'method' => null, 'priority' => null, 'routePriority' => null, + 'rangeUnit' => null, 'processor' => null, 'provider' => null, 'stateOptions' => null, diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 62d820e1c8..e242763e2b 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -321,6 +321,7 @@ public function testValidYaml(): void 'queryParameterValidate' => null, 'priority' => null, 'routePriority' => null, + 'rangeUnit' => null, 'processor' => null, 'provider' => null, 'itemUriTemplate' => null, @@ -413,6 +414,7 @@ public function testValidYaml(): void 'queryParameterValidate' => null, 'priority' => null, 'routePriority' => null, + 'rangeUnit' => null, 'processor' => null, 'provider' => null, 'stateOptions' => null, diff --git a/src/State/Provider/RangeHeaderProvider.php b/src/State/Provider/RangeHeaderProvider.php index 19932b598b..39b5b0f818 100644 --- a/src/State/Provider/RangeHeaderProvider.php +++ b/src/State/Provider/RangeHeaderProvider.php @@ -17,19 +17,35 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\HttpException; /** - * Parses the Range request header and converts it to pagination filters. + * Serves paginated collections as HTTP range requests (RFC 9110 §14), opt-in per operation + * through {@see HttpOperation::getRangeUnit()}. * - * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.2 + * A range applies to a would-be 200 only (§14.2): it is ignored on other methods, other + * units, unparseable specifiers and whenever the request fails before reading. Choices the + * RFC leaves open: If-Range counts as a mismatch, collections carrying no validator + * (§13.1.5); the 206 is promised only once a paginator came back, every 206 having to + * carry a Content-Range (§15.3.7); a range pagination cannot serve as a page (misaligned, + * wider than the maximum) gets a 416 rather than being silently ignored, so that clients + * learn it. + * + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14 * * @author Julien Robic */ final class RangeHeaderProvider implements ProviderInterface { + /** Int-range "-" only (§14.1.2): pagination can serve neither an open-ended nor a suffix range. */ + private const RANGE_PATTERN = '/^(?[A-Za-z0-9!#$%&\'*+\-.^_`|~]+)=(?\d+)-(?\d+)$/'; + public function __construct( private readonly ProviderInterface $decorated, private readonly Pagination $pagination, @@ -41,69 +57,72 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $request = $context['request'] ?? null; if ( - !$request - || !$operation instanceof CollectionOperationInterface + !$request instanceof Request || !$operation instanceof HttpOperation - || !\in_array($request->getMethod(), ['GET', 'HEAD'], true) + || !$operation instanceof CollectionOperationInterface + || null === ($unit = $operation->getRangeUnit()) + || !$request->isMethod('GET') || !$request->headers->has('Range') + || $request->headers->has('If-Range') + || !\in_array($operation->getStatus(), [null, Response::HTTP_OK], true) + || !preg_match(self::RANGE_PATTERN, $request->headers->get('Range', ''), $range) + || strtolower($range['unit']) !== strtolower($unit) ) { return $this->decorated->provide($operation, $uriVariables, $context); } - $rangeHeader = $request->headers->get('Range'); + $first = (int) $range['first']; + $last = (int) $range['last']; - if (!preg_match('/^([a-z]+)=(\d+)-(\d+)$/i', $rangeHeader, $matches)) { - return $this->decorated->provide($operation, $uriVariables, $context); + if ($first > $last) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, \sprintf('The range first position (%d) must not exceed its last position (%d).', $first, $last)); } - [, $unit, $startStr, $endStr] = $matches; - $expectedUnit = self::extractRangeUnit($operation); + $length = $last - $first + 1; + $maximumItemsPerPage = $operation->getPaginationMaximumItemsPerPage() ?? $this->pagination->getOptions()['maximum_items_per_page']; - if (strtolower($unit) !== $expectedUnit) { - return $this->decorated->provide($operation, $uriVariables, $context); + if (null !== $maximumItemsPerPage && $length > $maximumItemsPerPage) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, \sprintf('A range must not span more than %d %s.', $maximumItemsPerPage, $unit)); } - $start = (int) $startStr; - $end = (int) $endStr; - - if ($start > $end) { - throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range start must not exceed end.'); + if (0 !== $first % $length) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, \sprintf('The range first position must be a multiple of its length (%d).', $length)); } - $itemsPerPage = $end - $start + 1; - - if (0 !== $start % $itemsPerPage) { - throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range must be aligned to page boundaries.'); + $options = $this->pagination->getOptions(); + $filters = $request->attributes->get('_api_filters'); + if (null === $filters) { + $queryString = RequestParser::getQueryString($request); + $filters = $queryString ? RequestParser::parseRequestParams($queryString) : []; } - $page = (int) ($start / $itemsPerPage) + 1; - - $options = $this->pagination->getOptions(); - $filters = $request->attributes->get('_api_filters', []); - $filters[$options['page_parameter_name']] = $page; - $filters[$options['items_per_page_parameter_name']] = $itemsPerPage; + // Pagination::getLimit() ignores the items-per-page filter unless the client may choose it: + // set the operation too, so that the range wins over the query string. + $filters[$options['page_parameter_name']] = intdiv($first, $length) + 1; + $filters[$options['items_per_page_parameter_name']] = $length; $request->attributes->set('_api_filters', $filters); - $operation = $operation->withStatus(Response::HTTP_PARTIAL_CONTENT); + $operation = $operation->withPaginationItemsPerPage($length); $request->attributes->set('_api_operation', $operation); - return $this->decorated->provide($operation, $uriVariables, $context); - } + $data = $this->decorated->provide($operation, $uriVariables, $context); - /** - * Extracts the range unit from the operation's uriTemplate (e.g., "/books{._format}" → "books"). - * Falls back to lowercase shortName, then "items". - */ - private static function extractRangeUnit(HttpOperation $operation): string - { - if ($uriTemplate = $operation->getUriTemplate()) { - $path = strtok($uriTemplate, '{'); - $segments = array_filter(explode('/', trim($path, '/'))); - if ($last = end($segments)) { - return strtolower($last); + if (!$data instanceof PartialPaginatorInterface) { + return $data; + } + + if ($data instanceof PaginatorInterface) { + $totalItems = (int) $data->getTotalItems(); + + if ($first >= $totalItems) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, \sprintf('The range first position (%d) is beyond the collection (%d %s).', $first, $totalItems, $unit), null, ['Content-Range' => \sprintf('%s */%d', $unit, $totalItems)]); } + } elseif (0 === \count($data)) { + throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, \sprintf('The range first position (%d) is beyond the collection.', $first)); } - return strtolower($operation->getShortName() ?? 'items') ?: 'items'; + $request->attributes->set('_api_operation', $operation->withStatus(Response::HTTP_PARTIAL_CONTENT)); + + return $data; } } diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index 90fcf24b6f..b076b0fa75 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -148,44 +148,32 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $this->addLinkedDataPlatformHeaders($headers, $operation); } - if ($operation instanceof CollectionOperationInterface && $originalData instanceof PartialPaginatorInterface) { - $headers['Accept-Ranges'] = self::extractRangeUnit($operation); + if ( + $operation instanceof CollectionOperationInterface + && null !== ($rangeUnit = $operation->getRangeUnit()) + && \in_array($status, [Response::HTTP_OK, Response::HTTP_PARTIAL_CONTENT], true) + ) { + $headers['Accept-Ranges'] = $rangeUnit; - if ('HEAD' !== $method) { - $this->addContentRangeHeader($headers, $operation, $originalData); + if (Response::HTTP_PARTIAL_CONTENT === $status && $originalData instanceof PartialPaginatorInterface && $contentRange = $this->getContentRange($rangeUnit, $originalData)) { + $headers['Content-Range'] = $contentRange; } } return $headers; } - private function addContentRangeHeader(array &$headers, HttpOperation $operation, PartialPaginatorInterface $paginator): void + private function getContentRange(string $unit, PartialPaginatorInterface $paginator): ?string { - $unit = self::extractRangeUnit($operation); - $currentCount = $paginator->count(); - $rangeStart = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage()); - - if ($paginator instanceof PaginatorInterface) { - $totalItems = (int) $paginator->getTotalItems(); - $headers['Content-Range'] = 0 === $currentCount - ? \sprintf('%s */%d', $unit, $totalItems) - : \sprintf('%s %d-%d/%d', $unit, $rangeStart, $rangeStart + $currentCount - 1, $totalItems); - } elseif (0 < $currentCount) { - $headers['Content-Range'] = \sprintf('%s %d-%d/*', $unit, $rangeStart, $rangeStart + $currentCount - 1); + $count = \count($paginator); + if (0 === $count) { + return null; } - } - private static function extractRangeUnit(HttpOperation $operation): string - { - if ($uriTemplate = $operation->getUriTemplate()) { - $path = strtok($uriTemplate, '{'); - $segments = array_filter(explode('/', trim($path, '/'))); - if ($last = end($segments)) { - return strtolower($last); - } - } + $first = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage()); + $completeLength = $paginator instanceof PaginatorInterface ? (string) (int) $paginator->getTotalItems() : '*'; - return strtolower($operation->getShortName() ?? 'items') ?: 'items'; + return \sprintf('%s %d-%d/%s', $unit, $first, $first + $count - 1, $completeLength); } private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index 89a1403262..7d9d78e0a1 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -52,7 +52,7 @@ $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); $services->set('api_platform.state_provider.range_header', RangeHeaderProvider::class) - ->decorate('api_platform.state_provider.read', null, 1) + ->decorate('api_platform.state_provider.read', null, 120) ->args([ service('api_platform.state_provider.range_header.inner'), service('api_platform.pagination'), diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index ffbd42bc10..2882120af7 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -21,6 +21,7 @@ use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; +use ApiPlatform\State\Provider\RangeHeaderProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\Symfony\Action\DocumentationAction; use ApiPlatform\Symfony\Action\EntrypointAction; @@ -57,6 +58,13 @@ ->arg(1, service('api_platform.serializer.context_builder')) ->arg('$logger', service('logger')->nullOnInvalid()); + $services->set('api_platform.state_provider.range_header', RangeHeaderProvider::class) + ->decorate('api_platform.state_provider.read', null, 120) + ->args([ + service('api_platform.state_provider.range_header.inner'), + service('api_platform.pagination'), + ]); + // Outermost decorator of the read chain (access checkers sit at 0) so parameters are // resolved, and their values propagated to the uriVariables, before anything reads them. $services->set('api_platform.state_provider.parameter', ParameterProvider::class) diff --git a/tests/Fixtures/TestBundle/ApiResource/RangeRequest/RangeRequestResource.php b/tests/Fixtures/TestBundle/ApiResource/RangeRequest/RangeRequestResource.php new file mode 100644 index 0000000000..ee641a8584 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/RangeRequest/RangeRequestResource.php @@ -0,0 +1,76 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\RangeRequest; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'RangeRequest', + operations: [ + new GetCollection( + uriTemplate: '/range_requests', + rangeUnit: 'items', + provider: [self::class, 'provideCollection'], + ), + new GetCollection( + uriTemplate: '/range_requests_disabled', + provider: [self::class, 'provideCollection'], + ), + new GetCollection( + uriTemplate: '/range_requests_secured', + rangeUnit: 'items', + security: 'is_granted("ROLE_ADMIN")', + provider: [self::class, 'provideCollection'], + ), + ], + paginationItemsPerPage: 10, +)] +final class RangeRequestResource +{ + public const TOTAL_ITEMS = 25; + + #[ApiProperty(identifier: true)] + public int $id; + + public string $name; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Item #{$id}"; + } + + /** + * @param array $uriVariables + * @param array $context + */ + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): ArrayPaginator + { + $items = array_map(static fn (int $id): self => new self($id), range(1, self::TOTAL_ITEMS)); + $filters = $context['filters'] ?? []; + + if (isset($filters['name'])) { + $items = array_values(array_filter($items, static fn (self $item): bool => $item->name === $filters['name'])); + } + + $page = max(1, (int) ($filters['page'] ?? 1)); + $itemsPerPage = $operation->getPaginationItemsPerPage() ?? 10; + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} diff --git a/tests/Functional/RangeRequestTest.php b/tests/Functional/RangeRequestTest.php new file mode 100644 index 0000000000..ecf1d0ef96 --- /dev/null +++ b/tests/Functional/RangeRequestTest.php @@ -0,0 +1,161 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\RangeRequest\RangeRequestResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class RangeRequestTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RangeRequestResource::class]; + } + + public function testAFullResponseAdvertisesTheRangeUnitOnly(): void + { + $response = self::createClient()->request('GET', '/range_requests', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Accept-Ranges', 'items'); + $this->assertResponseNotHasHeader('Content-Range'); + $this->assertSame(range(1, 10), array_column($response->toArray()['hydra:member'], 'id')); + } + + /** + * @param list $ids + */ + #[DataProvider('provideSatisfiableRanges')] + public function testARangeIsServedAsAPartialContent(string $range, string $contentRange, array $ids): void + { + $response = self::createClient()->request('GET', '/range_requests', ['headers' => ['Accept' => 'application/ld+json', 'Range' => $range]]); + + $this->assertResponseStatusCodeSame(206); + $this->assertResponseHeaderSame('Accept-Ranges', 'items'); + $this->assertResponseHeaderSame('Content-Range', $contentRange); + $body = $response->toArray(); + $this->assertSame(RangeRequestResource::TOTAL_ITEMS, $body['hydra:totalItems']); + $this->assertSame($ids, array_column($body['hydra:member'], 'id')); + } + + /** + * @return iterable}> + */ + public static function provideSatisfiableRanges(): iterable + { + yield 'first page' => ['items=0-9', 'items 0-9/25', range(1, 10)]; + yield 'second page, ignoring the client items per page permission' => ['items=10-19', 'items 10-19/25', range(11, 20)]; + yield 'last page' => ['items=20-24', 'items 20-24/25', range(21, 25)]; + yield 'shorter page' => ['items=15-19', 'items 15-19/25', range(16, 20)]; + yield 'case-insensitive unit' => ['Items=0-4', 'items 0-4/25', range(1, 5)]; + } + + public function testARangeKeepsTheOtherFilters(): void + { + $response = self::createClient()->request('GET', '/range_requests?name=Item%20%2312', ['headers' => ['Accept' => 'application/ld+json', 'Range' => 'items=0-0']]); + + $this->assertResponseStatusCodeSame(206); + $this->assertResponseHeaderSame('Content-Range', 'items 0-0/1'); + $this->assertSame([12], array_column($response->toArray()['hydra:member'], 'id')); + } + + public function testARangeBeyondTheCollectionIsNotSatisfiable(): void + { + self::createClient()->request('GET', '/range_requests', ['headers' => ['Accept' => 'application/ld+json', 'Range' => 'items=30-39']]); + + $this->assertResponseStatusCodeSame(416); + $this->assertResponseHeaderSame('Content-Range', 'items */25'); + } + + #[DataProvider('provideInvalidRanges')] + public function testAnInvalidRangeIsNotSatisfiable(string $range): void + { + self::createClient()->request('GET', '/range_requests', ['headers' => ['Accept' => 'application/ld+json', 'Range' => $range]]); + + $this->assertResponseStatusCodeSame(416); + $this->assertResponseNotHasHeader('Content-Range'); + } + + /** + * @return iterable + */ + public static function provideInvalidRanges(): iterable + { + yield 'first position beyond last position' => ['items=9-0']; + yield 'not aligned on a page' => ['items=5-14']; + } + + /** + * @param array $headers + */ + #[DataProvider('provideIgnoredRanges')] + public function testARangeIsIgnoredWhenRfc9110SaysSo(string $method, string $range, array $headers = []): void + { + self::createClient()->request($method, '/range_requests', ['headers' => $headers + ['Accept' => 'application/ld+json', 'Range' => $range]]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Accept-Ranges', 'items'); + $this->assertResponseNotHasHeader('Content-Range'); + } + + /** + * @return iterable}> + */ + public static function provideIgnoredRanges(): iterable + { + yield 'HEAD request' => ['HEAD', 'items=10-19', []]; + yield 'unknown unit' => ['GET', 'books=10-19', []]; + yield 'If-Range precondition' => ['GET', 'items=10-19', ['If-Range' => '"abc"']]; + } + + public function testARangeIsIgnoredWithoutARangeUnit(): void + { + $response = self::createClient()->request('GET', '/range_requests_disabled', ['headers' => ['Accept' => 'application/ld+json', 'Range' => 'items=10-19']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseNotHasHeader('Accept-Ranges'); + $this->assertResponseNotHasHeader('Content-Range'); + $this->assertSame(range(1, 10), array_column($response->toArray()['hydra:member'], 'id')); + } + + public function testAccessIsCheckedBeforeTheRange(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('user', 'password', ['ROLE_USER'])); + + $client->request('GET', '/range_requests_secured', ['headers' => ['Accept' => 'application/ld+json', 'Range' => 'items=5-14']]); + + $this->assertResponseStatusCodeSame(403); + $this->assertResponseNotHasHeader('Content-Range'); + } + + public function testASecuredCollectionStillServesRangesToAGrantedUser(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'password', ['ROLE_ADMIN'])); + + $response = $client->request('GET', '/range_requests_secured', ['headers' => ['Accept' => 'application/ld+json', 'Range' => 'items=0-4']]); + + $this->assertResponseStatusCodeSame(206); + $this->assertResponseHeaderSame('Content-Range', 'items 0-4/25'); + $this->assertSame(range(1, 5), array_column($response->toArray()['hydra:member'], 'id')); + } +} diff --git a/tests/State/ContentRangeHeaderTest.php b/tests/State/ContentRangeHeaderTest.php index 691d62134c..cd220bc0b7 100644 --- a/tests/State/ContentRangeHeaderTest.php +++ b/tests/State/ContentRangeHeaderTest.php @@ -15,256 +15,136 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\State\Pagination\PaginatorInterface; +use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\State\Pagination\PartialPaginatorInterface; use ApiPlatform\State\Processor\RespondProcessor; use PHPUnit\Framework\TestCase; -use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; -class ContentRangeHeaderTest extends TestCase +final class ContentRangeHeaderTest extends TestCase { - use ProphecyTrait; - - public function testContentRangeForPartialCollection(): void + public function testAdvertisesTheRangeUnitOnAFullResponse(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond(new GetCollection(rangeUnit: 'books'), new ArrayPaginator(range(1, 201), 0, 30)); - $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); - $this->assertSame('books', $response->headers->get('Accept-Ranges')); $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertFalse($response->headers->has('Content-Range'), 'RFC 9110 §14.4: Content-Range is only meaningful on 206 and 416 responses.'); } - public function testContentRangeForPageThree(): void + public function testAdvertisesTheRangeUnitOnAHeadResponse(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + $response = $this->respond(new GetCollection(rangeUnit: 'books'), new ArrayPaginator(range(1, 201), 0, 30), Request::create('/books', 'HEAD')); - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(3.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); - - $this->assertSame('books 60-89/201', $response->headers->get('Content-Range')); - $this->assertSame(200, $response->getStatusCode()); - } - - public function testContentRangeForFullCollection(): void - { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(3); - $paginator->getTotalItems()->willReturn(3.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); - - $this->assertSame('books 0-2/3', $response->headers->get('Content-Range')); $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testContentRangeForPartialPaginatorUnknownTotal(): void + public function testDescribesThePartialContent(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PartialPaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator(range(1, 201), 60, 30)); - $this->assertSame('books 0-29/*', $response->headers->get('Content-Range')); + $this->assertSame(206, $response->getStatusCode()); $this->assertSame('books', $response->headers->get('Accept-Ranges')); - $this->assertSame(200, $response->getStatusCode()); + $this->assertSame('books 60-89/201', $response->headers->get('Content-Range')); } - public function testContentRangeForEmptyPageKnownTotal(): void + public function testDescribesTheLastPartialContent(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(0); - $paginator->getTotalItems()->willReturn(201.0); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator(range(1, 201), 180, 30)); - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); - - $this->assertSame('books */201', $response->headers->get('Content-Range')); - $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertSame('books 180-200/201', $response->headers->get('Content-Range')); } - public function testNoContentRangeForEmptyPageUnknownTotal(): void + public function testDescribesThePartialContentOfUnknownCompleteLength(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), $this->createPartialPaginator(range(31, 60), 2, 30)); - $paginator = $this->prophesize(PartialPaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); - - $this->assertNull($response->headers->get('Content-Range')); - $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertSame(206, $response->getStatusCode()); + $this->assertSame('books 30-59/*', $response->headers->get('Content-Range')); } - public function testContentRangeDoesNotAffectStatusCode(): void + public function testDoesNotDescribeAnEmptyPartialContent(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator([], 0, 30)); - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testNoContentRangeForNonCollectionOperation(): void + public function testDoesNothingWithoutARangeUnit(): void { - $operation = new Get(shortName: 'Book'); + $response = $this->respond(new GetCollection(status: 206), new ArrayPaginator(range(1, 201), 0, 30)); - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); - - $this->assertNull($response->headers->get('Content-Range')); - $this->assertNull($response->headers->get('Accept-Ranges')); + $this->assertFalse($response->headers->has('Accept-Ranges')); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testContentRangeWithNoShortNameFallsBackToItems(): void + public function testDoesNothingOnAnItemOperation(): void { - $operation = new GetCollection(shortName: null); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond((new Get())->withRangeUnit('books'), new ArrayPaginator(range(1, 201), 0, 30)); - $this->assertSame('items 0-29/201', $response->headers->get('Content-Range')); - $this->assertSame('items', $response->headers->get('Accept-Ranges')); + $this->assertFalse($response->headers->has('Accept-Ranges')); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testHeadRequestOmitsContentRangeWithoutCountingCollection(): void + public function testDoesNothingWhenTheProviderDoesNotPaginate(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->shouldNotBeCalled(); - $paginator->getItemsPerPage()->shouldNotBeCalled(); - $paginator->count()->shouldNotBeCalled(); - $paginator->getTotalItems()->shouldNotBeCalled(); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('', $operation, context: [ - 'request' => Request::create('/books', 'HEAD'), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), [new \stdClass()]); - $this->assertNull($response->headers->get('Content-Range')); $this->assertSame('books', $response->headers->get('Accept-Ranges')); - $this->assertEmpty($response->getContent()); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testStatus206WhenOperationStatusIsPartialContent(): void + public function testDoesNotAdvertiseTheRangeUnitOutsideOfASuccessfulResponse(): void { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(1.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), - ]); + $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 204), new ArrayPaginator(range(1, 201), 0, 30)); - $this->assertSame(206, $response->getStatusCode()); - $this->assertSame('books 0-29/201', $response->headers->get('Content-Range')); - $this->assertSame('books', $response->headers->get('Accept-Ranges')); + $this->assertFalse($response->headers->has('Accept-Ranges')); + $this->assertFalse($response->headers->has('Content-Range')); } - public function testStatus206ForPageTwo(): void + private function respond(Get|GetCollection $operation, mixed $originalData, ?Request $request = null): Response { - $operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206); - - $paginator = $this->prophesize(PaginatorInterface::class); - $paginator->getCurrentPage()->willReturn(2.0); - $paginator->getItemsPerPage()->willReturn(30.0); - $paginator->count()->willReturn(30); - $paginator->getTotalItems()->willReturn(201.0); - - $respondProcessor = new RespondProcessor(); - $response = $respondProcessor->process('content', $operation, context: [ - 'request' => new Request(), - 'original_data' => $paginator->reveal(), + return (new RespondProcessor())->process('content', $operation, context: [ + 'request' => $request ?? Request::create('/books'), + 'original_data' => $originalData, ]); + } - $this->assertSame(206, $response->getStatusCode()); - $this->assertSame('books 30-59/201', $response->headers->get('Content-Range')); + /** + * @param list $items + */ + private function createPartialPaginator(array $items, int $currentPage, int $itemsPerPage): PartialPaginatorInterface + { + return new class($items, $currentPage, $itemsPerPage) implements \IteratorAggregate, PartialPaginatorInterface { + /** + * @param list $items + */ + public function __construct(private readonly array $items, private readonly int $currentPage, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return $this->currentPage; + } + + public function getItemsPerPage(): float + { + return $this->itemsPerPage; + } + }; } } diff --git a/tests/State/RangeHeaderProviderTest.php b/tests/State/RangeHeaderProviderTest.php index 604a75f495..4fcc7a13ab 100644 --- a/tests/State/RangeHeaderProviderTest.php +++ b/tests/State/RangeHeaderProviderTest.php @@ -15,155 +15,248 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; use ApiPlatform\State\Provider\RangeHeaderProvider; use ApiPlatform\State\ProviderInterface; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\HttpException; -class RangeHeaderProviderTest extends TestCase +final class RangeHeaderProviderTest extends TestCase { - private function createProvider(?ProviderInterface $decorated = null): RangeHeaderProvider - { - $decorated ??= $this->createStub(ProviderInterface::class); - $pagination = new Pagination(); - - return new RangeHeaderProvider($decorated, $pagination); - } + private const UNIT = 'books'; - public function testDelegatesWhenNoRangeHeader(): void + #[DataProvider('provideIgnoredRequests')] + public function testIgnoresTheRangeHeaderWhenRfc9110SaysSo(Request $request, Operation $operation): void { $decorated = $this->createMock(ProviderInterface::class); - $decorated->expects($this->once())->method('provide')->willReturn([]); - - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $result = $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => new Request()]); - - $this->assertSame([], $result); + $decorated->expects($this->once()) + ->method('provide') + ->with($this->identicalTo($operation), [], ['request' => $request]) + ->willReturn($paginator = new ArrayPaginator(range(1, 100), 0, 30)); + + $this->assertSame($paginator, $this->createProvider($decorated)->provide($operation, [], ['request' => $request])); + $this->assertFalse($request->attributes->has('_api_filters')); + $this->assertFalse($request->attributes->has('_api_operation')); } - public function testDelegatesWhenNotCollectionOperation(): void + /** + * @return iterable + */ + public static function provideIgnoredRequests(): iterable { - $decorated = $this->createMock(ProviderInterface::class); - $decorated->expects($this->once())->method('provide')->willReturn(null); - - $request = new Request(); - $request->headers->set('Range', 'books=0-29'); - - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new Get(shortName: 'Book'), [], ['request' => $request]); + yield 'no Range header' => [Request::create('/books'), self::createOperation()]; + yield 'no range unit declared on the operation' => [self::createRequest(), new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}')]; + yield 'item operation' => [self::createRequest(), (new Get(shortName: 'Book'))->withRangeUnit(self::UNIT)]; + yield 'HEAD request (§14.2: range handling is defined for GET only)' => [self::createRequest(method: 'HEAD'), self::createOperation()]; + yield 'POST request' => [self::createRequest(method: 'POST'), self::createOperation()]; + yield 'If-Range precondition (§13.1.5: no validator to match)' => [self::createRequest(headers: ['If-Range' => '"abc"']), self::createOperation()]; + yield 'operation not responding with 200' => [self::createRequest(), self::createOperation(status: 202)]; + yield 'unknown range unit' => [self::createRequest('items=0-9'), self::createOperation()]; + yield 'open-ended range' => [self::createRequest('books=10-'), self::createOperation()]; + yield 'suffix range' => [self::createRequest('books=-10'), self::createOperation()]; + yield 'multiple ranges' => [self::createRequest('books=0-9, 20-29'), self::createOperation()]; + yield 'malformed range' => [self::createRequest('not a range'), self::createOperation()]; } - public function testDelegatesWhenNotGetOrHead(): void + public function testTranslatesTheRangeIntoAPageWhateverTheClientPaginationPermissions(): void { + $request = self::createRequest('books=10-19', uri: '/books?title=foo'); + $operation = self::createOperation(); + $paginator = new ArrayPaginator(range(1, 100), 10, 10); + $decorated = $this->createMock(ProviderInterface::class); - $decorated->expects($this->once())->method('provide')->willReturn(null); + $decorated->expects($this->once()) + ->method('provide') + ->with($this->callback(static fn (GetCollection $operation): bool => 10 === $operation->getPaginationItemsPerPage()), [], ['request' => $request]) + ->willReturn($paginator); - $request = Request::create('/books', 'POST'); - $request->headers->set('Range', 'books=0-29'); + $this->assertSame($paginator, $this->createProvider($decorated)->provide($operation, [], ['request' => $request])); + $this->assertSame(['title' => 'foo', 'page' => 2, 'itemsPerPage' => 10], $request->attributes->get('_api_filters')); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + $operation = $request->attributes->get('_api_operation'); + $this->assertInstanceOf(GetCollection::class, $operation); + $this->assertSame(206, $operation->getStatus()); + $this->assertSame(10, $operation->getPaginationItemsPerPage()); } - public function testIgnoresUnparseableRangeFormat(): void + public function testOverridesTheClientPaginationParameters(): void { - $decorated = $this->createMock(ProviderInterface::class); - $decorated->expects($this->once())->method('provide')->willReturn([]); + $request = self::createRequest('BOOKS=0-4', uri: '/books?page=3&itemsPerPage=50'); + $request->attributes->set('_api_filters', ['page' => '3', 'itemsPerPage' => '50', 'author' => 'bar']); + $operation = self::createOperation(paginationClientItemsPerPage: true); + + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(new ArrayPaginator(range(1, 100), 0, 5)); - $request = new Request(); - $request->headers->set('Range', 'invalid-format'); + $this->createProvider($decorated)->provide($operation, [], ['request' => $request]); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + $this->assertSame(['page' => 1, 'itemsPerPage' => 5, 'author' => 'bar'], $request->attributes->get('_api_filters')); + $this->assertSame(206, $request->attributes->get('_api_operation')->getStatus()); } - public function testIgnoresWrongUnit(): void + public function testDoesNotPromiseAPartialContentWhenTheProviderDoesNotPaginate(): void { - $decorated = $this->createMock(ProviderInterface::class); - $decorated->expects($this->once())->method('provide')->willReturn([]); + $request = self::createRequest(); + $operation = self::createOperation(); - $request = new Request(); - $request->headers->set('Range', 'items=0-29'); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn($data = [new \stdClass()]); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + $this->assertSame($data, $this->createProvider($decorated)->provide($operation, [], ['request' => $request])); + $this->assertNull($request->attributes->get('_api_operation')->getStatus()); } - public function testHeadRequestWithRangeHeaderSetsFilters(): void + public function testPromisesAPartialContentForAPartialPaginator(): void { - $decorated = $this->createStub(ProviderInterface::class); - $decorated->method('provide')->willReturn([]); - - $request = Request::create('/books', 'HEAD'); - $request->headers->set('Range', 'books=0-29'); + $request = self::createRequest('books=30-59'); + $operation = self::createOperation(); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(self::createPartialPaginator(range(31, 60), 2, 30)); - $filters = $request->attributes->get('_api_filters'); - $this->assertSame(1, $filters['page']); - $this->assertSame(30, $filters['itemsPerPage']); + $this->createProvider($decorated)->provide($operation, [], ['request' => $request]); - $operation = $request->attributes->get('_api_operation'); - $this->assertSame(206, $operation->getStatus()); + $this->assertSame(['page' => 2, 'itemsPerPage' => 30], $request->attributes->get('_api_filters')); + $this->assertSame(206, $request->attributes->get('_api_operation')->getStatus()); } - public function testValidRangeSetsFiltersAndStatus206(): void + public function testRejectsARangeBeyondTheCollectionWithItsCompleteLength(): void { + $request = self::createRequest('books=30-39'); + $decorated = $this->createStub(ProviderInterface::class); - $decorated->method('provide')->willReturn([]); + $decorated->method('provide')->willReturn(new ArrayPaginator(range(1, 25), 30, 10)); + + try { + $this->createProvider($decorated)->provide(self::createOperation(), [], ['request' => $request]); + $this->fail('A 416 exception should have been thrown.'); + } catch (HttpException $e) { + $this->assertSame(416, $e->getStatusCode()); + $this->assertSame(['Content-Range' => 'books */25'], $e->getHeaders()); + } + } - $request = new Request(); - $request->headers->set('Range', 'books=0-29'); + public function testRejectsARangeOnAnEmptyCollection(): void + { + $request = self::createRequest('books=0-9'); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(new ArrayPaginator([], 0, 10)); + + try { + $this->createProvider($decorated)->provide(self::createOperation(), [], ['request' => $request]); + $this->fail('A 416 exception should have been thrown.'); + } catch (HttpException $e) { + $this->assertSame(416, $e->getStatusCode()); + $this->assertSame(['Content-Range' => 'books */0'], $e->getHeaders()); + } + } - $filters = $request->attributes->get('_api_filters'); - $this->assertSame(1, $filters['page']); - $this->assertSame(30, $filters['itemsPerPage']); + public function testRejectsARangeBeyondAPartialPaginatorWithoutCompleteLength(): void + { + $request = self::createRequest('books=30-39'); - $operation = $request->attributes->get('_api_operation'); - $this->assertSame(206, $operation->getStatus()); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(self::createPartialPaginator([], 4, 10)); + + try { + $this->createProvider($decorated)->provide(self::createOperation(), [], ['request' => $request]); + $this->fail('A 416 exception should have been thrown.'); + } catch (HttpException $e) { + $this->assertSame(416, $e->getStatusCode()); + $this->assertSame([], $e->getHeaders()); + } } - public function testValidRangePageTwo(): void + #[DataProvider('provideInvalidRanges')] + public function testRejectsAnInvalidRangeBeforeReadingTheCollection(string $range, Operation $operation, string $message): void { - $decorated = $this->createStub(ProviderInterface::class); - $decorated->method('provide')->willReturn([]); + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->never())->method('provide'); - $request = new Request(); - $request->headers->set('Range', 'books=30-59'); + $this->expectException(HttpException::class); + $this->expectExceptionMessage($message); - $provider = new RangeHeaderProvider($decorated, new Pagination()); - $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + try { + $this->createProvider($decorated)->provide($operation, [], ['request' => self::createRequest($range)]); + } catch (HttpException $e) { + $this->assertSame(416, $e->getStatusCode()); - $filters = $request->attributes->get('_api_filters'); - $this->assertSame(2, $filters['page']); - $this->assertSame(30, $filters['itemsPerPage']); + throw $e; + } } - public function testStartGreaterThanEndThrows416(): void + /** + * @return iterable + */ + public static function provideInvalidRanges(): iterable { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Range start must not exceed end.'); + yield 'first position beyond last position' => ['books=50-20', self::createOperation(), 'The range first position (50) must not exceed its last position (20).']; + yield 'range not aligned on a page' => ['books=10-25', self::createOperation(), 'The range first position must be a multiple of its length (16).']; + yield 'range wider than the operation maximum items per page' => ['books=0-9', self::createOperation(paginationMaximumItemsPerPage: 5), 'A range must not span more than 5 books.']; + yield 'range wider than the global maximum items per page' => ['books=0-99', self::createOperation(), 'A range must not span more than 50 books.']; + } - $request = new Request(); - $request->headers->set('Range', 'books=50-20'); + private function createProvider(ProviderInterface $decorated): RangeHeaderProvider + { + return new RangeHeaderProvider($decorated, new Pagination(['maximum_items_per_page' => 50])); + } - $this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + private static function createOperation(mixed ...$arguments): GetCollection + { + return new GetCollection(...$arguments + ['shortName' => 'Book', 'uriTemplate' => '/books{._format}', 'rangeUnit' => self::UNIT]); } - public function testNonPageAlignedRangeThrows416(): void + /** + * @param array $headers + */ + private static function createRequest(?string $range = 'books=0-29', string $method = 'GET', array $headers = [], string $uri = '/books'): Request { - $this->expectException(HttpException::class); - $this->expectExceptionMessage('Range must be aligned to page boundaries.'); + $request = Request::create($uri, $method); + foreach ($headers + ['Range' => $range] as $name => $value) { + $request->headers->set($name, $value); + } - $request = new Request(); - $request->headers->set('Range', 'books=10-25'); + return $request; + } - $this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]); + /** + * @param list $items + */ + private static function createPartialPaginator(array $items, int $currentPage, int $itemsPerPage): PartialPaginatorInterface + { + return new class($items, $currentPage, $itemsPerPage) implements \IteratorAggregate, PartialPaginatorInterface { + /** + * @param list $items + */ + public function __construct(private readonly array $items, private readonly int $currentPage, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return $this->currentPage; + } + + public function getItemsPerPage(): float + { + return $this->itemsPerPage; + } + }; } }