• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 27461882718

13 Jun 2026 08:39AM UTC coverage: 89.056% (+0.07%) from 88.991%
27461882718

Pull #10302

github

web-flow
Merge b8adcfc91 into 3f961107f
Pull Request #10302: feat: add strict field protection for Models

28 of 31 new or added lines in 4 files covered. (90.32%)

43 existing lines in 6 files now uncovered.

24892 of 27951 relevant lines covered (89.06%)

225.43 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.55
/system/API/BaseTransformer.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\API;
15

16
use CodeIgniter\HTTP\IncomingRequest;
17
use InvalidArgumentException;
18

19
/**
20
 * Base class for transforming resources into arrays.
21
 * Fulfills common functionality of the TransformerInterface,
22
 * and provides helper methods for conditional inclusion/exclusion of values.
23
 *
24
 * Supports the following query variables from the request:
25
 * - fields: Comma-separated list of fields to include in the response
26
 *      (e.g., ?fields=id,name,email)
27
 *      If not provided, all fields from toArray() are included.
28
 *      Since v4.8.0, per-type sparse fieldsets are also supported via the
29
 *      bracketed form (e.g., ?fields[posts]=id,slug). A transformer reads the
30
 *      fieldset that matches its declared $resourceType, so a nested
31
 *      PostTransformer with $resourceType = 'posts' applies ?fields[posts]=...
32
 *      while the root resource is unaffected.
33
 * - include: Comma-separated list of related resources to include
34
 *      (e.g., ?include=posts,comments)
35
 *      This looks for methods named `include{Resource}()` on the transformer,
36
 *      and calls them to get the related data, which are added as a new key to the output.
37
 *
38
 * Example:
39
 *
40
 * class UserTransformer extends BaseTransformer
41
 * {
42
 *    public function toArray(mixed $resource): array
43
 *    {
44
 *      return [
45
 *          'id' => $resource['id'],
46
 *          'name' => $resource['name'],
47
 *          'email' => $resource['email'],
48
 *          'created_at' => $resource['created_at'],
49
 *          'updated_at' => $resource['updated_at'],
50
 *      ];
51
 *    }
52
 *
53
 *   protected function includePosts(): array
54
 *   {
55
 *       $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll();
56
 *       return (new PostTransformer())->transformMany($posts);
57
 *   }
58
 * }
59
 */
