From 26e824f11a84201f29d80ecfbaeec5579221ae61 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 14 Oct 2025 00:23:57 +0200 Subject: [PATCH 1/5] feat(app): Added pagination response to API ResponseTrait --- system/API/ResponseTrait.php | 152 ++++++- system/Language/en/RESTful.php | 2 + tests/system/API/ResponseTraitTest.php | 371 ++++++++++++++++++ .../source/outgoing/api_responses.rst | 84 +++- .../source/outgoing/api_responses/018.php | 18 + .../source/outgoing/api_responses/019.php | 18 + utils/phpstan-baseline/property.notFound.neon | 32 +- 7 files changed, 638 insertions(+), 39 deletions(-) create mode 100644 user_guide_src/source/outgoing/api_responses/018.php create mode 100644 user_guide_src/source/outgoing/api_responses/019.php diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index f64cc671a514..a7d3f136aa6d 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -13,11 +13,16 @@ namespace CodeIgniter\API; +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Format\Format; use CodeIgniter\Format\FormatterInterface; use CodeIgniter\HTTP\CLIRequest; use CodeIgniter\HTTP\IncomingRequest; use CodeIgniter\HTTP\ResponseInterface; +use CodeIgniter\HTTP\URI; +use CodeIgniter\Model; +use Throwable; /** * Provides common, more readable, methods to provide @@ -321,7 +326,8 @@ protected function format($data = null) // if we don't have a formatter, make one $this->formatter ??= $format->getFormatter($mime); - $asHtml = $this->stringAsHtml ?? false; + // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType (trait used in contexts with/without this property) + $asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false; if ( ($mime === 'application/json' && $asHtml && is_string($data)) @@ -360,4 +366,148 @@ protected function setResponseFormat(?string $format = null) return $this; } + + // -------------------------------------------------------------------- + // Pagination Methods + // -------------------------------------------------------------------- + + /** + * Paginates the given model or query builder and returns + * an array containing the paginated results along with + * metadata such as total items, total pages, current page, + * and items per page. + * + * The result would be in the following format: + * [ + * 'data' => [...], + * 'meta' => [ + * 'page' => 1, + * 'perPage' => 20, + * 'total' => 100, + * 'totalPages' => 5, + * ], + * 'links' => [ + * 'self' => '/api/items?page=1&perPage=20', + * 'first' => '/api/items?page=1&perPage=20', + * 'last' => '/api/items?page=5&perPage=20', + * 'prev' => null, + * 'next' => '/api/items?page=2&perPage=20', + * ] + * ] + */ + protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface + { + try { + assert($this->request instanceof IncomingRequest); + + $page = max(1, (int) ($this->request->getGet('page') ?? 1)); + + // If using a Model we can use its built-in paginate method + if ($resource instanceof Model) { + $data = $resource->paginate($perPage, 'default', $page); + $pager = $resource->pager; + + $meta = [ + 'page' => $pager->getCurrentPage(), + 'perPage' => $pager->getPerPage(), + 'total' => $pager->getTotal(), + 'totalPages' => $pager->getPageCount(), + ]; + } else { + // Query Builder, we need to handle pagination manually + $offset = ($page - 1) * $perPage; + $total = (clone $resource)->countAllResults(); + $data = $resource->limit($perPage, $offset)->get()->getResultArray(); + + $meta = [ + 'page' => $page, + 'perPage' => $perPage, + 'total' => $total, + 'totalPages' => (int) ceil($total / $perPage), + ]; + } + + $links = $this->buildLinks($meta); + + $this->response->setHeader('Link', $this->linkHeader($links)); + $this->response->setHeader('X-Total-Count', (string) $meta['total']); + + return $this->respond([ + 'data' => $data, + 'meta' => $meta, + 'links' => $links, + ]); + } catch (DatabaseException $e) { + log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage()); + + return $this->failServerError(lang('RESTful.cannotPaginate')); + } catch (Throwable $e) { + log_message('error', lang('RESTful.paginateError') . ' ' . $e->getMessage()); + + return $this->failServerError(lang('RESTful.paginateError')); + } + } + + /** + * Builds pagination links based on the current request URI and pagination metadata. + * + * @param array $meta Pagination metadata (page, perPage, total, totalPages) + * + * @return array Array of pagination links with relations as keys + */ + private function buildLinks(array $meta): array + { + assert($this->request instanceof IncomingRequest); + + /** @var URI $uri */ + $uri = current_url(true); + $query = $this->request->getGet(); + + $set = static function ($page) use ($uri, $query, $meta): string { + $params = $query; + $params['page'] = $page; + + // Ensure perPage is in the links if it's not default + if (! isset($params['perPage']) && $meta['perPage'] !== 20) { + $params['perPage'] = $meta['perPage']; + } + + return (string) (new URI((string) $uri))->setQuery(http_build_query($params)); + }; + + $totalPages = max(1, (int) $meta['totalPages']); + $page = (int) $meta['page']; + + return [ + 'self' => $set($page), + 'first' => $set(1), + 'last' => $set($totalPages), + 'prev' => $page > 1 ? $set($page - 1) : null, + 'next' => $page < $totalPages ? $set($page + 1) : null, + ]; + } + + /** + * Formats the pagination links into a single Link header string + * for middleware/machine use. + * + * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Link + * @see https://datatracker.ietf.org/doc/html/rfc8288 + * + * @param array $links Pagination links with relations as keys + * + * @return string Formatted Link header value + */ + private function linkHeader(array $links): string + { + $parts = []; + + foreach (['self', 'first', 'prev', 'next', 'last'] as $rel) { + if ($links[$rel] !== null && $links[$rel] !== '') { + $parts[] = "<{$links[$rel]}>; rel=\"{$rel}\""; + } + } + + return implode(', ', $parts); + } } diff --git a/system/Language/en/RESTful.php b/system/Language/en/RESTful.php index 59e014b72f7e..e1aaa10f12d5 100644 --- a/system/Language/en/RESTful.php +++ b/system/Language/en/RESTful.php @@ -14,4 +14,6 @@ // RESTful language settings return [ 'notImplemented' => '"{0}" action not implemented.', + 'cannotPaginate' => 'Unable to retrieve paginated data.', + 'paginateError' => 'An error occurred while paginating results.', ]; diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index 0443d90dc391..cc8748ce1b4a 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -14,6 +14,10 @@ namespace CodeIgniter\API; use CodeIgniter\Config\Factories; +use CodeIgniter\Database\BaseBuilder; +use CodeIgniter\Database\BaseConnection; +use CodeIgniter\Database\BaseResult; +use CodeIgniter\Database\Exceptions\DatabaseException; use CodeIgniter\Format\FormatterInterface; use CodeIgniter\Format\JSONFormatter; use CodeIgniter\Format\XMLFormatter; @@ -21,12 +25,15 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\HTTP\SiteURI; use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Model; +use CodeIgniter\Pager\Pager; use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\Mock\MockIncomingRequest; use CodeIgniter\Test\Mock\MockResponse; use Config\App; use Config\Cookie; use Config\Services; +use Exception; use PHPUnit\Framework\Attributes\Group; use stdClass; @@ -682,4 +689,368 @@ private function invoke(object $controller, string $method, array $args = []): o return $method(...$args); } + + /** + * Helper method to create a mock Model with a mock Pager + * + * @param array> $data + */ + private function createMockModelWithPager(array $data, int $page, int $perPage, int $total, int $totalPages): Model + { + // Create a mock Pager + $pager = $this->createMock(Pager::class); + $pager->method('getCurrentPage')->willReturn($page); + $pager->method('getPerPage')->willReturn($perPage); + $pager->method('getTotal')->willReturn($total); + $pager->method('getPageCount')->willReturn($totalPages); + + // Create a mock Model with a public pager property + $model = $this->createMock(Model::class); + + $model->method('paginate')->willReturn($data); + $model->pager = $pager; + + return $model; + } + + public function testPaginateWithModel(): void + { + // Mock data + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Check response structure + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertArrayHasKey('data', $responseBody); + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + + // Check meta + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + + // Check data + $this->assertSame($data, $responseBody['data']); + + // Check headers + $this->assertSame('50', $this->response->getHeaderLine('X-Total-Count')); + $this->assertNotEmpty($this->response->getHeaderLine('Link')); + } + + public function testPaginateWithQueryBuilder(): void + { + // Mock the database and builder + $db = $this->createMock(BaseConnection::class); + + $builder = $this->getMockBuilder(BaseBuilder::class) + ->setConstructorArgs(['test_table', $db]) + ->onlyMethods(['countAllResults', 'limit', 'get']) + ->getMock(); + + $result = $this->createMock(BaseResult::class); + + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // Mock the query builder chain + $builder->method('countAllResults')->willReturn(50); + $builder->method('limit')->willReturnSelf(); + $builder->method('get')->willReturn($result); + $result->method('getResultArray')->willReturn($data); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$builder, 20]); + + // Check response structure + $responseBody = json_decode($this->response->getBody(), true); + + $this->assertArrayHasKey('data', $responseBody); + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + + // Check meta + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(20, $responseBody['meta']['perPage']); + $this->assertSame(50, $responseBody['meta']['total']); + $this->assertSame(3, $responseBody['meta']['totalPages']); + } + + public function testPaginateWithCustomPerPage(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ['id' => 3, 'name' => 'Item 3'], + ['id' => 4, 'name' => 'Item 4'], + ['id' => 5, 'name' => 'Item 5'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 5, 25, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 5]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check meta with custom perPage + $this->assertSame(5, $responseBody['meta']['perPage']); + $this->assertSame(25, $responseBody['meta']['total']); + $this->assertSame(5, $responseBody['meta']['totalPages']); + } + + public function testPaginateWithPageParameter(): void + { + $data = [ + ['id' => 21, 'name' => 'Item 21'], + ['id' => 22, 'name' => 'Item 22'], + ]; + + $model = $this->createMockModelWithPager($data, 2, 20, 50, 3); + + // Create controller with page=2 in query string + $controller = $this->makeController('/api/items?page=2'); + Services::superglobals()->setGet('page', '2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that we're on page 2 + $this->assertSame(2, $responseBody['meta']['page']); + + // Check links + $this->assertStringContainsString('page=2', (string) $responseBody['links']['self']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['prev']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['next']); + } + + public function testPaginateLinksStructure(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); + + Services::superglobals()->setGet('page', '2'); + $controller = $this->makeController('/api/items?page=2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check all link types exist + $this->assertArrayHasKey('self', $responseBody['links']); + $this->assertArrayHasKey('first', $responseBody['links']); + $this->assertArrayHasKey('last', $responseBody['links']); + $this->assertArrayHasKey('prev', $responseBody['links']); + $this->assertArrayHasKey('next', $responseBody['links']); + + // Check link values + $this->assertStringContainsString('page=2', (string) $responseBody['links']['self']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); + $this->assertStringContainsString('page=5', (string) $responseBody['links']['last']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['prev']); + $this->assertStringContainsString('page=3', (string) $responseBody['links']['next']); + } + + public function testPaginateFirstPageNoPrevLink(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // First page should not have a prev link + $this->assertNull($responseBody['links']['prev']); + // But should have a next link + $this->assertNotNull($responseBody['links']['next']); + } + + public function testPaginateLastPageNoNextLink(): void + { + $data = [['id' => 41, 'name' => 'Item 41']]; + + $model = $this->createMockModelWithPager($data, 3, 20, 50, 3); + + Services::superglobals()->setGet('page', '3'); + $controller = $this->makeController('/api/items?page=3'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Last page should not have a next link + $this->assertNull($responseBody['links']['next']); + // But should have a prev link + $this->assertNotNull($responseBody['links']['prev']); + } + + public function testPaginateLinkHeader(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 2, 20, 100, 5); + + Services::superglobals()->setGet('page', '2'); + $controller = $this->makeController('/api/items?page=2'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $linkHeader = $this->response->getHeaderLine('Link'); + + // Check that Link header is properly formatted + $this->assertStringContainsString('rel="self"', $linkHeader); + $this->assertStringContainsString('rel="first"', $linkHeader); + $this->assertStringContainsString('rel="last"', $linkHeader); + $this->assertStringContainsString('rel="prev"', $linkHeader); + $this->assertStringContainsString('rel="next"', $linkHeader); + + // Check format ; rel="relation" + $this->assertMatchesRegularExpression('/<[^>]+>;\s*rel="self"/', $linkHeader); + $this->assertMatchesRegularExpression('/<[^>]+>;\s*rel="first"/', $linkHeader); + } + + public function testPaginateXTotalCountHeader(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 150, 8); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Check X-Total-Count header + $this->assertSame('150', $this->response->getHeaderLine('X-Total-Count')); + } + + public function testPaginateWithDatabaseException(): void + { + $model = $this->createMock(Model::class); + + // Make the model throw a DatabaseException + $model->method('paginate')->willThrowException( + new DatabaseException('Database error'), + ); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Should return a 500 error + $this->assertSame(500, $this->response->getStatusCode()); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check error response structure + $this->assertArrayHasKey('status', $responseBody); + $this->assertArrayHasKey('error', $responseBody); + $this->assertArrayHasKey('messages', $responseBody); + $this->assertSame(500, $responseBody['status']); + } + + public function testPaginateWithGenericException(): void + { + $model = $this->createMock(Model::class); + + // Make the model throw a generic exception + $model->method('paginate')->willThrowException( + new Exception('Generic error'), + ); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + // Should return a 500 error + $this->assertSame(500, $this->response->getStatusCode()); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check error response structure + $this->assertSame(500, $responseBody['status']); + $this->assertArrayHasKey('error', $responseBody); + } + + public function testPaginateWithNonDefaultPerPageInLinks(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 10, 50, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 10]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that perPage is included in links when it's not the default (20) + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['self']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['first']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['last']); + $this->assertStringContainsString('perPage=10', (string) $responseBody['links']['next']); + } + + public function testPaginatePreservesOtherQueryParameters(): void + { + $data = [['id' => 1, 'name' => 'Item 1']]; + + $model = $this->createMockModelWithPager($data, 1, 20, 50, 3); + + Services::superglobals()->setGet('filter', 'active'); + Services::superglobals()->setGet('sort', 'name'); + $controller = $this->makeController('/api/items?filter=active&sort=name'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that other query parameters are preserved in links + $this->assertStringContainsString('filter=active', (string) $responseBody['links']['self']); + $this->assertStringContainsString('sort=name', (string) $responseBody['links']['self']); + $this->assertStringContainsString('filter=active', (string) $responseBody['links']['next']); + $this->assertStringContainsString('sort=name', (string) $responseBody['links']['next']); + } + + public function testPaginateSinglePage(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 2, 1); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20]); + + $responseBody = json_decode($this->response->getBody(), true); + + // For a single page, prev and next should be null + $this->assertNull($responseBody['links']['prev']); + $this->assertNull($responseBody['links']['next']); + // First and last should point to page 1 + $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); + $this->assertStringContainsString('page=1', (string) $responseBody['links']['last']); + } } diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index eee219591f8f..6b6ac1345d71 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -1,9 +1,9 @@ -################## -API Response Trait -################## +############# +API Responses +############# Much of modern PHP development requires building APIs, whether simply to provide data for a javascript-heavy -single page application, or as a standalone product. CodeIgniter provides an API Response trait that can be +single page application, or as a standalone product. CodeIgniter provides a couple of traits that can be used with any controller to make common response types simple, with no need to remember which HTTP status code should be returned for which response types. @@ -11,9 +11,9 @@ should be returned for which response types. :local: :depth: 2 -************* -Example Usage -************* +***************** +Response Examples +***************** The following example shows a common usage pattern within your controllers. @@ -250,3 +250,73 @@ Class Reference Sets the appropriate status code to use when there is a server error. .. literalinclude:: api_responses/017.php + + +******************** +Pagination Responses +******************** + +When returning paginated results from an API endpoint, you can use the ``paginate()`` method to return the +results along with the pagination information. This helps to keep consistent responses across your API, while +providing all of the information that clients will need to properly page through the results. + +------------- +Example Usage +------------- + +.. literalinclude:: api_responses/018.php + +A typical response might look like: + +```json +{ + "data": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com" + }, + { + "id": 2, + "username": "user", + "email": "user@example.com" + } + ], + "meta": { + "page": 1, + "perPage": 20, + "total": 2, + "totalPages": 1 + }, + "links": { + "self": "http://example.com/users?page=1", + "first": "http://example.com/users?page=1", + "last": "http://example.com/users?page=1", + "next": null, + "previous": null + } +} +``` + +The ``paginate()`` method will always wrap the results in a ``data`` element, and will also include ``meta`` +and ``links`` elements to help the client page through the results. If there are no results, the ``data`` element will +be an empty array, and the ``meta`` and ``links`` elements will still be present, but with values that indicate no results. + +You can also pass it a Builder instance instead of a Model, as long as the Builder is properly configured with the table +name and any necessary joins or where clauses. + +.. literalinclude:: api_responses/019.php + +*************** +Class Reference +*************** + +.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20) + + :param Model|BaseBuilder $resource: The resource to paginate, either a Model or a Builder instance. + :param int $perPage: The number of items to return per page. + + Generates a paginated response from the given resource. The resource can be either a Model or a Builder + instance. The method will automatically determine the current page from the request's query parameters. + The response will include the paginated data, along with metadata about the pagination state and links + to navigate through the pages. diff --git a/user_guide_src/source/outgoing/api_responses/018.php b/user_guide_src/source/outgoing/api_responses/018.php new file mode 100644 index 000000000000..efacd81ac89b --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/018.php @@ -0,0 +1,18 @@ +where('active', 1); + + return $this->paginate($model, 20); + } +} diff --git a/user_guide_src/source/outgoing/api_responses/019.php b/user_guide_src/source/outgoing/api_responses/019.php new file mode 100644 index 000000000000..63f249085c8a --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/019.php @@ -0,0 +1,18 @@ +table('users') + ->where('active', 1); + + return $this->paginate(resource: $builder, perPage: 20); + } +} diff --git a/utils/phpstan-baseline/property.notFound.neon b/utils/phpstan-baseline/property.notFound.neon index 3fab0404eb87..3d66f73b98bc 100644 --- a/utils/phpstan-baseline/property.notFound.neon +++ b/utils/phpstan-baseline/property.notFound.neon @@ -1,4 +1,4 @@ -# total 57 errors +# total 51 errors parameters: ignoreErrors: @@ -17,21 +17,6 @@ parameters: count: 14 path: ../../system/Database/SQLSRV/Forge.php - - - message: '#^Access to an undefined property CodeIgniter\\Debug\\ExceptionHandler\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Debug/ExceptionHandler.php - - - - message: '#^Access to an undefined property CodeIgniter\\Debug\\Exceptions\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Debug/Exceptions.php - - - - message: '#^Access to an undefined property CodeIgniter\\RESTful\\ResourceController\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/RESTful/ResourceController.php - - message: '#^Access to an undefined property Config\\Session\:\:\$lockAttempts\.$#' count: 1 @@ -42,21 +27,6 @@ parameters: count: 1 path: ../../system/Session/Handlers/RedisHandler.php - - - message: '#^Access to an undefined property CodeIgniter\\Test\\Mock\\MockResourcePresenter\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../system/Test/Mock/MockResourcePresenter.php - - - - message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:123\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - - - message: '#^Access to an undefined property class@anonymous/tests/system/API/ResponseTraitTest\.php\:621\:\:\$stringAsHtml\.$#' - count: 1 - path: ../../tests/system/API/ResponseTraitTest.php - - message: '#^Access to an undefined property CodeIgniter\\Database\\BaseConnection\:\:\$mysqli\.$#' count: 1 From fb3ee8bb6bd078bce5d97a58745f284a7c0bbde7 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 14 Oct 2025 00:35:00 +0200 Subject: [PATCH 2/5] chore(app): Updating deptrack for pagination method Updated deptrac.yaml to allow the API layer to also depend on: --- deptrac.yaml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/deptrac.yaml b/deptrac.yaml index 7f5687af4bca..c70f4d7126cc 100644 --- a/deptrac.yaml +++ b/deptrac.yaml @@ -164,6 +164,10 @@ deptrac: API: - Format - HTTP + - Database + - Model + - Pager + - URI Cache: - I18n Controller: From 05906bbcb9bae1ce786c9ce0a888f9d76dc2e402 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 14 Oct 2025 00:39:21 +0200 Subject: [PATCH 3/5] docs(app): Fixing docs error and adding changelog --- user_guide_src/source/changelogs/v4.7.0.rst | 1 + .../source/outgoing/api_responses.rst | 52 +++++++++---------- 2 files changed, 27 insertions(+), 26 deletions(-) diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index d91e9e2fd604..308943ef6228 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -81,6 +81,7 @@ Libraries - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. +- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` Commands diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index 6b6ac1345d71..f067a738e2f8 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -268,35 +268,35 @@ Example Usage A typical response might look like: -```json -{ - "data": [ - { - "id": 1, - "username": "admin", - "email": "admin@example.com" +.. code-block:: json + + { + "data": [ + { + "id": 1, + "username": "admin", + "email": "admin@example.com" + }, + { + "id": 2, + "username": "user", + "email": "user@example.com" + } + ], + "meta": { + "page": 1, + "perPage": 20, + "total": 2, + "totalPages": 1 }, - { - "id": 2, - "username": "user", - "email": "user@example.com" + "links": { + "self": "http://example.com/users?page=1", + "first": "http://example.com/users?page=1", + "last": "http://example.com/users?page=1", + "next": null, + "previous": null } - ], - "meta": { - "page": 1, - "perPage": 20, - "total": 2, - "totalPages": 1 - }, - "links": { - "self": "http://example.com/users?page=1", - "first": "http://example.com/users?page=1", - "last": "http://example.com/users?page=1", - "next": null, - "previous": null } -} -``` The ``paginate()`` method will always wrap the results in a ``data`` element, and will also include ``meta`` and ``links`` elements to help the client page through the results. If there are no results, the ``data`` element will From 4064c52ed478ff9727fec230847968ea3bf8ecec Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 14 Oct 2025 22:32:28 +0200 Subject: [PATCH 4/5] docs(app): Added missing label to docs --- .gitignore | 3 +++ user_guide_src/source/changelogs/v4.7.0.rst | 2 +- user_guide_src/source/outgoing/api_responses.rst | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 988be09b1948..485512fa4a7b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ $RECYCLE.BIN/ .env .vagrant Vagrantfile +user_guide_src/venv/ +.python-version +user_guide_src/.python-version #------------------------- # Temporary Files diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index 308943ef6228..bd93171be60e 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -81,7 +81,7 @@ Libraries - **Email:** Added support for choosing the SMTP authorization method. You can change it via ``Config\Email::$SMTPAuthMethod`` option. - **Image:** The ``ImageMagickHandler`` has been rewritten to rely solely on the PHP ``imagick`` extension. - **Image:** Added ``ImageMagickHandler::clearMetadata()`` method to remove image metadata for privacy protection. -- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. +- **ResponseTrait:** Added ``paginate``` method to simplify paginated API responses. See :ref:`ResponseTrait::paginate() ` for details. - **Time:** added methods ``Time::addCalendarMonths()`` and ``Time::subCalendarMonths()`` Commands diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index f067a738e2f8..645951cf4347 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -251,6 +251,7 @@ Class Reference .. literalinclude:: api_responses/017.php +.. _api_response_trait_paginate: ******************** Pagination Responses From 6e297169c1123d39f0bf88dd7c157c8e36cf0a7f Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Tue, 14 Oct 2025 22:36:54 +0200 Subject: [PATCH 5/5] chore(app): PHPStan baseline --- system/API/ResponseTrait.php | 1 - .../function.alreadyNarrowedType.neon | 13 +++++++++++++ .../function.impossibleType.neon | 18 ++++++++++++++++++ utils/phpstan-baseline/loader.neon | 4 +++- 4 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 utils/phpstan-baseline/function.alreadyNarrowedType.neon create mode 100644 utils/phpstan-baseline/function.impossibleType.neon diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index a7d3f136aa6d..cc04fa509fd9 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -326,7 +326,6 @@ protected function format($data = null) // if we don't have a formatter, make one $this->formatter ??= $format->getFormatter($mime); - // @phpstan-ignore function.impossibleType, function.alreadyNarrowedType (trait used in contexts with/without this property) $asHtml = property_exists($this, 'stringAsHtml') ? $this->stringAsHtml : false; if ( diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon new file mode 100644 index 000000000000..7f6d5a1b1fdd --- /dev/null +++ b/utils/phpstan-baseline/function.alreadyNarrowedType.neon @@ -0,0 +1,13 @@ +# total 2 errors + +parameters: + ignoreErrors: + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:188\) and ''stringAsHtml'' will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:308\) and ''stringAsHtml'' will always evaluate to true\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/function.impossibleType.neon b/utils/phpstan-baseline/function.impossibleType.neon new file mode 100644 index 000000000000..431a49bc5aed --- /dev/null +++ b/utils/phpstan-baseline/function.impossibleType.neon @@ -0,0 +1,18 @@ +# total 3 errors + +parameters: + ignoreErrors: + - + message: '#^Call to function property_exists\(\) with \$this\(CodeIgniter\\Debug\\ExceptionHandler\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../system/Debug/ExceptionHandler.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:130\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php + + - + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:628\) and ''stringAsHtml'' will always evaluate to false\.$#' + count: 1 + path: ../../tests/system/API/ResponseTraitTest.php diff --git a/utils/phpstan-baseline/loader.neon b/utils/phpstan-baseline/loader.neon index c1fa33e7414b..07dad94ede7f 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2767 errors +# total 2766 errors includes: - argument.type.neon @@ -10,6 +10,8 @@ includes: - codeigniter.superglobalAccessAssign.neon - deadCode.unreachable.neon - empty.notAllowed.neon + - function.alreadyNarrowedType.neon + - function.impossibleType.neon - method.alreadyNarrowedType.neon - method.childParameterType.neon - method.childReturnType.neon