Skip to content
Open
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
46 changes: 32 additions & 14 deletions system/API/BaseTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@
*/
abstract class BaseTransformer implements TransformerInterface
{
/**
* Nesting depth of the transformation currently in progress (0 = root).
*/
private static int $depth = 0;

/**
* @var list<string>|null
*/
Expand All @@ -69,17 +74,24 @@ abstract class BaseTransformer implements TransformerInterface
public function __construct(
private ?IncomingRequest $request = null,
) {
// An explicitly provided request is always honored - its scope is
// intentional. Only the implicit global fallback is suppressed for
// nested transformers, which is the actual leak vector.
$explicitRequest = $request instanceof IncomingRequest;

$this->request = $request ?? request();

$fields = $this->request->getGet('fields');
$this->fields = is_string($fields)
? array_map(trim(...), explode(',', $fields))
: $fields;
if ($explicitRequest || self::$depth === 0) {
$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;
$includes = $this->request->getGet('include');
$this->includes = is_string($includes)
? array_map(trim(...), explode(',', $includes))
: $includes;
}
}

/**
Expand Down Expand Up @@ -207,13 +219,19 @@ private function insertIncludes(array $data): array
}
}

foreach ($this->includes as $include) {
$method = 'include' . ucfirst($include);
if (method_exists($this, $method)) {
$data[$include] = $this->{$method}();
} else {
throw ApiException::forMissingInclude($include);
self::$depth++;

try {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really liked the use of try block!

foreach ($this->includes as $include) {
$method = 'include' . ucfirst($include);
if (method_exists($this, $method)) {
$data[$include] = $this->{$method}();
} else {
throw ApiException::forMissingInclude($include);
}
}
} finally {
self::$depth--;
}

return $data;
Expand Down
31 changes: 31 additions & 0 deletions tests/_support/API/ChildTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?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;

/**
* Nested transformer used to verify that the root request's scope
* (fields/includes) does not leak into related resources.
*/
class ChildTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'child_id' => $resource['id'] ?? null,
'status' => 'transformed',
];
}
}
67 changes: 67 additions & 0 deletions tests/_support/API/ParentTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?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;

/**
* Root transformer used to verify that nested transformers do not inherit
* the root request's `fields`/`include` query state.
*/
class ParentTransformer extends BaseTransformer
{
public function toArray(mixed $resource): array
{
return [
'parent_id' => $resource['id'] ?? null,
];
}

/**
* Includes a single related child resource.
*
* @return array<string, mixed>
*/
protected function includeChildren(): array
{
return (new ChildTransformer())->transform(['id' => 99]);
}

/**
* Includes a collection of related child resources.
*
* @return list<array<string, mixed>>
*/
protected function includeChildrenCollection(): array
{
return (new ChildTransformer())->transformMany([
['id' => 77],
['id' => 88],
]);
}

/**
* Includes a single child while explicitly forwarding the request, opting
* the child into the request-derived scope even though it is nested.
*
* @return array<string, mixed>
*/
protected function includeExplicitChild(): array
{
$childRequest = clone request();
$childRequest->setGlobal('get', ['fields' => 'child_id']);

return (new ChildTransformer($childRequest))->transform(['id' => 99]);
}
}
160 changes: 160 additions & 0 deletions tests/system/API/TransformerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
use Config\Services;
use PHPUnit\Framework\Attributes\Group;
use stdClass;
use Tests\Support\API\ChildTransformer;
use Tests\Support\API\ParentTransformer;

