Skip to content

Commit 05c7217

Browse files
authored
feat: API transformers (codeigniter4#9763)
* feat(app): Added API Transformers * chore(app): Style, Rector, and Stan changes * chore(app): Remove unneccessary file * (app): Remove when / whenNot from BaseTransformer * refactor(app): Remove Entitiy dependency in BaseTransformer * fix(app): Fixing a Psalm issue with transform method * docs(app): Fix an error with user guide builds * docs(app): Fix docs issue with overline length_ * chore(app): Update PHPStan baseline for test issues * docs(app): Additional docs fixes * fix(app): Addressing review comments * build(app): Apply Rector fix
1 parent 5add16d commit 05c7217

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

42 files changed

+2312
-20
lines changed

loader.neon

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# total 5 errors
2+
3+
includes:
4+
- missingType.iterableValue.neon

system/API/ApiException.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
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\Exceptions\FrameworkException;
17+
18+
/**
19+
* Custom exception for API-related errors.
20+
*/
21+
final class ApiException extends FrameworkException
22+
{
23+
/**
24+
* Thrown when the fields requested in a URL are not valid.
25+
*/
26+
public static function forInvalidFields(string $field): self
27+
{
28+
return new self(lang('Api.invalidFields', [$field]));
29+
}
30+
31+
/**
32+
* Thrown when the includes requested in a URL are not valid.
33+
*/
34+
public static function forInvalidIncludes(string $include): self
35+
{
36+
return new self(lang('Api.invalidIncludes', [$include]));
37+
}
38+
39+
/**
40+
* Thrown when an include is requested, but the method to handle it
41+
* does not exist on the model.
42+
*/
43+
public static function forMissingInclude(string $include): self
44+
{
45+
return new self(lang('Api.missingInclude', [$include]));
46+
}
47+
48+
/**
49+
* Thrown when a transformer class cannot be found.
50+
*/
51+
public static function forTransformerNotFound(string $transformerClass): self
52+
{
53+
return new self(lang('Api.transformerNotFound', [$transformerClass]));
54+
}
55+
56+
/**
57+
* Thrown when a transformer class does not implement TransformerInterface.
58+
*/
59+
public static function forInvalidTransformer(string $transformerClass): self
60+
{
61+
return new self(lang('Api.invalidTransformer', [$transformerClass]));
62+
}
63+
}

system/API/BaseTransformer.php

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
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+
* - include: Comma-separated list of related resources to include
29+
* (e.g., ?include=posts,comments)
30+
* This looks for methods named `include{Resource}()` on the transformer,
31+
* and calls them to get the related data, which are added as a new key to the output.
32+
*
33+
* Example:
34+
*
35+
* class UserTransformer extends BaseTransformer
36+
* {
37+
* public function toArray(mixed $resource): array
38+
* {
39+
* return [
40+
* 'id' => $resource['id'],
41+
* 'name' => $resource['name'],
42+
* 'email' => $resource['email'],
43+
* 'created_at' => $resource['created_at'],
44+
* 'updated_at' => $resource['updated_at'],
45+
* ];
46+
* }
47+
*
48+
* protected function includePosts(): array
49+
* {
50+
* $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll();
51+
* return (new PostTransformer())->transformMany($posts);
52+
* }
53+
* }
54+
*/
55+
abstract class BaseTransformer implements TransformerInterface
56+
{
57+
/**
58+
* @var list<string>|null
59+
*/
60+
private ?array $fields = null;
61+
62+
/**
63+
* @var list<string>|null
64+
*/
65+
private ?array $includes = null;
66+
67+
protected mixed $resource = null;
68+
69+
public function __construct(
70+
private ?IncomingRequest $request = null,
71+
) {
72+
$this->request = $request ?? request();
73+
74+
$fields = $this->request->getGet('fields');
75+
$this->fields = is_string($fields)
76+
? array_map('trim', explode(',', $fields))
77+
: $fields;
78+
79+
$includes = $this->request->getGet('include');
80+
$this->includes = is_string($includes)
81+
? array_map('trim', explode(',', $includes))
82+
: $includes;
83+
}
84+
85+
/**
86+
* Converts the resource to an array representation.
87+
* This is overridden by child classes to define the
88+
* API-safe resource representation.
89+
*
90+
* @param mixed $resource The resource being transformed
91+
*/
92+
abstract public function toArray(mixed $resource): array;
93+
94+
/**
95+
* Transforms the given resource into an array using
96+
* the $this->toArray().
97+
*/
98+
public function transform(array|object|null $resource = null): array
99+
{
100+
// Store the resource so include methods can access it
101+
$this->resource = $resource;
102+
103+
if ($resource === null) {
104+
$data = $this->toArray(null);
105+
} elseif (is_object($resource) && method_exists($resource, 'toArray')) {
106+
$data = $this->toArray($resource->toArray());
107+
} else {
108+
$data = $this->toArray((array) $resource);
109+
}
110+
111+
$data = $this->limitFields($data);
112+
113+
return $this->insertIncludes($data);
114+
}
115+
116+
/**
117+
* Transforms a collection of resources using $this->transform() on each item.
118+
*
119+
* If the request's 'fields' query variable is set, only those fields will be included
120+
* in the transformed output.
121+
*/
122+
public function transformMany(array $resources): array
123+
{
124+
return array_map(fn ($resource): array => $this->transform($resource), $resources);
125+
}
126+
127+
/**
128+
* Define which fields can be requested via the 'fields' query parameter.
129+
* Override in child classes to restrict available fields.
130+
* Return null to allow all fields from toArray().
131+
*
132+
* @return list<string>|null
133+
*/
134+
protected function getAllowedFields(): ?array
135+
{
136+
return null;
137+
}
138+
139+
/**
140+
* Define which related resources can be included via the 'include' query parameter.
141+
* Override in child classes to restrict available includes.
142+
* Return null to allow all includes that have corresponding methods.
143+
* Return an empty array to disable all includes.
144+
*
145+
* @return list<string>|null
146+
*/
147+
protected function getAllowedIncludes(): ?array
148+
{
149+
return null;
150+
}
151+
152+
/**
153+
* Limits the given data array to only the fields specified
154+
*
155+
* @param array<string, mixed> $data
156+
*
157+
* @return array<string, mixed>
158+
*
159+
* @throws InvalidArgumentException
160+
*/
161+
private function limitFields(array $data): array
162+
{
163+
if ($this->fields === null || $this->fields === []) {
164+
return $data;
165+
}
166+
167+
$allowedFields = $this->getAllowedFields();
168+
169+
// If whitelist is defined, validate against it
170+
if ($allowedFields !== null) {
171+
$invalidFields = array_diff($this->fields, $allowedFields);
172+
173+
if ($invalidFields !== []) {
174+
throw ApiException::forInvalidFields(implode(', ', $invalidFields));
175+
}
176+
}
177+
178+
return array_intersect_key($data, array_flip($this->fields));
179+
}
180+
181+
/**
182+
* Checks the request for 'include' query variable, and if present,
183+
* calls the corresponding include{Resource} methods to add related data.
184+
*
185+
* @param array<string, mixed> $data
186+
*
187+
* @return array<string, mixed>
188+
*/
189+
private function insertIncludes(array $data): array
190+
{
191+
if ($this->includes === null) {
192+
return $data;
193+
}
194+
195+
$allowedIncludes = $this->getAllowedIncludes();
196+
197+
if ($allowedIncludes === []) {
198+
return $data; // No includes allowed
199+
}
200+
201+
// If whitelist is defined, filter the requested includes
202+
if ($allowedIncludes !== null) {
203+
$invalidIncludes = array_diff($this->includes, $allowedIncludes);
204+
205+
if ($invalidIncludes !== []) {
206+
throw ApiException::forInvalidIncludes(implode(', ', $invalidIncludes));
207+
}
208+
}
209+
210+
foreach ($this->includes as $include) {
211+
$method = 'include' . ucfirst($include);
212+
if (method_exists($this, $method)) {
213+
$data[$include] = $this->{$method}();
214+
} else {
215+
throw ApiException::forMissingInclude($include);
216+
}
217+
}
218+
219+
return $data;
220+
}
221+
}

system/API/ResponseTrait.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,10 @@ protected function setResponseFormat(?string $format = null)
393393
* 'next' => '/api/items?page=2&perPage=20',
394394
* ]
395395
* ]
396+
*
397+
* @param class-string<TransformerInterface>|null $transformWith
396398
*/
397-
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface
399+
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface
398400
{
399401
try {
400402
assert($this->request instanceof IncomingRequest);
@@ -426,6 +428,21 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res
426428
];
427429
}
428430

