1 | <?php |
||||||
2 | |||||||
3 | namespace alsvanzelf\jsonapi\profiles; |
||||||
4 | |||||||
5 | use alsvanzelf\jsonapi\Document; |
||||||
6 | use alsvanzelf\jsonapi\ResourceDocument; |
||||||
7 | use alsvanzelf\jsonapi\interfaces\PaginableInterface; |
||||||
8 | use alsvanzelf\jsonapi\interfaces\ProfileInterface; |
||||||
9 | use alsvanzelf\jsonapi\interfaces\ResourceInterface; |
||||||
10 | use alsvanzelf\jsonapi\objects\ErrorObject; |
||||||
11 | use alsvanzelf\jsonapi\objects\LinkObject; |
||||||
12 | |||||||
13 | /** |
||||||
14 | * cursor-based pagination (aka keyset pagination) is a common pagination strategy that avoids many of the pitfalls of 'offset–limit' pagination |
||||||
15 | * |
||||||
16 | * @see https://jsonapi.org/profiles/ethanresnick/cursor-pagination/ |
||||||
17 | * |
||||||
18 | * related query parameters: |
||||||
19 | * - sort |
||||||
20 | * - page[size] |
||||||
21 | * - page[before] |
||||||
22 | * - page[after] |
||||||
23 | * |
||||||
24 | * handling different use cases: |
||||||
25 | * |
||||||
26 | * 1. handle requests with 'after' nor 'before' when the client requests a first page |
||||||
27 | * call {@see setLinksFirstPage} with the last cursor in the first page |
||||||
28 | * |
||||||
29 | * 2. handle requests with 'after' when the client requests a next page |
||||||
30 | * call {@see setLinks} with the first and last cursor in the current page |
||||||
31 | * call {@see setLinksLastPage} with the first cursor in the current page, when this is the last page |
||||||
32 | * |
||||||
33 | * 3. handle requests with 'before' when the client requests a previous page |
||||||
34 | * call {@see setLinks} with the first and last cursor in the current page |
||||||
35 | * |
||||||
36 | * 4. handle requests with 'after' and 'before' when the client requests a specific page |
||||||
37 | * call {@see setLinks} with the first and last cursor in the current page, when there are previous/next pages |
||||||
38 | * call {@see setPaginationLinkObjectsExplicitlyEmpty}, when the results is everything between after and before |
||||||
39 | * |
||||||
40 | * other interesting methods: |
||||||
41 | * - {@see setCursor} to expose the cursor of a pagination item to allow custom url building |
||||||
42 | * - {@see setCount} to expose the total count(s) of the pagination data |
||||||
43 | * - {@see get*ErrorObject} to generate ErrorObjects for specific error cases |
||||||
44 | * - {@see generatePreviousLink} {@see generateNextLink} to apply the links manually |
||||||
45 | */ |
||||||
46 | class CursorPaginationProfile implements ProfileInterface { |
||||||
47 | /** |
||||||
48 | * human api |
||||||
49 | */ |
||||||
50 | |||||||
51 | /** |
||||||
52 | * set links to paginate the data using cursors of the paginated data |
||||||
53 | * |
||||||
54 | * @param PaginableInterface $paginable a CollectionDocument or RelationshipObject |
||||||
55 | * @param string $baseOrCurrentUrl |
||||||
56 | * @param string $firstCursor |
||||||
57 | * @param string $lastCursor |
||||||
58 | */ |
||||||
59 | 2 | public function setLinks(PaginableInterface $paginable, $baseOrCurrentUrl, $firstCursor, $lastCursor) { |
|||||
60 | 2 | $previousLinkObject = new LinkObject($this->generatePreviousLink($baseOrCurrentUrl, $firstCursor)); |
|||||
61 | 2 | $nextLinkObject = new LinkObject($this->generateNextLink($baseOrCurrentUrl, $lastCursor)); |
|||||
62 | |||||||
63 | 2 | $this->setPaginationLinkObjects($paginable, $previousLinkObject, $nextLinkObject); |
|||||
64 | } |
||||||
65 | |||||||
66 | /** |
||||||
67 | * @param PaginableInterface $paginable a CollectionDocument or RelationshipObject |
||||||
68 | * @param string $baseOrCurrentUrl |
||||||
69 | * @param string $lastCursor |
||||||
70 | */ |
||||||
71 | 2 | public function setLinksFirstPage(PaginableInterface $paginable, $baseOrCurrentUrl, $lastCursor) { |
|||||
72 | 2 | $this->setPaginationLinkObjectsWithoutPrevious($paginable, $baseOrCurrentUrl, $lastCursor); |
|||||
73 | } |
||||||
74 | |||||||
75 | /** |
||||||
76 | * @param PaginableInterface $paginable a CollectionDocument or RelationshipObject |
||||||
77 | * @param string $baseOrCurrentUrl |
||||||
78 | * @param string $firstCursor |
||||||
79 | */ |
||||||
80 | 1 | public function setLinksLastPage(PaginableInterface $paginable, $baseOrCurrentUrl, $firstCursor) { |
|||||
81 | 1 | $this->setPaginationLinkObjectsWithoutNext($paginable, $baseOrCurrentUrl, $firstCursor); |
|||||
82 | } |
||||||
83 | |||||||
84 | /** |
||||||
85 | * set the cursor of a specific resource to allow pagination after or before this resource |
||||||
86 | * |
||||||
87 | * @param ResourceInterface $resource |
||||||
88 | * @param string $cursor |
||||||
89 | */ |
||||||
90 | 3 | public function setCursor(ResourceInterface $resource, $cursor) { |
|||||
91 | 3 | $this->setItemMeta($resource, $cursor); |
|||||
92 | } |
||||||
93 | |||||||
94 | /** |
||||||
95 | * set count(s) to tell about the (estimated) total size |
||||||
96 | * |
||||||
97 | * @param PaginableInterface $paginable a CollectionDocument or RelationshipObject |
||||||
98 | * @param int $exactTotal optional |
||||||
99 | * @param int $bestGuessTotal optional |
||||||
100 | */ |
||||||
101 | 2 | public function setCount(PaginableInterface $paginable, $exactTotal=null, $bestGuessTotal=null) { |
|||||
102 | 2 | $this->setPaginationMeta($paginable, $exactTotal, $bestGuessTotal); |
|||||
103 | } |
||||||
104 | |||||||
105 | /** |
||||||
106 | * spec api |
||||||
107 | */ |
||||||
108 | |||||||
109 | /** |
||||||
110 | * helper to get generate a correct page[before] link, use to apply manually |
||||||
111 | * |
||||||
112 | * @param string $baseOrCurrentUrl |
||||||
113 | * @param string $beforeCursor |
||||||
114 | * @return string |
||||||
115 | */ |
||||||
116 | 3 | public function generatePreviousLink($baseOrCurrentUrl, $beforeCursor) { |
|||||
117 | 3 | return $this->setQueryParameter($baseOrCurrentUrl, 'page[before]', $beforeCursor); |
|||||
118 | } |
||||||
119 | |||||||
120 | /** |
||||||
121 | * helper to get generate a correct page[after] link, use to apply manually |
||||||
122 | * |
||||||
123 | * @param string $baseOrCurrentUrl |
||||||
124 | * @param string $afterCursor |
||||||
125 | * @return string |
||||||
126 | */ |
||||||
127 | 4 | public function generateNextLink($baseOrCurrentUrl, $afterCursor) { |
|||||
128 | 4 | return $this->setQueryParameter($baseOrCurrentUrl, 'page[after]', $afterCursor); |
|||||
129 | } |
||||||
130 | |||||||
131 | /** |
||||||
132 | * pagination links are inside the links object that is a sibling of the paginated data |
||||||
133 | * |
||||||
134 | * ends up at one of: |
||||||
135 | * - /links/prev & /links/next |
||||||
136 | * - /data/relationships/foo/links/prev & /data/relationships/foo/links/next |
||||||
137 | * - /data/0/relationships/foo/links/prev & /data/0/relationships/foo/links/next |
||||||
138 | * |
||||||
139 | * @see https://jsonapi.org/profiles/ethanresnick/cursor-pagination/#terms-pagination-links |
||||||
140 | * |
||||||
141 | * @param PaginableInterface $paginable |
||||||
142 | * @param LinkObject $previousLinkObject |
||||||
143 | * @param LinkObject $nextLinkObject |
||||||
144 | */ |
||||||
145 | 6 | public function setPaginationLinkObjects(PaginableInterface $paginable, LinkObject $previousLinkObject, LinkObject $nextLinkObject) { |
|||||
146 | 6 | $paginable->addLinkObject('prev', $previousLinkObject); |
|||||
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||||||
147 | 6 | $paginable->addLinkObject('next', $nextLinkObject); |
|||||
148 | } |
||||||
149 | |||||||
150 | /** |
||||||
151 | * @param PaginableInterface $paginable |
||||||
152 | * @param string $baseOrCurrentUrl |
||||||
153 | * @param string $firstCursor |
||||||
154 | */ |
||||||
155 | 1 | public function setPaginationLinkObjectsWithoutNext(PaginableInterface $paginable, $baseOrCurrentUrl, $firstCursor) { |
|||||
156 | 1 | $this->setPaginationLinkObjects($paginable, new LinkObject($this->generatePreviousLink($baseOrCurrentUrl, $firstCursor)), new LinkObject()); |
|||||
157 | } |
||||||
158 | |||||||
159 | /** |
||||||
160 | * @param PaginableInterface $paginable |
||||||
161 | * @param string $baseOrCurrentUrl |
||||||
162 | * @param string $lastCursor |
||||||
163 | */ |
||||||
164 | 2 | public function setPaginationLinkObjectsWithoutPrevious(PaginableInterface $paginable, $baseOrCurrentUrl, $lastCursor) { |
|||||
165 | 2 | $this->setPaginationLinkObjects($paginable, new LinkObject(), new LinkObject($this->generateNextLink($baseOrCurrentUrl, $lastCursor))); |
|||||
166 | } |
||||||
167 | |||||||
168 | /** |
||||||
169 | * @param PaginableInterface $paginable |
||||||
170 | */ |
||||||
171 | 1 | public function setPaginationLinkObjectsExplicitlyEmpty(PaginableInterface $paginable) { |
|||||
172 | 1 | $this->setPaginationLinkObjects($paginable, new LinkObject(), new LinkObject()); |
|||||
173 | } |
||||||
174 | |||||||
175 | /** |
||||||
176 | * pagination item metadata is the page meta at the top-level of a paginated item |
||||||
177 | * |
||||||
178 | * ends up at one of: |
||||||
179 | * - /data/meta/page |
||||||
180 | * - /data/relationships/foo/meta/page |
||||||
181 | * - /data/0/relationships/foo/meta/page |
||||||
182 | * |
||||||
183 | * @see https://jsonapi.org/profiles/ethanresnick/cursor-pagination/#terms-pagination-item-metadata |
||||||
184 | * |
||||||
185 | * @param ResourceInterface $resource |
||||||
186 | * @param string $cursor |
||||||
187 | */ |
||||||
188 | 3 | public function setItemMeta(ResourceInterface $resource, $cursor) { |
|||||
189 | 3 | $metadata = [ |
|||||
190 | 'cursor' => $cursor, |
||||||
191 | ]; |
||||||
192 | |||||||
193 | 3 | if ($resource instanceof ResourceDocument) { |
|||||
194 | 1 | $resource->addMeta('page', $metadata, $level=Document::LEVEL_RESOURCE); |
|||||
195 | } |
||||||
196 | else { |
||||||
197 | 2 | $resource->addMeta('page', $metadata); |
|||||
0 ignored issues
–
show
The method
addMeta() does not exist on alsvanzelf\jsonapi\interfaces\ResourceInterface . Since it exists in all sub-types, consider adding an abstract or default implementation to alsvanzelf\jsonapi\interfaces\ResourceInterface .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
198 | } |
||||||
199 | } |
||||||
200 | |||||||
201 | /** |
||||||
202 | * pagination metadata is the page meta that is a sibling of the paginated data (and pagination links) |
||||||
203 | * |
||||||
204 | * ends up at one of: |
||||||
205 | * - /meta/page/total & /meta/page/estimatedTotal/bestGuess & /meta/page/rangeTruncated |
||||||
206 | * - /data/relationships/foo/meta/page/total & /data/relationships/foo/meta/page/estimatedTotal/bestGuess & /data/relationships/foo/meta/page/rangeTruncated |
||||||
207 | * - /data/0/relationships/foo/meta/page/total & /data/0/relationships/foo/meta/page/estimatedTotal/bestGuess & /data/0/relationships/foo/meta/page/rangeTruncated |
||||||
208 | * |
||||||
209 | * @see https://jsonapi.org/profiles/ethanresnick/cursor-pagination/#terms-pagination-metadata |
||||||
210 | * |
||||||
211 | * @param PaginableInterface $paginable |
||||||
212 | * @param int $exactTotal optional |
||||||
213 | * @param int $bestGuessTotal optional |
||||||
214 | * @param boolean $rangeIsTruncated optional, if both after and before are supplied but the items exceed requested or max size |
||||||
215 | */ |
||||||
216 | 3 | public function setPaginationMeta(PaginableInterface $paginable, $exactTotal=null, $bestGuessTotal=null, $rangeIsTruncated=null) { |
|||||
217 | 3 | $metadata = []; |
|||||
218 | |||||||
219 | 3 | if ($exactTotal !== null) { |
|||||
220 | 3 | $metadata['total'] = $exactTotal; |
|||||
221 | } |
||||||
222 | 3 | if ($bestGuessTotal !== null) { |
|||||
223 | 3 | $metadata['estimatedTotal'] = [ |
|||||
224 | 'bestGuess' => $bestGuessTotal, |
||||||
225 | ]; |
||||||
226 | } |
||||||
227 | 3 | if ($rangeIsTruncated !== null) { |
|||||
228 | 1 | $metadata['rangeTruncated'] = $rangeIsTruncated; |
|||||
229 | } |
||||||
230 | |||||||
231 | 3 | $paginable->addMeta('page', $metadata); |
|||||
0 ignored issues
–
show
The method
addMeta() does not exist on alsvanzelf\jsonapi\interfaces\PaginableInterface . Since it exists in all sub-types, consider adding an abstract or default implementation to alsvanzelf\jsonapi\interfaces\PaginableInterface .
(
Ignorable by Annotation
)
If this is a false-positive, you can also ignore this issue in your code via the
![]() |
|||||||
232 | } |
||||||
233 | |||||||
234 | /** |
||||||
235 | * get an ErrorObject for when the requested sorting cannot efficiently be paginated |
||||||
236 | * |
||||||
237 | * ends up at: |
||||||
238 | * - /errors/0/code |
||||||
239 | * - /errors/0/status |
||||||
240 | * - /errors/0/source/parameter |
||||||
241 | * - /errors/0/links/type/0 |
||||||
242 | * - /errors/0/title optional |
||||||
243 | * - /errors/0/detail optional |
||||||
244 | * |
||||||
245 | * @param string $genericTitle optional |
||||||
246 | * @param string $specificDetails optional |
||||||
247 | * @return ErrorObject |
||||||
248 | */ |
||||||
249 | 1 | public function getUnsupportedSortErrorObject($genericTitle=null, $specificDetails=null) { |
|||||
250 | 1 | $errorObject = new ErrorObject('Unsupported sort'); |
|||||
251 | 1 | $errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/unsupported-sort'); |
|||||
252 | 1 | $errorObject->blameQueryParameter('sort'); |
|||||
253 | 1 | $errorObject->setHttpStatusCode(400); |
|||||
254 | |||||||
255 | 1 | if ($genericTitle !== null) { |
|||||
256 | 1 | $errorObject->setHumanExplanation($genericTitle, $specificDetails); |
|||||
257 | } |
||||||
258 | |||||||
259 | 1 | return $errorObject; |
|||||
260 | } |
||||||
261 | |||||||
262 | /** |
||||||
263 | * get an ErrorObject for when the requested page size exceeds the server-defined max page size |
||||||
264 | * |
||||||
265 | * ends up at: |
||||||
266 | * - /errors/0/code |
||||||
267 | * - /errors/0/status |
||||||
268 | * - /errors/0/source/parameter |
||||||
269 | * - /errors/0/links/type/0 |
||||||
270 | * - /errors/0/meta/page/maxSize |
||||||
271 | * - /errors/0/title optional |
||||||
272 | * - /errors/0/detail optional |
||||||
273 | * |
||||||
274 | * @param int $maxSize |
||||||
275 | * @param string $genericTitle optional, e.g. 'Page size requested is too large.' |
||||||
276 | * @param string $specificDetails optional, e.g. 'You requested a size of 200, but 100 is the maximum.' |
||||||
277 | * @return ErrorObject |
||||||
278 | */ |
||||||
279 | 1 | public function getMaxPageSizeExceededErrorObject($maxSize, $genericTitle=null, $specificDetails=null) { |
|||||
280 | 1 | $errorObject = new ErrorObject('Max page size exceeded'); |
|||||
281 | 1 | $errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/max-size-exceeded'); |
|||||
282 | 1 | $errorObject->blameQueryParameter('page[size]'); |
|||||
283 | 1 | $errorObject->setHttpStatusCode(400); |
|||||
284 | 1 | $errorObject->addMeta('page', $value=['maxSize' => $maxSize]); |
|||||
285 | |||||||
286 | 1 | if ($genericTitle !== null) { |
|||||
287 | 1 | $errorObject->setHumanExplanation($genericTitle, $specificDetails); |
|||||
288 | } |
||||||
289 | |||||||
290 | 1 | return $errorObject; |
|||||
291 | } |
||||||
292 | |||||||
293 | /** |
||||||
294 | * get an ErrorObject for when the requested page size is not a positive integer, or when the requested page after/before is not a valid cursor |
||||||
295 | * |
||||||
296 | * ends up at: |
||||||
297 | * - /errors/0/code |
||||||
298 | * - /errors/0/status |
||||||
299 | * - /errors/0/source/parameter |
||||||
300 | * - /errors/0/links/type/0 optional |
||||||
301 | * - /errors/0/title optional |
||||||
302 | * - /errors/0/detail optional |
||||||
303 | * |
||||||
304 | * @param int $queryParameter e.g. 'sort' or 'page[size]' |
||||||
305 | * @param string $typeLink optional |
||||||
306 | * @param string $genericTitle optional, e.g. 'Invalid Parameter.' |
||||||
307 | * @param string $specificDetails optional, e.g. 'page[size] must be a positive integer; got 0' |
||||||
308 | * @return ErrorObject |
||||||
309 | */ |
||||||
310 | 1 | public function getInvalidParameterValueErrorObject($queryParameter, $typeLink=null, $genericTitle=null, $specificDetails=null) { |
|||||
311 | 1 | $errorObject = new ErrorObject('Invalid parameter value'); |
|||||
312 | 1 | $errorObject->blameQueryParameter($queryParameter); |
|||||
313 | 1 | $errorObject->setHttpStatusCode(400); |
|||||
314 | |||||||
315 | 1 | if ($typeLink !== null) { |
|||||
316 | 1 | $errorObject->setTypeLink($typeLink); |
|||||
317 | } |
||||||
318 | |||||||
319 | 1 | if ($genericTitle !== null) { |
|||||
320 | 1 | $errorObject->setHumanExplanation($genericTitle, $specificDetails); |
|||||
321 | } |
||||||
322 | |||||||
323 | 1 | return $errorObject; |
|||||
324 | } |
||||||
325 | |||||||
326 | /** |
||||||
327 | * get an ErrorObject for when range pagination requests (when both 'page[after]' and 'page[before]' are requested) are not supported |
||||||
328 | * |
||||||
329 | * ends up at: |
||||||
330 | * - /errors/0/code |
||||||
331 | * - /errors/0/status |
||||||
332 | * - /errors/0/links/type/0 |
||||||
333 | * |
||||||
334 | * @param string $genericTitle optional |
||||||
335 | * @param string $specificDetails optional |
||||||
336 | * @return ErrorObject |
||||||
337 | */ |
||||||
338 | 1 | public function getRangePaginationNotSupportedErrorObject($genericTitle=null, $specificDetails=null) { |
|||||
339 | 1 | $errorObject = new ErrorObject('Range pagination not supported'); |
|||||
340 | 1 | $errorObject->setTypeLink('https://jsonapi.org/profiles/ethanresnick/cursor-pagination/range-pagination-not-supported'); |
|||||
341 | 1 | $errorObject->setHttpStatusCode(400); |
|||||
342 | |||||||
343 | 1 | if ($genericTitle !== null) { |
|||||
344 | 1 | $errorObject->setHumanExplanation($genericTitle, $specificDetails); |
|||||
345 | } |
||||||
346 | |||||||
347 | 1 | return $errorObject; |
|||||
348 | } |
||||||
349 | |||||||
350 | /** |
||||||
351 | * internal api |
||||||
352 | */ |
||||||
353 | |||||||
354 | /** |
||||||
355 | * add or adjust a key in the query string of a url |
||||||
356 | * |
||||||
357 | * @param string $url |
||||||
358 | * @param string $key |
||||||
359 | * @param string $value |
||||||
360 | */ |
||||||
361 | 7 | private function setQueryParameter($url, $key, $value) { |
|||||
362 | 7 | $originalQuery = parse_url($url, PHP_URL_QUERY); |
|||||
363 | 7 | $decodedQuery = urldecode($originalQuery); |
|||||
364 | 7 | $originalIsEncoded = ($decodedQuery !== $originalQuery); |
|||||
365 | |||||||
366 | 7 | $originalParameters = []; |
|||||
367 | 7 | parse_str($decodedQuery, $originalParameters); |
|||||
368 | |||||||
369 | 7 | $newParameters = []; |
|||||
370 | 7 | parse_str($key.'='.$value, $newParameters); |
|||||
371 | |||||||
372 | 7 | $fullParameters = array_replace_recursive($originalParameters, $newParameters); |
|||||
373 | |||||||
374 | 7 | $newQuery = http_build_query($fullParameters); |
|||||
375 | 7 | if ($originalIsEncoded === false) { |
|||||
376 | 6 | $newQuery = urldecode($newQuery); |
|||||
377 | } |
||||||
378 | |||||||
379 | 7 | $newUrl = str_replace($originalQuery, $newQuery, $url); |
|||||
380 | |||||||
381 | 7 | return $newUrl; |
|||||
382 | } |
||||||
383 | |||||||
384 | /** |
||||||
385 | * ProfileInterface |
||||||
386 | */ |
||||||
387 | |||||||
388 | /** |
||||||
389 | * @inheritDoc |
||||||
390 | */ |
||||||
391 | 8 | public function getOfficialLink() { |
|||||
392 | 8 | return 'https://jsonapi.org/profiles/ethanresnick/cursor-pagination/'; |
|||||
393 | } |
||||||
394 | |||||||
395 | /** |
||||||
396 | * returns the keyword without aliasing |
||||||
397 | * |
||||||
398 | * @deprecated since aliasing was removed from the profiles spec |
||||||
399 | * |
||||||
400 | * @param string $keyword |
||||||
401 | * @return string |
||||||
402 | */ |
||||||
403 | 1 | public function getKeyword($keyword) { |
|||||
404 | 1 | return $keyword; |
|||||
405 | } |
||||||
406 | } |
||||||
407 |