diff --git a/system/API/BaseTransformer.php b/system/API/BaseTransformer.php index 540d443d1cdc..88b8f1aa8b67 100644 --- a/system/API/BaseTransformer.php +++ b/system/API/BaseTransformer.php @@ -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); * } * } */ @@ -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 $transformer + * @param array|object|null $resources + * + * @return array>|array + */ + 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. diff --git a/tests/_support/API/ChildTransformer.php b/tests/_support/API/ChildTransformer.php new file mode 100644 index 000000000000..9c1d0f8bc189 --- /dev/null +++ b/tests/_support/API/ChildTransformer.php @@ -0,0 +1,30 @@ + + * + * 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', + ]; + } +} diff --git a/tests/_support/API/ParentTransformer.php b/tests/_support/API/ParentTransformer.php new file mode 100644 index 000000000000..bda2b800d24a --- /dev/null +++ b/tests/_support/API/ParentTransformer.php @@ -0,0 +1,54 @@ + + * + * 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); + } +} diff --git a/tests/system/API/TransformerTest.php b/tests/system/API/TransformerTest.php index 8c6f50857d23..47d12b1bcd33 100644 --- a/tests/system/API/TransformerTest.php +++ b/tests/system/API/TransformerTest.php @@ -22,6 +22,7 @@ use Config\Services; use PHPUnit\Framework\Attributes\Group; use stdClass; +use Tests\Support\API\ParentTransformer; /** * @internal @@ -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']); + } } diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index 82f4fbf95d21..f3cc7bfced3f 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -237,6 +237,7 @@ Others - Added :php:class:`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 ===== diff --git a/user_guide_src/source/outgoing/api_transformers.rst b/user_guide_src/source/outgoing/api_transformers.rst index e462b588c159..327835adff19 100644 --- a/user_guide_src/source/outgoing/api_transformers.rst +++ b/user_guide_src/source/outgoing/api_transformers.rst @@ -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. @@ -301,6 +309,20 @@ Class Reference .. literalinclude:: api_transformers/019.php + .. php:method:: transformRelated(string|TransformerInterface $transformer, mixed $resources) + + :param class-string|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 diff --git a/user_guide_src/source/outgoing/api_transformers/009.php b/user_guide_src/source/outgoing/api_transformers/009.php index f1aa4624ad87..b7ba20384376 100644 --- a/user_guide_src/source/outgoing/api_transformers/009.php +++ b/user_guide_src/source/outgoing/api_transformers/009.php @@ -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); } } diff --git a/user_guide_src/source/outgoing/api_transformers/010.php b/user_guide_src/source/outgoing/api_transformers/010.php index 4a5538088fd9..32637918f70d 100644 --- a/user_guide_src/source/outgoing/api_transformers/010.php +++ b/user_guide_src/source/outgoing/api_transformers/010.php @@ -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 @@ -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); } } diff --git a/user_guide_src/source/outgoing/api_transformers/023.php b/user_guide_src/source/outgoing/api_transformers/023.php index 8b0cb071a924..7a8d1d7c331a 100644 --- a/user_guide_src/source/outgoing/api_transformers/023.php +++ b/user_guide_src/source/outgoing/api_transformers/023.php @@ -26,7 +26,7 @@ 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 @@ -34,6 +34,6 @@ 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); } } diff --git a/user_guide_src/source/outgoing/api_transformers/024.php b/user_guide_src/source/outgoing/api_transformers/024.php new file mode 100644 index 000000000000..84b95934b893 --- /dev/null +++ b/user_guide_src/source/outgoing/api_transformers/024.php @@ -0,0 +1,25 @@ + $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); + } +}