Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 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]));
}
}
221 changes: 221 additions & 0 deletions system/API/BaseTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
<?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\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'],
* ];
* }
*
* 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(array|object|null $resource = null): array
{
// Store the resource so include methods can access it
$this->resource = $resource;

if ($resource === null) {
$data = $this->toArray(null);
} elseif (is_object($resource) && method_exists($resource, 'toArray')) {
$data = $this->toArray($resource->toArray());
} else {
$data = $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);
}

/**
* 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
51 changes: 51 additions & 0 deletions system/API/TransformerInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?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;

/**
* Interface for transforming resources into arrays.
*
* This interface can be implemented by classes that need to transform
* data into a standardized array format, such as for API responses.
*/
interface TransformerInterface
{
/**
* Converts the resource to an array representation.
* This is overridden by child classes to define specific fields.
*
* @param mixed $resource The resource being transformed
*
* @return array<string, mixed>
*/
public function toArray(mixed $resource): array;

/**
* Transforms the given resource into an array.
*
* @param array<string, mixed>|object|null $resource
*
* @return array<string, mixed>
*/
public function transform(array|object|null $resource): array;

/**
* Transforms a collection of resources using $this->transform() on each item.
*
* @param array<int|string, mixed> $resources
*
* @return array<int, array<string, mixed>>
*/
public function transformMany(array $resources): array;
}
Loading
Loading