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; + } + }; + } +}