Skip to content

Commit 1606fe2

Browse files
Nayte91claude
andcommitted
feat(state): range request for paginated collections
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3be0991 commit 1606fe2

3 files changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\State\Provider;
15+
16+
use ApiPlatform\Metadata\CollectionOperationInterface;
17+
use ApiPlatform\Metadata\HttpOperation;
18+
use ApiPlatform\Metadata\Operation;
19+
use ApiPlatform\State\Pagination\Pagination;
20+
use ApiPlatform\State\ProviderInterface;
21+
use Symfony\Component\HttpFoundation\Response;
22+
use Symfony\Component\HttpKernel\Exception\HttpException;
23+
24+
/**
25+
* Parses the Range request header and converts it to pagination filters.
26+
*
27+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.2
28+
*
29+
* @author Julien Robic <nayte91@gmail.com>
30+
*/
31+
final class RangeHeaderProvider implements ProviderInterface
32+
{
33+
public function __construct(
34+
private readonly ProviderInterface $decorated,
35+
private readonly Pagination $pagination,
36+
) {
37+
}
38+
39+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
40+
{
41+
$request = $context['request'] ?? null;
42+
43+
if (
44+
!$request
45+
|| !$operation instanceof CollectionOperationInterface
46+
|| !$operation instanceof HttpOperation
47+
|| !\in_array($request->getMethod(), ['GET', 'HEAD'], true)
48+
|| !$request->headers->has('Range')
49+
) {
50+
return $this->decorated->provide($operation, $uriVariables, $context);
51+
}
52+
53+
$rangeHeader = $request->headers->get('Range');
54+
55+
if (!preg_match('/^([a-z]+)=(\d+)-(\d+)$/i', $rangeHeader, $matches)) {
56+
return $this->decorated->provide($operation, $uriVariables, $context);
57+
}
58+
59+
[, $unit, $startStr, $endStr] = $matches;
60+
$expectedUnit = self::extractRangeUnit($operation);
61+
62+
if (strtolower($unit) !== $expectedUnit) {
63+
return $this->decorated->provide($operation, $uriVariables, $context);
64+
}
65+
66+
$start = (int) $startStr;
67+
$end = (int) $endStr;
68+
69+
if ($start > $end) {
70+
throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range start must not exceed end.');
71+
}
72+
73+
$itemsPerPage = $end - $start + 1;
74+
75+
if (0 !== $start % $itemsPerPage) {
76+
throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range must be aligned to page boundaries.');
77+
}
78+
79+
$page = (int) ($start / $itemsPerPage) + 1;
80+
81+
$options = $this->pagination->getOptions();
82+
$filters = $request->attributes->get('_api_filters', []);
83+
$filters[$options['page_parameter_name']] = $page;
84+
$filters[$options['items_per_page_parameter_name']] = $itemsPerPage;
85+
$request->attributes->set('_api_filters', $filters);
86+
87+
$operation = $operation->withStatus(Response::HTTP_PARTIAL_CONTENT);
88+
$request->attributes->set('_api_operation', $operation);
89+
90+
return $this->decorated->provide($operation, $uriVariables, $context);
91+
}
92+
93+
/**
94+
* Extracts the range unit from the operation's uriTemplate (e.g., "/books{._format}" → "books").
95+
* Falls back to lowercase shortName, then "items".
96+
*/
97+
private static function extractRangeUnit(HttpOperation $operation): string
98+
{
99+
if ($uriTemplate = $operation->getUriTemplate()) {
100+
$path = strtok($uriTemplate, '{');
101+
$segments = array_filter(explode('/', trim($path, '/')));
102+
if ($last = end($segments)) {
103+
return strtolower($last);
104+
}
105+
}
106+
107+
return strtolower($operation->getShortName() ?? 'items') ?: 'items';
108+
}
109+
}

src/Symfony/Bundle/Resources/config/state/provider.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use ApiPlatform\State\Provider\ContentNegotiationProvider;
1717
use ApiPlatform\State\Provider\DeserializeProvider;
1818
use ApiPlatform\State\Provider\ParameterProvider;
19+
use ApiPlatform\State\Provider\RangeHeaderProvider;
1920
use ApiPlatform\State\Provider\ReadProvider;
2021
use ApiPlatform\Symfony\EventListener\ErrorListener;
2122

@@ -40,6 +41,13 @@
4041
service('api_platform.serializer.context_builder'),
4142
]);
4243