/**
* @internal
Expand All @@ -33,12 +35,14 @@ protected function setUp(): void
{
parent::setUp();

Services::resetSingle('request');
Services::superglobals()->setGetArray([]);
}

protected function tearDown(): void
{
Services::superglobals()->setGetArray([]);
Services::resetSingle('request');

parent::tearDown();
}
Expand Down Expand Up @@ -641,4 +645,160 @@ protected function includePosts(): array
$this->assertArrayHasKey('posts', $result);
$this->assertSame([['id' => 1, 'title' => 'Post 1']], $result['posts']);
}

public function testNestedTransformerDoesNotInheritIncludeState(): void
{
// The child transformer has no includeChildren() method. If the root
// request's `include=children` leaked into it, transforming the child
// would raise an ApiException for the missing include method.
$request = $this->createMockRequest('include=children');
Services::injectMock('request', $request);

$transformer = new ParentTransformer($request);

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

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

public function testNestedTransformerDoesNotInheritFieldFilter(): void
{
// `fields=parent_id` applies to the root only. If it leaked into the
// child, array_intersect_key would strip every child field, leaving [].
$request = $this->createMockRequest('include=children&fields=parent_id');
Services::injectMock('request', $request);

$transformer = new ParentTransformer($request);

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

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

public function testNestedCollectionTransformerDoesNotInheritState(): void
{
$request = $this->createMockRequest('include=childrenCollection&fields=parent_id');
Services::injectMock('request', $request);

$transformer = new ParentTransformer($request);

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

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

public function testBareNestedInstantiationDoesNotInheritState(): void
{
// Reproduces the exact leak vector: a child created with a bare
// `new ChildTransformer()` (no request passed) inside an include
// method must not pick up the root request's scope from request().
$request = $this->createMockRequest('include=children&fields=parent_id');
Services::injectMock('request', $request);

$root = new ParentTransformer();

$result = $root->transform(['id' => 5]);

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

public function testNestedTransformerHonorsExplicitRequest(): void
{
// A child created with an explicitly passed request must honor that
// request's scope even while nested - the isolation only suppresses
// the implicit global fallback, not deliberate developer intent.
$request = $this->createMockRequest('include=explicitChild&fields=child_id,parent_id');
Services::injectMock('request', $request);

$transformer = new ParentTransformer($request);

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

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

public function testRootScopeStillAppliesAfterNesting(): void
{
// Sanity check that the root transformer keeps applying its own scope
// while nested children are isolated.
$request = $this->createMockRequest('include=children&fields=parent_id');
Services::injectMock('request', $request);

$transformer = new ParentTransformer($request);

$result = $transformer->transform(['id' => 1, 'secret' => 'hidden']);

// Root keeps only parent_id (plus the include key), the child is intact.
$this->assertArrayHasKey('parent_id', $result);
$this->assertArrayNotHasKey('secret', $result);
$this->assertSame(['child_id' => 99, 'status' => 'transformed'], $result['children']);
}

public function testDepthIsRestoredAfterIncludeThrows(): void
{
$request = $this->createMockRequest('include=nonexistent');
Services::injectMock('request', $request);

$throwingRoot = new class ($request) extends BaseTransformer {
public function toArray(mixed $resource): array
{
return $resource;
}
};

try {
$throwingRoot->transform(['id' => 1, 'name' => 'Test']);
$this->fail('Expected ApiException was not thrown.');
} catch (ApiException) {
// expected
}

// The nesting depth must be balanced after the exception, so a fresh
// root transformer still applies the request scope (depth back to 0).
$fieldsRequest = $this->createMockRequest('fields=id');
Services::injectMock('request', $fieldsRequest);

$nextRoot = new class ($fieldsRequest) extends BaseTransformer {
public function toArray(mixed $resource): array
{
return $resource;
}
};

$result = $nextRoot->transform(['id' => 1, 'name' => 'Test']);

$this->assertSame(['id' => 1], $result);
}

public function testBareNestedTransformerStillUsedByChildTransformerDirectly(): void
{
// When ChildTransformer is itself the root (no parent), it must apply
// request scope as usual - the isolation only affects nesting.
$request = $this->createMockRequest('fields=child_id');
Services::injectMock('request', $request);

$transformer = new ChildTransformer($request);

$result = $transformer->transform(['id' => 42]);

$this->assertSame(['child_id' => 42], $result);
}
}
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.4.rst
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Deprecations
Bugs Fixed
**********

- **API:** Fixed a bug in Transformers where the root request's ``fields`` and ``include`` query parameters leaked into nested transformers created inside ``include*()`` methods, causing incorrect field filtering, unexpected includes, or infinite recursion.
- **Database:** Fixed a bug where ``updateBatch()`` could be called after Query Builder ``where()`` conditions, even though it's not supported. In this situation, now the ``DatabaseException`` is thrown.
- **HTTP:** Fixed a bug where the User Agent library reported Safari's WebKit version instead of the browser version from the ``Version`` token.

Expand Down
15 changes: 15 additions & 0 deletions user_guide_src/source/outgoing/api_transformers.rst
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,14 @@ The response would include:
]
}
.. note:: The ``fields`` and ``include`` query parameters describe the **root** resource only.
Transformers instantiated inside an ``include*()`` method (such as ``PostTransformer`` above)
are treated as nested resources: when created **without an explicit request** they do **not**
inherit the root request's ``fields``/``include`` state. This prevents the parent's query
parameters from leaking into related resources, which could otherwise cause incorrect field
filtering or unexpected/recursive includes. If you deliberately pass a request to a nested
transformer, that request's scope is honored.

Restricting Available Includes
===============================

Expand Down Expand Up @@ -262,6 +270,13 @@ Class Reference

Initializes the transformer and extracts the ``fields`` and ``include`` query parameters from the request.

.. note:: When no request is explicitly passed, the global request's ``fields`` and ``include``
are read **only for the root transformer**. A transformer constructed during a nested
transformation (i.e. inside an ``include*()`` method) without an explicit request still uses
the global request object, but does not read ``fields``/``include`` from it, to avoid leaking
the root's scope. Passing an explicit request always applies that request's scope, regardless
of nesting.

.. php:method:: toArray(mixed $resource)
:param mixed $resource: The resource being transformed (Entity, array, object, or null)
Expand Down