Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion system/API/BaseTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@
* protected function includePosts(): array
* {
* $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll();
* return (new PostTransformer())->transformMany($posts);
*
* // Use transformRelated() to safely transform nested resources
* // without leaking the parent's request state (fields/includes).
* return $this->transformRelated(PostTransformer::class, $posts);
* }
* }
*/
Expand Down Expand Up @@ -124,6 +127,32 @@ public function transformMany(array $resources): array
return array_map($this->transform(...), $resources);
}

/**
* Transforms related resources using a fresh, isolated transformer.
* Automatically determines whether to use transform() or transformMany().
*
* @param class-string<TransformerInterface>|TransformerInterface $transformer
* @param array<int|string, mixed>|object|null $resources
*
* @return array<int, array<string, mixed>>|array<string, mixed>
*/
protected function transformRelated(string|TransformerInterface $transformer, mixed $resources): array
{
$instance = is_string($transformer) ? new $transformer() : $transformer;

if ($instance instanceof self) {
// Prevent Global State Leakage silently.
$instance->fields = null;
$instance->includes = null;
}

if (is_array($resources) && array_is_list($resources)) {
return $instance->transformMany($resources);
}

return $instance->transform($resources);
}

/**
* Define which fields can be requested via the 'fields' query parameter.
* Override in child classes to restrict available fields.
Expand Down
30 changes: 30 additions & 0 deletions tests/_support/API/ChildTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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;

/**
* Child transformer for testing transformRelated() and Global State Leakage.
*/
class ChildTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'child_id' => $resource['id'] ?? null,
'status' => 'transformed',
];
}
}
54 changes: 54 additions & 0 deletions tests/_support/API/ParentTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

/**
* This file is part of CodeIgniter 4 framework.
*
* (c) CodeIgniter Foundation <admin@codeigniter.com>
*
* 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;

/**
* Parent transformer for testing transformRelated() and Global State Leakage.
*/
class ParentTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'parent_id' => $resource['id'] ?? null,
];
}

/**
* Test include that triggers a child transformer.
* If Global State leaks (?include=child), the child will try to find
* includeChild() on itself and throw an ApiException.
*/
protected function includeChild(): array
{
$childData = ['id' => 99];

return $this->transformRelated(ChildTransformer::class, $childData);
}

/**
* Test include that returns a collection of items to verify smart routing.
*/
protected function includeChildrenCollection(): array
{
$collectionData = [
['id' => 77],
['id' => 88],
];

return $this->transformRelated(ChildTransformer::class, $collectionData);
}
}
64 changes: 64 additions & 0 deletions tests/system/API/TransformerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Config\Services;
use PHPUnit\Framework\Attributes\Group;
use stdClass;
use Tests\Support\API\ParentTransformer;

