Skip to content

Commit 9262a93

Browse files
committed
feat(state): content-range response for paginated collections
1 parent c2909a1 commit 9262a93

File tree

2 files changed

+256
-0
lines changed

2 files changed

+256
-0
lines changed

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: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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+
// @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
33+
public function testContentRangeForPartialCollection(): void
34+
{
35+
$operation = new GetCollection(shortName: 'Book');
36+
37+
$paginator = $this->prophesize(PaginatorInterface::class);
38+
$paginator->getCurrentPage()->willReturn(1.0);
39+
$paginator->getItemsPerPage()->willReturn(30.0);
40+
$paginator->count()->willReturn(30);
41+
$paginator->getTotalItems()->willReturn(201.0);
42+
43+
$respondProcessor = new RespondProcessor();
44+
$response = $respondProcessor->process('content', $operation, context: [
45+
'request' => new Request(),
46+
'original_data' => $paginator->reveal(),
47+
]);
48+
49+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
50+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
51+
$this->assertSame(200, $response->getStatusCode());
52+
}
53+
54+
/**
55+
* Verifies range offset calculation on page > 1.
56+
*/
57+
public function testContentRangeForPageThree(): void
58+
{
59+
$operation = new GetCollection(shortName: 'Book');
60+
61+
$paginator = $this->prophesize(PaginatorInterface::class);
62+
$paginator->getCurrentPage()->willReturn(3.0);
63+
$paginator->getItemsPerPage()->willReturn(30.0);
64+
$paginator->count()->willReturn(30);
65+
$paginator->getTotalItems()->willReturn(201.0);
66+
67+
$respondProcessor = new RespondProcessor();
68+
$response = $respondProcessor->process('content', $operation, context: [
69+
'request' => new Request(),
70+
'original_data' => $paginator->reveal(),
71+
]);
72+
73+
$this->assertSame('book 60-89/201', $response->headers->get('Content-Range'));
74+
$this->assertSame(200, $response->getStatusCode());
75+
}
76+
77+
public function testContentRangeForFullCollection(): void
78+
{
79+
$operation = new GetCollection(shortName: 'Book');
80+
81+
$paginator = $this->prophesize(PaginatorInterface::class);
82+
$paginator->getCurrentPage()->willReturn(1.0);
83+
$paginator->getItemsPerPage()->willReturn(30.0);
84+
$paginator->count()->willReturn(3);
85+
$paginator->getTotalItems()->willReturn(3.0);
86+
87+
$respondProcessor = new RespondProcessor();
88+
$response = $respondProcessor->process('content', $operation, context: [
89+
'request' => new Request(),
90+
'original_data' => $paginator->reveal(),
91+
]);
92+
93+
$this->assertSame('book 0-2/3', $response->headers->get('Content-Range'));
94+
$this->assertSame(200, $response->getStatusCode());
95+
}
96+
97+
// PartialPaginatorInterface: total unknown → complete-length = "*".
98+
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
99+
public function testContentRangeForPartialPaginatorUnknownTotal(): void
100+
{
101+
$operation = new GetCollection(shortName: 'Book');
102+
103+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
104+
$paginator->getCurrentPage()->willReturn(1.0);
105+
$paginator->getItemsPerPage()->willReturn(30.0);
106+
$paginator->count()->willReturn(30);
107+
108+
$respondProcessor = new RespondProcessor();
109+
$response = $respondProcessor->process('content', $operation, context: [
110+
'request' => new Request(),
111+
'original_data' => $paginator->reveal(),
112+
]);
113+
114+
$this->assertSame('book 0-29/*', $response->headers->get('Content-Range'));
115+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
116+
$this->assertSame(200, $response->getStatusCode());
117+
}
118+
119+
// Empty page with known total: unsatisfied-range format.
120+
// @see https://datatracker.ietf.org/doc/html/rfc9110#section-14.4
121+
public function testContentRangeForEmptyPageKnownTotal(): void
122+
{
123+
$operation = new GetCollection(shortName: 'Book');
124+
125+
$paginator = $this->prophesize(PaginatorInterface::class);
126+
$paginator->getCurrentPage()->willReturn(1.0);
127+
$paginator->getItemsPerPage()->willReturn(30.0);
128+
$paginator->count()->willReturn(0);
129+
$paginator->getTotalItems()->willReturn(201.0);
130+
131+
$respondProcessor = new RespondProcessor();
132+
$response = $respondProcessor->process('content', $operation, context: [
133+
'request' => new Request(),
134+
'original_data' => $paginator->reveal(),
135+
]);
136+
137+
$this->assertSame('book */201', $response->headers->get('Content-Range'));
138+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
139+
}
140+
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
144+
public function testNoContentRangeForEmptyPageUnknownTotal(): void
145+
{
146+
$operation = new GetCollection(shortName: 'Book');
147+
148+
$paginator = $this->prophesize(PartialPaginatorInterface::class);
149+
$paginator->getCurrentPage()->willReturn(1.0);
150+
$paginator->getItemsPerPage()->willReturn(30.0);
151+
$paginator->count()->willReturn(0);
152+
153+
$respondProcessor = new RespondProcessor();
154+
$response = $respondProcessor->process('content', $operation, context: [
155+
'request' => new Request(),
156+
'original_data' => $paginator->reveal(),
157+
]);
158+
159+
$this->assertNull($response->headers->get('Content-Range'));
160+
$this->assertSame('book', $response->headers->get('Accept-Ranges'));
161+
}
162+
163+
public function testContentRangeDoesNotAffectStatusCode(): void
164+
{
165+
$operation = new GetCollection(shortName: 'Book');
166+
167+
$paginator = $this->prophesize(PaginatorInterface::class);
168+
$paginator->getCurrentPage()->willReturn(1.0);
169+
$paginator->getItemsPerPage()->willReturn(30.0);
170+
$paginator->count()->willReturn(30);
171+
$paginator->getTotalItems()->willReturn(201.0);
172+
173+
$respondProcessor = new RespondProcessor();
174+
$response = $respondProcessor->process('content', $operation, context: [
175+
'request' => new Request(),
176+
'original_data' => $paginator->reveal(),
177+
]);
178+
179+
$this->assertSame(200, $response->getStatusCode());
180+
$this->assertSame('book 0-29/201', $response->headers->get('Content-Range'));
181+
}
182+
183+
public function testNoContentRangeForNonCollectionOperation(): void
184+
{
185+
$operation = new Get(shortName: 'Book');
186+
187+
$paginator = $this->prophesize(PaginatorInterface::class);
188+
$paginator->getCurrentPage()->willReturn(1.0);
189+
$paginator->getItemsPerPage()->willReturn(30.0);
190+
$paginator->count()->willReturn(30);
191+
$paginator->getTotalItems()->willReturn(201.0);
192+
193+
$respondProcessor = new RespondProcessor();
194+
$response = $respondProcessor->process('content', $operation, context: [
195+
'request' => new Request(),
196+
'original_data' => $paginator->reveal(),
197+
]);
198+
199+
$this->assertNull($response->headers->get('Content-Range'));
200+
$this->assertNull($response->headers->get('Accept-Ranges'));
201+
}
202+
203+
public function testContentRangeWithNoShortNameFallsBackToItems(): void
204+
{
205+
$operation = new GetCollection(shortName: null);
206+
207+
$paginator = $this->prophesize(PaginatorInterface::class);
208+
$paginator->getCurrentPage()->willReturn(1.0);
209+
$paginator->getItemsPerPage()->willReturn(30.0);
210+
$paginator->count()->willReturn(30);
211+
$paginator->getTotalItems()->willReturn(201.0);
212+
213+
$respondProcessor = new RespondProcessor();
214+
$response = $respondProcessor->process('content', $operation, context: [
215+
'request' => new Request(),
216+
'original_data' => $paginator->reveal(),
217+
]);
218+
219+
$this->assertSame('items 0-29/201', $response->headers->get('Content-Range'));
220+
$this->assertSame('items', $response->headers->get('Accept-Ranges'));
221+
}
222+
}

0 commit comments

Comments
 (0)