Skip to content

Commit 847436c

Browse files
committed
feat(state): content-range response for paginated collections
1 parent 1515eb7 commit 847436c

2 files changed

Lines changed: 310 additions & 0 deletions

File tree

src/State/Util/HttpResponseHeadersTrait.php

Lines changed: 40 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;
@@ -145,9 +148,46 @@ private function getHeaders(Request $request, HttpOperation $operation, array $c
145148
$this->addLinkedDataPlatformHeaders($headers, $operation);
146149
}
147150

151+
if ($operation instanceof CollectionOperationInterface && $originalData instanceof PartialPaginatorInterface) {
152+
$headers['Accept-Ranges'] = self::extractRangeUnit($operation);
153+
154+
if ('HEAD' !== $method) {
155+
$this->addContentRangeHeader($headers, $operation, $originalData);
156+
}
157+
}
158+
148159
return $headers;
149160
}
150161

162+
private function addContentRangeHeader(array &$headers, HttpOperation $operation, PartialPaginatorInterface $paginator): void
163+
{
164+
$unit = self::extractRangeUnit($operation);
165+
$currentCount = $paginator->count();
166+
$rangeStart = (int) (($paginator->getCurrentPage() - 1) * $paginator->getItemsPerPage());
167+
168+
if ($paginator instanceof PaginatorInterface) {
169+
$totalItems = (int) $paginator->getTotalItems();
170+
$headers['Content-Range'] = 0 === $currentCount
171+
? \sprintf('%s */%d', $unit, $totalItems)
172+
: \sprintf('%s %d-%d/%d', $unit, $rangeStart, $rangeStart + $currentCount - 1, $totalItems);
173+
} elseif (0 < $currentCount) {
174+
$headers['Content-Range'] = \sprintf('%s %d-%d/*', $unit, $rangeStart, $rangeStart + $currentCount - 1);
175+
}
176+
}
177+
178+
private static function extractRangeUnit(HttpOperation $operation): string
179+
{
180+
if ($uriTemplate = $operation->getUriTemplate()) {
181+
$path = strtok($uriTemplate, '{');
182+
$segments = array_filter(explode('/', trim($path, '/')));
183+
if ($last = end($segments)) {
184+
return strtolower($last);
185+
}
186+
}
187+
188+
return strtolower($operation->getShortName() ?? 'items') ?: 'items';
189+
}
190+
151191
private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $operation): void
152192
{
153193
if (!$this->resourceMetadataCollectionFactory) {
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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+
class ContentRangeHeaderTest extends TestCase
26+
{
27+
use ProphecyTrait;
28+
29+
public function testContentRangeForPartialCollection(): void
30+
{
31+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
32+
33+
$paginator = $this->prophesize(PaginatorInterface::class);
34+
$paginator->getCurrentPage()->willReturn(1.0);
35+
$paginator->getItemsPerPage()->willReturn(30.0);
36+
$paginator->count()->willReturn(30);
37+
$paginator->getTotalItems()->willReturn(201.0);
38+
39+
$respondProcessor = new RespondProcessor();
40+
$response = $respondProcessor->process('content', $operation, context: [
41+
'request' => new Request(),
42+
'original_data' => $paginator->reveal(),
43+
]);
44+
45+
$this->assertSame('books 0-29/201', $response->headers->get('Content-Range'));
46+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
47+
$this->assertSame(200, $response->getStatusCode());
48+
}
49+
50+
public function testContentRangeForPageThree(): void
51+
{
52+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
53+
54+
$paginator = $this->prophesize(PaginatorInterface::class);
55+
$paginator->getCurrentPage()->willReturn(3.0);
56+
$paginator->getItemsPerPage()->willReturn(30.0);
57+
$paginator->count()->willReturn(30);
58+
$paginator->getTotalItems()->willReturn(201.0);
59+
60+
$respondProcessor = new RespondProcessor();
61+
$response = $respondProcessor->process('content', $operation, context: [
62+
'request' => new Request(),
63+
'original_data' => $paginator->reveal(),
64+
]);
65+
66+
$this->assertSame('books 60-89/201', $response->headers->get('Content-Range'));
67+
$this->assertSame(200, $response->getStatusCode());
68+
}
69+
70+
public function testContentRangeForFullCollection(): void
71+
{
72+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
73+
74+
$paginator = $this->prophesize(PaginatorInterface::class);
75+
$paginator->getCurrentPage()->willReturn(1.0);
76+
$paginator->getItemsPerPage()->willReturn(30.0);
77+
$paginator->count()->willReturn(3);
78+
$paginator->getTotalItems()->willReturn(3.0);
79+
80+
$respondProcessor = new RespondProcessor();
81+
$response = $respondProcessor->process('content', $operation, context: [
82+
'request' => new Request(),
83+
'original_data' => $paginator->reveal(),
84+
]);
85+
86+
$this->assertSame('books 0-2/3', $response->headers->get('Content-Range'));
87+
$this->assertSame(200, $response->getStatusCode());
88+
}
89+
90+
public function testContentRangeForPartialPaginatorUnknownTotal(): void
91+
{
92+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
93+
94+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
95+
$paginator->getCurrentPage()->willReturn(1.0);
96+
$paginator->getItemsPerPage()->willReturn(30.0);
97+
$paginator->count()->willReturn(30);
98+
99+
$respondProcessor = new RespondProcessor();
100+
$response = $respondProcessor->process('content', $operation, context: [
101+
'request' => new Request(),
102+
'original_data' => $paginator->reveal(),
103+
]);
104+
105+
$this->assertSame('books 0-29/*', $response->headers->get('Content-Range'));
106+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
107+
$this->assertSame(200, $response->getStatusCode());
108+
}
109+
110+
public function testContentRangeForEmptyPageKnownTotal(): void
111+
{
112+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
113+
114+
$paginator = $this->prophesize(PaginatorInterface::class);
115+
$paginator->getCurrentPage()->willReturn(1.0);
116+
$paginator->getItemsPerPage()->willReturn(30.0);
117+
$paginator->count()->willReturn(0);
118+
$paginator->getTotalItems()->willReturn(201.0);
119+
120+
$respondProcessor = new RespondProcessor();
121+
$response = $respondProcessor->process('content', $operation, context: [
122+
'request' => new Request(),
123+
'original_data' => $paginator->reveal(),
124+
]);
125+
126+
$this->assertSame('books */201', $response->headers->get('Content-Range'));
127+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
128+
}
129+
130+
public function testNoContentRangeForEmptyPageUnknownTotal(): void
131+
{
132+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
133+
134+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
135+
$paginator->getCurrentPage()->willReturn(1.0);
136+
$paginator->getItemsPerPage()->willReturn(30.0);
137+
$paginator->count()->willReturn(0);
138+
139+
$respondProcessor = new RespondProcessor();
140+
$response = $respondProcessor->process('content', $operation, context: [
141+
'request' => new Request(),
142+
'original_data' => $paginator->reveal(),
143+
]);
144+
145+
$this->assertNull($response->headers->get('Content-Range'));
146+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
147+
}
148+
149+
public function testContentRangeDoesNotAffectStatusCode(): void
150+
{
151+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
152+
153+
$paginator = $this->prophesize(PaginatorInterface::class);
154+
$paginator->getCurrentPage()->willReturn(1.0);
155+
$paginator->getItemsPerPage()->willReturn(30.0);
156+
$paginator->count()->willReturn(30);
157+
$paginator->getTotalItems()->willReturn(201.0);
158+
159+
$respondProcessor = new RespondProcessor();
160+
$response = $respondProcessor->process('content', $operation, context: [
161+
'request' => new Request(),
162+
'original_data' => $paginator->reveal(),
163+
]);
164+
165+
$this->assertSame(200, $response->getStatusCode());
166+
$this->assertSame('books 0-29/201', $response->headers->get('Content-Range'));
167+
}
168+
169+
public function testNoContentRangeForNonCollectionOperation(): void
170+
{
171+
$operation = new Get(shortName: 'Book');
172+
173+
$paginator = $this->prophesize(PaginatorInterface::class);
174+
$paginator->getCurrentPage()->willReturn(1.0);
175+
$paginator->getItemsPerPage()->willReturn(30.0);
176+
$paginator->count()->willReturn(30);
177+
$paginator->getTotalItems()->willReturn(201.0);
178+
179+
$respondProcessor = new RespondProcessor();
180+
$response = $respondProcessor->process('content', $operation, context: [
181+
'request' => new Request(),
182+
'original_data' => $paginator->reveal(),
183+
]);
184+
185+
$this->assertNull($response->headers->get('Content-Range'));
186+
$this->assertNull($response->headers->get('Accept-Ranges'));
187+
}
188+
189+
public function testContentRangeWithNoShortNameFallsBackToItems(): void
190+
{
191+
$operation = new GetCollection(shortName: null);
192+
193+
$paginator = $this->prophesize(PaginatorInterface::class);
194+
$paginator->getCurrentPage()->willReturn(1.0);
195+
$paginator->getItemsPerPage()->willReturn(30.0);
196+
$paginator->count()->willReturn(30);
197+
$paginator->getTotalItems()->willReturn(201.0);
198+
199+
$respondProcessor = new RespondProcessor();
200+
$response = $respondProcessor->process('content', $operation, context: [
201+
'request' => new Request(),
202+
'original_data' => $paginator->reveal(),
203+
]);
204+
205+
$this->assertSame('items 0-29/201', $response->headers->get('Content-Range'));
206+
$this->assertSame('items', $response->headers->get('Accept-Ranges'));
207+
}
208+
209+
public function testHeadRequestOmitsContentRangeWithoutCountingCollection(): void
210+
{
211+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}');
212+
213+
$paginator = $this->prophesize(PaginatorInterface::class);
214+
$paginator->getCurrentPage()->shouldNotBeCalled();
215+
$paginator->getItemsPerPage()->shouldNotBeCalled();
216+
$paginator->count()->shouldNotBeCalled();
217+
$paginator->getTotalItems()->shouldNotBeCalled();
218+
219+
$respondProcessor = new RespondProcessor();
220+
$response = $respondProcessor->process('', $operation, context: [
221+
'request' => Request::create('/books', 'HEAD'),
222+
'original_data' => $paginator->reveal(),
223+
]);
224+
225+
$this->assertNull($response->headers->get('Content-Range'));
226+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
227+
$this->assertEmpty($response->getContent());
228+
}
229+
230+
public function testStatus206WhenOperationStatusIsPartialContent(): void
231+
{
232+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206);
233+
234+
$paginator = $this->prophesize(PaginatorInterface::class);
235+
$paginator->getCurrentPage()->willReturn(1.0);
236+
$paginator->getItemsPerPage()->willReturn(30.0);
237+
$paginator->count()->willReturn(30);
238+
$paginator->getTotalItems()->willReturn(201.0);
239+
240+
$respondProcessor = new RespondProcessor();
241+
$response = $respondProcessor->process('content', $operation, context: [
242+
'request' => new Request(),
243+
'original_data' => $paginator->reveal(),
244+
]);
245+
246+
$this->assertSame(206, $response->getStatusCode());
247+
$this->assertSame('books 0-29/201', $response->headers->get('Content-Range'));
248+
$this->assertSame('books', $response->headers->get('Accept-Ranges'));
249+
}
250+
251+
public function testStatus206ForPageTwo(): void
252+
{
253+
$operation = new GetCollection(shortName: 'Book', uriTemplate: '/books{._format}', status: 206);
254+
255+
$paginator = $this->prophesize(PaginatorInterface::class);
256+
$paginator->getCurrentPage()->willReturn(2.0);
257+
$paginator->getItemsPerPage()->willReturn(30.0);
258+
$paginator->count()->willReturn(30);
259+
$paginator->getTotalItems()->willReturn(201.0);
260+
261+
$respondProcessor = new RespondProcessor();
262+
$response = $respondProcessor->process('content', $operation, context: [
263+
'request' => new Request(),
264+
'original_data' => $paginator->reveal(),
265+
]);
266+
267+
$this->assertSame(206, $response->getStatusCode());
268+
$this->assertSame('books 30-59/201', $response->headers->get('Content-Range'));
269+
}
270+
}

0 commit comments

Comments
 (0)