/**
* @internal
Expand Down Expand Up @@ -641,4 +642,67 @@ protected function includePosts(): array
$this->assertArrayHasKey('posts', $result);
$this->assertSame([['id' => 1, 'title' => 'Post 1']], $result['posts']);
}

public function testTransformRelatedPreventsGlobalStateLeakage(): void
{
$request = $this->createMockRequest('include=child');

$transformer = new ParentTransformer($request);
$result = $transformer->transform(['id' => 1]);

$this->assertSame([
'parent_id' => 1,
'child' => [
'child_id' => 99,
'status' => 'transformed',
],
], $result);
}

public function testTransformRelatedSmartRoutingForSingleItem(): void
{
$request = $this->createMockRequest('include=child');

$transformer = new ParentTransformer($request);
$result = $transformer->transform(['id' => 1]);

$this->assertIsArray($result['child']);
$this->assertArrayHasKey('child_id', $result['child']);
$this->assertSame(99, $result['child']['child_id']);
}

public function testTransformRelatedSmartRoutingForCollection(): void
{
$request = $this->createMockRequest('include=childrenCollection');

$transformer = new ParentTransformer($request);
$result = $transformer->transform(['id' => 1]);

$this->assertIsArray($result['childrenCollection']);
$this->assertCount(2, $result['childrenCollection']);
$this->assertSame(77, $result['childrenCollection'][0]['child_id']);
$this->assertSame(88, $result['childrenCollection'][1]['child_id']);
}

public function testTransformRelatedWorksWhenParentUsesTransformMany(): void
{
$request = $this->createMockRequest('include=child');
$transformer = new ParentTransformer($request);

$parents = [
['id' => 1],
['id' => 2],
];

$result = $transformer->transformMany($parents);

$this->assertCount(2, $result);

$this->assertSame(1, $result[0]['parent_id']);
$this->assertIsArray($result[0]['child']);
$this->assertSame(99, $result[0]['child']['child_id']);

$this->assertSame(2, $result[1]['parent_id']);
$this->assertSame(99, $result[1]['child']['child_id']);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.8.0.rst
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ Others
- Added :php:class:`UniqueConstraintViolationException <CodeIgniter\\Database\\Exceptions\\UniqueConstraintViolationException>` which extends ``DatabaseException`` and is thrown on duplicate key (unique constraint) violations across all database drivers. See :ref:`database-unique-constraint-violation`.
- Added ``$db->getLastException()`` which returns the typed exception even when ``DBDebug`` is ``false``. See :ref:`database-get-last-exception`.
- Added ``DatabaseException::getDatabaseCode()`` returning the native driver error code as ``int|string``; ``getCode()`` is constrained to ``int`` by PHP's ``Throwable`` interface and cannot carry string SQLSTATE codes.
- Added the ``transformRelated()`` method to ``CodeIgniter\API\BaseTransformer`` to safely transform related/nested resources. This prevents global request state leakage (like ``?include=`` and ``?fields=`` query parameters) from the parent request into child transformers, and intelligently routes the data to ``transform()`` or ``transformMany()`` based on the input type.

Debug
=====
Expand Down
22 changes: 22 additions & 0 deletions user_guide_src/source/outgoing/api_transformers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,14 @@ resource name. Inside these methods, you can access the current resource being t

.. literalinclude:: api_transformers/009.php

.. important::
When transforming related resources inside an include method, always use ``$this->transformRelated()`` instead of manually instantiating a new transformer (e.g., ``new PostTransformer()``).

The ``transformRelated()`` method provides two major benefits:

1. It automatically determines whether to transform a single item or a collection based on the provided data.
2. It prevents **Global State Leakage**, ensuring that the parent request's query parameters (like ``?include=`` or ``?fields=``) do not accidentally bleed into the child transformer and cause unexpected infinite loops or `ApiException`.

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.

Expand Down Expand Up @@ -301,6 +309,20 @@ Class Reference

.. literalinclude:: api_transformers/019.php

.. php:method:: transformRelated(string|TransformerInterface $transformer, mixed $resources)

:param class-string<TransformerInterface>|TransformerInterface $transformer: The class name or instance of the transformer to use
:param mixed $resources: The related resource data (Entity, array, object, or collection of these)
:returns: The transformed array (either a single item or a collection)
:rtype: array

Transforms related resources safely by isolating the new transformer from the parent's request state.
This prevents nested transformers from incorrectly inheriting the ``fields`` or ``includes`` requested for the parent resource.

It automatically uses ``transform()`` for single resources and ``transformMany()`` for collections (lists) by inspecting the provided ``$resources``.

.. literalinclude:: api_transformers/024.php

.. php:method:: getAllowedFields()

:returns: Array of allowed field names, or ``null`` to allow all fields
Expand Down
4 changes: 2 additions & 2 deletions user_guide_src/source/outgoing/api_transformers/009.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,13 @@ 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);
return $this->transformRelated(PostTransformer::class, $posts);
}

protected function includeComments(): array
{
$comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll();

return (new CommentTransformer())->transformMany($comments);
return $this->transformRelated(CommentTransformer::class, $comments);
}
}
6 changes: 3 additions & 3 deletions user_guide_src/source/outgoing/api_transformers/010.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,14 @@ protected function includePosts(): array
{
$posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll();

return (new PostTransformer())->transformMany($posts);
return $this->transformRelated(PostTransformer::class, $posts);
}

protected function includeComments(): array
{
$comments = model('CommentModel')->where('user_id', $this->resource['id'])->findAll();

return (new CommentTransformer())->transformMany($comments);
return $this->transformRelated(CommentTransformer::class, $comments);
}

protected function includeOrders(): array
Expand All @@ -43,6 +43,6 @@ protected function includeOrders(): array
// because 'orders' is not in getAllowedIncludes()
$orders = model('OrderModel')->where('user_id', $this->resource['id'])->findAll();

return (new OrderTransformer())->transformMany($orders);
return $this->transformRelated(OrderTransformer::class, $orders);
}
}
4 changes: 2 additions & 2 deletions user_guide_src/source/outgoing/api_transformers/023.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,14 @@ protected function includePosts(): array
{
$posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll();

return (new PostTransformer())->transformMany($posts);
return $this->transformRelated(PostTransformer::class, $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);
return $this->transformRelated(OrderTransformer::class, $orders);
}
}
25 changes: 25 additions & 0 deletions user_guide_src/source/outgoing/api_transformers/024.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Transformers;

use CodeIgniter\API\BaseTransformer;

class UserTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'id' => $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 $this->transformRelated(PostTransformer::class, $posts);
}
}
Loading