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 new file mode 100644 index 000000000000..9af9e31ca63c --- /dev/null +++ b/system/API/ApiException.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +use CodeIgniter\Exceptions\FrameworkException; + +/** + * Custom exception for API-related errors. + */ +final class ApiException extends FrameworkException +{ + /** + * Thrown when the fields requested in a URL are not valid. + */ + 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. + */ + 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. + */ + public static function forMissingInclude(string $include): self + { + return new self(lang('Api.missingInclude', [$include])); + } + + /** + * Thrown when a transformer class cannot be found. + */ + public static function forTransformerNotFound(string $transformerClass): self + { + return new self(lang('Api.transformerNotFound', [$transformerClass])); + } + + /** + * Thrown when a transformer class does not implement TransformerInterface. + */ + 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..7019741aee77 --- /dev/null +++ b/system/API/BaseTransformer.php @@ -0,0 +1,221 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +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'], + * ]; + * } + * + * protected function includePosts(): array + * { + * $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); + * return (new PostTransformer())->transformMany($posts); + * } + * } + */ +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'); + $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(array|object|null $resource = null): array + { + // Store the resource so include methods can access it + $this->resource = $resource; + + if ($resource === null) { + $data = $this->toArray(null); + } elseif (is_object($resource) && method_exists($resource, 'toArray')) { + $data = $this->toArray($resource->toArray()); + } else { + $data = $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): array => $this->transform($resource), $resources); + } + + /** + * 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 + { + 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. + * + * @return list|null + */ + protected function getAllowedIncludes(): ?array + { + return null; + } + + /** + * Limits the given data array to only the fields specified + * + * @param array $data + * + * @return array + * + * @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. + * + * @param array $data + * + * @return array + */ + 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..0449e91bf07c 100644 --- a/system/API/ResponseTrait.php +++ b/system/API/ResponseTrait.php @@ -393,8 +393,10 @@ 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): ResponseInterface + protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface { try { assert($this->request instanceof IncomingRequest); @@ -426,6 +428,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 +453,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..3d994251b302 --- /dev/null +++ b/system/API/TransformerInterface.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\API; + +/** + * Interface for transforming resources into arrays. + * + * This interface can be implemented by classes that need to transform + * data into a standardized array format, such as for API responses. + */ +interface TransformerInterface +{ + /** + * Converts the resource to an array representation. + * 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|null $resource + * + * @return array + */ + public function transform(array|object|null $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/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..69dd6fee6ab8 --- /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..ebed31568137 --- /dev/null +++ b/tests/_support/API/TestTransformer.php @@ -0,0 +1,37 @@ + + * + * 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. + * + * @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..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 @@ -1053,4 +1055,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, 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, 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', [InvalidTransformer::class])); + + $this->invoke($controller, 'paginate', [$model, 20, 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, 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, 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..790fa4d70d43 --- /dev/null +++ b/tests/system/API/TransformerTest.php @@ -0,0 +1,597 @@ + + * + * 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 TransformerTest 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->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->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 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..a37a29099eaa --- /dev/null +++ b/tests/system/Commands/TransformerGeneratorTest.php @@ -0,0 +1,100 @@ + + * + * 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 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 ''; + } + + $contents = file_get_contents($filepath); + + return $contents !== false ? $contents : ''; + } + + 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..e462b588c159 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -0,0 +1,358 @@ +.. _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_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 +a parameter, allowing you to access and transform its data. + +Basic Transformer +================= + +.. 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 +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_transformers/003.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_transformers/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_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. +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_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. + +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_transformers/010.php + +If you want to disable all includes, return an empty array: + +.. literalinclude:: api_transformers/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_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. + +Transforming Entities +===================== + +When you pass an ``Entity`` instance to ``transform()``, it automatically calls the entity's ``toArray()`` +method to get the data: + +.. literalinclude:: api_transformers/013.php + +Transforming Arrays +=================== + +You can transform plain arrays as well: + +.. literalinclude:: api_transformers/014.php + +Transforming Objects +==================== + +Any object can be cast to an array and transformed: + +.. 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_transformers/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_transformers/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_transformers/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_transformers/019.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_transformers/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_transformers/023.php + +******************* +Exception Reference +******************* + +.. 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..b3a7bb0ad9bf --- /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/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..4a5538088fd9 --- /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..0c0df79ad350 --- /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/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 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..7a467812bf34 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\: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\: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\: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\: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\: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\: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\: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\: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\: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\:579\:\: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