Skip to content

Commit 8a3a8f7

Browse files
committed
feat(state): response header content-range for paginated collections
1 parent c2909a1 commit 8a3a8f7

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

src/State/Util/HttpResponseHeadersTrait.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace ApiPlatform\State\Util;
1515

16+
use ApiPlatform\Metadata\CollectionOperationInterface;
1617
use ApiPlatform\Metadata\Error;
1718
use ApiPlatform\Metadata\Exception\HttpExceptionInterface;
1819
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
@@ -26,6 +27,8 @@
2627
use ApiPlatform\Metadata\UrlGeneratorInterface;
2728
use ApiPlatform\Metadata\Util\ClassInfoTrait;
2829
use ApiPlatform\Metadata\Util\CloneTrait;
30+
use ApiPlatform\State\Pagination\PaginatorInterface;
31+
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
2932
use Symfony\Component\HttpFoundation\Request;
3033
use Symfony\Component\HttpFoundation\Response;
3134
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface as SymfonyHttpExceptionInterface;
@@ -135,9 +138,40 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
135138
$this->addLinkedDataPlatformHeaders($headers, $operation);
136139
}
137140

141+
if ($operation instanceof CollectionOperationInterface && $originalData instanceof PartialPaginatorInterface) {
142+
$this->addContentRangeHeaders($headers, $operation, $originalData);
143+
}
144+
138145
return $headers;
139146
}
140147

