Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/State/Provider/RangeHeaderProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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 <nayte91@gmail.com>
*/
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';
}
}
51 changes: 51 additions & 0 deletions src/State/Util/HttpResponseHeadersTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions src/Symfony/Bundle/Resources/config/state/provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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([
Expand Down
Loading
Loading