Skip to content

Commit 5add16d

Browse files
authored
feat(app): Added pagination response to API ResponseTrait (#9758)
* feat(app): Added pagination response to API ResponseTrait * chore(app): Updating deptrack for pagination method Updated deptrac.yaml to allow the API layer to also depend on: * docs(app): Fixing docs error and adding changelog * docs(app): Added missing label to docs * chore(app): PHPStan baseline
1 parent ddc06b5 commit 5add16d

File tree

13 files changed

+680
-40
lines changed

13 files changed

+680
-40
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ $RECYCLE.BIN/
4444
.env
4545
.vagrant
4646
Vagrantfile
47+
user_guide_src/venv/
48+
.python-version
49+
user_guide_src/.python-version
4750

4851
#-------------------------
4952
# Temporary Files

deptrac.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ deptrac:
164164
API:
165165
- Format
166166
- HTTP
167+
- Database
168+
- Model
169+
- Pager
170+
- URI
167171
Cache:
168172
- I18n
169173
Controller:

system/API/ResponseTrait.php

Lines changed: 150 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,16 @@
1313

1414
namespace CodeIgniter\API;
1515

16+
use CodeIgniter\Database\BaseBuilder;
17+
use CodeIgniter\Database\Exceptions\DatabaseException;
1618
use CodeIgniter\Format\Format;
1719
use CodeIgniter\Format\FormatterInterface;
1820
use CodeIgniter\HTTP\CLIRequest;
1921
use CodeIgniter\HTTP\IncomingRequest;
2022
use CodeIgniter\HTTP\ResponseInterface;
23+
use CodeIgniter\HTTP\URI;
24+
use CodeIgniter\Model;
25+
use Throwable;
2126

2227
/**
2328
* Provides common, more readable, methods to provide
@@ -321,7 +326,7 @@ protected function format($data = null)
321326
// if we don't have a formatter, make one
322327
$this->formatter ??= $format->getFormatter($mime);
323328

324-
$asHtml = $this->stringAsHtml ?? false;
329+
$asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false;
325330

326331
if (
327332
($mime === 'application/json' && $asHtml && is_string($data))
@@ -360,4 +365,148 @@ protected function setResponseFormat(?string $format = null)
360365

361366
return $this;
362367
}
368+
369+
// --------------------------------------------------------------------
370+
// Pagination Methods
371+
// --------------------------------------------------------------------
372+
373+
/**
374+
* Paginates the given model or query builder and returns
375+
* an array containing the paginated results along with
376+
* metadata such as total items, total pages, current page,
377+
* and items per page.
378+
*
379+
* The result would be in the following format:
380+
* [
381+
* 'data' => [...],
382+
* 'meta' => [
383+
* 'page' => 1,
384+
* 'perPage' => 20,
385+
* 'total' => 100,
386+
* 'totalPages' => 5,
387+
* ],
388+
* 'links' => [
389+
* 'self' => '/api/items?page=1&perPage=20',
390+
* 'first' => '/api/items?page=1&perPage=20',
391+
* 'last' => '/api/items?page=5&perPage=20',
392+
* 'prev' => null,
393+
* 'next' => '/api/items?page=2&perPage=20',
394+
* ]
395+
* ]
396+
*/
397+
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface
398+
{
399+
try {
400+
assert($this->request instanceof IncomingRequest);
401+
402+
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
403+
404+
// If using a Model we can use its built-in paginate method
405+
if ($resource instanceof Model) {
406+
$data = $resource->paginate($perPage, 'default', $page);
407+
$pager = $resource->pager;
408+
409+
$meta = [
410+
'page' => $pager->getCurrentPage(),
411+
'perPage' => $pager->getPerPage(),
412+
'total' => $pager->getTotal(),
413+
'totalPages' => $pager->getPageCount(),
414+
];
415+
} else {
416+
// Query Builder, we need to handle pagination manually
417+
$offset = ($page - 1) * $perPage;
418+
$total = (clone $resource)->countAllResults();
419+
$data = $resource->limit($perPage, $offset)->get()->getResultArray();
420+
421+
$meta = [
422+
'page' => $page,
423+
'perPage' => $perPage,
424+
'total' => $total,
425+
'totalPages' => (int) ceil($total / $perPage),
426+
];
427+
}
428+
429+
$links = $this->buildLinks($meta);
430+
431+
$this->response->setHeader('Link', $this->linkHeader($links));
432+
$this->response->setHeader('X-Total-Count', (string) $meta['total']);
433+
434+
return $this->respond([
435+
'data' => $data,
436+
'meta' => $meta,
437+
'links' => $links,
438+
]);
439+
} catch (DatabaseException $e) {
440+
log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage());
441+
442+
return $this->failServerError(lang('RESTful.cannotPaginate'));
443+
} catch (Throwable $e) {
444+
log_message('error', lang('RESTful.paginateError') . ' ' . $e->getMessage());
445+
446+
return $this->failServerError(lang('RESTful.paginateError'));
447+
}
448+
}
449+
450+
/**
451+
* Builds pagination links based on the current request URI and pagination metadata.
452+
*
453+
* @param array<string, int> $meta Pagination metadata (page, perPage, total, totalPages)
454+
*
455+
* @return array<string, string|null> Array of pagination links with relations as keys
456+
*/
457+
private function buildLinks(array $meta): array
458+
{
459+
assert($this->request instanceof IncomingRequest);
460+
461+
/** @var URI $uri */
462+
$uri = current_url(true);
463+
$query = $this->request->getGet();
464+
465+
$set = static function ($page) use ($uri, $query, $meta): string {
466+
$params = $query;
467+
$params['page'] = $page;
468+
469+
// Ensure perPage is in the links if it's not default
470+
if (! isset($params['perPage']) && $meta['perPage'] !== 20) {
471+
$params['perPage'] = $meta['perPage'];
472+
}
473+
474+
return (string) (new URI((string) $uri))->setQuery(http_build_query($params));
475+
};
476+
477+
$totalPages = max(1, (int) $meta['totalPages']);
478+
$page = (int) $meta['page'];
479+
480+
return [
481+
'self' => $set($page),
482+
'first' => $set(1),
483+
'last' => $set($totalPages),
484+
'prev' => $page > 1 ? $set($page - 1) : null,
485+
'next' => $page < $totalPages ? $set($page + 1) : null,
486+
];
487+
}
488+
489+
/**
490+
* Formats the pagination links into a single Link header string
491+
* for middleware/machine use.
492+
*
493+
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link
494+
* @see https://datatracker.ietf.org/doc/html/rfc8288
495+
*
496+
* @param array<string, string|null> $links Pagination links with relations as keys
497+
*
498+
* @return string Formatted Link header value
499+
*/
500+
private function linkHeader(array $links): string
501+
{
502+
$parts = [];
503+
504+
foreach (['self', 'first', 'prev', 'next', 'last'] as $rel) {
505+
if ($links[$rel] !== null && $links[$rel] !== '') {
506+
$parts[] = "<{$links[$rel]}>; rel=\"{$rel}\"";
507+
}
508+
}
509+
510+
return implode(', ', $parts);
511+
}
363512
}

system/Language/en/RESTful.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,4 +14,6 @@
1414
// RESTful language settings
1515
return [
1616
'notImplemented' => '"{0}" action not implemented.',
17+
'cannotPaginate' => 'Unable to retrieve paginated data.',
18+
'paginateError' => 'An error occurred while paginating results.',
1719
];

0 commit comments

Comments
 (0)