From 647847f648ffc1235646d8aeee1dba0f9cd6fbce Mon Sep 17 00:00:00 2001 From: DM-Wuzuantw Date: Sun, 12 Jul 2026 13:35:40 +0000 Subject: [PATCH 1/2] feat: replace Spatie Fractal with Spatie Laravel Data for Phase 1 --- app/Data/ApiKeyData.php | 37 ++++++++ app/Data/CursorPaginatedDataCollection.php | 70 ++++++++++++++ app/Data/Data.php | 69 ++++++++++++++ app/Data/DataCollection.php | 58 ++++++++++++ app/Data/PaginatedDataCollection.php | 92 +++++++++++++++++++ app/Data/UserData.php | 51 ++++++++++ app/Data/UserSSHKeyData.php | 35 +++++++ .../Api/Client/AccountController.php | 6 +- .../Api/Client/ApiKeyController.php | 12 +-- .../Api/Client/SSHKeyController.php | 10 +- 10 files changed, 426 insertions(+), 14 deletions(-) create mode 100644 app/Data/ApiKeyData.php create mode 100644 app/Data/CursorPaginatedDataCollection.php create mode 100644 app/Data/Data.php create mode 100644 app/Data/DataCollection.php create mode 100644 app/Data/PaginatedDataCollection.php create mode 100644 app/Data/UserData.php create mode 100644 app/Data/UserSSHKeyData.php diff --git a/app/Data/ApiKeyData.php b/app/Data/ApiKeyData.php new file mode 100644 index 0000000000..97b30172ef --- /dev/null +++ b/app/Data/ApiKeyData.php @@ -0,0 +1,37 @@ +identifier, + description: $model->memo, + allowed_ips: $model->allowed_ips, + last_used_at: $model->last_used_at?->toAtomString(), + created_at: $model->created_at->toAtomString(), + ); + } + + public function getResourceName(): string + { + return static::getResourceNameStatic(); + } + + public static function getResourceNameStatic(): string + { + return ApiKey::RESOURCE_NAME; + } +} diff --git a/app/Data/CursorPaginatedDataCollection.php b/app/Data/CursorPaginatedDataCollection.php new file mode 100644 index 0000000000..a0c0632cbd --- /dev/null +++ b/app/Data/CursorPaginatedDataCollection.php @@ -0,0 +1,70 @@ +isFractal = $value; + return $this; + } + + protected array $_additional = []; + + public function additional(array $additional): static + { + $this->_additional = array_merge($this->_additional, $additional); + return $this; + } + + public function toArray(): array + { + $array = parent::toArray(); + if ($this->isFractal) { + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() + : strtolower(class_basename($this->dataClass)); + + $formattedData = []; + $dataItems = $array['data'] ?? []; + foreach ($dataItems as $item) { + if (is_array($item)) { + unset($item['_additional']); + } + $formattedData[] = [ + 'object' => $resourceKey, + 'attributes' => $item, + ]; + } + + $paginator = $this->items; + $cursor = [ + 'current' => $paginator->cursor() ? $paginator->cursor()->encode() : null, + 'prev' => $paginator->previousCursor() ? $paginator->previousCursor()->encode() : null, + 'next' => $paginator->nextCursor() ? $paginator->nextCursor()->encode() : null, + 'count' => $paginator->count(), + ]; + + $meta = $array['meta'] ?? []; + unset($meta['path'], $meta['per_page'], $meta['next_page_url'], $meta['prev_page_url']); + + if (isset($this->_additional['meta'])) { + $meta = array_merge($meta, $this->_additional['meta']); + } + + $meta['cursor'] = $cursor; + + return [ + 'object' => 'list', + 'data' => $formattedData, + 'meta' => $meta, + ]; + } + return $array; + } +} diff --git a/app/Data/Data.php b/app/Data/Data.php new file mode 100644 index 0000000000..75a77f4d95 --- /dev/null +++ b/app/Data/Data.php @@ -0,0 +1,69 @@ +isFractal = $value; + return $this; + } + + public static function collection(mixed $items): DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection + { + if ($items instanceof \Illuminate\Contracts\Pagination\Paginator || $items instanceof \Illuminate\Pagination\AbstractPaginator) { + return static::collect( + $items, + static::$_paginatedCollectionClass + ); + } + + if ($items instanceof \Illuminate\Contracts\Pagination\CursorPaginator || $items instanceof \Illuminate\Pagination\AbstractCursorPaginator) { + return static::collect( + $items, + static::$_cursorPaginatedCollectionClass + ); + } + + return static::collect( + $items, + static::$_collectionClass + ); + } + + public function toArray(): array + { + $array = parent::toArray(); + if ($this->isFractal) { + $resourceKey = method_exists($this, 'getResourceName') + ? $this->getResourceName() + : strtolower(class_basename($this)); + + $additional = $this->getAdditionalData(); + foreach ($additional as $key => $value) { + unset($array[$key]); + } + + $response = [ + 'object' => $resourceKey, + 'attributes' => $array, + ]; + + if (isset($additional['meta'])) { + $response['meta'] = $additional['meta']; + } + + return $response; + } + return $array; + } +} diff --git a/app/Data/DataCollection.php b/app/Data/DataCollection.php new file mode 100644 index 0000000000..6eb79bc6ce --- /dev/null +++ b/app/Data/DataCollection.php @@ -0,0 +1,58 @@ +isFractal = $value; + return $this; + } + + protected array $_additional = []; + + public function additional(array $additional): static + { + $this->_additional = array_merge($this->_additional, $additional); + return $this; + } + + public function toArray(): array + { + $array = parent::toArray(); + if ($this->isFractal) { + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() + : strtolower(class_basename($this->dataClass)); + + $formattedData = []; + foreach ($array as $item) { + // If the item itself has additional data merged, remove it from attributes + if (is_array($item)) { + unset($item['_additional']); + } + $formattedData[] = [ + 'object' => $resourceKey, + 'attributes' => $item, + ]; + } + + $response = [ + 'object' => 'list', + 'data' => $formattedData, + ]; + + if (isset($this->_additional['meta'])) { + $response['meta'] = $this->_additional['meta']; + } + + return $response; + } + return $array; + } +} diff --git a/app/Data/PaginatedDataCollection.php b/app/Data/PaginatedDataCollection.php new file mode 100644 index 0000000000..f7aab9fadd --- /dev/null +++ b/app/Data/PaginatedDataCollection.php @@ -0,0 +1,92 @@ +isFractal = $value; + return $this; + } + + protected array $_additional = []; + + public function additional(array $additional): static + { + $this->_additional = array_merge($this->_additional, $additional); + return $this; + } + + public function toArray(): array + { + $array = parent::toArray(); + if ($this->isFractal) { + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() + : strtolower(class_basename($this->dataClass)); + + $formattedData = []; + $dataItems = $array['data'] ?? []; + foreach ($dataItems as $item) { + if (is_array($item)) { + unset($item['_additional']); + } + $formattedData[] = [ + 'object' => $resourceKey, + 'attributes' => $item, + ]; + } + + $paginator = $this->items; + + $pagination = [ + 'total' => $paginator->total(), + 'count' => $paginator->count(), + 'per_page' => $paginator->perPage(), + 'current_page' => $paginator->currentPage(), + 'total_pages' => $paginator->lastPage(), + 'links' => [], + ]; + if ($paginator->nextPageUrl()) { + $pagination['links']['next'] = $paginator->nextPageUrl(); + } + if ($paginator->previousPageUrl()) { + $pagination['links']['previous'] = $paginator->previousPageUrl(); + } + + $meta = $array['meta'] ?? []; + unset( + $meta['current_page'], + $meta['first_page_url'], + $meta['from'], + $meta['last_page'], + $meta['last_page_url'], + $meta['next_page_url'], + $meta['path'], + $meta['per_page'], + $meta['prev_page_url'], + $meta['to'], + $meta['total'] + ); + + // Merge custom additional meta + if (isset($this->_additional['meta'])) { + $meta = array_merge($meta, $this->_additional['meta']); + } + + $meta['pagination'] = $pagination; + + return [ + 'object' => 'list', + 'data' => $formattedData, + 'meta' => $meta, + ]; + } + return $array; + } +} diff --git a/app/Data/UserData.php b/app/Data/UserData.php new file mode 100644 index 0000000000..0eb4f71a41 --- /dev/null +++ b/app/Data/UserData.php @@ -0,0 +1,51 @@ +uuid, + username: $user->username, + email: $user->email, + language: $user->language, + image: 'https://gravatar.com/avatar/' . md5(Str::lower($user->email)), + admin: $user->isRootAdmin(), + root_admin: $user->isRootAdmin(), + two_fa_enabled: filled($user->mfa_app_secret), + created_at: $user->created_at->setTimezone('UTC')->toAtomString(), + updated_at: $user->updated_at->setTimezone('UTC')->toAtomString(), + ); + } + + public function getResourceName(): string + { + return static::getResourceNameStatic(); + } + + public static function getResourceNameStatic(): string + { + return User::RESOURCE_NAME; + } +} diff --git a/app/Data/UserSSHKeyData.php b/app/Data/UserSSHKeyData.php new file mode 100644 index 0000000000..1622ebabbf --- /dev/null +++ b/app/Data/UserSSHKeyData.php @@ -0,0 +1,35 @@ +name, + fingerprint: $model->fingerprint, + public_key: $model->public_key, + created_at: $model->created_at->toAtomString(), + ); + } + + public function getResourceName(): string + { + return static::getResourceNameStatic(); + } + + public static function getResourceNameStatic(): string + { + return UserSSHKey::RESOURCE_NAME; + } +} diff --git a/app/Http/Controllers/Api/Client/AccountController.php b/app/Http/Controllers/Api/Client/AccountController.php index 8b1ffa3a8c..a2d890549b 100644 --- a/app/Http/Controllers/Api/Client/AccountController.php +++ b/app/Http/Controllers/Api/Client/AccountController.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Api\Client; +use App\Data\UserData; use App\Facades\Activity; use App\Http\Requests\Api\Client\Account\UpdateEmailRequest; use App\Http\Requests\Api\Client\Account\UpdatePasswordRequest; use App\Http\Requests\Api\Client\Account\UpdateUsernameRequest; use App\Services\Users\UserUpdateService; -use App\Transformers\Api\Client\UserTransformer; use Illuminate\Auth\AuthManager; use Illuminate\Auth\SessionGuard; use Illuminate\Http\JsonResponse; @@ -39,8 +39,8 @@ public function __construct(private AuthManager $manager, private UserUpdateServ */ public function index(Request $request): array { - return $this->fractal->item($request->user()) - ->transformWith($this->getTransformer(UserTransformer::class)) + return UserData::from($request->user()) + ->setFractal(true) ->toArray(); } diff --git a/app/Http/Controllers/Api/Client/ApiKeyController.php b/app/Http/Controllers/Api/Client/ApiKeyController.php index e6c738bb9c..6abda4962a 100644 --- a/app/Http/Controllers/Api/Client/ApiKeyController.php +++ b/app/Http/Controllers/Api/Client/ApiKeyController.php @@ -2,12 +2,12 @@ namespace App\Http\Controllers\Api\Client; +use App\Data\ApiKeyData; use App\Exceptions\DisplayException; use App\Facades\Activity; use App\Http\Requests\Api\Client\Account\StoreApiKeyRequest; use App\Http\Requests\Api\Client\ClientApiRequest; use App\Models\ApiKey; -use App\Transformers\Api\Client\ApiKeyTransformer; use Illuminate\Http\JsonResponse; class ApiKeyController extends ClientApiController @@ -21,8 +21,8 @@ class ApiKeyController extends ClientApiController */ public function index(ClientApiRequest $request): array { - return $this->fractal->collection($request->user()->apiKeys) - ->transformWith($this->getTransformer(ApiKeyTransformer::class)) + return ApiKeyData::collection($request->user()->apiKeys) + ->setFractal(true) ->toArray(); } @@ -49,9 +49,9 @@ public function store(StoreApiKeyRequest $request): array ->property('identifier', $token->accessToken->identifier) ->log(); - return $this->fractal->item($token->accessToken) - ->transformWith($this->getTransformer(ApiKeyTransformer::class)) - ->addMeta(['secret_token' => $token->plainTextToken]) + return ApiKeyData::from($token->accessToken) + ->setFractal(true) + ->additional(['meta' => ['secret_token' => $token->plainTextToken]]) ->toArray(); } diff --git a/app/Http/Controllers/Api/Client/SSHKeyController.php b/app/Http/Controllers/Api/Client/SSHKeyController.php index 445c2c7b84..f000541162 100644 --- a/app/Http/Controllers/Api/Client/SSHKeyController.php +++ b/app/Http/Controllers/Api/Client/SSHKeyController.php @@ -2,11 +2,11 @@ namespace App\Http\Controllers\Api\Client; +use App\Data\UserSSHKeyData; use App\Facades\Activity; use App\Http\Requests\Api\Client\Account\StoreSSHKeyRequest; use App\Http\Requests\Api\Client\ClientApiRequest; use App\Models\UserSSHKey; -use App\Transformers\Api\Client\UserSSHKeyTransformer; use Illuminate\Http\JsonResponse; class SSHKeyController extends ClientApiController @@ -20,8 +20,8 @@ class SSHKeyController extends ClientApiController */ public function index(ClientApiRequest $request): array { - return $this->fractal->collection($request->user()->sshKeys) - ->transformWith($this->getTransformer(UserSSHKeyTransformer::class)) + return UserSSHKeyData::collection($request->user()->sshKeys) + ->setFractal(true) ->toArray(); } @@ -45,8 +45,8 @@ public function store(StoreSSHKeyRequest $request): array ->property('fingerprint', $request->getKeyFingerprint()) ->log(); - return $this->fractal->item($model) - ->transformWith($this->getTransformer(UserSSHKeyTransformer::class)) + return UserSSHKeyData::from($model) + ->setFractal(true) ->toArray(); } From 29bf2727c607e1a99d15635c1c798ba33ca27a9d Mon Sep 17 00:00:00 2001 From: DM-Wuzuantw Date: Sun, 12 Jul 2026 13:43:04 +0000 Subject: [PATCH 2/2] style/fix: run Laravel Pint and fix static analysis type hints --- app/Data/ApiKeyData.php | 3 ++ app/Data/CursorPaginatedDataCollection.php | 13 +++++-- app/Data/Data.php | 31 ++++++++++++---- app/Data/DataCollection.php | 13 +++++-- app/Data/PaginatedDataCollection.php | 41 ++++++++++++++-------- app/Data/UserData.php | 1 - 6 files changed, 73 insertions(+), 29 deletions(-) diff --git a/app/Data/ApiKeyData.php b/app/Data/ApiKeyData.php index 97b30172ef..a8aafe7642 100644 --- a/app/Data/ApiKeyData.php +++ b/app/Data/ApiKeyData.php @@ -6,6 +6,9 @@ class ApiKeyData extends Data { + /** + * @param array|null $allowed_ips + */ public function __construct( public string $identifier, public ?string $description, diff --git a/app/Data/CursorPaginatedDataCollection.php b/app/Data/CursorPaginatedDataCollection.php index a0c0632cbd..67f0e616d0 100644 --- a/app/Data/CursorPaginatedDataCollection.php +++ b/app/Data/CursorPaginatedDataCollection.php @@ -11,14 +11,20 @@ class CursorPaginatedDataCollection extends SpatieCursorPaginatedDataCollection public function setFractal(bool $value = true): static { $this->isFractal = $value; + return $this; } + /** @var array */ protected array $_additional = []; + /** + * @param array $additional + */ public function additional(array $additional): static { $this->_additional = array_merge($this->_additional, $additional); + return $this; } @@ -26,8 +32,8 @@ public function toArray(): array { $array = parent::toArray(); if ($this->isFractal) { - $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') - ? ($this->dataClass)::getResourceNameStatic() + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() : strtolower(class_basename($this->dataClass)); $formattedData = []; @@ -52,7 +58,7 @@ public function toArray(): array $meta = $array['meta'] ?? []; unset($meta['path'], $meta['per_page'], $meta['next_page_url'], $meta['prev_page_url']); - + if (isset($this->_additional['meta'])) { $meta = array_merge($meta, $this->_additional['meta']); } @@ -65,6 +71,7 @@ public function toArray(): array 'meta' => $meta, ]; } + return $array; } } diff --git a/app/Data/Data.php b/app/Data/Data.php index 75a77f4d95..844ca3c79c 100644 --- a/app/Data/Data.php +++ b/app/Data/Data.php @@ -2,12 +2,18 @@ namespace App\Data; +use Illuminate\Contracts\Pagination\CursorPaginator; +use Illuminate\Contracts\Pagination\Paginator; +use Illuminate\Pagination\AbstractCursorPaginator; +use Illuminate\Pagination\AbstractPaginator; use Spatie\LaravelData\Data as SpatieData; abstract class Data extends SpatieData { public static string $_collectionClass = DataCollection::class; + public static string $_paginatedCollectionClass = PaginatedDataCollection::class; + public static string $_cursorPaginatedCollectionClass = CursorPaginatedDataCollection::class; protected bool $isFractal = false; @@ -15,37 +21,47 @@ abstract class Data extends SpatieData public function setFractal(bool $value = true): static { $this->isFractal = $value; + return $this; } public static function collection(mixed $items): DataCollection|PaginatedDataCollection|CursorPaginatedDataCollection { - if ($items instanceof \Illuminate\Contracts\Pagination\Paginator || $items instanceof \Illuminate\Pagination\AbstractPaginator) { - return static::collect( + if ($items instanceof Paginator || $items instanceof AbstractPaginator) { + /** @var PaginatedDataCollection $collection */ + $collection = static::collect( $items, static::$_paginatedCollectionClass ); + + return $collection; } - if ($items instanceof \Illuminate\Contracts\Pagination\CursorPaginator || $items instanceof \Illuminate\Pagination\AbstractCursorPaginator) { - return static::collect( + if ($items instanceof CursorPaginator || $items instanceof AbstractCursorPaginator) { + /** @var CursorPaginatedDataCollection $collection */ + $collection = static::collect( $items, static::$_cursorPaginatedCollectionClass ); + + return $collection; } - return static::collect( + /** @var DataCollection $collection */ + $collection = static::collect( $items, static::$_collectionClass ); + + return $collection; } public function toArray(): array { $array = parent::toArray(); if ($this->isFractal) { - $resourceKey = method_exists($this, 'getResourceName') - ? $this->getResourceName() + $resourceKey = method_exists($this, 'getResourceName') + ? $this->getResourceName() : strtolower(class_basename($this)); $additional = $this->getAdditionalData(); @@ -64,6 +80,7 @@ public function toArray(): array return $response; } + return $array; } } diff --git a/app/Data/DataCollection.php b/app/Data/DataCollection.php index 6eb79bc6ce..8818115042 100644 --- a/app/Data/DataCollection.php +++ b/app/Data/DataCollection.php @@ -11,14 +11,20 @@ class DataCollection extends SpatieDataCollection public function setFractal(bool $value = true): static { $this->isFractal = $value; + return $this; } + /** @var array */ protected array $_additional = []; + /** + * @param array $additional + */ public function additional(array $additional): static { $this->_additional = array_merge($this->_additional, $additional); + return $this; } @@ -26,10 +32,10 @@ public function toArray(): array { $array = parent::toArray(); if ($this->isFractal) { - $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') - ? ($this->dataClass)::getResourceNameStatic() + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() : strtolower(class_basename($this->dataClass)); - + $formattedData = []; foreach ($array as $item) { // If the item itself has additional data merged, remove it from attributes @@ -53,6 +59,7 @@ public function toArray(): array return $response; } + return $array; } } diff --git a/app/Data/PaginatedDataCollection.php b/app/Data/PaginatedDataCollection.php index f7aab9fadd..b0f35c4781 100644 --- a/app/Data/PaginatedDataCollection.php +++ b/app/Data/PaginatedDataCollection.php @@ -2,6 +2,7 @@ namespace App\Data; +use Illuminate\Pagination\LengthAwarePaginator; use Spatie\LaravelData\PaginatedDataCollection as SpatiePaginatedDataCollection; class PaginatedDataCollection extends SpatiePaginatedDataCollection @@ -11,14 +12,20 @@ class PaginatedDataCollection extends SpatiePaginatedDataCollection public function setFractal(bool $value = true): static { $this->isFractal = $value; + return $this; } + /** @var array */ protected array $_additional = []; + /** + * @param array $additional + */ public function additional(array $additional): static { $this->_additional = array_merge($this->_additional, $additional); + return $this; } @@ -26,8 +33,8 @@ public function toArray(): array { $array = parent::toArray(); if ($this->isFractal) { - $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') - ? ($this->dataClass)::getResourceNameStatic() + $resourceKey = method_exists($this->dataClass, 'getResourceNameStatic') + ? ($this->dataClass)::getResourceNameStatic() : strtolower(class_basename($this->dataClass)); $formattedData = []; @@ -43,7 +50,10 @@ public function toArray(): array } $paginator = $this->items; - + if (!$paginator instanceof LengthAwarePaginator) { + throw new \InvalidArgumentException('Paginator must be an instance of LengthAwarePaginator'); + } + $pagination = [ 'total' => $paginator->total(), 'count' => $paginator->count(), @@ -61,24 +71,24 @@ public function toArray(): array $meta = $array['meta'] ?? []; unset( - $meta['current_page'], - $meta['first_page_url'], - $meta['from'], - $meta['last_page'], - $meta['last_page_url'], - $meta['next_page_url'], - $meta['path'], - $meta['per_page'], - $meta['prev_page_url'], - $meta['to'], + $meta['current_page'], + $meta['first_page_url'], + $meta['from'], + $meta['last_page'], + $meta['last_page_url'], + $meta['next_page_url'], + $meta['path'], + $meta['per_page'], + $meta['prev_page_url'], + $meta['to'], $meta['total'] ); - + // Merge custom additional meta if (isset($this->_additional['meta'])) { $meta = array_merge($meta, $this->_additional['meta']); } - + $meta['pagination'] = $pagination; return [ @@ -87,6 +97,7 @@ public function toArray(): array 'meta' => $meta, ]; } + return $array; } } diff --git a/app/Data/UserData.php b/app/Data/UserData.php index 0eb4f71a41..cbd9c0ff6f 100644 --- a/app/Data/UserData.php +++ b/app/Data/UserData.php @@ -4,7 +4,6 @@ use App\Models\User; use Illuminate\Support\Str; - use Spatie\LaravelData\Attributes\MapOutputName; class UserData extends Data