Skip to content

Commit 12349a0

Browse files
committed
feat(app): Added API Transformers
1 parent 5add16d commit 12349a0

Some content is hidden

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

43 files changed

+2529
-14
lines changed

app/Transformers/Customer.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
<?php
2+
3+
namespace App\Transformers;
4+
5+
use CodeIgniter\API\BaseTransformer;
6+
7+
class Customer extends BaseTransformer
8+
{
9+
/**
10+
* Transform the resource into an array.
11+
*
12+
* @param mixed $resource
13+
*
14+
* @return array<string, mixed>
15+
*/
16+
public function toArray(mixed $resource): array
17+
{
18+
return [
19+
// Add your transformation logic here
20+
];
21+
}
22+
}

system/API/ApiException.php

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
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+
* Custom exception for API-related errors.
18+
*/
19+
class ApiException extends \Exception
20+
{
21+
/**
22+
* Thrown when the fields requested in a URL are not valid.
23+
*
24+
* @return ApiException
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+
* @return ApiException
35+
*/
36+
public static function forInvalidIncludes(string $include): self
37+
{
38+
return new self(lang('Api.invalidIncludes', [$include]));
39+
}
40+
41+
/**
42+
* Thrown when an include is requested, but the method to handle it
43+
* does not exist on the model.
44+
*
45+
* @return ApiException
46+
*/
47+
public static function forMissingInclude(string $include): self
48+
{
49+
return new self(lang('Api.missingInclude', [$include]));
50+
}
51+
52+
/**
53+
* Thrown when a transformer class cannot be found.
54+
*
55+
* @return ApiException
56+
*/
57+
public static function forTransformerNotFound(string $transformerClass): self
58+
{
59+
return new self(lang('Api.transformerNotFound', [$transformerClass]));
60+
}
61+
62+
/**
63+
* Thrown when a transformer class does not implement TransformerInterface.
64+
*
65+
* @return ApiException
66+
*/
67+
public static function forInvalidTransformer(string $transformerClass): self
68+
{
69+
return new self(lang('Api.invalidTransformer', [$transformerClass]));
70+
}
71+
}

system/API/BaseTransformer.php

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

system/API/ResponseTrait.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ protected function setResponseFormat(?string $format = null)
394394
* ]
395395
* ]
396396
*/
397-
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface
397+
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface
398398
{
399399
try {
400400
assert($this->request instanceof IncomingRequest);
@@ -426,6 +426,21 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res
426426
];
427427
}
428428

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

431446
$this->response->setHeader('Link', $this->linkHeader($links));
@@ -436,6 +451,9 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res
436451
'meta' => $meta,
437452
'links' => $links,
438453
]);
454+
} catch (ApiException $e) {
455+
// Re-throw ApiExceptions so they can be handled by the caller
456+
throw $e;
439457
} catch (DatabaseException $e) {
440458
log_message('error', lang('RESTful.cannotPaginate') . ' ' . $e->getMessage());
441459

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace CodeIgniter\API;
6+
7+
/**
8+
* Interface for transforming resources into arrays.
9+
*
10+
* This interface can be implemented by classes that need to transform
11+
* data into a standardized array format, such as for API responses.
12+
*/
13+
interface TransformerInterface
14+
{
15+
/**
16+
* Converts the resource to an array representation.
17+
* This is overridden by child classes to define specific fields.
18+
*
19+
* @param mixed $resource The resource being transformed
20+
*/
21+
public function toArray(mixed $resource): array;
22+
23+
/**
24+
* Transforms the given resource into an array.
25+
*/
26+
public function transform(object|array $resource): array;
27+
28+
/**
29+
* Transforms a collection of resources using $this->transform() on each item.
30+
*/
31+
public function transformMany(array $resources): array;
32+
}

0 commit comments

Comments
 (0)