431+
// Transform data if a transformer is provided
432+
if ($transformWith !== null) {
433+
if (! class_exists($transformWith)) {
434+
throw ApiException::forTransformerNotFound($transformWith);
435+
}
436+
437+
$transformer = new $transformWith($this->request);
438+
439+
if (! $transformer instanceof TransformerInterface) {
440+
throw ApiException::forInvalidTransformer($transformWith);
441+
}
442+
443+
$data = $transformer->transformMany($data);
444+
}
445+
429446
$links = $this->buildLinks($meta);
430447

431448
$this->response->setHeader('Link', $this->linkHeader($links));
@@ -436,6 +453,9 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res
436453
'meta' => $meta,
437454
'links' => $links,
438455
]);
456+
} catch (ApiException $e) {
457+
// Re-throw ApiExceptions so they can be handled by the caller
458+
throw $e;
439459
} catch (DatabaseException $e) {
440460
log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage());
441461

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
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+
/**
17+
* Interface for transforming resources into arrays.
18+
*
19+
* This interface can be implemented by classes that need to transform
20+
* data into a standardized array format, such as for API responses.
21+
*/
22+
interface TransformerInterface
23+
{
24+
/**
25+
* Converts the resource to an array representation.
26+
* This is overridden by child classes to define specific fields.
27+
*
28+
* @param mixed $resource The resource being transformed
29+
*
30+
* @return array<string, mixed>
31+
*/
32+
public function toArray(mixed $resource): array;
33+
34+
/**
35+
* Transforms the given resource into an array.
36+
*
37+
* @param array<string, mixed>|object|null $resource
38+
*
39+
* @return array<string, mixed>
40+
*/
41+
public function transform(array|object|null $resource): array;
42+
43+
/**
44+
* Transforms a collection of resources using $this->transform() on each item.
45+
*
46+
* @param array<int|string, mixed> $resources
47+
*
48+
* @return array<int, array<string, mixed>>
49+
*/
50+
public function transformMany(array $resources): array;
51+
}

0 commit comments

Comments
 (0)