44+
$services->set('api_platform.state_provider.range_header', RangeHeaderProvider::class)
45+
->decorate('api_platform.state_provider.read', null, 1)
46+
->args([
47+
service('api_platform.state_provider.range_header.inner'),
48+
service('api_platform.pagination'),
49+
]);
50+
4351
$services->set('api_platform.state_provider.deserialize', DeserializeProvider::class)
4452
->decorate('api_platform.state_provider.main', null, 300)
4553
->args([
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Tests\State;
15+
16+
use ApiPlatform\Metadata\Get;
17+
use ApiPlatform\Metadata\GetCollection;
18+
use ApiPlatform\State\Pagination\Pagination;
19+
use ApiPlatform\State\Provider\RangeHeaderProvider;
20+
use ApiPlatform\State\ProviderInterface;
21+
use PHPUnit\Framework\TestCase;
22+
use Symfony\Component\HttpFoundation\Request;
23+
use Symfony\Component\HttpKernel\Exception\HttpException;
24+
25+
class RangeHeaderProviderTest extends TestCase
26+
{
27+
private function createProvider(?ProviderInterface $decorated = null): RangeHeaderProvider
28+
{
29+
$decorated ??= $this->createStub(ProviderInterface::class);
30+
$pagination = new Pagination();
31+
32+
return new RangeHeaderProvider($decorated, $pagination);
33+
}
34+
35+
public function testDelegatesWhenNoRangeHeader(): void
36+
{
37+
$decorated = $this->createMock(ProviderInterface::class);
38+
$decorated->expects($this->once())->method('provide')->willReturn([]);
39+
40+
$provider = new RangeHeaderProvider($decorated, new Pagination());
41+
$result = $provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => new Request()]);
42+
43+
$this->assertSame([], $result);
44+
}
45+
46+
public function testDelegatesWhenNotCollectionOperation(): void
47+
{
48+
$decorated = $this->createMock(ProviderInterface::class);
49+
$decorated->expects($this->once())->method('provide')->willReturn(null);
50+
51+
$request = new Request();
52+
$request->headers->set('Range', 'books=0-29');
53+
54+
$provider = new RangeHeaderProvider($decorated, new Pagination());
55+
$provider->provide(new Get(shortName: 'Book'), [], ['request' => $request]);
56+
}
57+
58+
public function testDelegatesWhenNotGetOrHead(): void
59+
{
60+
$decorated = $this->createMock(ProviderInterface::class);
61+
$decorated->expects($this->once())->method('provide')->willReturn(null);
62+
63+
$request = Request::create('/books', 'POST');
64+
$request->headers->set('Range', 'books=0-29');
65+
66+
$provider = new RangeHeaderProvider($decorated, new Pagination());
67+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
68+
}
69+
70+
public function testIgnoresUnparseableRangeFormat(): void
71+
{
72+
$decorated = $this->createMock(ProviderInterface::class);
73+
$decorated->expects($this->once())->method('provide')->willReturn([]);
74+
75+
$request = new Request();
76+
$request->headers->set('Range', 'invalid-format');
77+
78+
$provider = new RangeHeaderProvider($decorated, new Pagination());
79+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
80+
}
81+
82+
public function testIgnoresWrongUnit(): void
83+
{
84+
$decorated = $this->createMock(ProviderInterface::class);
85+
$decorated->expects($this->once())->method('provide')->willReturn([]);
86+
87+
$request = new Request();
88+
$request->headers->set('Range', 'items=0-29');
89+
90+
$provider = new RangeHeaderProvider($decorated, new Pagination());
91+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
92+
}
93+
94+
public function testHeadRequestWithRangeHeaderSetsFilters(): void
95+
{
96+
$decorated = $this->createStub(ProviderInterface::class);
97+
$decorated->method('provide')->willReturn([]);
98+
99+
$request = Request::create('/books', 'HEAD');
100+
$request->headers->set('Range', 'books=0-29');
101+
102+
$provider = new RangeHeaderProvider($decorated, new Pagination());
103+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
104+
105+
$filters = $request->attributes->get('_api_filters');
106+
$this->assertSame(1, $filters['page']);
107+
$this->assertSame(30, $filters['itemsPerPage']);
108+
109+
$operation = $request->attributes->get('_api_operation');
110+
$this->assertSame(206, $operation->getStatus());
111+
}
112+
113+
public function testValidRangeSetsFiltersAndStatus206(): void
114+
{
115+
$decorated = $this->createStub(ProviderInterface::class);
116+
$decorated->method('provide')->willReturn([]);
117+
118+
$request = new Request();
119+
$request->headers->set('Range', 'books=0-29');
120+
121+
$provider = new RangeHeaderProvider($decorated, new Pagination());
122+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
123+
124+
$filters = $request->attributes->get('_api_filters');
125+
$this->assertSame(1, $filters['page']);
126+
$this->assertSame(30, $filters['itemsPerPage']);
127+
128+
$operation = $request->attributes->get('_api_operation');
129+
$this->assertSame(206, $operation->getStatus());
130+
}
131+
132+
public function testValidRangePageTwo(): void
133+
{
134+
$decorated = $this->createStub(ProviderInterface::class);
135+
$decorated->method('provide')->willReturn([]);
136+
137+
$request = new Request();
138+
$request->headers->set('Range', 'books=30-59');
139+
140+
$provider = new RangeHeaderProvider($decorated, new Pagination());
141+
$provider->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
142+
143+
$filters = $request->attributes->get('_api_filters');
144+
$this->assertSame(2, $filters['page']);
145+
$this->assertSame(30, $filters['itemsPerPage']);
146+
}
147+
148+
public function testStartGreaterThanEndThrows416(): void
149+
{
150+
$this->expectException(HttpException::class);
151+
$this->expectExceptionMessage('Range start must not exceed end.');
152+
153+
$request = new Request();
154+
$request->headers->set('Range', 'books=50-20');
155+
156+
$this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
157+
}
158+
159+
public function testNonPageAlignedRangeThrows416(): void
160+
{
161+
$this->expectException(HttpException::class);
162+
$this->expectExceptionMessage('Range must be aligned to page boundaries.');
163+
164+
$request = new Request();
165+
$request->headers->set('Range', 'books=10-25');
166+
167+
$this->createProvider()->provide(new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}'), [], ['request' => $request]);
168+
}
169+
}

0 commit comments

Comments
 (0)