Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 4 additions & 0 deletions loader.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# total 5 errors

includes:
- missingType.iterableValue.neon
63 changes: 63 additions & 0 deletions system/API/ApiException.php
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
{
/**
* 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]));
}
}
249 changes: 249 additions & 0 deletions system/API/BaseTransformer.php
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

Check failure on line 108 in system/API/BaseTransformer.php

View workflow job for this annotation

GitHub Actions / Architectural Inspection

CodeIgniter\API\BaseTransformer must not depend on CodeIgniter\Entity\Entity (API on 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;
}
}
20 changes: 19 additions & 1 deletion system/API/ResponseTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ protected function setResponseFormat(?string $format = null)
* ]
* ]
*/
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): ResponseInterface
protected function paginate(BaseBuilder|Model $resource, int $perPage = 20, ?string $transformWith = null): ResponseInterface
{
try {
assert($this->request instanceof IncomingRequest);
Expand Down Expand Up @@ -426,6 +426,21 @@ protected function paginate(BaseBuilder|Model $resource, int $perPage = 20): Res
];
}

// Transform data if a transformer is provided
if ($transformWith !== null) {
if (! class_exists($transformWith)) {
throw ApiException::forTransformerNotFound($transformWith);
}

$transformer = new $transformWith($this->request);

if (! $transformer instanceof TransformerInterface) {
throw ApiException::forInvalidTransformer($transformWith);
}

$data = $transformer->transformMany($data);
}

$links = $this->buildLinks($meta);

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

Expand Down
Loading
Loading