Passed
Push — main ( f8d78a...6afd83 )
by Lode
01:12 queued 12s
created

CursorPaginationProfile::setCount()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
cc 1
eloc 1
c 1
b 0
f 1
nc 1
nop 3
dl 0
loc 2
ccs 2
cts 2
cp 1
crap 1
rs 10
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 2
	}
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 2
	}
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 1
	}
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 3
	}
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 2
	}
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
The method addLinkObject() 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 ignore-call  annotation

146
		$paginable->/** @scrutinizer ignore-call */ 
147
              addLinkObject('prev', $previousLinkObject);
Loading history...
147 6
		$paginable->addLinkObject('next', $nextLinkObject);
148 6
	}
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 1
	}
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 2
	}
167
	
168
	/**
169
	 * @param PaginableInterface $paginable
170
	 */
171 1
	public function setPaginationLinkObjectsExplicitlyEmpty(PaginableInterface $paginable) {
172 1
		$this->setPaginationLinkObjects($paginable, new LinkObject(), new LinkObject());
173 1
	}
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
		$metadata = [
190 3
			'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
Bug introduced by
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 ignore-call  annotation

197
			$resource->/** @scrutinizer ignore-call */ 
198
              addMeta('page', $metadata);
Loading history...
198
		}
199 3
	}
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 3
				'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
Bug introduced by
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 ignore-call  annotation

231
		$paginable->/** @scrutinizer ignore-call */ 
232
              addMeta('page', $metadata);
Loading history...
232 3
	}
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