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
new file mode 100644
index 0000000000..39b5b0f818
--- /dev/null
+++ b/src/State/Provider/RangeHeaderProvider.php
@@ -0,0 +1,128 @@
+
+ *
+ * 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\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;
+
+/**
+ * Serves paginated collections as HTTP range requests (RFC 9110 §14), opt-in per operation
+ * through {@see HttpOperation::getRangeUnit()}.
+ *
+ * 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,
+ ) {
+ }
+
+ public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
+ {
+ $request = $context['request'] ?? null;
+
+ if (
+ !$request instanceof Request
+ || !$operation instanceof HttpOperation
+ || !$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);
+ }
+
+ $first = (int) $range['first'];
+ $last = (int) $range['last'];
+
+ 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));
+ }
+
+ $length = $last - $first + 1;
+ $maximumItemsPerPage = $operation->getPaginationMaximumItemsPerPage() ?? $this->pagination->getOptions()['maximum_items_per_page'];
+
+ 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));
+ }
+
+ 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));
+ }
+
+ $options = $this->pagination->getOptions();
+ $filters = $request->attributes->get('_api_filters');
+ if (null === $filters) {
+ $queryString = RequestParser::getQueryString($request);
+ $filters = $queryString ? RequestParser::parseRequestParams($queryString) : [];
+ }
+
+ // 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->withPaginationItemsPerPage($length);
+ $request->attributes->set('_api_operation', $operation);
+
+ $data = $this->decorated->provide($operation, $uriVariables, $context);
+
+ 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));
+ }
+
+ $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 a608706fa0..b076b0fa75 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,34 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
$this->addLinkedDataPlatformHeaders($headers, $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 (Response::HTTP_PARTIAL_CONTENT === $status && $originalData instanceof PartialPaginatorInterface && $contentRange = $this->getContentRange($rangeUnit, $originalData)) {
+ $headers['Content-Range'] = $contentRange;
+ }
+ }
+
return $headers;
}
+ private function getContentRange(string $unit, PartialPaginatorInterface $paginator): ?string
+ {
+ $count = \count($paginator);
+ if (0 === $count) {
+ return null;
+ }
+
+ $first = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage());
+ $completeLength = $paginator instanceof PaginatorInterface ? (string) (int) $paginator->getTotalItems() : '*';
+
+ return \sprintf('%s %d-%d/%s', $unit, $first, $first + $count - 1, $completeLength);
+ }
+
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 50f32a83fb..7d9d78e0a1 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, 120)
+ ->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/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
new file mode 100644
index 0000000000..cd220bc0b7
--- /dev/null
+++ b/tests/State/ContentRangeHeaderTest.php
@@ -0,0 +1,150 @@
+
+ *
+ * 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\ArrayPaginator;
+use ApiPlatform\State\Pagination\PartialPaginatorInterface;
+use ApiPlatform\State\Processor\RespondProcessor;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\Response;
+
+final class ContentRangeHeaderTest extends TestCase
+{
+ public function testAdvertisesTheRangeUnitOnAFullResponse(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books'), new ArrayPaginator(range(1, 201), 0, 30));
+
+ $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 testAdvertisesTheRangeUnitOnAHeadResponse(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books'), new ArrayPaginator(range(1, 201), 0, 30), Request::create('/books', 'HEAD'));
+
+ $this->assertSame(200, $response->getStatusCode());
+ $this->assertSame('books', $response->headers->get('Accept-Ranges'));
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ public function testDescribesThePartialContent(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator(range(1, 201), 60, 30));
+
+ $this->assertSame(206, $response->getStatusCode());
+ $this->assertSame('books', $response->headers->get('Accept-Ranges'));
+ $this->assertSame('books 60-89/201', $response->headers->get('Content-Range'));
+ }
+
+ public function testDescribesTheLastPartialContent(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator(range(1, 201), 180, 30));
+
+ $this->assertSame('books 180-200/201', $response->headers->get('Content-Range'));
+ }
+
+ public function testDescribesThePartialContentOfUnknownCompleteLength(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), $this->createPartialPaginator(range(31, 60), 2, 30));
+
+ $this->assertSame(206, $response->getStatusCode());
+ $this->assertSame('books 30-59/*', $response->headers->get('Content-Range'));
+ }
+
+ public function testDoesNotDescribeAnEmptyPartialContent(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), new ArrayPaginator([], 0, 30));
+
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ public function testDoesNothingWithoutARangeUnit(): void
+ {
+ $response = $this->respond(new GetCollection(status: 206), new ArrayPaginator(range(1, 201), 0, 30));
+
+ $this->assertFalse($response->headers->has('Accept-Ranges'));
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ public function testDoesNothingOnAnItemOperation(): void
+ {
+ $response = $this->respond((new Get())->withRangeUnit('books'), new ArrayPaginator(range(1, 201), 0, 30));
+
+ $this->assertFalse($response->headers->has('Accept-Ranges'));
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ public function testDoesNothingWhenTheProviderDoesNotPaginate(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 206), [new \stdClass()]);
+
+ $this->assertSame('books', $response->headers->get('Accept-Ranges'));
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ public function testDoesNotAdvertiseTheRangeUnitOutsideOfASuccessfulResponse(): void
+ {
+ $response = $this->respond(new GetCollection(rangeUnit: 'books', status: 204), new ArrayPaginator(range(1, 201), 0, 30));
+
+ $this->assertFalse($response->headers->has('Accept-Ranges'));
+ $this->assertFalse($response->headers->has('Content-Range'));
+ }
+
+ private function respond(Get|GetCollection $operation, mixed $originalData, ?Request $request = null): Response
+ {
+ return (new RespondProcessor())->process('content', $operation, context: [
+ 'request' => $request ?? Request::create('/books'),
+ 'original_data' => $originalData,
+ ]);
+ }
+
+ /**
+ * @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
new file mode 100644
index 0000000000..4fcc7a13ab
--- /dev/null
+++ b/tests/State/RangeHeaderProviderTest.php
@@ -0,0 +1,262 @@
+
+ *
+ * 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\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;
+
+final class RangeHeaderProviderTest extends TestCase
+{
+ private const UNIT = 'books';
+
+ #[DataProvider('provideIgnoredRequests')]
+ public function testIgnoresTheRangeHeaderWhenRfc9110SaysSo(Request $request, Operation $operation): void
+ {
+ $decorated = $this->createMock(ProviderInterface::class);
+ $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'));
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideIgnoredRequests(): iterable
+ {
+ 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 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')
+ ->with($this->callback(static fn (GetCollection $operation): bool => 10 === $operation->getPaginationItemsPerPage()), [], ['request' => $request])
+ ->willReturn($paginator);
+
+ $this->assertSame($paginator, $this->createProvider($decorated)->provide($operation, [], ['request' => $request]));
+ $this->assertSame(['title' => 'foo', 'page' => 2, 'itemsPerPage' => 10], $request->attributes->get('_api_filters'));
+
+ $operation = $request->attributes->get('_api_operation');
+ $this->assertInstanceOf(GetCollection::class, $operation);
+ $this->assertSame(206, $operation->getStatus());
+ $this->assertSame(10, $operation->getPaginationItemsPerPage());
+ }
+
+ public function testOverridesTheClientPaginationParameters(): void
+ {
+ $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));
+
+ $this->createProvider($decorated)->provide($operation, [], ['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 testDoesNotPromiseAPartialContentWhenTheProviderDoesNotPaginate(): void
+ {
+ $request = self::createRequest();
+ $operation = self::createOperation();
+
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn($data = [new \stdClass()]);
+
+ $this->assertSame($data, $this->createProvider($decorated)->provide($operation, [], ['request' => $request]));
+ $this->assertNull($request->attributes->get('_api_operation')->getStatus());
+ }
+
+ public function testPromisesAPartialContentForAPartialPaginator(): void
+ {
+ $request = self::createRequest('books=30-59');
+ $operation = self::createOperation();
+
+ $decorated = $this->createStub(ProviderInterface::class);
+ $decorated->method('provide')->willReturn(self::createPartialPaginator(range(31, 60), 2, 30));
+
+ $this->createProvider($decorated)->provide($operation, [], ['request' => $request]);
+
+ $this->assertSame(['page' => 2, 'itemsPerPage' => 30], $request->attributes->get('_api_filters'));
+ $this->assertSame(206, $request->attributes->get('_api_operation')->getStatus());
+ }
+
+ public function testRejectsARangeBeyondTheCollectionWithItsCompleteLength(): void
+ {
+ $request = self::createRequest('books=30-39');
+
+ $decorated = $this->createStub(ProviderInterface::class);
+ $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());
+ }
+ }
+
+ public function testRejectsARangeOnAnEmptyCollection(): void
+ {
+ $request = self::createRequest('books=0-9');
+
+ $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());
+ }
+ }
+
+ public function testRejectsARangeBeyondAPartialPaginatorWithoutCompleteLength(): void
+ {
+ $request = self::createRequest('books=30-39');
+
+ $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());
+ }
+ }
+
+ #[DataProvider('provideInvalidRanges')]
+ public function testRejectsAnInvalidRangeBeforeReadingTheCollection(string $range, Operation $operation, string $message): void
+ {
+ $decorated = $this->createMock(ProviderInterface::class);
+ $decorated->expects($this->never())->method('provide');
+
+ $this->expectException(HttpException::class);
+ $this->expectExceptionMessage($message);
+
+ try {
+ $this->createProvider($decorated)->provide($operation, [], ['request' => self::createRequest($range)]);
+ } catch (HttpException $e) {
+ $this->assertSame(416, $e->getStatusCode());
+
+ throw $e;
+ }
+ }
+
+ /**
+ * @return iterable
+ */
+ public static function provideInvalidRanges(): iterable
+ {
+ 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.'];
+ }
+
+ private function createProvider(ProviderInterface $decorated): RangeHeaderProvider
+ {
+ return new RangeHeaderProvider($decorated, new Pagination(['maximum_items_per_page' => 50]));
+ }
+
+ private static function createOperation(mixed ...$arguments): GetCollection
+ {
+ return new GetCollection(...$arguments + ['shortName' => 'Book', 'uriTemplate' => '/books{._format}', 'rangeUnit' => self::UNIT]);
+ }
+
+ /**
+ * @param array $headers
+ */
+ private static function createRequest(?string $range = 'books=0-29', string $method = 'GET', array $headers = [], string $uri = '/books'): Request
+ {
+ $request = Request::create($uri, $method);
+ foreach ($headers + ['Range' => $range] as $name => $value) {
+ $request->headers->set($name, $value);
+ }
+
+ return $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;
+ }
+ };
+ }
+}