From 12349a049644da08e786e3595eadc77bb16c1eaa Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 00:38:07 +0200 Subject: [PATCH 01/12] feat(app): Added API Transformers --- app/Transformers/Customer.php | 22 + system/API/ApiException.php | 71 ++ system/API/BaseTransformer.php | 224 ++++++ system/API/ResponseTrait.php | 20 +- system/API/TransformerInterface.php | 32 + .../Generators/TransformerGenerator.php | 94 +++ .../Generators/Views/transformer.tpl.php | 22 + system/Language/en/Api.php | 21 + system/Language/en/CLI.php | 25 +- tests/_support/API/InvalidTransformer.php | 26 + tests/_support/API/TestTransformer.php | 39 + tests/system/API/ResponseTraitTest.php | 158 ++++ tests/system/API/TransformerTest.php | 713 ++++++++++++++++++ .../Commands/TransformerGeneratorTest.php | 99 +++ user_guide_src/source/changelogs/v4.7.0.rst | 1 + user_guide_src/source/cli/cli_generators.rst | 21 + .../source/outgoing/api_responses.rst | 12 +- .../source/outgoing/api_responses/020.php | 18 + .../source/outgoing/api_transformers.rst | 417 ++++++++++ .../source/outgoing/api_transformers/001.php | 24 + .../source/outgoing/api_transformers/002.php | 18 + .../source/outgoing/api_transformers/003.php | 33 + .../source/outgoing/api_transformers/004.php | 18 + .../source/outgoing/api_transformers/005.php | 18 + .../source/outgoing/api_transformers/006.php | 17 + .../source/outgoing/api_transformers/007.php | 22 + .../source/outgoing/api_transformers/008.php | 25 + .../source/outgoing/api_transformers/009.php | 32 + .../source/outgoing/api_transformers/010.php | 48 ++ .../source/outgoing/api_transformers/011.php | 23 + .../source/outgoing/api_transformers/012.php | 21 + .../source/outgoing/api_transformers/013.php | 13 + .../source/outgoing/api_transformers/014.php | 12 + .../source/outgoing/api_transformers/015.php | 11 + .../source/outgoing/api_transformers/016.php | 21 + .../source/outgoing/api_transformers/017.php | 19 + .../source/outgoing/api_transformers/018.php | 17 + .../source/outgoing/api_transformers/019.php | 13 + .../source/outgoing/api_transformers/020.php | 20 + .../source/outgoing/api_transformers/021.php | 18 + .../source/outgoing/api_transformers/022.php | 25 + .../source/outgoing/api_transformers/023.php | 39 + user_guide_src/source/outgoing/index.rst | 1 + 43 files changed, 2529 insertions(+), 14 deletions(-) create mode 100644 app/Transformers/Customer.php create mode 100644 system/API/ApiException.php create mode 100644 system/API/BaseTransformer.php create mode 100644 system/API/TransformerInterface.php create mode 100644 system/Commands/Generators/TransformerGenerator.php create mode 100644 system/Commands/Generators/Views/transformer.tpl.php create mode 100644 system/Language/en/Api.php create mode 100644 tests/_support/API/InvalidTransformer.php create mode 100644 tests/_support/API/TestTransformer.php create mode 100644 tests/system/API/TransformerTest.php create mode 100644 tests/system/Commands/TransformerGeneratorTest.php create mode 100644 user_guide_src/source/outgoing/api_responses/020.php create mode 100644 user_guide_src/source/outgoing/api_transformers.rst create mode 100644 user_guide_src/source/outgoing/api_transformers/001.php create mode 100644 user_guide_src/source/outgoing/api_transformers/002.php create mode 100644 user_guide_src/source/outgoing/api_transformers/003.php create mode 100644 user_guide_src/source/outgoing/api_transformers/004.php create mode 100644 user_guide_src/source/outgoing/api_transformers/005.php create mode 100644 user_guide_src/source/outgoing/api_transformers/006.php create mode 100644 user_guide_src/source/outgoing/api_transformers/007.php create mode 100644 user_guide_src/source/outgoing/api_transformers/008.php create mode 100644 user_guide_src/source/outgoing/api_transformers/009.php create mode 100644 user_guide_src/source/outgoing/api_transformers/010.php create mode 100644 user_guide_src/source/outgoing/api_transformers/011.php create mode 100644 user_guide_src/source/outgoing/api_transformers/012.php create mode 100644 user_guide_src/source/outgoing/api_transformers/013.php create mode 100644 user_guide_src/source/outgoing/api_transformers/014.php create mode 100644 user_guide_src/source/outgoing/api_transformers/015.php create mode 100644 user_guide_src/source/outgoing/api_transformers/016.php create mode 100644 user_guide_src/source/outgoing/api_transformers/017.php create mode 100644 user_guide_src/source/outgoing/api_transformers/018.php create mode 100644 user_guide_src/source/outgoing/api_transformers/019.php create mode 100644 user_guide_src/source/outgoing/api_transformers/020.php create mode 100644 user_guide_src/source/outgoing/api_transformers/021.php create mode 100644 user_guide_src/source/outgoing/api_transformers/022.php create mode 100644 user_guide_src/source/outgoing/api_transformers/023.php diff --git a/app/Transformers/Customer.php b/app/Transformers/Customer.php new file mode 100644 index 000000000000..bb0a0c5ccc2c --- /dev/null +++ b/app/Transformers/Customer.php @@ -0,0 +1,22 @@ + + */ + public function toArray(mixed $resource): array + { + return [ + // Add your transformation logic here + ]; + } +} diff --git a/system/API/ApiException.php b/system/API/ApiException.php new file mode 100644 index 000000000000..809c89a4444b --- /dev/null +++ b/system/API/ApiException.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +/** + * Custom exception for API-related errors. + */ +class ApiException extends \Exception +{ + /** + * Thrown when the fields requested in a URL are not valid. + * + * @return ApiException + */ + public static function forInvalidFields(string $field): self + { + return new self(lang('Api.invalidFields', [$field])); + } + + /** + * Thrown when the includes requested in a URL are not valid. + * + * @return ApiException + */ + public static function forInvalidIncludes(string $include): self + { + return new self(lang('Api.invalidIncludes', [$include])); + } + + /** + * Thrown when an include is requested, but the method to handle it + * does not exist on the model. + * + * @return ApiException + */ + public static function forMissingInclude(string $include): self + { + return new self(lang('Api.missingInclude', [$include])); + } + + /** + * Thrown when a transformer class cannot be found. + * + * @return ApiException + */ + public static function forTransformerNotFound(string $transformerClass): self + { + return new self(lang('Api.transformerNotFound', [$transformerClass])); + } + + /** + * Thrown when a transformer class does not implement TransformerInterface. + * + * @return ApiException + */ + public static function forInvalidTransformer(string $transformerClass): self + { + return new self(lang('Api.invalidTransformer', [$transformerClass])); + } +} diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php new file mode 100644 index 000000000000..4a9994cf9af5 --- /dev/null +++ b/system/API/BaseTransformer.php @@ -0,0 +1,224 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\HTTP\IncomingRequest; +use InvalidArgumentException; + +/** + * Base class for transforming resources into arrays. + * Fulfills common functionality of the TransformerInterface, + * and provides helper methods for conditional inclusion/exclusion of values. + * + * Supports the following query variables from the request: + * - fields: Comma-separated list of fields to include in the response + * (e.g., ?fields=id,name,email) + * If not provided, all fields from toArray() are included. + * - include: Comma-separated list of related resources to include + * (e.g., ?include=posts,comments) + * This looks for methods named `include{Resource}()` on the transformer, + * and calls them to get the related data, which are added as a new key to the output. + * + * Example: + * + * class UserTransformer extends BaseTransformer + * { + * public function toArray(mixed $resource): array + * { + * return [ + * 'id' => $resource['id'], + * 'name' => $resource['name'], + * 'email' => $resource['email'], + * 'created_at' => $resource['created_at'], + * 'updated_at' => $resource['updated_at'], + * 'bio' => $this->when(($resource['bio'] ?? null) !== null, $resource['bio'] ?? null), + * ]; + * } + * + * protected function includePosts(): array + * { + * $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + * return (new PostTransformer())->transformMany($posts); + * } + * } + */ +abstract class BaseTransformer implements TransformerInterface +{ + private ?array $fields = null; + private ?array $includes = null; + protected mixed $resource = null; + + public function __construct( + private ?IncomingRequest $request = null, + ) + { + $this->request = $request ?? request(); + + $fields = $this->request->getGet('fields'); + $this->fields = is_string($fields) + ? array_map('trim', explode(',', $fields)) + : $fields; + + $includes = $this->request->getGet('include'); + $this->includes = is_string($includes) + ? array_map('trim', explode(',', $includes)) + : $includes; + } + + /** + * Converts the resource to an array representation. + * This is overridden by child classes to define the + * API-safe resource representation. + * + * @param mixed $resource The resource being transformed + */ + abstract public function toArray(mixed $resource): array; + + /** + * Transforms the given resource into an array using + * the $this->toArray(). + */ + public function transform(mixed $resource = null): array + { + // Store the resource so include methods can access it + $this->resource = $resource; + + if ($resource === null) { + $data = $this->toArray(null); + } else { + $data = $resource instanceof Entity + ? $this->toArray($resource->toArray()) + : $this->toArray((array) $resource); + } + + $data = $this->limitFields($data); + + return $this->insertIncludes($data); + } + + /** + * Transforms a collection of resources using $this->transform() on each item. + * + * If the request's 'fields' query variable is set, only those fields will be included + * in the transformed output. + */ + public function transformMany(array $resources): array + { + return array_map(fn($resource) => $this->transform($resource), $resources); + } + + /** + * Conditionally include a value. + * + * @param mixed $value + * @param mixed $default + * @return mixed + */ + protected function when(bool $condition, $value, $default = null) + { + return $condition ? $value : $default; + } + + /** + * Conditionally exclude a value. + */ + protected function whenNot(bool $condition, $value, $default = null) + { + return ! $condition ? $value : $default; + } + + /** + * Define which fields can be requested via the 'fields' query parameter. + * Override in child classes to restrict available fields. + * Return null to allow all fields from toArray(). + */ + protected function getAllowedFields(): ?array + { + return null; + } + + /** + * Define which related resources can be included via the 'include' query parameter. + * Override in child classes to restrict available includes. + * Return null to allow all includes that have corresponding methods. + * Return an empty array to disable all includes. + */ + protected function getAllowedIncludes(): ?array + { + return null; + } + + /** + * Limits the given data array to only the fields specified + * + * @throws InvalidArgumentException + */ + private function limitFields(array $data): array + { + if ($this->fields === null || $this->fields === []) { + return $data; + } + + $allowedFields = $this->getAllowedFields(); + + // If whitelist is defined, validate against it + if ($allowedFields !== null) { + $invalidFields = array_diff($this->fields, $allowedFields); + + if ($invalidFields !== []) { + throw ApiException::forInvalidFields(implode(', ', $invalidFields)); + } + } + + return array_intersect_key($data, array_flip($this->fields)); + } + + /** + * Checks the request for 'include' query variable, and if present, + * calls the corresponding include{Resource} methods to add related data. + */ + private function insertIncludes(array $data): array + { + if ($this->includes === null) { + return $data; + } + + $allowedIncludes = $this->getAllowedIncludes(); + + if ($allowedIncludes === []) { + return $data; // No includes allowed + } + + // If whitelist is defined, filter the requested includes + if ($allowedIncludes !== null) { + $invalidIncludes = array_diff($this->includes, $allowedIncludes); + + if ($invalidIncludes !== []) { + throw ApiException::forInvalidIncludes(implode(', ', $invalidIncludes)); + } + } + + foreach ($this->includes as $include) { + $method = 'include' . ucfirst($include); + if (method_exists($this, $method)) { + $data[$include] = $this->$method(); + } else { + throw ApiException::forMissingInclude($include); + } + } + + return $data; + } +} diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index cc04fa509fd9..22c899c6bc7d 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -394,7 +394,7 @@ protected function setResponseFormat(?string $format = null) * ] * ] */ - protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface + protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface { try { assert($this->request instanceof IncomingRequest); @@ -426,6 +426,21 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res ]; } + // Transform data if a transformer is provided + if ($transformWith !== null) { + if (! class_exists($transformWith)) { + throw ApiException::forTransformerNotFound($transformWith); + } + + $transformer = new $transformWith($this->request); + + if (! $transformer instanceof TransformerInterface) { + throw ApiException::forInvalidTransformer($transformWith); + } + + $data = $transformer->transformMany($data); + } + $links = $this->buildLinks($meta); $this->response->setHeader('Link', $this->linkHeader($links)); @@ -436,6 +451,9 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res 'meta' => $meta, 'links' => $links, ]); + } catch (ApiException $e) { + // Re-throw ApiExceptions so they can be handled by the caller + throw $e; } catch (DatabaseException $e) { log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage()); diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php new file mode 100644 index 000000000000..4d5a7865b7fa --- /dev/null +++ b/system/API/TransformerInterface.php @@ -0,0 +1,32 @@ +transform() on each item. + */ + public function transformMany(array $resources): array; +} diff --git a/system/Commands/Generators/TransformerGenerator.php b/system/Commands/Generators/TransformerGenerator.php new file mode 100644 index 000000000000..6e9143b5dc48 --- /dev/null +++ b/system/Commands/Generators/TransformerGenerator.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands\Generators; + +use CodeIgniter\CLI\BaseCommand; +use CodeIgniter\CLI\GeneratorTrait; + +/** + * Generates a skeleton transformer file. + */ +class TransformerGenerator extends BaseCommand +{ + use GeneratorTrait; + + /** + * The Command's Group + * + * @var string + */ + protected $group = 'Generators'; + + /** + * The Command's Name + * + * @var string + */ + protected $name = 'make:transformer'; + + /** + * The Command's Description + * + * @var string + */ + protected $description = 'Generates a new transformer file.'; + + /** + * The Command's Usage + * + * @var string + */ + protected $usage = 'make:transformer [options]'; + + /** + * The Command's Arguments + * + * @var array + */ + protected $arguments = [ + 'name' => 'The transformer class name.', + ]; + + /** + * The Command's Options + * + * @var array + */ + protected $options = [ + '--namespace' => 'Set root namespace. Default: "APP_NAMESPACE".', + '--suffix' => 'Append the component title to the class name (e.g. User => UserTransformer).', + '--force' => 'Force overwrite existing file.', + ]; + + /** + * Actually execute a command. + */ + public function run(array $params) + { + $this->component = 'Transformer'; + $this->directory = 'Transformers'; + $this->template = 'transformer.tpl.php'; + + $this->classNameLang = 'CLI.generator.className.transformer'; + $this->generateClass($params); + } + + /** + * Prepare options and do the necessary replacements. + */ + protected function prepare(string $class): string + { + return $this->parseTemplate($class); + } +} diff --git a/system/Commands/Generators/Views/transformer.tpl.php b/system/Commands/Generators/Views/transformer.tpl.php new file mode 100644 index 000000000000..f06b2e81b897 --- /dev/null +++ b/system/Commands/Generators/Views/transformer.tpl.php @@ -0,0 +1,22 @@ +<@php + +namespace {namespace}; + +use CodeIgniter\API\BaseTransformer; + +class {class} extends BaseTransformer +{ + /** + * Transform the resource into an array. + * + * @param mixed $resource + * + * @return array + */ + public function toArray(mixed $resource): array + { + return [ + // Add your transformation logic here + ]; + } +} diff --git a/system/Language/en/Api.php b/system/Language/en/Api.php new file mode 100644 index 000000000000..251652f9313a --- /dev/null +++ b/system/Language/en/Api.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +// API language settings +return [ + 'invalidFields' => 'Invalid field requested: {0}', + 'invalidIncludes' => 'Invalid include requested: {0}', + 'missingInclude' => 'Missing include method for: {0}', + 'transformerNotFound' => 'Transformer class \'{0}\' not found.', + 'invalidTransformer' => 'Transformer class \'{0}\' must implement TransformerInterface.', +]; diff --git a/system/Language/en/CLI.php b/system/Language/en/CLI.php index 247cd0158331..01e60c402955 100644 --- a/system/Language/en/CLI.php +++ b/system/Language/en/CLI.php @@ -19,18 +19,19 @@ 'generator' => [ 'cancelOperation' => 'Operation has been cancelled.', 'className' => [ - 'cell' => 'Cell class name', - 'command' => 'Command class name', - 'config' => 'Config class name', - 'controller' => 'Controller class name', - 'default' => 'Class name', - 'entity' => 'Entity class name', - 'filter' => 'Filter class name', - 'migration' => 'Migration class name', - 'model' => 'Model class name', - 'seeder' => 'Seeder class name', - 'test' => 'Test class name', - 'validation' => 'Validation class name', + 'cell' => 'Cell class name', + 'command' => 'Command class name', + 'config' => 'Config class name', + 'controller' => 'Controller class name', + 'default' => 'Class name', + 'entity' => 'Entity class name', + 'filter' => 'Filter class name', + 'migration' => 'Migration class name', + 'model' => 'Model class name', + 'seeder' => 'Seeder class name', + 'test' => 'Test class name', + 'transformer' => 'Transformer class name', + 'validation' => 'Validation class name', ], 'commandType' => 'Command type', 'databaseGroup' => 'Database group', diff --git a/tests/_support/API/InvalidTransformer.php b/tests/_support/API/InvalidTransformer.php new file mode 100644 index 000000000000..5e3c7e524778 --- /dev/null +++ b/tests/_support/API/InvalidTransformer.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +/** + * Invalid transformer for testing error handling + * Does not implement TransformerInterface + */ +class InvalidTransformer +{ + public function toArray(mixed $resource): array + { + return []; + } +} diff --git a/tests/_support/API/TestTransformer.php b/tests/_support/API/TestTransformer.php new file mode 100644 index 000000000000..6814d5643839 --- /dev/null +++ b/tests/_support/API/TestTransformer.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\API; + +use CodeIgniter\API\BaseTransformer; + +/** + * Test transformer for testing paginate() with transformers + */ +class TestTransformer extends BaseTransformer +{ + /** + * Transform the resource into an array. + * + * @param mixed $resource + * + * @return array + */ + public function toArray(mixed $resource): array + { + return [ + 'id' => $resource['id'] ?? null, + 'name' => $resource['name'] ?? null, + 'transformed' => true, + 'name_upper' => isset($resource['name']) ? strtoupper($resource['name']) : null, + ]; + } +} diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index cc8748ce1b4a..440deaffb02c 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -1053,4 +1053,162 @@ public function testPaginateSinglePage(): void $this->assertStringContainsString('page=1', (string) $responseBody['links']['first']); $this->assertStringContainsString('page=1', (string) $responseBody['links']['last']); } + + public function testPaginateWithTransformer(): 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, \Tests\Support\API\TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is transformed + $this->assertArrayHasKey('data', $responseBody); + $this->assertCount(2, $responseBody['data']); + + // Check first item is transformed + $this->assertArrayHasKey('transformed', $responseBody['data'][0]); + $this->assertTrue($responseBody['data'][0]['transformed']); + $this->assertArrayHasKey('name_upper', $responseBody['data'][0]); + $this->assertSame('ITEM 1', $responseBody['data'][0]['name_upper']); + + // Check second item is transformed + $this->assertArrayHasKey('transformed', $responseBody['data'][1]); + $this->assertTrue($responseBody['data'][1]['transformed']); + $this->assertArrayHasKey('name_upper', $responseBody['data'][1]); + $this->assertSame('ITEM 2', $responseBody['data'][1]['name_upper']); + + // Meta and links should still be present + $this->assertArrayHasKey('meta', $responseBody); + $this->assertArrayHasKey('links', $responseBody); + } + + public function testPaginateWithTransformerAndQueryBuilder(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ]; + + // 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); + $result->method('getResultArray')->willReturn($data); + + $builder->method('countAllResults')->willReturn(2); + $builder->method('limit')->willReturnSelf(); + $builder->method('get')->willReturn($result); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$builder, 20, \Tests\Support\API\TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is transformed + $this->assertArrayHasKey('data', $responseBody); + $this->assertCount(2, $responseBody['data']); + $this->assertTrue($responseBody['data'][0]['transformed']); + $this->assertSame('ITEM 1', $responseBody['data'][0]['name_upper']); + } + + public function testPaginateWithNonExistentTransformer(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 1, 1); + + $controller = $this->makeController('/api/items'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.transformerNotFound', ['NonExistent\\Transformer'])); + + $this->invoke($controller, 'paginate', [$model, 20, 'NonExistent\\Transformer']); + } + + public function testPaginateWithInvalidTransformer(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 20, 1, 1); + + $controller = $this->makeController('/api/items'); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.invalidTransformer', [\Tests\Support\API\InvalidTransformer::class])); + + $this->invoke($controller, 'paginate', [$model, 20, \Tests\Support\API\InvalidTransformer::class]); + } + + public function testPaginateWithTransformerPreservesMetaAndLinks(): void + { + $data = [ + ['id' => 1, 'name' => 'Item 1'], + ['id' => 2, 'name' => 'Item 2'], + ['id' => 3, 'name' => 'Item 3'], + ]; + + $model = $this->createMockModelWithPager($data, 1, 2, 10, 5); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 2, \Tests\Support\API\TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check meta is correct + $this->assertSame(1, $responseBody['meta']['page']); + $this->assertSame(2, $responseBody['meta']['perPage']); + $this->assertSame(10, $responseBody['meta']['total']); + $this->assertSame(5, $responseBody['meta']['totalPages']); + + // Check links are present + $this->assertArrayHasKey('self', $responseBody['links']); + $this->assertArrayHasKey('first', $responseBody['links']); + $this->assertArrayHasKey('last', $responseBody['links']); + $this->assertArrayHasKey('next', $responseBody['links']); + $this->assertArrayHasKey('prev', $responseBody['links']); + + // Check headers + $this->assertSame('10', $this->response->getHeaderLine('X-Total-Count')); + $this->assertNotEmpty($this->response->getHeaderLine('Link')); + } + + public function testPaginateWithTransformerEmptyData(): void + { + $data = []; + + $model = $this->createMockModelWithPager($data, 1, 20, 0, 0); + + $controller = $this->makeController('/api/items'); + + $this->invoke($controller, 'paginate', [$model, 20, \Tests\Support\API\TestTransformer::class]); + + $responseBody = json_decode($this->response->getBody(), true); + + // Check that data is empty array + $this->assertArrayHasKey('data', $responseBody); + $this->assertSame([], $responseBody['data']); + + // Meta should show no results + $this->assertSame(0, $responseBody['meta']['total']); + $this->assertSame(0, $responseBody['meta']['totalPages']); + } } diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php new file mode 100644 index 000000000000..a62a650a5243 --- /dev/null +++ b/tests/system/API/TransformerTest.php @@ -0,0 +1,713 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Entity\Entity; +use CodeIgniter\HTTP\IncomingRequest; +use CodeIgniter\HTTP\SiteURI; +use CodeIgniter\HTTP\UserAgent; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockAppConfig; +use PHPUnit\Framework\Attributes\Group; +use stdClass; + +/** + * @internal + */ +#[Group('Others')] +final class BaseTransformerTest extends CIUnitTestCase +{ + private function createMockRequest(string $query = ''): IncomingRequest + { + $config = new MockAppConfig(); + $uri = new SiteURI($config, 'http://example.com/test' . ($query !== '' ? '?' . $query : '')); + $userAgent = new UserAgent(); + + $request = $this->getMockBuilder(IncomingRequest::class) + ->setConstructorArgs([$config, $uri, null, $userAgent]) + ->onlyMethods(['isCLI']) + ->getMock(); + $request->method('isCLI')->willReturn(false); + + // Parse query string and set GET globals + if ($query !== '') { + parse_str($query, $get); + $request->setGlobal('get', $get); + } + + return $request; + } + + public function testConstructorWithNoRequest(): void + { + $transformer = new class () extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(null); + + $this->assertIsArray($result); + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testConstructorWithRequest(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(null); + + $this->assertIsArray($result); + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testTransformWithNull(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => 1, 'name' => 'Test']; + } + }; + + $result = $transformer->transform(); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testTransformWithEntity(): void + { + $request = $this->createMockRequest(); + $entity = new class () extends Entity { + protected $attributes = [ + 'id' => 1, + 'name' => 'Test Entity', + ]; + }; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($entity); + + $this->assertSame(['id' => 1, 'name' => 'Test Entity'], $result); + } + + public function testTransformWithArray(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test Array']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test Array'], $result); + } + + public function testTransformWithObject(): void + { + $request = $this->createMockRequest(); + $object = new stdClass(); + $object->id = 1; + $object->name = 'Test Object'; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($object); + + $this->assertSame(['id' => 1, 'name' => 'Test Object'], $result); + } + + public function testTransformMany(): void + { + $request = $this->createMockRequest(); + $data = [ + ['id' => 1, 'name' => 'First'], + ['id' => 2, 'name' => 'Second'], + ['id' => 3, 'name' => 'Third'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(3, $result); + $this->assertSame(['id' => 1, 'name' => 'First'], $result[0]); + $this->assertSame(['id' => 2, 'name' => 'Second'], $result[1]); + $this->assertSame(['id' => 3, 'name' => 'Third'], $result[2]); + } + + public function testTransformManyWithEmptyArray(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource ?? []; + } + }; + + $result = $transformer->transformMany([]); + + $this->assertSame([], $result); + } + + public function testWhenConditionIsTrue(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->when(true, 'Visible Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Visible Name'], $result); + } + + public function testWhenConditionIsFalse(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->when(false, 'Visible Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => null], $result); + } + + public function testWhenConditionIsFalseWithDefault(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->when(false, 'Visible Name', 'Default Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Default Name'], $result); + } + + public function testWhenNotConditionIsTrue(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->whenNot(true, 'Visible Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => null], $result); + } + + public function testWhenNotConditionIsFalse(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->whenNot(false, 'Visible Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Visible Name'], $result); + } + + public function testWhenNotConditionIsTrueWithDefault(): void + { + $request = $this->createMockRequest(); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return [ + 'id' => 1, + 'name' => $this->whenNot(true, 'Visible Name', 'Default Name'), + ]; + } + }; + + $result = $transformer->transform(null); + + $this->assertSame(['id' => 1, 'name' => 'Default Name'], $result); + } + + public function testLimitFieldsWithNoFieldsParam(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test', 'email' => 'test@example.com'], $result); + } + + public function testLimitFieldsWithFieldsParam(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testLimitFieldsWithSingleField(): void + { + $request = $this->createMockRequest('fields=name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['name' => 'Test'], $result); + } + + public function testLimitFieldsWithSpaces(): void + { + $request = $this->createMockRequest('fields=id, name, email'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com', 'bio' => 'Bio']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test', 'email' => 'test@example.com'], $result); + } + + public function testLimitFieldsWithAllowedFieldsValidation(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedFields(): ?array + { + return ['id', 'name', 'email']; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + } + + public function testLimitFieldsThrowsExceptionForInvalidField(): void + { + $this->expectException(ApiException::class); + + $request = $this->createMockRequest('fields=id,password'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedFields(): ?array + { + return ['id', 'name', 'email']; + } + }; + + $transformer->transform($data); + } + + public function testInsertIncludesWithNoIncludeParam(): void + { + $request = $this->createMockRequest(); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + $this->assertArrayNotHasKey('posts', $result); + } + + public function testInsertIncludesWithIncludeParam(): void + { + $request = $this->createMockRequest('include=posts'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + ], $result); + } + + public function testInsertIncludesWithMultipleIncludes(): void + { + $request = $this->createMockRequest('include=posts,comments'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + + protected function includeComments(): array + { + return [['id' => 1, 'text' => 'Comment 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + 'comments' => [['id' => 1, 'text' => 'Comment 1']], + ], $result); + } + + public function testInsertIncludesThrowsExceptionForNonExistentMethod(): void + { + $request = $this->createMockRequest('include=posts,nonexistent'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['nonexistent'])); + + $transformer->transform($data); + } + + public function testInsertIncludesWithEmptyAllowedIncludes(): void + { + $request = $this->createMockRequest('include=posts'); + $data = ['id' => 1, 'name' => 'Test']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function getAllowedIncludes(): ?array + { + return []; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame(['id' => 1, 'name' => 'Test'], $result); + $this->assertArrayNotHasKey('posts', $result); + } + + public function testCombinedFieldsAndIncludes(): void + { + $request = $this->createMockRequest('fields=id,name&include=posts'); + $data = ['id' => 1, 'name' => 'Test', 'email' => 'test@example.com']; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transform($data); + + $this->assertSame([ + 'id' => 1, + 'name' => 'Test', + 'posts' => [['id' => 1, 'title' => 'Post 1']], + ], $result); + $this->assertArrayNotHasKey('email', $result); + } + + public function testTransformManyWithFieldsFilter(): void + { + $request = $this->createMockRequest('fields=id,name'); + $data = [ + ['id' => 1, 'name' => 'First', 'email' => 'first@example.com'], + ['id' => 2, 'name' => 'Second', 'email' => 'second@example.com'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(2, $result); + $this->assertSame(['id' => 1, 'name' => 'First'], $result[0]); + $this->assertSame(['id' => 2, 'name' => 'Second'], $result[1]); + } + + public function testTransformManyWithIncludes(): void + { + $request = $this->createMockRequest('include=posts'); + $data = [ + ['id' => 1, 'name' => 'First'], + ['id' => 2, 'name' => 'Second'], + ]; + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return $resource; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $result = $transformer->transformMany($data); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('posts', $result[0]); + $this->assertArrayHasKey('posts', $result[1]); + } + + public function testTransformThrowsExceptionForInvalidInclude(): void + { + $request = $this->createMockRequest('include=nonexistent'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['nonexistent'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformThrowsExceptionForMissingIncludeMethod(): void + { + $request = $this->createMockRequest('include=invalid'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['invalid'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformWithMultipleIncludesValidatesAll(): void + { + $request = $this->createMockRequest('include=posts,invalid'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $this->expectException(ApiException::class); + $this->expectExceptionMessage(lang('Api.missingInclude', ['invalid'])); + + $data = ['id' => 1, 'name' => 'Test']; + $transformer->transform($data); + } + + public function testTransformWithValidIncludeDoesNotThrowException(): void + { + $request = $this->createMockRequest('include=posts'); + + $transformer = new class ($request) extends BaseTransformer { + public function toArray(mixed $resource): array + { + return ['id' => $resource['id'], 'name' => $resource['name']]; + } + + protected function includePosts(): array + { + return [['id' => 1, 'title' => 'Post 1']]; + } + }; + + $data = ['id' => 1, 'name' => 'Test']; + $result = $transformer->transform($data); + + $this->assertArrayHasKey('posts', $result); + $this->assertSame([['id' => 1, 'title' => 'Post 1']], $result['posts']); + } +} diff --git a/tests/system/Commands/TransformerGeneratorTest.php b/tests/system/Commands/TransformerGeneratorTest.php new file mode 100644 index 000000000000..1439f63d528f --- /dev/null +++ b/tests/system/Commands/TransformerGeneratorTest.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\Commands; + +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\StreamFilterTrait; +use CodeIgniter\API\ApiException; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[Group('Others')] +final class TransformerGeneratorTest extends CIUnitTestCase +{ + use StreamFilterTrait; + + protected function tearDown(): void + { + $result = str_replace(["\033[0;32m", "\033[0m", "\n"], '', $this->getStreamFilterBuffer()); + $file = str_replace('APPPATH' . DIRECTORY_SEPARATOR, APPPATH, trim(substr($result, 14))); + if (is_file($file)) { + unlink($file); + } + } + + protected function getFileContents(string $filepath): string + { + if (! is_file($filepath)) { + return ''; + } + + return file_get_contents($filepath) ?: ''; + } + + public function testGenerateTransformer(): void + { + command('make:transformer user'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/User.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('extends BaseTransformer', $contents); + $this->assertStringContainsString('namespace App\Transformers', $contents); + $this->assertStringContainsString('public function toArray(mixed $resource): array', $contents); + } + + public function testGenerateTransformerWithSubdirectory(): void + { + command('make:transformer api/v1/product'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/Api/V1/Product.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('namespace App\Transformers\Api\V1', $contents); + $this->assertStringContainsString('class Product extends BaseTransformer', $contents); + } + + public function testGenerateTransformerWithOptionSuffix(): void + { + command('make:transformer order -suffix'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/OrderTransformer.php'; + $this->assertFileExists($file); + $contents = $this->getFileContents($file); + $this->assertStringContainsString('class OrderTransformer extends BaseTransformer', $contents); + } + + public function testGenerateTransformerWithOptionForce(): void + { + // Create the file first + command('make:transformer customer'); + $this->assertStringContainsString('File created: ', $this->getStreamFilterBuffer()); + $file = APPPATH . 'Transformers/Customer.php'; + $this->assertFileExists($file); + + // Try to overwrite without force + $this->resetStreamFilterBuffer(); + command('make:transformer customer'); + $this->assertStringContainsString('File exists: ', $this->getStreamFilterBuffer()); + + // Now overwrite with force + $this->resetStreamFilterBuffer(); + command('make:transformer customer -force'); + $this->assertStringContainsString('File overwritten: ', $this->getStreamFilterBuffer()); + $this->assertFileExists($file); + } +} diff --git a/user_guide_src/source/changelogs/v4.7.0.rst b/user_guide_src/source/changelogs/v4.7.0.rst index bd93171be60e..565505b9b18e 100644 --- a/user_guide_src/source/changelogs/v4.7.0.rst +++ b/user_guide_src/source/changelogs/v4.7.0.rst @@ -73,6 +73,7 @@ Enhancements Libraries ========= +- **API Transformers:** This new feature provides a structured way to transform data for API responses. See :ref:`API Transformers ` for details. - **CLI:** Added ``SignalTrait`` to provide unified handling of operating system signals in CLI commands. - **CURLRequest:** Added ``shareConnection`` config item to change default share connection. - **CURLRequest:** Added ``dns_cache_timeout`` option to change default DNS cache timeout. diff --git a/user_guide_src/source/cli/cli_generators.rst b/user_guide_src/source/cli/cli_generators.rst index 86f577e7d064..e2710411069d 100644 --- a/user_guide_src/source/cli/cli_generators.rst +++ b/user_guide_src/source/cli/cli_generators.rst @@ -251,6 +251,27 @@ Options: * ``--namespace``: Set the root namespace. Defaults to value of ``Tests``. * ``--force``: Set this flag to overwrite existing files on destination. +make:transformer +---------------- + +Creates a new API transformer file. + +Usage: +====== +:: + + make:transformer [options] + +Argument: +========= +* ``name``: The name of the transformer class. **[REQUIRED]** + +Options: +======== +* ``--namespace``: Set the root namespace. Defaults to value of ``APP_NAMESPACE``. +* ``--suffix``: Append the component suffix to the generated class name. +* ``--force``: Set this flag to overwrite existing files on destination. + make:migration -------------- diff --git a/user_guide_src/source/outgoing/api_responses.rst b/user_guide_src/source/outgoing/api_responses.rst index 645951cf4347..e1c21e6dac08 100644 --- a/user_guide_src/source/outgoing/api_responses.rst +++ b/user_guide_src/source/outgoing/api_responses.rst @@ -312,12 +312,22 @@ name and any necessary joins or where clauses. Class Reference *************** -.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20) +.. php:method:: paginate(Model|BaseBuilder $resource, int $perPage = 20, ?string $transformWith = null) :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. + :param string|null $transformWith: Optional transformer class name to transform the results. 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. + + If you provide a ``$transformWith`` parameter with a transformer class name, each item in the paginated + results will be transformed using that transformer before being returned. This is useful for controlling + the structure and content of your API responses. See :ref:`API Transformers ` for more + information on creating and using transformers. + + Example with transformer: + + .. literalinclude:: api_responses/020.php diff --git a/user_guide_src/source/outgoing/api_responses/020.php b/user_guide_src/source/outgoing/api_responses/020.php new file mode 100644 index 000000000000..e54e8cc69fff --- /dev/null +++ b/user_guide_src/source/outgoing/api_responses/020.php @@ -0,0 +1,18 @@ +paginate(resource: $model, perPage: 20, transformWith: UserTransformer::class); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst new file mode 100644 index 000000000000..bf015d610cb8 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -0,0 +1,417 @@ +.. _api_transformers: + +############# +API Resources +############# + +When building APIs, you often need to transform your data models into a consistent format before sending +them to the client. API Resources, implemented through transformers, provide a clean way to convert your +entities, arrays, or objects into structured API responses. They help separate your internal data structure +from what you expose through your API, making it easier to maintain and evolve your API over time. + +.. contents:: + :local: + :depth: 2 + +***************** +Quick Example +***************** + +The following example shows a common usage pattern for transformers in your application. + +.. literalinclude:: api_resources/001.php + +In this example, the ``UserTransformer`` defines which fields from a user entity should be included in the +API response. The ``transform()`` method converts a single resource, while ``transformMany()`` handles +collections of resources. + +****************** +Creating a Transformer +****************** + +To create a transformer, extend the ``BaseTransformer`` class and implement the ``toArray()`` method to +define your API resource structure. The ``toArray()`` method receives the resource being transformed as +a parameter, allowing you to access and transform its data. + +Basic Transformer +================= + +.. literalinclude:: api_resources/002.php + +The ``toArray()`` method receives the resource (entity, array, or object) as its parameter and defines +the structure of your API response. You can include any fields you want from the resource, and you can +also rename or transform values as needed. + +Generating Transformer Files +============================= + +CodeIgniter provides a CLI command to quickly generate transformer skeleton files: + +.. code-block:: console + + php spark make:transformer User + +This creates a new transformer file at **app/Transformers/User.php** with the basic structure already in place. + +Command Options +--------------- + +The ``make:transformer`` command supports several options: + +**--suffix** + Appends "Transformer" to the class name: + + .. code-block:: console + + php spark make:transformer User --suffix + + Creates **app/Transformers/UserTransformer.php** + +**--namespace** + Specifies a custom root namespace: + + .. code-block:: console + + php spark make:transformer User --namespace="MyCompany\\API" + +**--force** + Forces overwriting an existing file: + + .. code-block:: console + + php spark make:transformer User --force + +Subdirectories +-------------- + +You can organize transformers into subdirectories by including the path in the name: + +.. code-block:: console + + php spark make:transformer api/v1/User + +This creates **app/Transformers/Api/V1/User.php** with the appropriate namespace ``App\Transformers\Api\V1``. + +Using Transformers in Controllers +================================== + +Once you've created a transformer, you can use it in your controllers to transform data before returning +it to the client. + +.. literalinclude:: api_resources/003.php + +*********************** +Conditional Fields +*********************** + +Often you'll want to include certain fields only under specific conditions. The ``when()`` and ``whenNot()`` +methods make this easy. + +Using when() +============ + +The ``when()`` method includes a value only if the condition is true: + +.. literalinclude:: api_resources/004.php + +In this example, the ``bio`` field will only be included if it has a non-null value. If the condition is +false, the field will be set to ``null`` by default. + +Providing a Default Value +========================== + +You can provide a default value to use when the condition is false: + +.. literalinclude:: api_resources/005.php + +Using whenNot() +=============== + +The ``whenNot()`` method is the opposite of ``when()`` - it includes a value only if the condition is false: + +.. literalinclude:: api_resources/006.php + +*********************** +Field Filtering +*********************** + +The transformer automatically supports field filtering through the ``fields`` query parameter of the current URL. +This allows API clients to request only specific fields they need, reducing bandwidth and improving performance. + +.. literalinclude:: api_resources/007.php + +A request to ``/users/1?fields=id,name`` would return only: + +.. code-block:: json + + { + "id": 1, + "name": "John Doe" + } + +Restricting Available Fields +============================= + +By default, clients can request any field defined in your ``toArray()`` method. You can restrict which +fields are allowed by overriding the ``getAllowedFields()`` method: + +.. literalinclude:: api_resources/008.php + +Now, even if a client requests ``/users/1?fields=email``, an ``ApiException`` will be thrown because +``email`` is not in the allowed fields list. + +*********************** +Including Related Resources +*********************** + +Transformers support loading of related resources through the ``include`` query parameter. This +follows a common API pattern where clients can specify which relationships they want included. +While relationships are the most frequent use case, you can include any additional data +you want by defining custom include methods. + +Defining Include Methods +========================= + +To support including related resources, create methods prefixed with ``include`` followed by the +resource name. Inside these methods, you can access the current resource being transformed via +``$this->resource``: + +.. literalinclude:: api_resources/009.php + +Note how the include methods use ``$this->resource['id']`` to access the ID of the user being transformed. +The ``$this->resource`` property is automatically set by the transformer when ``transform()`` is called. + +Clients can now request: ``/users/1?include=posts,comments`` + +The response would include: + +.. code-block:: json + + { + "id": 1, + "name": "John Doe", + "email": "john@example.com", + "posts": [ + { + "id": 1, + "title": "First Post" + } + ], + "comments": [ + { + "id": 1, + "content": "Great article!" + } + ] + } + +Restricting Available Includes +=============================== + +Similar to field filtering, you can restrict which relationships can be included by overriding the +``getAllowedIncludes()`` method: + +.. literalinclude:: api_resources/010.php + +If you want to disable all includes, return an empty array: + +.. literalinclude:: api_resources/011.php + +Include Validation +================== + +The transformer automatically validates that any requested includes have corresponding ``include*()`` methods +defined in your transformer class. If a client requests an include that doesn't exist, an ``ApiException`` +will be thrown. + +For example, if a client requests:: + + GET /api/users?include=invalid + +And your transformer doesn't have an ``includeInvalid()`` method, an exception will be thrown with the message: +"Missing include method for: invalid". + +This helps catch typos and prevents unexpected behavior. + +*********************** +Transforming Collections +*********************** + +The ``transformMany()`` method makes it easy to transform arrays of resources: + +.. literalinclude:: api_resources/012.php + +The ``transformMany()`` method applies the same transformation logic to each item in the collection, +including any field filtering or includes specified in the request. + +*********************** +Working with Different Data Types +*********************** + +Transformers can handle various data types, not just entities. + +Transforming Entities +===================== + +When you pass an ``Entity`` instance to ``transform()``, it automatically calls the entity's ``toArray()`` +method to get the data: + +.. literalinclude:: api_resources/013.php + +Transforming Arrays +=================== + +You can transform plain arrays as well: + +.. literalinclude:: api_resources/014.php + +Transforming Objects +==================== + +Any object can be cast to an array and transformed: + +.. literalinclude:: api_resources/015.php + +Using toArray() Only +==================== + +If you don't pass a resource to ``transform()``, it will use the data from your ``toArray()`` method: + +.. literalinclude:: api_resources/016.php + +*************** +Class Reference +*************** + +.. php:namespace:: CodeIgniter\API + +.. php:class:: BaseTransformer + + .. php:method:: __construct(?IncomingRequest $request = null) + + :param IncomingRequest|null $request: Optional request instance. If not provided, the global request will be used. + + Initializes the transformer and extracts the ``fields`` and ``include`` query parameters from the request. + + .. php:method:: toArray(mixed $resource) + + :param mixed $resource: The resource being transformed (Entity, array, object, or null) + :returns: The array representation of the resource + :rtype: array + + This abstract method must be implemented by child classes to define the structure of the API resource. + The resource parameter contains the data being transformed. Return an array with the fields you want + to include in the API response, accessing data from the ``$resource`` parameter. + + .. literalinclude:: api_resources/017.php + + .. php:method:: transform($resource = null) + + :param mixed $resource: The resource to transform (Entity, array, object, or null) + :returns: The transformed array + :rtype: array + + Transforms the given resource into an array by calling ``toArray()`` with the resource data. + If ``$resource`` is ``null``, passes ``null`` to ``toArray()``. + If it's an Entity, extracts its array representation first. Otherwise, casts it to an array. + + The resource is also stored in ``$this->resource`` so include methods can access it. + + The method automatically applies field filtering and includes based on query parameters. + + .. literalinclude:: api_resources/018.php + + .. php:method:: transformMany(array $resources) + + :param array $resources: The array of resources to transform + :returns: Array of transformed resources + :rtype: array + + Transforms a collection of resources by calling ``transform()`` on each item. Field filtering and + includes are applied consistently to all items. + + .. literalinclude:: api_resources/019.php + + .. php:method:: when(bool $condition, $value, $default = null) + + :param bool $condition: The condition to evaluate + :param mixed $value: The value to return if the condition is true + :param mixed $default: The value to return if the condition is false (defaults to ``null``) + :returns: The value or default based on the condition + :rtype: mixed + + Helper method to conditionally include a value in the transformation. Returns ``$value`` if + ``$condition`` is true, otherwise returns ``$default``. + + .. literalinclude:: api_resources/020.php + + .. php:method:: whenNot(bool $condition, $value, $default = null) + + :param bool $condition: The condition to evaluate + :param mixed $value: The value to return if the condition is false + :param mixed $default: The value to return if the condition is true (defaults to ``null``) + :returns: The value or default based on the condition + :rtype: mixed + + Helper method to conditionally exclude a value. Returns ``$value`` if ``$condition`` is false, + otherwise returns ``$default``. + + .. literalinclude:: api_resources/021.php + + .. php:method:: getAllowedFields() + + :returns: Array of allowed field names, or ``null`` to allow all fields + :rtype: array|null + + Override this method to restrict which fields can be requested via the ``fields`` query parameter. + Return ``null`` (the default) to allow all fields from ``toArray()``. Return an array of field names + to create a whitelist of allowed fields. + + .. literalinclude:: api_resources/022.php + + .. php:method:: getAllowedIncludes() + + :returns: Array of allowed include names, or ``null`` to allow all includes + :rtype: array|null + + Override this method to restrict which related resources can be included via the ``include`` query + parameter. Return ``null`` (the default) to allow all includes that have corresponding methods. + Return an array of include names to create a whitelist. Return an empty array to disable all includes. + + .. literalinclude:: api_resources/023.php + +*************** +Exception Reference +*************** + +.. php:namespace:: CodeIgniter\API + +.. php:class:: ApiException + + .. php:staticmethod:: forInvalidFields(string $field) + + :param string $field: The invalid field name(s) + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests a field via the ``fields`` query parameter that is not in the allowed + fields list. + + .. php:staticmethod:: forInvalidIncludes(string $include) + + :param string $include: The invalid include name(s) + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests an include via the ``include`` query parameter that is not in the + allowed includes list. + + .. php:staticmethod:: forMissingInclude(string $include) + + :param string $include: The missing include method name + :returns: ApiException instance + :rtype: ApiException + + Thrown when a client requests an include via the ``include`` query parameter, but the corresponding + ``include*()`` method does not exist in the transformer class. This validation ensures that all + requested includes have proper handler methods defined. diff --git a/user_guide_src/source/outgoing/api_transformers/001.php b/user_guide_src/source/outgoing/api_transformers/001.php new file mode 100644 index 000000000000..a8b209562e18 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/001.php @@ -0,0 +1,24 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + ]; + } +} + +// In your controller +$user = model('UserModel')->find(1); +$transformer = new UserTransformer(); + +return $this->respond($transformer->transform($user)); diff --git a/user_guide_src/source/outgoing/api_transformers/002.php b/user_guide_src/source/outgoing/api_transformers/002.php new file mode 100644 index 000000000000..1980e4951ca3 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/002.php @@ -0,0 +1,18 @@ + $resource['id'], + 'username' => $resource['name'], // Renaming the field + 'email' => $resource['email'], + 'member_since' => date('Y-m-d', strtotime($resource['created_at'])), // Formatting + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/003.php b/user_guide_src/source/outgoing/api_transformers/003.php new file mode 100644 index 000000000000..8f4616091c64 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/003.php @@ -0,0 +1,33 @@ +find($id); + + if (! $user) { + return $this->failNotFound('User not found'); + } + + $transformer = new UserTransformer(); + + return $this->respond($transformer->transform($user)); + } + + public function index() + { + $users = model('UserModel')->findAll(); + + $transformer = new UserTransformer(); + + return $this->respond($transformer->transformMany($users)); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/004.php b/user_guide_src/source/outgoing/api_transformers/004.php new file mode 100644 index 000000000000..d1f001eafe12 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/004.php @@ -0,0 +1,18 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'bio' => $this->when(($resource['bio'] ?? null) !== null, $resource['bio'] ?? null), + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/005.php b/user_guide_src/source/outgoing/api_transformers/005.php new file mode 100644 index 000000000000..f1cdadf25323 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/005.php @@ -0,0 +1,18 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'status' => $this->when($resource['is_active'], 'active', 'inactive'), + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/006.php b/user_guide_src/source/outgoing/api_transformers/006.php new file mode 100644 index 000000000000..2cf70ff79543 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/006.php @@ -0,0 +1,17 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $this->whenNot($resource['email_hidden'], $resource['email'], '[hidden]'), + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/007.php b/user_guide_src/source/outgoing/api_transformers/007.php new file mode 100644 index 000000000000..9a2f436d7c41 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/007.php @@ -0,0 +1,22 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + 'updated_at' => $resource['updated_at'], + ]; + } +} + +// Request: GET /users/1?fields=id,name +// Response: {"id": 1, "name": "John Doe"} diff --git a/user_guide_src/source/outgoing/api_transformers/008.php b/user_guide_src/source/outgoing/api_transformers/008.php new file mode 100644 index 000000000000..776a090d3ce3 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/008.php @@ -0,0 +1,25 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + 'updated_at' => $resource['updated_at'], + ]; + } + + protected function getAllowedFields(): ?array + { + // Only these fields can be requested + return ['id', 'name', 'created_at']; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/009.php b/user_guide_src/source/outgoing/api_transformers/009.php new file mode 100644 index 000000000000..f1aa4624ad87 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/009.php @@ -0,0 +1,32 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function includePosts(): array + { + // Use $this->resource to access the current resource being transformed + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeComments(): array + { + $comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new CommentTransformer())->transformMany($comments); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/010.php b/user_guide_src/source/outgoing/api_transformers/010.php new file mode 100644 index 000000000000..2efc7c5e9a6c --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/010.php @@ -0,0 +1,48 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Only these relationships can be included + return ['posts', 'comments']; + } + + protected function includePosts(): array + { + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeComments(): array + { + $comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new CommentTransformer())->transformMany($comments); + } + + protected function includeOrders(): array + { + // This method exists but won't be callable from the API + // because 'orders' is not in getAllowedIncludes() + $orders = model('OrderModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new OrderTransformer())->transformMany($orders); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/011.php b/user_guide_src/source/outgoing/api_transformers/011.php new file mode 100644 index 000000000000..7d4d53ea2b30 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/011.php @@ -0,0 +1,23 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Return empty array to disable all includes + return []; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/012.php b/user_guide_src/source/outgoing/api_transformers/012.php new file mode 100644 index 000000000000..f3f3313c9cd1 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/012.php @@ -0,0 +1,21 @@ +findAll(); + + $transformer = new UserTransformer(); + $data = $transformer->transformMany($users); + + return $this->respond($data); + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/013.php b/user_guide_src/source/outgoing/api_transformers/013.php new file mode 100644 index 000000000000..b54fc29fd156 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/013.php @@ -0,0 +1,13 @@ + 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', +]); + +$transformer = new UserTransformer(); +$result = $transformer->transform($user); diff --git a/user_guide_src/source/outgoing/api_transformers/014.php b/user_guide_src/source/outgoing/api_transformers/014.php new file mode 100644 index 000000000000..f47e9056d753 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/014.php @@ -0,0 +1,12 @@ + 1, + 'name' => 'John Doe', + 'email' => 'john@example.com', +]; + +$transformer = new UserTransformer(); +$result = $transformer->transform($userData); diff --git a/user_guide_src/source/outgoing/api_transformers/015.php b/user_guide_src/source/outgoing/api_transformers/015.php new file mode 100644 index 000000000000..7526d4ee7b71 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/015.php @@ -0,0 +1,11 @@ +id = 1; +$user->name = 'John Doe'; +$user->email = 'john@example.com'; + +$transformer = new UserTransformer(); +$result = $transformer->transform($user); diff --git a/user_guide_src/source/outgoing/api_transformers/016.php b/user_guide_src/source/outgoing/api_transformers/016.php new file mode 100644 index 000000000000..2436acc7d5de --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/016.php @@ -0,0 +1,21 @@ + '1.0', + 'status' => 'active', + 'message' => 'API is running', + ]; + } +} + +// Usage +$transformer = new StaticDataTransformer(); +$result = $transformer->transform(null); // No resource passed diff --git a/user_guide_src/source/outgoing/api_transformers/017.php b/user_guide_src/source/outgoing/api_transformers/017.php new file mode 100644 index 000000000000..4e856838feec --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/017.php @@ -0,0 +1,19 @@ + $resource['id'], + 'name' => $resource['name'], + 'price' => $resource['price'], + 'in_stock' => $resource['stock_quantity'] > 0, + 'description' => $resource['description'], + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/018.php b/user_guide_src/source/outgoing/api_transformers/018.php new file mode 100644 index 000000000000..a8580b7dbe0a --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/018.php @@ -0,0 +1,17 @@ +find(1); + +$transformer = new UserTransformer(); + +// Transform an entity +$result = $transformer->transform($user); + +// Transform an array +$userData = ['id' => 1, 'name' => 'John Doe']; +$result = $transformer->transform($userData); + +// Use toArray() data +$result = $transformer->transform(); diff --git a/user_guide_src/source/outgoing/api_transformers/019.php b/user_guide_src/source/outgoing/api_transformers/019.php new file mode 100644 index 000000000000..bb847b852397 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/019.php @@ -0,0 +1,13 @@ +findAll(); + +$transformer = new UserTransformer(); +$results = $transformer->transformMany($users); + +// $results is an array of transformed user arrays +foreach ($results as $user) { + // Each $user is the result of calling transform() on an individual user +} diff --git a/user_guide_src/source/outgoing/api_transformers/020.php b/user_guide_src/source/outgoing/api_transformers/020.php new file mode 100644 index 000000000000..6864d9ff6f0f --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/020.php @@ -0,0 +1,20 @@ + $resource['id'], + 'name' => $resource['name'], + // Include email only if user is verified + 'email' => $this->when($resource['is_verified'], $resource['email']), + // Include role or default to 'user' + 'role' => $this->when(($resource['role'] ?? null) !== null, $resource['role'], 'user'), + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/021.php b/user_guide_src/source/outgoing/api_transformers/021.php new file mode 100644 index 000000000000..e24112c8c6fc --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/021.php @@ -0,0 +1,18 @@ + $resource['id'], + 'name' => $resource['name'], + // Hide email if privacy mode is enabled + 'email' => $this->whenNot($resource['privacy_mode'], $resource['email'], '[hidden]'), + ]; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/022.php b/user_guide_src/source/outgoing/api_transformers/022.php new file mode 100644 index 000000000000..f22631c56d70 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/022.php @@ -0,0 +1,25 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + 'created_at' => $resource['created_at'], + ]; + } + + protected function getAllowedFields(): ?array + { + // Clients can only request id, name, and created_at + // Attempting to request 'email' will throw an ApiException + return ['id', 'name', 'created_at']; + } +} diff --git a/user_guide_src/source/outgoing/api_transformers/023.php b/user_guide_src/source/outgoing/api_transformers/023.php new file mode 100644 index 000000000000..8b0cb071a924 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/023.php @@ -0,0 +1,39 @@ + $resource['id'], + 'name' => $resource['name'], + 'email' => $resource['email'], + ]; + } + + protected function getAllowedIncludes(): ?array + { + // Only 'posts' can be included via ?include=posts + // Attempting to include 'orders' will throw an ApiException + return ['posts']; + } + + protected function includePosts(): array + { + $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new PostTransformer())->transformMany($posts); + } + + protected function includeOrders(): array + { + // This method exists but cannot be called via the API + $orders = model('OrderModel')->where('user_id', $this->resource['id'])->findAll(); + + return (new OrderTransformer())->transformMany($orders); + } +} diff --git a/user_guide_src/source/outgoing/index.rst b/user_guide_src/source/outgoing/index.rst index 7b643ae5aad7..3468043f229d 100644 --- a/user_guide_src/source/outgoing/index.rst +++ b/user_guide_src/source/outgoing/index.rst @@ -16,6 +16,7 @@ View components are used to build what is returned to the user. table response api_responses + api_transformers csp localization alternative_php From 32c7bdc7051eee9be4039dae4d0931e2eef2a171 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 01:01:56 +0200 Subject: [PATCH 02/12] chore(app): Style, Rector, and Stan changes --- app/Transformers/Customer.php | 2 - loader.neon | 4 ++ system/API/ApiException.php | 14 ++--- system/API/BaseTransformer.php | 41 ++++++++++++--- system/API/TransformerInterface.php | 21 +++++++- system/Language/en/Api.php | 10 ++-- tests/_support/API/TestTransformer.php | 10 ++-- tests/system/API/ResponseTraitTest.php | 14 ++--- tests/system/API/TransformerTest.php | 16 +++--- .../Commands/TransformerGeneratorTest.php | 5 +- .../source/outgoing/api_transformers/001.php | 2 +- .../source/outgoing/api_transformers/010.php | 4 +- .../source/outgoing/api_transformers/015.php | 2 +- .../source/outgoing/api_transformers/020.php | 6 +-- .../source/outgoing/api_transformers/021.php | 4 +- .../function.alreadyNarrowedType.neon | 4 +- .../function.impossibleType.neon | 4 +- utils/phpstan-baseline/loader.neon | 2 +- .../missingType.iterableValue.neon | 52 ++++++++++++++++++- 19 files changed, 152 insertions(+), 65 deletions(-) create mode 100644 loader.neon diff --git a/app/Transformers/Customer.php b/app/Transformers/Customer.php index bb0a0c5ccc2c..f12807e78d47 100644 --- a/app/Transformers/Customer.php +++ b/app/Transformers/Customer.php @@ -9,8 +9,6 @@ class Customer extends BaseTransformer /** * Transform the resource into an array. * - * @param mixed $resource - * * @return array */ public function toArray(mixed $resource): array diff --git a/loader.neon b/loader.neon new file mode 100644 index 000000000000..b01c551b4dc0 --- /dev/null +++ b/loader.neon @@ -0,0 +1,4 @@ +# total 5 errors + +includes: + - missingType.iterableValue.neon diff --git a/system/API/ApiException.php b/system/API/ApiException.php index 809c89a4444b..f27ef4a1af0b 100644 --- a/system/API/ApiException.php +++ b/system/API/ApiException.php @@ -13,15 +13,15 @@ namespace CodeIgniter\API; +use Exception; + /** * Custom exception for API-related errors. */ -class ApiException extends \Exception +class ApiException extends Exception { /** * Thrown when the fields requested in a URL are not valid. - * - * @return ApiException */ public static function forInvalidFields(string $field): self { @@ -30,8 +30,6 @@ public static function forInvalidFields(string $field): self /** * Thrown when the includes requested in a URL are not valid. - * - * @return ApiException */ public static function forInvalidIncludes(string $include): self { @@ -41,8 +39,6 @@ public static function forInvalidIncludes(string $include): self /** * Thrown when an include is requested, but the method to handle it * does not exist on the model. - * - * @return ApiException */ public static function forMissingInclude(string $include): self { @@ -51,8 +47,6 @@ public static function forMissingInclude(string $include): self /** * Thrown when a transformer class cannot be found. - * - * @return ApiException */ public static function forTransformerNotFound(string $transformerClass): self { @@ -61,8 +55,6 @@ public static function forTransformerNotFound(string $transformerClass): self /** * Thrown when a transformer class does not implement TransformerInterface. - * - * @return ApiException */ public static function forInvalidTransformer(string $transformerClass): self { diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 4a9994cf9af5..50e70c4a36b5 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -56,22 +56,29 @@ */ abstract class BaseTransformer implements TransformerInterface { + /** + * @var list|null + */ private ?array $fields = null; + + /** + * @var list|null + */ private ?array $includes = null; + protected mixed $resource = null; public function __construct( private ?IncomingRequest $request = null, - ) - { + ) { $this->request = $request ?? request(); - $fields = $this->request->getGet('fields'); + $fields = $this->request->getGet('fields'); $this->fields = is_string($fields) ? array_map('trim', explode(',', $fields)) : $fields; - $includes = $this->request->getGet('include'); + $includes = $this->request->getGet('include'); $this->includes = is_string($includes) ? array_map('trim', explode(',', $includes)) : $includes; @@ -116,7 +123,7 @@ public function transform(mixed $resource = null): array */ public function transformMany(array $resources): array { - return array_map(fn($resource) => $this->transform($resource), $resources); + return array_map(fn ($resource): array => $this->transform($resource), $resources); } /** @@ -124,6 +131,7 @@ public function transformMany(array $resources): array * * @param mixed $value * @param mixed $default + * * @return mixed */ protected function when(bool $condition, $value, $default = null) @@ -133,16 +141,23 @@ protected function when(bool $condition, $value, $default = null) /** * Conditionally exclude a value. + * + * @param mixed $value + * @param mixed|null $default + * + * @return mixed */ protected function whenNot(bool $condition, $value, $default = null) { - return ! $condition ? $value : $default; + return $condition ? $default : $value; } /** * Define which fields can be requested via the 'fields' query parameter. * Override in child classes to restrict available fields. * Return null to allow all fields from toArray(). + * + * @return list|null */ protected function getAllowedFields(): ?array { @@ -154,6 +169,8 @@ protected function getAllowedFields(): ?array * Override in child classes to restrict available includes. * Return null to allow all includes that have corresponding methods. * Return an empty array to disable all includes. + * + * @return list|null */ protected function getAllowedIncludes(): ?array { @@ -163,6 +180,10 @@ protected function getAllowedIncludes(): ?array /** * Limits the given data array to only the fields specified * + * @param array $data + * + * @return array + * * @throws InvalidArgumentException */ private function limitFields(array $data): array @@ -188,6 +209,10 @@ private function limitFields(array $data): array /** * Checks the request for 'include' query variable, and if present, * calls the corresponding include{Resource} methods to add related data. + * + * @param array $data + * + * @return array */ private function insertIncludes(array $data): array { @@ -201,7 +226,7 @@ private function insertIncludes(array $data): array return $data; // No includes allowed } - // If whitelist is defined, filter the requested includes + // If whitelist is defined, filter the requested includes if ($allowedIncludes !== null) { $invalidIncludes = array_diff($this->includes, $allowedIncludes); @@ -213,7 +238,7 @@ private function insertIncludes(array $data): array foreach ($this->includes as $include) { $method = 'include' . ucfirst($include); if (method_exists($this, $method)) { - $data[$include] = $this->$method(); + $data[$include] = $this->{$method}(); } else { throw ApiException::forMissingInclude($include); } diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php index 4d5a7865b7fa..af28919a4612 100644 --- a/system/API/TransformerInterface.php +++ b/system/API/TransformerInterface.php @@ -2,6 +2,15 @@ declare(strict_types=1); +/** + * This file is part of CodeIgniter 4 framework. + * + * (c) CodeIgniter Foundation + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + namespace CodeIgniter\API; /** @@ -17,16 +26,26 @@ interface TransformerInterface * This is overridden by child classes to define specific fields. * * @param mixed $resource The resource being transformed + * + * @return array */ public function toArray(mixed $resource): array; /** * Transforms the given resource into an array. + * + * @param array|object $resource + * + * @return array */ - public function transform(object|array $resource): array; + public function transform(array|object $resource): array; /** * Transforms a collection of resources using $this->transform() on each item. + * + * @param array $resources + * + * @return array> */ public function transformMany(array $resources): array; } diff --git a/system/Language/en/Api.php b/system/Language/en/Api.php index 251652f9313a..69dd6fee6ab8 100644 --- a/system/Language/en/Api.php +++ b/system/Language/en/Api.php @@ -13,9 +13,9 @@ // API language settings return [ - 'invalidFields' => 'Invalid field requested: {0}', - 'invalidIncludes' => 'Invalid include requested: {0}', - 'missingInclude' => 'Missing include method for: {0}', - 'transformerNotFound' => 'Transformer class \'{0}\' not found.', - 'invalidTransformer' => 'Transformer class \'{0}\' must implement TransformerInterface.', + 'invalidFields' => 'Invalid field requested: {0}', + 'invalidIncludes' => 'Invalid include requested: {0}', + 'missingInclude' => 'Missing include method for: {0}', + 'transformerNotFound' => 'Transformer class \'{0}\' not found.', + 'invalidTransformer' => 'Transformer class \'{0}\' must implement TransformerInterface.', ]; diff --git a/tests/_support/API/TestTransformer.php b/tests/_support/API/TestTransformer.php index 6814d5643839..ebed31568137 100644 --- a/tests/_support/API/TestTransformer.php +++ b/tests/_support/API/TestTransformer.php @@ -23,17 +23,15 @@ class TestTransformer extends BaseTransformer /** * Transform the resource into an array. * - * @param mixed $resource - * * @return array */ public function toArray(mixed $resource): array { return [ - 'id' => $resource['id'] ?? null, - 'name' => $resource['name'] ?? null, - 'transformed' => true, - 'name_upper' => isset($resource['name']) ? strtoupper($resource['name']) : null, + 'id' => $resource['id'] ?? null, + 'name' => $resource['name'] ?? null, + 'transformed' => true, + 'name_upper' => isset($resource['name']) ? strtoupper($resource['name']) : null, ]; } } diff --git a/tests/system/API/ResponseTraitTest.php b/tests/system/API/ResponseTraitTest.php index 440deaffb02c..a0196189493d 100644 --- a/tests/system/API/ResponseTraitTest.php +++ b/tests/system/API/ResponseTraitTest.php @@ -36,6 +36,8 @@ use Exception; use PHPUnit\Framework\Attributes\Group; use stdClass; +use Tests\Support\API\InvalidTransformer; +use Tests\Support\API\TestTransformer; /** * @internal @@ -1065,7 +1067,7 @@ public function testPaginateWithTransformer(): void $controller = $this->makeController('/api/items'); - $this->invoke($controller, 'paginate', [$model, 20, \Tests\Support\API\TestTransformer::class]); + $this->invoke($controller, 'paginate', [$model, 20, TestTransformer::class]); $responseBody = json_decode($this->response->getBody(), true); @@ -1114,7 +1116,7 @@ public function testPaginateWithTransformerAndQueryBuilder(): void $controller = $this->makeController('/api/items'); - $this->invoke($controller, 'paginate', [$builder, 20, \Tests\Support\API\TestTransformer::class]); + $this->invoke($controller, 'paginate', [$builder, 20, TestTransformer::class]); $responseBody = json_decode($this->response->getBody(), true); @@ -1152,9 +1154,9 @@ public function testPaginateWithInvalidTransformer(): void $controller = $this->makeController('/api/items'); $this->expectException(ApiException::class); - $this->expectExceptionMessage(lang('Api.invalidTransformer', [\Tests\Support\API\InvalidTransformer::class])); + $this->expectExceptionMessage(lang('Api.invalidTransformer', [InvalidTransformer::class])); - $this->invoke($controller, 'paginate', [$model, 20, \Tests\Support\API\InvalidTransformer::class]); + $this->invoke($controller, 'paginate', [$model, 20, InvalidTransformer::class]); } public function testPaginateWithTransformerPreservesMetaAndLinks(): void @@ -1169,7 +1171,7 @@ public function testPaginateWithTransformerPreservesMetaAndLinks(): void $controller = $this->makeController('/api/items'); - $this->invoke($controller, 'paginate', [$model, 2, \Tests\Support\API\TestTransformer::class]); + $this->invoke($controller, 'paginate', [$model, 2, TestTransformer::class]); $responseBody = json_decode($this->response->getBody(), true); @@ -1199,7 +1201,7 @@ public function testPaginateWithTransformerEmptyData(): void $controller = $this->makeController('/api/items'); - $this->invoke($controller, 'paginate', [$model, 20, \Tests\Support\API\TestTransformer::class]); + $this->invoke($controller, 'paginate', [$model, 20, TestTransformer::class]); $responseBody = json_decode($this->response->getBody(), true); diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index a62a650a5243..5d37ad8e8b34 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -26,7 +26,7 @@ * @internal */ #[Group('Others')] -final class BaseTransformerTest extends CIUnitTestCase +final class TransformerTest extends CIUnitTestCase { private function createMockRequest(string $query = ''): IncomingRequest { @@ -60,7 +60,6 @@ public function toArray(mixed $resource): array $result = $transformer->transform(null); - $this->assertIsArray($result); $this->assertSame(['id' => 1, 'name' => 'Test'], $result); } @@ -77,7 +76,6 @@ public function toArray(mixed $resource): array $result = $transformer->transform(null); - $this->assertIsArray($result); $this->assertSame(['id' => 1, 'name' => 'Test'], $result); } @@ -138,8 +136,8 @@ public function toArray(mixed $resource): array public function testTransformWithObject(): void { - $request = $this->createMockRequest(); - $object = new stdClass(); + $request = $this->createMockRequest(); + $object = new stdClass(); $object->id = 1; $object->name = 'Test Object'; @@ -388,7 +386,7 @@ public function toArray(mixed $resource): array return $resource; } - protected function getAllowedFields(): ?array + protected function getAllowedFields(): array { return ['id', 'name', 'email']; } @@ -412,7 +410,7 @@ public function toArray(mixed $resource): array return $resource; } - protected function getAllowedFields(): ?array + protected function getAllowedFields(): array { return ['id', 'name', 'email']; } @@ -536,7 +534,7 @@ public function toArray(mixed $resource): array return $resource; } - protected function getAllowedIncludes(): ?array + protected function getAllowedIncludes(): array { return []; } @@ -704,7 +702,7 @@ protected function includePosts(): array } }; - $data = ['id' => 1, 'name' => 'Test']; + $data = ['id' => 1, 'name' => 'Test']; $result = $transformer->transform($data); $this->assertArrayHasKey('posts', $result); diff --git a/tests/system/Commands/TransformerGeneratorTest.php b/tests/system/Commands/TransformerGeneratorTest.php index 1439f63d528f..a37a29099eaa 100644 --- a/tests/system/Commands/TransformerGeneratorTest.php +++ b/tests/system/Commands/TransformerGeneratorTest.php @@ -15,7 +15,6 @@ use CodeIgniter\Test\CIUnitTestCase; use CodeIgniter\Test\StreamFilterTrait; -use CodeIgniter\API\ApiException; use PHPUnit\Framework\Attributes\Group; /** @@ -41,7 +40,9 @@ protected function getFileContents(string $filepath): string return ''; } - return file_get_contents($filepath) ?: ''; + $contents = file_get_contents($filepath); + + return $contents !== false ? $contents : ''; } public function testGenerateTransformer(): void diff --git a/user_guide_src/source/outgoing/api_transformers/001.php b/user_guide_src/source/outgoing/api_transformers/001.php index a8b209562e18..b3a7bb0ad9bf 100644 --- a/user_guide_src/source/outgoing/api_transformers/001.php +++ b/user_guide_src/source/outgoing/api_transformers/001.php @@ -18,7 +18,7 @@ public function toArray(mixed $resource): array } // In your controller -$user = model('UserModel')->find(1); +$user = model('UserModel')->find(1); $transformer = new UserTransformer(); return $this->respond($transformer->transform($user)); diff --git a/user_guide_src/source/outgoing/api_transformers/010.php b/user_guide_src/source/outgoing/api_transformers/010.php index 2efc7c5e9a6c..4a5538088fd9 100644 --- a/user_guide_src/source/outgoing/api_transformers/010.php +++ b/user_guide_src/source/outgoing/api_transformers/010.php @@ -2,9 +2,9 @@ namespace App\Transformers; -use CodeIgniter\API\BaseTransformer; -use App\Transformers\PostTransformer; use App\Transformers\CommentTransformer; +use App\Transformers\PostTransformer; +use CodeIgniter\API\BaseTransformer; class UserTransformer extends BaseTransformer { diff --git a/user_guide_src/source/outgoing/api_transformers/015.php b/user_guide_src/source/outgoing/api_transformers/015.php index 7526d4ee7b71..0c0df79ad350 100644 --- a/user_guide_src/source/outgoing/api_transformers/015.php +++ b/user_guide_src/source/outgoing/api_transformers/015.php @@ -2,7 +2,7 @@ use App\Transformers\UserTransformer; -$user = new stdClass(); +$user = new \stdClass(); $user->id = 1; $user->name = 'John Doe'; $user->email = 'john@example.com'; diff --git a/user_guide_src/source/outgoing/api_transformers/020.php b/user_guide_src/source/outgoing/api_transformers/020.php index 6864d9ff6f0f..fc23535230d1 100644 --- a/user_guide_src/source/outgoing/api_transformers/020.php +++ b/user_guide_src/source/outgoing/api_transformers/020.php @@ -9,12 +9,12 @@ class UserTransformer extends BaseTransformer public function toArray(mixed $resource): array { return [ - 'id' => $resource['id'], - 'name' => $resource['name'], + 'id' => $resource['id'], + 'name' => $resource['name'], // Include email only if user is verified 'email' => $this->when($resource['is_verified'], $resource['email']), // Include role or default to 'user' - 'role' => $this->when(($resource['role'] ?? null) !== null, $resource['role'], 'user'), + 'role' => $this->when(($resource['role'] ?? null) !== null, $resource['role'], 'user'), ]; } } diff --git a/user_guide_src/source/outgoing/api_transformers/021.php b/user_guide_src/source/outgoing/api_transformers/021.php index e24112c8c6fc..f27af4dabf6e 100644 --- a/user_guide_src/source/outgoing/api_transformers/021.php +++ b/user_guide_src/source/outgoing/api_transformers/021.php @@ -9,8 +9,8 @@ class UserTransformer extends BaseTransformer public function toArray(mixed $resource): array { return [ - 'id' => $resource['id'], - 'name' => $resource['name'], + 'id' => $resource['id'], + 'name' => $resource['name'], // Hide email if privacy mode is enabled 'email' => $this->whenNot($resource['privacy_mode'], $resource['email'], '[hidden]'), ]; diff --git a/utils/phpstan-baseline/function.alreadyNarrowedType.neon b/utils/phpstan-baseline/function.alreadyNarrowedType.neon index 7f6d5a1b1fdd..e39d8b2c49fc 100644 --- a/utils/phpstan-baseline/function.alreadyNarrowedType.neon +++ b/utils/phpstan-baseline/function.alreadyNarrowedType.neon @@ -3,11 +3,11 @@ 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\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:190\) 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\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:310\) 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 index 431a49bc5aed..43296fa89d08 100644 --- a/utils/phpstan-baseline/function.impossibleType.neon +++ b/utils/phpstan-baseline/function.impossibleType.neon @@ -8,11 +8,11 @@ parameters: 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\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:132\) 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\.$#' + message: '#^Call to function property_exists\(\) with \$this\(class@anonymous/tests/system/API/ResponseTraitTest\.php\:630\) 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 07dad94ede7f..3832d2bb7472 100644 --- a/utils/phpstan-baseline/loader.neon +++ b/utils/phpstan-baseline/loader.neon @@ -1,4 +1,4 @@ -# total 2766 errors +# total 2776 errors includes: - argument.type.neon diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 964c022fc992..5c1c64f91a15 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -1,4 +1,4 @@ -# total 1361 errors +# total 1371 errors parameters: ignoreErrors: @@ -5227,6 +5227,56 @@ parameters: count: 1 path: ../../system/View/Parser.php + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:427\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:450\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:476\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:476\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:508\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:531\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:559\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:611\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:670\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + + - + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:693\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + count: 1 + path: ../../tests/system/API/TransformerTest.php + - message: '#^Method CodeIgniter\\AutoReview\\ComposerJsonTest\:\:checkConfig\(\) has parameter \$fromComponent with no value type specified in iterable type array\.$#' count: 1 From 10796a61bef4699095edbd66cf8bc891003d77a6 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 08:54:14 +0200 Subject: [PATCH 03/12] chore(app): Remove unneccessary file --- app/Transformers/Customer.php | 20 -------------------- 1 file changed, 20 deletions(-) delete mode 100644 app/Transformers/Customer.php diff --git a/app/Transformers/Customer.php b/app/Transformers/Customer.php deleted file mode 100644 index f12807e78d47..000000000000 --- a/app/Transformers/Customer.php +++ /dev/null @@ -1,20 +0,0 @@ - - */ - public function toArray(mixed $resource): array - { - return [ - // Add your transformation logic here - ]; - } -} From b86320d234918c48a77abc02b44764f0f43bf8de Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:00:19 +0200 Subject: [PATCH 04/12] (app): Remove when / whenNot from BaseTransformer --- system/API/BaseTransformer.php | 27 ----- tests/system/API/TransformerTest.php | 114 ------------------ .../source/outgoing/api_transformers.rst | 57 --------- .../source/outgoing/api_transformers/004.php | 18 --- .../source/outgoing/api_transformers/005.php | 18 --- .../source/outgoing/api_transformers/006.php | 17 --- .../source/outgoing/api_transformers/020.php | 20 --- .../source/outgoing/api_transformers/021.php | 18 --- 8 files changed, 289 deletions(-) delete mode 100644 user_guide_src/source/outgoing/api_transformers/004.php delete mode 100644 user_guide_src/source/outgoing/api_transformers/005.php delete mode 100644 user_guide_src/source/outgoing/api_transformers/006.php delete mode 100644 user_guide_src/source/outgoing/api_transformers/020.php delete mode 100644 user_guide_src/source/outgoing/api_transformers/021.php diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 50e70c4a36b5..966bcc36e8a7 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -43,7 +43,6 @@ * 'email' => $resource['email'], * 'created_at' => $resource['created_at'], * 'updated_at' => $resource['updated_at'], - * 'bio' => $this->when(($resource['bio'] ?? null) !== null, $resource['bio'] ?? null), * ]; * } * @@ -126,32 +125,6 @@ public function transformMany(array $resources): array return array_map(fn ($resource): array => $this->transform($resource), $resources); } - /** - * Conditionally include a value. - * - * @param mixed $value - * @param mixed $default - * - * @return mixed - */ - protected function when(bool $condition, $value, $default = null) - { - return $condition ? $value : $default; - } - - /** - * Conditionally exclude a value. - * - * @param mixed $value - * @param mixed|null $default - * - * @return mixed - */ - protected function whenNot(bool $condition, $value, $default = null) - { - return $condition ? $default : $value; - } - /** * Define which fields can be requested via the 'fields' query parameter. * Override in child classes to restrict available fields. diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 5d37ad8e8b34..790fa4d70d43 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -193,120 +193,6 @@ public function toArray(mixed $resource): array $this->assertSame([], $result); } - public function testWhenConditionIsTrue(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->when(true, 'Visible Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => 'Visible Name'], $result); - } - - public function testWhenConditionIsFalse(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->when(false, 'Visible Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => null], $result); - } - - public function testWhenConditionIsFalseWithDefault(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->when(false, 'Visible Name', 'Default Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => 'Default Name'], $result); - } - - public function testWhenNotConditionIsTrue(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->whenNot(true, 'Visible Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => null], $result); - } - - public function testWhenNotConditionIsFalse(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->whenNot(false, 'Visible Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => 'Visible Name'], $result); - } - - public function testWhenNotConditionIsTrueWithDefault(): void - { - $request = $this->createMockRequest(); - - $transformer = new class ($request) extends BaseTransformer { - public function toArray(mixed $resource): array - { - return [ - 'id' => 1, - 'name' => $this->whenNot(true, 'Visible Name', 'Default Name'), - ]; - } - }; - - $result = $transformer->transform(null); - - $this->assertSame(['id' => 1, 'name' => 'Default Name'], $result); - } - public function testLimitFieldsWithNoFieldsParam(): void { $request = $this->createMockRequest(); diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index bf015d610cb8..40fda9a0fc62 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -100,37 +100,6 @@ it to the client. .. literalinclude:: api_resources/003.php -*********************** -Conditional Fields -*********************** - -Often you'll want to include certain fields only under specific conditions. The ``when()`` and ``whenNot()`` -methods make this easy. - -Using when() -============ - -The ``when()`` method includes a value only if the condition is true: - -.. literalinclude:: api_resources/004.php - -In this example, the ``bio`` field will only be included if it has a non-null value. If the condition is -false, the field will be set to ``null`` by default. - -Providing a Default Value -========================== - -You can provide a default value to use when the condition is false: - -.. literalinclude:: api_resources/005.php - -Using whenNot() -=============== - -The ``whenNot()`` method is the opposite of ``when()`` - it includes a value only if the condition is false: - -.. literalinclude:: api_resources/006.php - *********************** Field Filtering *********************** @@ -332,32 +301,6 @@ Class Reference .. literalinclude:: api_resources/019.php - .. php:method:: when(bool $condition, $value, $default = null) - - :param bool $condition: The condition to evaluate - :param mixed $value: The value to return if the condition is true - :param mixed $default: The value to return if the condition is false (defaults to ``null``) - :returns: The value or default based on the condition - :rtype: mixed - - Helper method to conditionally include a value in the transformation. Returns ``$value`` if - ``$condition`` is true, otherwise returns ``$default``. - - .. literalinclude:: api_resources/020.php - - .. php:method:: whenNot(bool $condition, $value, $default = null) - - :param bool $condition: The condition to evaluate - :param mixed $value: The value to return if the condition is false - :param mixed $default: The value to return if the condition is true (defaults to ``null``) - :returns: The value or default based on the condition - :rtype: mixed - - Helper method to conditionally exclude a value. Returns ``$value`` if ``$condition`` is false, - otherwise returns ``$default``. - - .. literalinclude:: api_resources/021.php - .. php:method:: getAllowedFields() :returns: Array of allowed field names, or ``null`` to allow all fields diff --git a/user_guide_src/source/outgoing/api_transformers/004.php b/user_guide_src/source/outgoing/api_transformers/004.php deleted file mode 100644 index d1f001eafe12..000000000000 --- a/user_guide_src/source/outgoing/api_transformers/004.php +++ /dev/null @@ -1,18 +0,0 @@ - $resource['id'], - 'name' => $resource['name'], - 'email' => $resource['email'], - 'bio' => $this->when(($resource['bio'] ?? null) !== null, $resource['bio'] ?? null), - ]; - } -} diff --git a/user_guide_src/source/outgoing/api_transformers/005.php b/user_guide_src/source/outgoing/api_transformers/005.php deleted file mode 100644 index f1cdadf25323..000000000000 --- a/user_guide_src/source/outgoing/api_transformers/005.php +++ /dev/null @@ -1,18 +0,0 @@ - $resource['id'], - 'name' => $resource['name'], - 'email' => $resource['email'], - 'status' => $this->when($resource['is_active'], 'active', 'inactive'), - ]; - } -} diff --git a/user_guide_src/source/outgoing/api_transformers/006.php b/user_guide_src/source/outgoing/api_transformers/006.php deleted file mode 100644 index 2cf70ff79543..000000000000 --- a/user_guide_src/source/outgoing/api_transformers/006.php +++ /dev/null @@ -1,17 +0,0 @@ - $resource['id'], - 'name' => $resource['name'], - 'email' => $this->whenNot($resource['email_hidden'], $resource['email'], '[hidden]'), - ]; - } -} diff --git a/user_guide_src/source/outgoing/api_transformers/020.php b/user_guide_src/source/outgoing/api_transformers/020.php deleted file mode 100644 index fc23535230d1..000000000000 --- a/user_guide_src/source/outgoing/api_transformers/020.php +++ /dev/null @@ -1,20 +0,0 @@ - $resource['id'], - 'name' => $resource['name'], - // Include email only if user is verified - 'email' => $this->when($resource['is_verified'], $resource['email']), - // Include role or default to 'user' - 'role' => $this->when(($resource['role'] ?? null) !== null, $resource['role'], 'user'), - ]; - } -} diff --git a/user_guide_src/source/outgoing/api_transformers/021.php b/user_guide_src/source/outgoing/api_transformers/021.php deleted file mode 100644 index f27af4dabf6e..000000000000 --- a/user_guide_src/source/outgoing/api_transformers/021.php +++ /dev/null @@ -1,18 +0,0 @@ - $resource['id'], - 'name' => $resource['name'], - // Hide email if privacy mode is enabled - 'email' => $this->whenNot($resource['privacy_mode'], $resource['email'], '[hidden]'), - ]; - } -} From af3f2a802a82c3f5df5e0815f3b0d8f01d71a2bc Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:03:20 +0200 Subject: [PATCH 05/12] refactor(app): Remove Entitiy dependency in BaseTransformer --- system/API/BaseTransformer.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 966bcc36e8a7..305f207d2564 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -13,7 +13,6 @@ namespace CodeIgniter\API; -use CodeIgniter\Entity\Entity; use CodeIgniter\HTTP\IncomingRequest; use InvalidArgumentException; @@ -103,10 +102,10 @@ public function transform(mixed $resource = null): array if ($resource === null) { $data = $this->toArray(null); + } elseif (is_object($resource) && method_exists($resource, 'toArray')) { + $data = $this->toArray($resource->toArray()); } else { - $data = $resource instanceof Entity - ? $this->toArray($resource->toArray()) - : $this->toArray((array) $resource); + $data = $this->toArray((array) $resource); } $data = $this->limitFields($data); From 4552c27b8d553562e04ecfc4ca9b609a7160d5c4 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:06:15 +0200 Subject: [PATCH 06/12] fix(app): Fixing a Psalm issue with transform method --- system/API/BaseTransformer.php | 2 +- system/API/TransformerInterface.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 305f207d2564..7019741aee77 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -95,7 +95,7 @@ abstract public function toArray(mixed $resource): array; * Transforms the given resource into an array using * the $this->toArray(). */ - public function transform(mixed $resource = null): array + public function transform(array|object|null $resource = null): array { // Store the resource so include methods can access it $this->resource = $resource; diff --git a/system/API/TransformerInterface.php b/system/API/TransformerInterface.php index af28919a4612..3d994251b302 100644 --- a/system/API/TransformerInterface.php +++ b/system/API/TransformerInterface.php @@ -34,11 +34,11 @@ public function toArray(mixed $resource): array; /** * Transforms the given resource into an array. * - * @param array|object $resource + * @param array|object|null $resource * * @return array */ - public function transform(array|object $resource): array; + public function transform(array|object|null $resource): array; /** * Transforms a collection of resources using $this->transform() on each item. From 1654847044ae2021389cf7487804d1a35ea2c9ed Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:09:24 +0200 Subject: [PATCH 07/12] docs(app): Fix an error with user guide builds --- user_guide_src/source/outgoing/api_transformers.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index 40fda9a0fc62..af31998f51bd 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -327,8 +327,6 @@ Class Reference Exception Reference *************** -.. php:namespace:: CodeIgniter\API - .. php:class:: ApiException .. php:staticmethod:: forInvalidFields(string $field) From 1b3065a072ce8ad52ebbb43f107156ed1f84330a Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:34:06 +0200 Subject: [PATCH 08/12] docs(app): Fix docs issue with overline length_ --- user_guide_src/source/outgoing/api_transformers.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index af31998f51bd..434e4f85221b 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -323,9 +323,9 @@ Class Reference .. literalinclude:: api_resources/023.php -*************** +******************* Exception Reference -*************** +******************* .. php:class:: ApiException From 5da3cbfa76ab54b7eace832245a93c822fbb0d34 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 22:35:35 +0200 Subject: [PATCH 09/12] chore(app): Update PHPStan baseline for test issues --- .../missingType.iterableValue.neon | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/utils/phpstan-baseline/missingType.iterableValue.neon b/utils/phpstan-baseline/missingType.iterableValue.neon index 5c1c64f91a15..7a467812bf34 100644 --- a/utils/phpstan-baseline/missingType.iterableValue.neon +++ b/utils/phpstan-baseline/missingType.iterableValue.neon @@ -5228,52 +5228,52 @@ parameters: path: ../../system/View/Parser.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:427\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:313\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:450\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:336\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:476\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includeComments\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:476\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:362\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:508\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:394\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:531\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:417\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:559\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:445\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:611\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:497\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:670\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:556\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php - - message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:693\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' + message: '#^Method CodeIgniter\\API\\BaseTransformer@anonymous/tests/system/API/TransformerTest\.php\:579\:\:includePosts\(\) return type has no value type specified in iterable type array\.$#' count: 1 path: ../../tests/system/API/TransformerTest.php From 4f26755e23597d8723b6be264e700fe3cb3ba277 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Thu, 16 Oct 2025 23:11:06 +0200 Subject: [PATCH 10/12] docs(app): Additional docs fixes --- .../source/outgoing/api_transformers.rst | 52 +++++++++---------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index 434e4f85221b..e462b588c159 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -19,15 +19,15 @@ Quick Example The following example shows a common usage pattern for transformers in your application. -.. literalinclude:: api_resources/001.php +.. literalinclude:: api_transformers/001.php In this example, the ``UserTransformer`` defines which fields from a user entity should be included in the API response. The ``transform()`` method converts a single resource, while ``transformMany()`` handles collections of resources. -****************** +********************** Creating a Transformer -****************** +********************** To create a transformer, extend the ``BaseTransformer`` class and implement the ``toArray()`` method to define your API resource structure. The ``toArray()`` method receives the resource being transformed as @@ -36,7 +36,7 @@ a parameter, allowing you to access and transform its data. Basic Transformer ================= -.. literalinclude:: api_resources/002.php +.. literalinclude:: api_transformers/002.php The ``toArray()`` method receives the resource (entity, array, or object) as its parameter and defines the structure of your API response. You can include any fields you want from the resource, and you can @@ -98,7 +98,7 @@ Using Transformers in Controllers Once you've created a transformer, you can use it in your controllers to transform data before returning it to the client. -.. literalinclude:: api_resources/003.php +.. literalinclude:: api_transformers/003.php *********************** Field Filtering @@ -107,7 +107,7 @@ Field Filtering The transformer automatically supports field filtering through the ``fields`` query parameter of the current URL. This allows API clients to request only specific fields they need, reducing bandwidth and improving performance. -.. literalinclude:: api_resources/007.php +.. literalinclude:: api_transformers/007.php A request to ``/users/1?fields=id,name`` would return only: @@ -124,14 +124,14 @@ Restricting Available Fields By default, clients can request any field defined in your ``toArray()`` method. You can restrict which fields are allowed by overriding the ``getAllowedFields()`` method: -.. literalinclude:: api_resources/008.php +.. literalinclude:: api_transformers/008.php Now, even if a client requests ``/users/1?fields=email``, an ``ApiException`` will be thrown because ``email`` is not in the allowed fields list. -*********************** +*************************** Including Related Resources -*********************** +*************************** Transformers support loading of related resources through the ``include`` query parameter. This follows a common API pattern where clients can specify which relationships they want included. @@ -145,7 +145,7 @@ To support including related resources, create methods prefixed with ``include`` resource name. Inside these methods, you can access the current resource being transformed via ``$this->resource``: -.. literalinclude:: api_resources/009.php +.. literalinclude:: api_transformers/009.php Note how the include methods use ``$this->resource['id']`` to access the ID of the user being transformed. The ``$this->resource`` property is automatically set by the transformer when ``transform()`` is called. @@ -180,11 +180,11 @@ Restricting Available Includes Similar to field filtering, you can restrict which relationships can be included by overriding the ``getAllowedIncludes()`` method: -.. literalinclude:: api_resources/010.php +.. literalinclude:: api_transformers/010.php If you want to disable all includes, return an empty array: -.. literalinclude:: api_resources/011.php +.. literalinclude:: api_transformers/011.php Include Validation ================== @@ -202,20 +202,20 @@ And your transformer doesn't have an ``includeInvalid()`` method, an exception w This helps catch typos and prevents unexpected behavior. -*********************** +************************ Transforming Collections -*********************** +************************ The ``transformMany()`` method makes it easy to transform arrays of resources: -.. literalinclude:: api_resources/012.php +.. literalinclude:: api_transformers/012.php The ``transformMany()`` method applies the same transformation logic to each item in the collection, including any field filtering or includes specified in the request. -*********************** +********************************* Working with Different Data Types -*********************** +********************************* Transformers can handle various data types, not just entities. @@ -225,28 +225,28 @@ Transforming Entities When you pass an ``Entity`` instance to ``transform()``, it automatically calls the entity's ``toArray()`` method to get the data: -.. literalinclude:: api_resources/013.php +.. literalinclude:: api_transformers/013.php Transforming Arrays =================== You can transform plain arrays as well: -.. literalinclude:: api_resources/014.php +.. literalinclude:: api_transformers/014.php Transforming Objects ==================== Any object can be cast to an array and transformed: -.. literalinclude:: api_resources/015.php +.. literalinclude:: api_transformers/015.php Using toArray() Only ==================== If you don't pass a resource to ``transform()``, it will use the data from your ``toArray()`` method: -.. literalinclude:: api_resources/016.php +.. literalinclude:: api_transformers/016.php *************** Class Reference @@ -272,7 +272,7 @@ Class Reference The resource parameter contains the data being transformed. Return an array with the fields you want to include in the API response, accessing data from the ``$resource`` parameter. - .. literalinclude:: api_resources/017.php + .. literalinclude:: api_transformers/017.php .. php:method:: transform($resource = null) @@ -288,7 +288,7 @@ Class Reference The method automatically applies field filtering and includes based on query parameters. - .. literalinclude:: api_resources/018.php + .. literalinclude:: api_transformers/018.php .. php:method:: transformMany(array $resources) @@ -299,7 +299,7 @@ Class Reference Transforms a collection of resources by calling ``transform()`` on each item. Field filtering and includes are applied consistently to all items. - .. literalinclude:: api_resources/019.php + .. literalinclude:: api_transformers/019.php .. php:method:: getAllowedFields() @@ -310,7 +310,7 @@ Class Reference Return ``null`` (the default) to allow all fields from ``toArray()``. Return an array of field names to create a whitelist of allowed fields. - .. literalinclude:: api_resources/022.php + .. literalinclude:: api_transformers/022.php .. php:method:: getAllowedIncludes() @@ -321,7 +321,7 @@ Class Reference parameter. Return ``null`` (the default) to allow all includes that have corresponding methods. Return an array of include names to create a whitelist. Return an empty array to disable all includes. - .. literalinclude:: api_resources/023.php + .. literalinclude:: api_transformers/023.php ******************* Exception Reference From 31285ffc3ff5ff6574d2b0917a52ea8079061125 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sat, 18 Oct 2025 23:03:07 +0200 Subject: [PATCH 11/12] fix(app): Addressing review comments --- system/API/ApiException.php | 3 ++- system/API/ResponseTrait.php | 2 ++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/system/API/ApiException.php b/system/API/ApiException.php index f27ef4a1af0b..7415d2e2c471 100644 --- a/system/API/ApiException.php +++ b/system/API/ApiException.php @@ -13,12 +13,13 @@ namespace CodeIgniter\API; +use CodeIgniter\Exceptions\FrameworkException; use Exception; /** * Custom exception for API-related errors. */ -class ApiException extends Exception +final class ApiException extends FrameworkException { /** * Thrown when the fields requested in a URL are not valid. diff --git a/system/API/ResponseTrait.php b/system/API/ResponseTrait.php index 22c899c6bc7d..0449e91bf07c 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -393,6 +393,8 @@ protected function setResponseFormat(?string $format = null) * 'next' => '/api/items?page=2&perPage=20', * ] * ] + * + * @param class-string|null $transformWith */ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface { From afb161b4389fe1ec063074fbdc7e9d17aa196141 Mon Sep 17 00:00:00 2001 From: Lonnie Ezell Date: Sat, 18 Oct 2025 23:11:36 +0200 Subject: [PATCH 12/12] build(app): Apply Rector fix --- system/API/ApiException.php | 1 - 1 file changed, 1 deletion(-) diff --git a/system/API/ApiException.php b/system/API/ApiException.php index 7415d2e2c471..9af9e31ca63c 100644 --- a/system/API/ApiException.php +++ b/system/API/ApiException.php @@ -14,7 +14,6 @@ namespace CodeIgniter\API; use CodeIgniter\Exceptions\FrameworkException; -use Exception; /** * Custom exception for API-related errors.