148+
/**
149+
* Adds Content-Range and Accept-Ranges headers for paginated collections.
150+
*
151+
* When the total is unknown (PartialPaginatorInterface), the unsatisfied-range
152+
* format is skipped because "*​/*" is invalid ABNF (complete-length = 1*DIGIT).
153+
*
154+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
155+
* @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.3
156+
*/
157+
private function addContentRangeHeaders(array &$headers, HttpOperation $operation, PartialPaginatorInterface $paginator): void
158+
{
159+
$unit = strtolower($operation->getShortName() ?? 'items') ?: 'items';
160+
$currentCount = $paginator->count();
161+
$rangeStart = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage());
162+
163+
if ($paginator instanceof PaginatorInterface) {
164+
$totalItems = (int) $paginator->getTotalItems();
165+
$headers['Content-Range'] = 0 === $currentCount
166+
? \sprintf('%s */%d', $unit, $totalItems)
167+
: \sprintf('%s %d-%d/%d', $unit, $rangeStart, $rangeStart + $currentCount - 1, $totalItems);
168+
} elseif (0 < $currentCount) {
169+
$headers['Content-Range'] = \sprintf('%s %d-%d/*', $unit, $rangeStart, $rangeStart + $currentCount - 1);
170+
}
171+
172+
$headers['Accept-Ranges'] = $unit;
173+
}
174+
141175
private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void
142176
{
143177
if (!$this->resourceMetadataCollectionFactory) {
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
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\PaginatorInterface;
19+
use ApiPlatform\State\Pagination\PartialPaginatorInterface;
20+
use ApiPlatform\State\Processor\RespondProcessor;
21+
use PHPUnit\Framework\TestCase;
22+
use Prophecy\PhpUnit\ProphecyTrait;
23+
use Symfony\Component\HttpFoundation\Request;
24+
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+
*/
30+
class ContentRangeHeaderTest extends TestCase
31+
{
32+
use ProphecyTrait;
33+
34+
public function testContentRangeForPartialCollection(): void
35+
{
36+
$operation = new GetCollection(shortName: 'Book');
37+
38+
$paginator = $this->prophesize(PaginatorInterface::class);
39+
$paginator->getCurrentPage()->willReturn(1.0);
40+
$paginator->getItemsPerPage()->willReturn(30.0);
41+
$paginator->count()->willReturn(30);
42+
$paginator->getTotalItems()->willReturn(201.0);
43+
44+
$respondProcessor = new RespondProcessor();
45+
$response = $respondProcessor->process('content', $operation, context: [
46+
'request' => new Request(),
47+
'original_data' => $paginator->reveal(),
48+
]);
49+
50+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
51+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
52+
$this->assertSame(200, $response->getStatusCode());
53+
}
54+
55+
public function testContentRangeForPageThree(): void
56+
{
57+
$operation = new GetCollection(shortName: 'Book');
58+
59+
$paginator = $this->prophesize(PaginatorInterface::class);
60+
$paginator->getCurrentPage()->willReturn(3.0);
61+
$paginator->getItemsPerPage()->willReturn(30.0);
62+
$paginator->count()->willReturn(30);
63+
$paginator->getTotalItems()->willReturn(201.0);
64+
65+
$respondProcessor = new RespondProcessor();
66+
$response = $respondProcessor->process('content', $operation, context: [
67+
'request' => new Request(),
68+
'original_data' => $paginator->reveal(),
69+
]);
70+
71+
$this->assertSame('book 60-89/201', $response->headers->get('Content-Range'));
72+
$this->assertSame(200, $response->getStatusCode());
73+
}
74+
75+
public function testContentRangeForFullCollection(): void
76+
{
77+
$operation = new GetCollection(shortName: 'Book');
78+
79+
$paginator = $this->prophesize(PaginatorInterface::class);
80+
$paginator->getCurrentPage()->willReturn(1.0);
81+
$paginator->getItemsPerPage()->willReturn(30.0);
82+
$paginator->count()->willReturn(3);
83+
$paginator->getTotalItems()->willReturn(3.0);
84+
85+
$respondProcessor = new RespondProcessor();
86+
$response = $respondProcessor->process('content', $operation, context: [
87+
'request' => new Request(),
88+
'original_data' => $paginator->reveal(),
89+
]);
90+
91+
$this->assertSame('book 0-2/3', $response->headers->get('Content-Range'));
92+
$this->assertSame(200, $response->getStatusCode());
93+
}
94+
95+
public function testContentRangeForPartialPaginatorUnknownTotal(): void
96+
{
97+
$operation = new GetCollection(shortName: 'Book');
98+
99+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
100+
$paginator->getCurrentPage()->willReturn(1.0);
101+
$paginator->getItemsPerPage()->willReturn(30.0);
102+
$paginator->count()->willReturn(30);
103+
104+
$respondProcessor = new RespondProcessor();
105+
$response = $respondProcessor->process('content', $operation, context: [
106+
'request' => new Request(),
107+
'original_data' => $paginator->reveal(),
108+
]);
109+
110+
$this->assertSame('book 0-29/*', $response->headers->get('Content-Range'));
111+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
112+
$this->assertSame(200, $response->getStatusCode());
113+
}
114+
115+
public function testContentRangeForEmptyPageKnownTotal(): void
116+
{
117+
$operation = new GetCollection(shortName: 'Book');
118+
119+
$paginator = $this->prophesize(PaginatorInterface::class);
120+
$paginator->getCurrentPage()->willReturn(1.0);
121+
$paginator->getItemsPerPage()->willReturn(30.0);
122+
$paginator->count()->willReturn(0);
123+
$paginator->getTotalItems()->willReturn(201.0);
124+
125+
$respondProcessor = new RespondProcessor();
126+
$response = $respondProcessor->process('content', $operation, context: [
127+
'request' => new Request(),
128+
'original_data' => $paginator->reveal(),
129+
]);
130+
131+
$this->assertSame('book */201', $response->headers->get('Content-Range'));
132+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
133+
}
134+
135+
public function testNoContentRangeForEmptyPageUnknownTotal(): void
136+
{
137+
$operation = new GetCollection(shortName: 'Book');
138+
139+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
140+
$paginator->getCurrentPage()->willReturn(1.0);
141+
$paginator->getItemsPerPage()->willReturn(30.0);
142+
$paginator->count()->willReturn(0);
143+
144+
$respondProcessor = new RespondProcessor();
145+
$response = $respondProcessor->process('content', $operation, context: [
146+
'request' => new Request(),
147+
'original_data' => $paginator->reveal(),
148+
]);
149+
150+
$this->assertNull($response->headers->get('Content-Range'));
151+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
152+
}
153+
154+
public function testContentRangeDoesNotAffectStatusCode(): void
155+
{
156+
$operation = new GetCollection(shortName: 'Book');
157+
158+
$paginator = $this->prophesize(PaginatorInterface::class);
159+
$paginator->getCurrentPage()->willReturn(1.0);
160+
$paginator->getItemsPerPage()->willReturn(30.0);
161+
$paginator->count()->willReturn(30);
162+
$paginator->getTotalItems()->willReturn(201.0);
163+
164+
$respondProcessor = new RespondProcessor();
165+
$response = $respondProcessor->process('content', $operation, context: [
166+
'request' => new Request(),
167+
'original_data' => $paginator->reveal(),
168+
]);
169+
170+
$this->assertSame(200, $response->getStatusCode());
171+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
172+
}
173+
174+
public function testNoContentRangeForNonCollectionOperation(): void
175+
{
176+
$operation = new Get(shortName: 'Book');
177+
178+
$paginator = $this->prophesize(PaginatorInterface::class);
179+
$paginator->getCurrentPage()->willReturn(1.0);
180+
$paginator->getItemsPerPage()->willReturn(30.0);
181+
$paginator->count()->willReturn(30);
182+
$paginator->getTotalItems()->willReturn(201.0);
183+
184+
$respondProcessor = new RespondProcessor();
185+
$response = $respondProcessor->process('content', $operation, context: [
186+
'request' => new Request(),
187+
'original_data' => $paginator->reveal(),
188+
]);
189+
190+
$this->assertNull($response->headers->get('Content-Range'));
191+
$this->assertNull($response->headers->get('Accept-Ranges'));
192+
}
193+
194+
public function testContentRangeWithNoShortNameFallsBackToItems(): void
195+
{
196+
$operation = new GetCollection(shortName: null);
197+
198+
$paginator = $this->prophesize(PaginatorInterface::class);
199+
$paginator->getCurrentPage()->willReturn(1.0);
200+
$paginator->getItemsPerPage()->willReturn(30.0);
201+
$paginator->count()->willReturn(30);
202+
$paginator->getTotalItems()->willReturn(201.0);
203+
204+
$respondProcessor = new RespondProcessor();
205+
$response = $respondProcessor->process('content', $operation, context: [
206+
'request' => new Request(),
207+
'original_data' => $paginator->reveal(),
208+
]);
209+
210+
$this->assertSame('items 0-29/201', $response->headers->get('Content-Range'));
211+
$this->assertSame('items', $response->headers->get('Accept-Ranges'));
212+
}
213+
214+
public function testHeadRequestReturnsContentRangeHeaders(): void
215+
{
216+
$operation = new GetCollection(shortName: 'Book');
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('', $operation, context: [
226+
'request' => Request::create('/books', 'HEAD'),
227+
'original_data' => $paginator->reveal(),
228+
]);
229+
230+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
231+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
232+
$this->assertEmpty($response->getContent());
233+
}
234+
235+
public function testStatus206WhenOperationStatusIsPartialContent(): void
236+
{
237+
$operation = new GetCollection(shortName: 'Book', status: 206);
238+
239+
$paginator = $this->prophesize(PaginatorInterface::class);
240+
$paginator->getCurrentPage()->willReturn(1.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 0-29/201', $response->headers->get('Content-Range'));
253+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
254+
}
255+
256+
public function testStatus206ForPageTwo(): void
257+
{
258+
$operation = new GetCollection(shortName: 'Book', status: 206);
259+
260+
$paginator = $this->prophesize(PaginatorInterface::class);
261+
$paginator->getCurrentPage()->willReturn(2.0);
262+
$paginator->getItemsPerPage()->willReturn(30.0);
263+
$paginator->count()->willReturn(30);
264+
$paginator->getTotalItems()->willReturn(201.0);
265+
266+
$respondProcessor = new RespondProcessor();
267+
$response = $respondProcessor->process('content', $operation, context: [
268+
'request' => new Request(),
269+
'original_data' => $paginator->reveal(),
270+
]);
271+
272+
$this->assertSame(206, $response->getStatusCode());
273+
$this->assertSame('book 30-59/201', $response->headers->get('Content-Range'));
274+
}
275+
}

0 commit comments

Comments
 (0)