-
Notifications
You must be signed in to change notification settings - Fork 2k
feat: API transformers #9763
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat: API transformers #9763
Changes from 3 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
12349a0
feat(app): Added API Transformers
lonnieezell 32c7bdc
chore(app): Style, Rector, and Stan changes
lonnieezell 10796a6
chore(app): Remove unneccessary file
lonnieezell b86320d
(app): Remove when / whenNot from BaseTransformer
lonnieezell af3f2a8
refactor(app): Remove Entitiy dependency in BaseTransformer
lonnieezell 4552c27
fix(app): Fixing a Psalm issue with transform method
lonnieezell 1654847
docs(app): Fix an error with user guide builds
lonnieezell 1b3065a
docs(app): Fix docs issue with overline length_
lonnieezell 5da3cbf
chore(app): Update PHPStan baseline for test issues
lonnieezell 4f26755
docs(app): Additional docs fixes
lonnieezell 31285ff
fix(app): Addressing review comments
lonnieezell afb161b
build(app): Apply Rector fix
lonnieezell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# total 5 errors | ||
|
||
includes: | ||
- missingType.iterableValue.neon |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/** | ||
* This file is part of CodeIgniter 4 framework. | ||
* | ||
* (c) CodeIgniter Foundation <[email protected]> | ||
* | ||
* For the full copyright and license information, please view | ||
* the LICENSE file that was distributed with this source code. | ||
*/ | ||
|
||
namespace CodeIgniter\API; | ||
|
||
use Exception; | ||
|
||
/** | ||
* Custom exception for API-related errors. | ||
*/ | ||
class ApiException extends Exception | ||
paulbalandan marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
{ | ||
/** | ||
* Thrown when the fields requested in a URL are not valid. | ||
*/ | ||
public static function forInvalidFields(string $field): self | ||
{ | ||
return new self(lang('Api.invalidFields', [$field])); | ||
} | ||
|
||
/** | ||
* Thrown when the includes requested in a URL are not valid. | ||
*/ | ||
public static function forInvalidIncludes(string $include): self | ||
{ | ||
return new self(lang('Api.invalidIncludes', [$include])); | ||
} | ||
|
||
/** | ||
* Thrown when an include is requested, but the method to handle it | ||
* does not exist on the model. | ||
*/ | ||
public static function forMissingInclude(string $include): self | ||
{ | ||
return new self(lang('Api.missingInclude', [$include])); | ||
} | ||
|
||
/** | ||
* Thrown when a transformer class cannot be found. | ||
*/ | ||
public static function forTransformerNotFound(string $transformerClass): self | ||
{ | ||
return new self(lang('Api.transformerNotFound', [$transformerClass])); | ||
} | ||
|
||
/** | ||
* Thrown when a transformer class does not implement TransformerInterface. | ||
*/ | ||
public static function forInvalidTransformer(string $transformerClass): self | ||
{ | ||
return new self(lang('Api.invalidTransformer', [$transformerClass])); | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,249 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
/** | ||
* This file is part of CodeIgniter 4 framework. | ||
* | ||
* (c) CodeIgniter Foundation <[email protected]> | ||
* | ||
* For the full copyright and license information, please view | ||
* the LICENSE file that was distributed with this source code. | ||
*/ | ||
|
||
namespace CodeIgniter\API; | ||
|
||
use CodeIgniter\Entity\Entity; | ||
use CodeIgniter\HTTP\IncomingRequest; | ||
use InvalidArgumentException; | ||
|
||
/** | ||
* Base class for transforming resources into arrays. | ||
* Fulfills common functionality of the TransformerInterface, | ||
* and provides helper methods for conditional inclusion/exclusion of values. | ||
* | ||
* Supports the following query variables from the request: | ||
* - fields: Comma-separated list of fields to include in the response | ||
* (e.g., ?fields=id,name,email) | ||
* If not provided, all fields from toArray() are included. | ||
* - include: Comma-separated list of related resources to include | ||
* (e.g., ?include=posts,comments) | ||
* This looks for methods named `include{Resource}()` on the transformer, | ||
* and calls them to get the related data, which are added as a new key to the output. | ||
* | ||
* Example: | ||
* | ||
* class UserTransformer extends BaseTransformer | ||
* { | ||
* public function toArray(mixed $resource): array | ||
* { | ||
* return [ | ||
* 'id' => $resource['id'], | ||
* 'name' => $resource['name'], | ||
* 'email' => $resource['email'], | ||
* 'created_at' => $resource['created_at'], | ||
* 'updated_at' => $resource['updated_at'], | ||
* 'bio' => $this->when(($resource['bio'] ?? null) !== null, $resource['bio'] ?? null), | ||
* ]; | ||
* } | ||
* | ||
* protected function includePosts(): array | ||
* { | ||
* $posts = model('PostModel')->where('user_id', $this->resource['id'])->findAll(); | ||
* return (new PostTransformer())->transformMany($posts); | ||
* } | ||
* } | ||
*/ | ||
abstract class BaseTransformer implements TransformerInterface | ||
{ | ||
/** | ||
* @var list<string>|null | ||
*/ | ||
private ?array $fields = null; | ||
|
||
/** | ||
* @var list<string>|null | ||
*/ | ||
private ?array $includes = null; | ||
|
||
protected mixed $resource = null; | ||
|
||
public function __construct( | ||
private ?IncomingRequest $request = null, | ||
) { | ||
$this->request = $request ?? request(); | ||
|
||
$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; | ||
} | ||
|
||
/** | ||
* Converts the resource to an array representation. | ||
* This is overridden by child classes to define the | ||
* API-safe resource representation. | ||
* | ||
* @param mixed $resource The resource being transformed | ||
*/ | ||
abstract public function toArray(mixed $resource): array; | ||
|
||
/** | ||
* Transforms the given resource into an array using | ||
* the $this->toArray(). | ||
*/ | ||
public function transform(mixed $resource = null): array | ||
{ | ||
// Store the resource so include methods can access it | ||
$this->resource = $resource; | ||
|
||
if ($resource === null) { | ||
$data = $this->toArray(null); | ||
} else { | ||
$data = $resource instanceof Entity | ||
? $this->toArray($resource->toArray()) | ||
: $this->toArray((array) $resource); | ||
} | ||
|
||
$data = $this->limitFields($data); | ||
|
||
return $this->insertIncludes($data); | ||
} | ||
|
||
/** | ||
* Transforms a collection of resources using $this->transform() on each item. | ||
* | ||
* If the request's 'fields' query variable is set, only those fields will be included | ||
* in the transformed output. | ||
*/ | ||
public function transformMany(array $resources): array | ||
{ | ||
return array_map(fn ($resource): array => $this->transform($resource), $resources); | ||
} | ||
|
||
/** | ||
* Conditionally include a value. | ||
* | ||
* @param mixed $value | ||
* @param mixed $default | ||
* | ||
* @return mixed | ||
*/ | ||
protected function when(bool $condition, $value, $default = null) | ||
{ | ||
return $condition ? $value : $default; | ||
} | ||
|
||
/** | ||
* Conditionally exclude a value. | ||
* | ||
* @param mixed $value | ||
* @param mixed|null $default | ||
* | ||
* @return mixed | ||
*/ | ||
protected function whenNot(bool $condition, $value, $default = null) | ||
{ | ||
return $condition ? $default : $value; | ||
} | ||
|
||
/** | ||
* Define which fields can be requested via the 'fields' query parameter. | ||
* Override in child classes to restrict available fields. | ||
* Return null to allow all fields from toArray(). | ||
* | ||
* @return list<string>|null | ||
*/ | ||
protected function getAllowedFields(): ?array | ||
{ | ||
return null; | ||
} | ||
|
||
/** | ||
* Define which related resources can be included via the 'include' query parameter. | ||
* Override in child classes to restrict available includes. | ||
* Return null to allow all includes that have corresponding methods. | ||
* Return an empty array to disable all includes. | ||
* | ||
* @return list<string>|null | ||
*/ | ||
protected function getAllowedIncludes(): ?array | ||
{ | ||
return null; | ||
} | ||
|
||
/** | ||
* Limits the given data array to only the fields specified | ||
* | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
* | ||
* @throws InvalidArgumentException | ||
*/ | ||
private function limitFields(array $data): array | ||
{ | ||
if ($this->fields === null || $this->fields === []) { | ||
return $data; | ||
} | ||
|
||
$allowedFields = $this->getAllowedFields(); | ||
|
||
// If whitelist is defined, validate against it | ||
if ($allowedFields !== null) { | ||
$invalidFields = array_diff($this->fields, $allowedFields); | ||
|
||
if ($invalidFields !== []) { | ||
throw ApiException::forInvalidFields(implode(', ', $invalidFields)); | ||
} | ||
} | ||
|
||
return array_intersect_key($data, array_flip($this->fields)); | ||
} | ||
|
||
/** | ||
* Checks the request for 'include' query variable, and if present, | ||
* calls the corresponding include{Resource} methods to add related data. | ||
* | ||
* @param array<string, mixed> $data | ||
* | ||
* @return array<string, mixed> | ||
*/ | ||
private function insertIncludes(array $data): array | ||
{ | ||
if ($this->includes === null) { | ||
return $data; | ||
} | ||
|
||
$allowedIncludes = $this->getAllowedIncludes(); | ||
|
||
if ($allowedIncludes === []) { | ||
return $data; // No includes allowed | ||
} | ||
|
||
// If whitelist is defined, filter the requested includes | ||
if ($allowedIncludes !== null) { | ||
$invalidIncludes = array_diff($this->includes, $allowedIncludes); | ||
|
||
if ($invalidIncludes !== []) { | ||
throw ApiException::forInvalidIncludes(implode(', ', $invalidIncludes)); | ||
} | ||
} | ||
|
||
foreach ($this->includes as $include) { | ||
$method = 'include' . ucfirst($include); | ||
if (method_exists($this, $method)) { | ||
$data[$include] = $this->{$method}(); | ||
} else { | ||
throw ApiException::forMissingInclude($include); | ||
} | ||
} | ||
|
||
return $data; | ||
} | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.