Skip to content

Commit 60d7cfe

Browse files
committed
feat(state): range request for paginated collections
1 parent 9262a93 commit 60d7cfe

File tree

4 files changed

+294
-14
lines changed

4 files changed

+294
-14
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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\Operation;
18+
use ApiPlatform\State\Pagination\Pagination;
19+
use ApiPlatform\State\ProviderInterface;
20+
use Symfony\Component\HttpFoundation\Response;
21+
use Symfony\Component\HttpKernel\Exception\HttpException;
22+
23+
/**
24+
* Parses the Range request header and converts it to pagination filters.
25+
*
26+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.2
27+
*
28+
* @author Julien Robic <nayte91@gmail.com>
29+
*/
30+
final class RangeHeaderProvider implements ProviderInterface
31+
{
32+
public function __construct(
33+
private readonly ProviderInterface $decorated,
34+
private readonly Pagination $pagination,
35+
) {
36+
}
37+
38+
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
39+
{
40+
$request = $context['request'] ?? null;
41+
42+
if (
43+
!$request
44+
|| !$operation instanceof CollectionOperationInterface
45+
|| !\in_array($request->getMethod(), ['GET', 'HEAD'], true)
46+
|| !$request->headers->has('Range')
47+
) {
48+
return $this->decorated->provide($operation, $uriVariables, $context);
49+
}
50+
51+
$rangeHeader = $request->headers->get('Range');
52+
53+
if (!preg_match('/^([a-z]+)=(\d+)-(\d+)$/i', $rangeHeader, $matches)) {
54+
return $this->decorated->provide($operation, $uriVariables, $context);
55+
}
56+
57+
[, $unit, $startStr, $endStr] = $matches;
58+
$expectedUnit = strtolower($operation->getShortName() ?? 'items') ?: 'items';
59+
60+
if (strtolower($unit) !== $expectedUnit) {
61+
return $this->decorated->provide($operation, $uriVariables, $context);
62+
}
63+
64+
$start = (int) $startStr;
65+
$end = (int) $endStr;
66+
67+
if ($start > $end) {
68+
throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range start must not exceed end.');
69+
}
70+
71+
$itemsPerPage = $end - $start + 1;
72+
73+
if (0 !== $start % $itemsPerPage) {
74+
throw new HttpException(Response::HTTP_REQUESTED_RANGE_NOT_SATISFIABLE, 'Range must be aligned to page boundaries.');
75+
}
76+
77+
$page = (int) ($start / $itemsPerPage) + 1;
78+
79+
$options = $this->pagination->getOptions();
80+
$filters = $request->attributes->get('_api_filters', []);
81+
$filters[$options['page_parameter_name']] = $page;
82+
$filters[$options['items_per_page_parameter_name']] = $itemsPerPage;
83+
$request->attributes->set('_api_filters', $filters);
84+
85+
$operation = $operation->withStatus(Response::HTTP_PARTIAL_CONTENT);
86+
$request->attributes->set('_api_operation', $operation);
87+
88+
return $this->decorated->provide($operation, $uriVariables, $context);
89+
}
90+
}

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([

tests/State/ContentRangeHeaderTest.php

Lines changed: 46 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,14 +22,15 @@
2222
use Prophecy\PhpUnit\ProphecyTrait;
2323
use Symfony\Component\HttpFoundation\Request;
2424

25+
/**
26+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
27+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.3
28+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-15.3.7
29+
*/
2530
class ContentRangeHeaderTest extends TestCase
2631
{
2732
use ProphecyTrait;
2833

29-
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
30-
// Content-Range = range-unit SP ( range-resp / unsatisfied-range )
31-
// range-resp = incl-range "/" ( complete-length / "*" )
32-
// unsatisfied-range = "*​/" complete-length — complete-length = 1*DIGIT
3334
public function testContentRangeForPartialCollection(): void
3435
{
3536
$operation = new GetCollection(shortName: 'Book');
@@ -51,9 +52,6 @@ public function testContentRangeForPartialCollection(): void
5152
$this->assertSame(200, $response->getStatusCode());
5253
}
5354

54-
/**
55-
* Verifies range offset calculation on page > 1.
56-
*/
5755
public function testContentRangeForPageThree(): void
5856
{
5957
$operation = new GetCollection(shortName: 'Book');
@@ -94,8 +92,6 @@ public function testContentRangeForFullCollection(): void
9492
$this->assertSame(200, $response->getStatusCode());
9593
}
9694

97-
// PartialPaginatorInterface: total unknown → complete-length = "*".
98-
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
9995
public function testContentRangeForPartialPaginatorUnknownTotal(): void
10096
{
10197
$operation = new GetCollection(shortName: 'Book');
@@ -116,8 +112,6 @@ public function testContentRangeForPartialPaginatorUnknownTotal(): void
116112
$this->assertSame(200, $response->getStatusCode());
117113
}
118114

119-
// Empty page with known total: unsatisfied-range format.
120-
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
121115
public function testContentRangeForEmptyPageKnownTotal(): void
122116
{
123117
$operation = new GetCollection(shortName: 'Book');
@@ -138,9 +132,6 @@ public function testContentRangeForEmptyPageKnownTotal(): void
138132
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
139133
}
140134

141-
// Empty page + unknown total: unsatisfied-range requires 1*DIGIT as complete-length.
142-
// Content-Range must not be emitted; Accept-Ranges still present.
143-
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
144135
public function testNoContentRangeForEmptyPageUnknownTotal(): void
145136
{
146137
$operation = new GetCollection(shortName: 'Book');
@@ -219,4 +210,45 @@ public function testContentRangeWithNoShortNameFallsBackToItems(): void
219210
$this->assertSame('items 0-29/201', $response->headers->get('Content-Range'));
220211
$this->assertSame('items', $response->headers->get('Accept-Ranges'));
221212
}
213+
214+
public function testStatus206WhenOperationStatusIsPartialContent(): void
215+
{
216+
$operation = new GetCollection(shortName: 'Book', status: 206);
217+
218+
$paginator = $this->prophesize(PaginatorInterface::class);
219+
$paginator->getCurrentPage()->willReturn(1.0);
220+
$paginator->getItemsPerPage()->willReturn(30.0);
221+
$paginator->count()->willReturn(30);
222+
$paginator->getTotalItems()->willReturn(201.0);
223+
224+
$respondProcessor = new RespondProcessor();
225+
$response = $respondProcessor->process('content', $operation, context: [
226+
'request' => new Request(),
227+
'original_data' => $paginator->reveal(),
228+
]);
229+
230+
$this->assertSame(206, $response->getStatusCode());
231+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
232+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
233+
}
234+
235+
public function testStatus206ForPageTwo(): void
236+
{
237+
$operation = new GetCollection(shortName: 'Book', status: 206);
238+
239+
$paginator = $this->prophesize(PaginatorInterface::class);
240+
$paginator->getCurrentPage()->willReturn(2.0);
241+
$paginator->getItemsPerPage()->willReturn(30.0);
242+
$paginator->count()->willReturn(30);
243+
$paginator->getTotalItems()->willReturn(201.0);
244+
245+
$respondProcessor = new RespondProcessor();
246+
$response = $respondProcessor->process('content', $operation, context: [
247+
'request' => new Request(),
248+
'original_data' => $paginator->reveal(),
249+
]);
250+
251+
$this->assertSame(206, $response->getStatusCode());
252+
$this->assertSame('book 30-59/201', $response->headers->get('Content-Range'));
253+
}
222254
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
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'), [], ['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', 'book=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', 'book=0-29');
65+
66+
$provider = new RangeHeaderProvider($decorated, new Pagination());
67+
$provider->provide(new GetCollection(shortName: 'Book'), [], ['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'), [], ['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'), [], ['request' => $request]);
92+
}
93+
94+
public function testValidRangeSetsFiltersAndStatus206(): void
95+
{
96+
$decorated = $this->createStub(ProviderInterface::class);
97+
$decorated->method('provide')->willReturn([]);
98+
99+
$request = new Request();
100+
$request->headers->set('Range', 'book=0-29');
101+
102+
$provider = new RangeHeaderProvider($decorated, new Pagination());
103+
$provider->provide(new GetCollection(shortName: 'Book'), [], ['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 testValidRangePageTwo(): void
114+
{
115+
$decorated = $this->createStub(ProviderInterface::class);
116+
$decorated->method('provide')->willReturn([]);
117+
118+
$request = new Request();
119+
$request->headers->set('Range', 'book=30-59');
120+
121+
$provider = new RangeHeaderProvider($decorated, new Pagination());
122+
$provider->provide(new GetCollection(shortName: 'Book'), [], ['request' => $request]);
123+
124+
$filters = $request->attributes->get('_api_filters');
125+
$this->assertSame(2, $filters['page']);
126+
$this->assertSame(30, $filters['itemsPerPage']);
127+
}
128+
129+
public function testStartGreaterThanEndThrows416(): void
130+
{
131+
$this->expectException(HttpException::class);
132+
$this->expectExceptionMessage('Range start must not exceed end.');
133+
134+
$request = new Request();
135+
$request->headers->set('Range', 'book=50-20');
136+
137+
$this->createProvider()->provide(new GetCollection(shortName: 'Book'), [], ['request' => $request]);
138+
}
139+
140+
public function testNonPageAlignedRangeThrows416(): void
141+
{
142+
$this->expectException(HttpException::class);
143+
$this->expectExceptionMessage('Range must be aligned to page boundaries.');
144+
145+
$request = new Request();
146+
$request->headers->set('Range', 'book=10-25');
147+
148+
$this->createProvider()->provide(new GetCollection(shortName: 'Book'), [], ['request' => $request]);
149+
}
150+
}

0 commit comments

Comments
 (0)