Skip to content

Commit 26e824f

Browse files
committed
feat(app): Added pagination response to API ResponseTrait
1 parent 01a45be commit 26e824f

File tree

7 files changed

+638
-39
lines changed

7 files changed

+638
-39
lines changed

system/API/ResponseTrait.php

Lines changed: 151 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,8 @@ 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+
// @phpstan-ignore function.impossibleType, function.alreadyNarrowedType (trait used in contexts with/without this property)
330+
$asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false;
325331

326332
if (
327333
($mime === 'application/json' && $asHtml && is_string($data))
@@ -360,4 +366,148 @@ protected function setResponseFormat(?string $format = null)
360366

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

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)