|
13 | 13 |
|
14 | 14 | namespace CodeIgniter\API; |
15 | 15 |
|
| 16 | +use CodeIgniter\Database\BaseBuilder; |
| 17 | +use CodeIgniter\Database\Exceptions\DatabaseException; |
16 | 18 | use CodeIgniter\Format\Format; |
17 | 19 | use CodeIgniter\Format\FormatterInterface; |
18 | 20 | use CodeIgniter\HTTP\CLIRequest; |
19 | 21 | use CodeIgniter\HTTP\IncomingRequest; |
20 | 22 | use CodeIgniter\HTTP\ResponseInterface; |
| 23 | +use CodeIgniter\HTTP\URI; |
| 24 | +use CodeIgniter\Model; |
| 25 | +use Throwable; |
21 | 26 |
|
22 | 27 | /** |
23 | 28 | * Provides common, more readable, methods to provide |
@@ -321,7 +326,7 @@ protected function format($data = null) |
321 | 326 | // if we don't have a formatter, make one |
322 | 327 | $this->formatter ??= $format->getFormatter($mime); |
323 | 328 |
|
324 | | - $asHtml = $this->stringAsHtml ?? false; |
| 329 | + $asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false; |
325 | 330 |
|
326 | 331 | if ( |
327 | 332 | ($mime === 'application/json' && $asHtml && is_string($data)) |
@@ -360,4 +365,148 @@ protected function setResponseFormat(?string $format = null) |
360 | 365 |
|
361 | 366 | return $this; |
362 | 367 | } |
| 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 | + } |
363 | 512 | } |
0 commit comments