diff --git a/src/State/Provider/RangeHeaderProvider.php b/src/State/Provider/RangeHeaderProvider.php new file mode 100644 index 00000000000..19932b598ba --- /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/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index e3f4fb01a62..a1e5aa347a3 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; @@ -135,9 +138,57 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c $this->addLinkedDataPlatformHeaders($headers, $operation); } + if ($operation instanceof CollectionOperationInterface && $originalData instanceof PartialPaginatorInterface) { + $this->addContentRangeHeaders($headers, $operation, $originalData); + } + return $headers; } + /** + * Adds Content-Range and Accept-Ranges headers for paginated collections. + * + * When the total is unknown (PartialPaginatorInterface), the unsatisfied-range + * format is skipped because "*​/*" is invalid ABNF (complete-length = 1*DIGIT). + * + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4 + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.3 + */ + private function addContentRangeHeaders(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); + } + + $headers['Accept-Ranges'] = $unit; + } + + /** + * 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'; + } + private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void { if (!$this->resourceMetadataCollectionFactory) { diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index f31fc2bc7c1..e5bb233611f 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -16,6 +16,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; @@ -40,6 +41,13 @@ service('api_platform.serializer.context_builder'), ]); + $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/ContentRangeHeaderTest.php b/tests/State/ContentRangeHeaderTest.php new file mode 100644 index 00000000000..e2fc262e878 --- /dev/null +++ b/tests/State/ContentRangeHeaderTest.php @@ -0,0 +1,275 @@ + + * + * 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; + +/** + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4 + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.3 + * @see https://datatracker.ietf.org/doc/html/rfc9110#section-15.3.7 + */ +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 testHeadRequestReturnsContentRangeHeaders(): 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('', $operation, context: [ + 'request' => Request::create('/books', 'HEAD'), + 'original_data' => $paginator->reveal(), + ]); + + $this->assertSame('books 0-29/201', $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')); + } +} diff --git a/tests/State/RangeHeaderProviderTest.php b/tests/State/RangeHeaderProviderTest.php new file mode 100644 index 00000000000..604a75f4950 --- /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]); + } +}