60
abstract class BaseTransformer implements TransformerInterface
61
{
62
    /**
63
     * Nesting depth of the transformation currently in progress (0 = root).
64
     */
65
    private static int $depth = 0;
66

67
    /**
68
     * @var list<string>|null
69
     */
70
    private ?array $fields = null;
71

72
    /**
73
     * @var list<string>|null
74
     */
75
    private ?array $includes = null;
76

77
    /**
78
     * Resource type used to resolve per-type sparse fieldsets.
79
     */
80
    protected ?string $resourceType = null;
81

82
    protected mixed $resource = null;
83

84
    public function __construct(
85
        private ?IncomingRequest $request = null,
86
    ) {
87
        // An explicitly provided request is always honored - its scope is
88
        // intentional. Only the implicit global fallback is suppressed for
89
        // nested transformers, which is the actual leak vector.
90
        $explicitRequest = $request instanceof IncomingRequest;
45✔
91

92
        $this->request = $request ?? request();
45✔
93

94
        if ($explicitRequest || self::$depth === 0) {
45✔
95
            $this->fields   = $this->resolveFields(true);
45✔
96
            $this->includes = $this->resolveIncludes();
45✔
97
        } elseif ($this->resourceType !== null) {
10✔
98
            $this->fields = $this->resolveFields(false);
10✔
99
        }
100
    }
101

102
    /**
103
     * Resolves the requested field list for this transformer from the request.
104
     *
105
     * Supports both the flat `?fields=a,b` form and the per-type sparse
106
     * fieldset form `?fields[<type>]=a,b`. The flat form is only honored when
107
     * $allowFlat is true (i.e. for the root transformer); a type-specific
108
     * fieldset is matched against this transformer's $resourceType at any
109
     * nesting level.
110
     *
111
     * @return list<string>|null
112
     */
113
    private function resolveFields(bool $allowFlat): ?array
114
    {
115
        $fields = $this->request->getGet('fields');
45✔
116

117
        // Sparse fieldsets: ?fields[posts]=id,slug -> ['posts' => 'id,slug']
118
        if (is_array($fields)) {
45✔
119
            $scoped = ($this->resourceType !== null && is_string($fields[$this->resourceType] ?? null))
6✔
120
                ? $fields[$this->resourceType]
5✔
121
                : null;
3✔
122

123
            return $scoped !== null ? $this->splitList($scoped) : null;
6✔
124
        }
125

126
        // Flat fieldset: ?fields=id,slug (applies to the root only)
127
        if ($allowFlat && is_string($fields)) {
39✔
128
            return $this->splitList($fields);
15✔
129
        }
130

131
        return null;
30✔
132
    }
133

134
    /**
135
     * Resolves the requested include list from the request's `include` param.
136
     *
137
     * @return list<string>|null
138
     */
139
    private function resolveIncludes(): ?array
140
    {
141
        $includes = $this->request->getGet('include');
45✔
142

143
        return is_string($includes) ? $this->splitList($includes) : $includes;
45✔
144
    }
145

146
    /**
147
     * Splits a comma-separated query value into a list of trimmed strings.
148
     *
149
     * @return list<string>
150
     */
151
    private function splitList(string $value): array
152
    {
153
        return array_map(trim(...), explode(',', $value));
31✔
154
    }
155

156
    /**
157
     * Converts the resource to an array representation.
158
     * This is overridden by child classes to define the
159
     * API-safe resource representation.
160
     *
161
     * @param mixed $resource The resource being transformed
162
     */
163
    abstract public function toArray(mixed $resource): array;
164

165
    /**
166
     * Transforms the given resource into an array using
167
     * the $this->toArray().
168
     */
169
    public function transform(array|object|null $resource = null): array
170
    {
171
        // Store the resource so include methods can access it
172
        $this->resource = $resource;
43✔
173

174
        if ($resource === null) {
43✔
175
            $data = $this->toArray(null);
3✔
176
        } elseif (is_object($resource) && method_exists($resource, 'toArray')) {
40✔
177
            $data = $this->toArray($resource->toArray());
1✔
178
        } else {
179
            $data = $this->toArray((array) $resource);
39✔
180
        }
181

182
        $data = $this->limitFields($data);
43✔
183

184
        return $this->insertIncludes($data);
41✔
185
    }
186

187
    /**
188
     * Transforms a collection of resources using $this->transform() on each item.
189
     *
190
     * If the request's 'fields' query variable is set, only those fields will be included
191
     * in the transformed output.
192
     */
193
    public function transformMany(array $resources): array
194
    {
195
        return array_map($this->transform(...), $resources);
9✔
196
    }
197

198
    /**
199
     * Define which fields can be requested via the 'fields' query parameter.
200
     * Override in child classes to restrict available fields.
201
     * Return null to allow all fields from toArray().
202
     *
203
     * @return list<string>|null
204
     */
205
    protected function getAllowedFields(): ?array
206
    {
207
        return null;
17✔
208
    }
209

210
    /**
211
     * Define which related resources can be included via the 'include' query parameter.
212
     * Override in child classes to restrict available includes.
213
     * Return null to allow all includes that have corresponding methods.
214
     * Return an empty array to disable all includes.
215
     *
216
     * @return list<string>|null
217
     */
218
    protected function getAllowedIncludes(): ?array
219
    {
220
        return null;
21✔
221
    }
222

223
    /**
224
     * Limits the given data array to only the fields specified
225
     *
226
     * @param array<string, mixed> $data
227
     *
228
     * @return array<string, mixed>
229
     *
230
     * @throws InvalidArgumentException
231
     */
232
    private function limitFields(array $data): array
233
    {
234
        if ($this->fields === null || $this->fields === []) {
43✔
235
            return $data;
31✔
236
        }
237

238
        $allowedFields = $this->getAllowedFields();
20✔
239

240
        // If whitelist is defined, validate against it
241
        if ($allowedFields !== null) {
20✔
242
            $invalidFields = array_diff($this->fields, $allowedFields);
3✔
243

244
            if ($invalidFields !== []) {
3✔
245
                throw ApiException::forInvalidFields(implode(', ', $invalidFields));
2✔
246
            }
247
        }
248

249
        return array_intersect_key($data, array_flip($this->fields));
18✔
250
    }
251

252
    /**
253
     * Checks the request for 'include' query variable, and if present,
254
     * calls the corresponding include{Resource} methods to add related data.
255
     *
256
     * @param array<string, mixed> $data
257
     *
258
     * @return array<string, mixed>
259
     */
260
    private function insertIncludes(array $data): array
261
    {
262
        if ($this->includes === null) {
41✔
263
            return $data;
31✔
264
        }
265

266
        $allowedIncludes = $this->getAllowedIncludes();
22✔
267

268
        if ($allowedIncludes === []) {
22✔
269
            return $data; // No includes allowed
1✔
270
        }
271

272
        // If whitelist is defined, filter the requested includes
273
        if ($allowedIncludes !== null) {
21✔
UNCOV
274
            $invalidIncludes = array_diff($this->includes, $allowedIncludes);
×
275

UNCOV
276
            if ($invalidIncludes !== []) {
×
UNCOV
277
                throw ApiException::forInvalidIncludes(implode(', ', $invalidIncludes));
×
278
            }
279
        }
280

281
        self::$depth++;
21✔
282

283
        try {
284
            foreach ($this->includes as $include) {
21✔
285
                $method = 'include' . ucfirst($include);
21✔
286
                if (method_exists($this, $method)) {
21✔
287
                    $data[$include] = $this->{$method}();
18✔
288
                } else {
289
                    throw ApiException::forMissingInclude($include);
5✔
290
                }
291
            }
292
        } finally {
293
            self::$depth--;
21✔
294
        }
295

296
        return $data;
16✔
297
    }
298
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc