From 1ac4043564be32839ffb215c93a31c4b925838d4 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 13:49:06 +0800 Subject: [PATCH 1/3] feat(resources): agnostic resource transformer registry Rewrite ResourceTransformerRegistry as a container singleton that any extension can register against without modifying the resource or model. Transformers target a resource class, model class, interface or '*' (subclasses match), chain in ascending priority, and can be scoped by channel (http, webhook, broadcast) and audience (internal, public). Closures and instances are accepted alongside classes. FleetbaseResource::resolve() applies registered transformers to every resource, nested resource and collection item, re-filtering conditional values and honouring without(). FleetbaseResourceCollection shares one context per collection so the new PreparesResourceTransformation::prepare() hook can batch-load once and avoid N+1 queries. ResourceLifecycleEvent, chat participant broadcasts, Utils::serializeJsonResource and the cached internal user payload serialize through resolve(), so transformers reach webhook and broadcast payloads and conditional MissingValues are no longer emitted as {}. CoreServiceProvider binds the registry, adds a $transformers property, and registerTransformers() / registerTransformersFrom() mirroring expansion discovery. Find::httpResourceForModel caches internal and public resolutions separately, consulting the request only when a model has a dedicated Internal resource. Removes the legacy duck-typed transformer API ($target + static output()), the transform(Model) entry point, resolveByTarget(), fixClassName() and the static $transformers array. The User resource no longer calls the registry directly. --- CHANGELOG.md | 13 + README.md | 68 ++ .../PreparesResourceTransformation.php | 22 + src/Contracts/ResourceTransformer.php | 35 + src/Events/ChatParticipantAdded.php | 3 +- src/Events/ChatParticipantRemoved.php | 3 +- src/Events/ResourceLifecycleEvent.php | 23 +- .../Internal/v1/UserController.php | 2 +- src/Http/Resources/FleetbaseResource.php | 113 +++- .../Resources/FleetbaseResourceCollection.php | 91 ++- src/Http/Resources/User.php | 4 +- src/Http/Transformers/Transformer.php | 80 +++ src/Providers/CoreServiceProvider.php | 94 +++ src/Support/Find.php | 81 ++- src/Support/ResourceTransformerContext.php | 118 ++++ src/Support/ResourceTransformerRegistry.php | 613 ++++++++++++++++-- src/Support/Utils.php | 2 +- tests/Pest.php | 3 + tests/Unit/EventsAndExceptionsTest.php | 109 ++++ .../Http/FleetbaseResourceCollectionTest.php | 23 + .../FleetbaseResourceTransformersTest.php | 275 ++++++++ .../CoreProviderTransformersTest.php | 173 +++++ .../Support/FindHttpResourceForModelTest.php | 64 ++ .../ResourceTransformerRegistryTest.php | 440 +++++++++++-- 24 files changed, 2294 insertions(+), 158 deletions(-) create mode 100644 src/Contracts/PreparesResourceTransformation.php create mode 100644 src/Contracts/ResourceTransformer.php create mode 100644 src/Http/Transformers/Transformer.php create mode 100644 src/Support/ResourceTransformerContext.php create mode 100644 tests/Unit/Http/FleetbaseResourceTransformersTest.php create mode 100644 tests/Unit/Providers/CoreProviderTransformersTest.php create mode 100644 tests/Unit/Support/FindHttpResourceForModelTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 15dd6eec..db652f5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,4 +5,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### Added +- `ResourceTransformerRegistry` rewritten as a container singleton so any extension can decorate resource output without modifying the resource or model. Transformers target a resource class, model class, interface or `'*'` (subclasses match), chain in ascending `priority`, and can be limited by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). +- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (batch `prepare()` hook to avoid N+1 queries), `Fleetbase\Support\ResourceTransformerContext` and the `Fleetbase\Http\Transformers\Transformer` base class. +- Closure/callable transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`. +- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom()` for declarative and directory-based registration from extensions. + +### Changed +- `FleetbaseResource::resolve()` applies registered transformers to every resource, nested resource and collection item; `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. +- `ResourceLifecycleEvent::getEventData()` and `broadcastWith()`, chat participant broadcast events, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers apply to webhook and broadcast payloads and conditional `MissingValue`s are no longer emitted as `{}`. +- `Find::httpResourceForModel()` caches internal and public resolutions separately. + +### Removed +- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. - Adds first version diff --git a/README.md b/README.md index 75159701..c0adb0e6 100644 --- a/README.md +++ b/README.md @@ -41,3 +41,71 @@ composer test:unit ```bash composer test ``` + +## Resource transformers + +Any extension can decorate the serialized output of any API resource without touching the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'`, and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. + +```php +namespace Fleetbase\MyExtension\Http\Transformers; + +use Fleetbase\Contracts\PreparesResourceTransformation; +use Fleetbase\Http\Transformers\Transformer; +use Fleetbase\Models\User; +use Fleetbase\Support\ResourceTransformerContext; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; + +class UserBadgeTransformer extends Transformer implements PreparesResourceTransformation +{ + protected static $target = User::class; // resource class, model class, interface, array of them, or '*' + protected static $priority = 10; // lower runs first; higher runs later and can override + protected static $contexts = ['http', 'webhook']; // null for every channel (http, webhook, broadcast) + protected static $only = 'internal'; // 'internal', 'public' or null for both + + // Optional: runs once per resolve with every model about to be serialized, so you can batch-load. + public function prepare(Collection $models, Request $request, ResourceTransformerContext $context): void + { + $context->set('badges', Badge::whereIn('user_uuid', $models->pluck('uuid'))->get()->keyBy('user_uuid')); + } + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['badge' => $context->get('badges')[$resource->resource->uuid]->name ?? null]; + } +} +``` + +Register transformers from your extension's service provider, either declaratively or by discovery: + +```php +class MyExtensionServiceProvider extends CoreServiceProvider +{ + public $transformers = [ + UserBadgeTransformer::class, + [OrderTotalsTransformer::class, ['priority' => 5]], + ]; + + public function boot() + { + $this->registerTransformers(); // the $transformers property + $this->registerTransformersFrom(__DIR__ . '/../Http/Transformers'); // every ResourceTransformer in the directory + } +} +``` + +Closures work too, for quick one-off tweaks: + +```php +ResourceTransformerRegistry::register( + fn (array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context) => $data + ['flag' => true], + ['target' => \Fleetbase\FleetOps\Models\Order::class, 'contexts' => ['webhook'], 'only' => 'public'] +); +``` + +Notes: + +- Transformers may return `MissingValue` / `MergeValue` objects; they are filtered like `when()` / `merge()` output. Keys excluded with `without()` stay excluded. +- Re-registering a class replaces its options; `ResourceTransformerRegistry::forget()` and `reset()` remove registrations. +- The registry is a container singleton (`app(ResourceTransformerRegistry::class)`); registrations happen at boot and are shared by every request in an Octane worker. diff --git a/src/Contracts/PreparesResourceTransformation.php b/src/Contracts/PreparesResourceTransformation.php new file mode 100644 index 00000000..22bc7160 --- /dev/null +++ b/src/Contracts/PreparesResourceTransformation.php @@ -0,0 +1,22 @@ + $models + */ + public function prepare(Collection $models, Request $request, ResourceTransformerContext $context): void; +} diff --git a/src/Contracts/ResourceTransformer.php b/src/Contracts/ResourceTransformer.php new file mode 100644 index 00000000..df1037f7 --- /dev/null +++ b/src/Contracts/ResourceTransformer.php @@ -0,0 +1,35 @@ + + */ + public static function target(): string|array; + + /** + * Transform the serialized resource data. + * + * @param array $data the data produced by the resource's `toArray()`, after filtering + * + * @return array + */ + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array; +} diff --git a/src/Events/ChatParticipantAdded.php b/src/Events/ChatParticipantAdded.php index d29a4273..cf7b7528 100644 --- a/src/Events/ChatParticipantAdded.php +++ b/src/Events/ChatParticipantAdded.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Resources\ChatParticipant as ChatParticipantResource; use Fleetbase\Models\ChatChannel; use Fleetbase\Models\ChatParticipant; +use Fleetbase\Support\ResourceTransformerContext; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; @@ -80,7 +81,7 @@ public function broadcastWith() 'event' => $this->broadcastAs(), 'created_at' => $this->createdAt->toDateTimeString(), 'channel_id' => $this->chatChannel->public_id, - 'data' => $resource ? $resource->toArray(request()) : [], + 'data' => $resource->resolveFor(ResourceTransformerContext::BROADCAST, request()), ]; } } diff --git a/src/Events/ChatParticipantRemoved.php b/src/Events/ChatParticipantRemoved.php index 4167107f..0ea59ec3 100644 --- a/src/Events/ChatParticipantRemoved.php +++ b/src/Events/ChatParticipantRemoved.php @@ -5,6 +5,7 @@ use Fleetbase\Http\Resources\ChatParticipant as ChatParticipantResource; use Fleetbase\Models\ChatChannel; use Fleetbase\Models\ChatParticipant; +use Fleetbase\Support\ResourceTransformerContext; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow; @@ -80,7 +81,7 @@ public function broadcastWith() 'event' => $this->broadcastAs(), 'created_at' => $this->createdAt->toDateTimeString(), 'channel_id' => $this->chatChannel->public_id, - 'data' => $resource ? $resource->toArray(request()) : [], + 'data' => $resource->resolveFor(ResourceTransformerContext::BROADCAST, request()), ]; } } diff --git a/src/Events/ResourceLifecycleEvent.php b/src/Events/ResourceLifecycleEvent.php index 11f06f96..cddc2f8c 100644 --- a/src/Events/ResourceLifecycleEvent.php +++ b/src/Events/ResourceLifecycleEvent.php @@ -2,8 +2,10 @@ namespace Fleetbase\Events; +use Fleetbase\Http\Resources\FleetbaseResource; use Fleetbase\Models\Model; use Fleetbase\Support\Resolve; +use Fleetbase\Support\ResourceTransformerContext; use Fleetbase\Support\Utils; use Illuminate\Broadcasting\Channel; use Illuminate\Broadcasting\InteractsWithSockets; @@ -133,7 +135,7 @@ public function broadcastAs(): string */ public function broadcastWith(): array { - return $this->getEventData(); + return $this->getEventData(ResourceTransformerContext::BROADCAST); } /** @@ -327,11 +329,13 @@ public function getNamespaceFromModel(Model $model): string * It checks if specific methods exist on the resource to format the data for a webhook payload or simply converts it to an array. * Additionally, it decides whether to keep relational data based on predefined criteria. * + * @param string $channel the transformer channel the payload is produced for (`webhook` or `broadcast`) + * * @return array An array representing the structured data for the event, including identifiers and formatted model data. * This array includes fields like 'id' for the event ID, 'api_version', 'event' for the event type, * 'created_at' for the timestamp, and 'data' containing the transformed model information. */ - public function getEventData(): array + public function getEventData(string $channel = ResourceTransformerContext::WEBHOOK): array { $model = $this->getModelRecord(); if (!$model) { @@ -346,10 +350,19 @@ public function getEventData(): array $shouldKeepRelations = in_array($this->modelName, $keepRelations); if ($resource) { + $request = request(); + + // Registered resource transformers apply to webhook/broadcast payloads too, tagged with the channel + // so transformers can opt in or out per channel. if (method_exists($resource, 'toWebhookPayload')) { - $resourceData = $resource->toWebhookPayload(); - } elseif (method_exists($resource, 'toArray')) { - $resourceData = $resource->toArray(request()); + $resourceData = (array) $resource->toWebhookPayload(); + if ($resource instanceof FleetbaseResource) { + $resourceData = $resource->transformPayload($resourceData, $channel, $request); + } + } elseif ($resource instanceof FleetbaseResource) { + $resourceData = $resource->resolveFor($channel, $request); + } else { + $resourceData = $resource->resolve($request); } } diff --git a/src/Http/Controllers/Internal/v1/UserController.php b/src/Http/Controllers/Internal/v1/UserController.php index 1f2fa2c0..df4f6089 100644 --- a/src/Http/Controllers/Internal/v1/UserController.php +++ b/src/Http/Controllers/Internal/v1/UserController.php @@ -665,7 +665,7 @@ public function current(Request $request) // Transform to resource $userData = new $this->resource($user); - $userArray = $userData->toArray($request); + $userArray = $userData->resolve($request); // Store in cache UserCacheService::put($user, $companyId, $userArray); diff --git a/src/Http/Resources/FleetbaseResource.php b/src/Http/Resources/FleetbaseResource.php index d87bbfc7..3c315c46 100644 --- a/src/Http/Resources/FleetbaseResource.php +++ b/src/Http/Resources/FleetbaseResource.php @@ -3,6 +3,10 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; +use Fleetbase\Support\ResourceTransformerContext; +use Fleetbase\Support\ResourceTransformerRegistry; +use Illuminate\Container\Container; +use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -14,10 +18,15 @@ class FleetbaseResource extends JsonResource */ protected array $excluded = []; + /** + * Transformer context injected by a parent collection so every item shares one `prepare()` pass. + */ + protected ?ResourceTransformerContext $transformerContext = null; + /** * Transform the resource into an array. * - * @param \Illuminate\Http\Request $request + * @param Request $request * * @return array|\Illuminate\Contracts\Support\Arrayable|\JsonSerializable */ @@ -29,6 +38,62 @@ public function toArray($request) return $data; } + /** + * Resolve the resource to an array and apply registered resource transformers. + * + * Laravel routes every serialization through `resolve()` (responses, nested resources, + * `jsonSerialize()`), so transformers registered with `ResourceTransformerRegistry` reach + * every resource without the resource having to opt in. + * + * @param Request|null $request + * + * @return array + */ + public function resolve($request = null) + { + $request = $this->resolveRequest($request); + + return $this->applyTransformers(parent::resolve($request), $request, ResourceTransformerContext::HTTP); + } + + /** + * Resolve the resource for a specific channel (`webhook`, `broadcast`). + * + * @param Request|null $request + * + * @return array + */ + public function resolveFor(string $channel, $request = null): array + { + $request = $this->resolveRequest($request); + + return $this->applyTransformers(parent::resolve($request), $request, $channel); + } + + /** + * Apply registered transformers to an already-built payload (e.g. `toWebhookPayload()` output). + * + * @param array $data + * @param Request|null $request + * + * @return array + */ + public function transformPayload(array $data, string $channel = ResourceTransformerContext::HTTP, $request = null): array + { + return $this->applyTransformers($data, $this->resolveRequest($request), $channel); + } + + /** + * Share a transformer context (clone, like `without()`). + */ + public function withTransformerContext(?ResourceTransformerContext $context): static + { + $clone = clone $this; + $clone->transformerContext = $context; + + return $clone; + } + /** * Create a new anonymous resource collection. * @@ -84,6 +149,52 @@ public function without(array|string $keys): static return $clone; } + /** + * Run registered transformers over serialized data, then re-filter conditional values and exclusions. + * + * @param array $data + * + * @return array + */ + protected function applyTransformers(array $data, Request $request, string $channel): array + { + $registry = ResourceTransformerRegistry::instance(); + + if (!$registry->hasTransformersFor($this)) { + return $data; + } + + $context = $this->transformerContext; + + if ($context === null) { + $context = $registry->newContext($request, $channel); + $registry->prepare(static::class, [$this->resource], $context); + } + + $data = $registry->apply($data, $this, $context); + + // Transformers may return when()/MissingValue/MergeValue values and must not reintroduce excluded keys. + return $this->filterExcluded($this->filter($data)); + } + + /** + * @param Request|null $request + */ + protected function resolveRequest($request): Request + { + if ($request instanceof Request) { + return $request; + } + + $resolved = Container::getInstance()->make('request'); + + if (!$resolved instanceof Request) { + throw new \RuntimeException('Unable to resolve the current request for resource serialization.'); + } + + return $resolved; + } + /** * Remove excluded keys recursively. */ diff --git a/src/Http/Resources/FleetbaseResourceCollection.php b/src/Http/Resources/FleetbaseResourceCollection.php index b12168be..3cff24e0 100644 --- a/src/Http/Resources/FleetbaseResourceCollection.php +++ b/src/Http/Resources/FleetbaseResourceCollection.php @@ -3,6 +3,8 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Http\Resources\Json\FleetbasePaginatedResourceResponse; +use Fleetbase\Support\ResourceTransformerContext; +use Fleetbase\Support\ResourceTransformerRegistry; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\ResourceCollection; use Illuminate\Support\Arr; @@ -74,7 +76,9 @@ public function without(array|string $keys): static * Convert the resource collection into an array. * * Applies the exclusion list to every item. If items are not already resources, - * they are wrapped using the $collects class (if provided). + * they are wrapped using the $collects class (if provided). Items are resolved (not just + * converted) so registered resource transformers apply, sharing one transformer context + * so `prepare()` runs once for the whole collection. * * @param \Illuminate\Http\Request $request * @@ -82,18 +86,18 @@ public function without(array|string $keys): static */ public function toArray($request): array { - return $this->collection->map(function ($item) use ($request) { - // If the item is already a resource and has ->without(), use it. + $context = $this->transformerContextFor($request); + + return $this->collection->map(function ($item) use ($request, $context) { + // If the item is already a resource, resolve it so transformers and filtering apply. if ($item instanceof JsonResource) { - if (method_exists($item, 'without')) { - /** @var object $item */ - $array = $item->without($this->excluded)->toArray($request); + if ($item instanceof FleetbaseResource) { + $array = $item->without($this->excluded)->withTransformerContext($context)->resolve($request); return $this->applyArrayExclusions($array); } - // Otherwise, just resolve it to array and then filter. - $array = $item->toArray($request); + $array = $item->resolve($request); return $this->applyArrayExclusions($array); } @@ -102,14 +106,19 @@ public function toArray($request): array if (is_string($this->collects) && class_exists($this->collects)) { $resource = new $this->collects($item); - if (method_exists($resource, 'without')) { - $array = $resource->without($this->excluded)->toArray($request); - } else { - $array = $resource->toArray($request); - $array = $this->applyArrayExclusions($array); + if ($resource instanceof FleetbaseResource) { + return $resource->without($this->excluded)->withTransformerContext($context)->resolve($request); + } + + if ($resource instanceof JsonResource) { + return $this->applyArrayExclusions($resource->resolve($request)); + } + + if (method_exists($resource, 'toArray')) { + return $this->applyArrayExclusions((array) $resource->toArray($request)); } - return $array; + return $this->applyArrayExclusions((array) $resource); } if (is_object($item) && method_exists($item, 'toArray')) { @@ -125,6 +134,60 @@ public function toArray($request): array })->all(); } + /** + * Build one transformer context for the whole collection and run `prepare()` once, + * or return null when no transformer applies to the item resource class. + * + * @param \Illuminate\Http\Request $request + */ + protected function transformerContextFor($request): ?ResourceTransformerContext + { + $registry = ResourceTransformerRegistry::instance(); + + if ($registry->isEmpty()) { + return null; + } + + $resourceClass = $this->itemResourceClass(); + + if ($resourceClass === null) { + return null; + } + + $models = $this->collection + ->map(fn ($item) => $item instanceof JsonResource ? $item->resource : $item) + ->filter(fn ($model) => is_object($model)) + ->values(); + + $first = $models->first(); + $modelClass = is_object($first) ? get_class($first) : null; + + if (!$registry->hasTransformersFor($resourceClass, $modelClass)) { + return null; + } + + $context = $registry->newContext($request, ResourceTransformerContext::HTTP); + $registry->prepare($resourceClass, $models->all(), $context); + + return $context; + } + + /** + * The resource class items are (or will be) wrapped in. + * + * @return class-string|null + */ + protected function itemResourceClass(): ?string + { + if (is_string($this->collects) && class_exists($this->collects)) { + return $this->collects; + } + + $first = $this->collection->first(fn ($item) => $item instanceof JsonResource); + + return $first instanceof JsonResource ? get_class($first) : null; + } + /** * Apply the exclusion list to an array using dot-notation. * diff --git a/src/Http/Resources/User.php b/src/Http/Resources/User.php index 0b70e4cd..8ab1ed63 100644 --- a/src/Http/Resources/User.php +++ b/src/Http/Resources/User.php @@ -3,7 +3,6 @@ namespace Fleetbase\Http\Resources; use Fleetbase\Support\Http; -use Fleetbase\Support\ResourceTransformerRegistry; use Fleetbase\Support\Utils; class User extends FleetbaseResource @@ -64,7 +63,8 @@ public function toArray($request) $data = array_merge($data, $this->resource->getAttribute('admin_authentication') ?? []); } - return ResourceTransformerRegistry::transform($this->resource, $data); + // Registered resource transformers are applied by FleetbaseResource::resolve(). + return $data; } /** diff --git a/src/Http/Transformers/Transformer.php b/src/Http/Transformers/Transformer.php new file mode 100644 index 00000000..cff7b895 --- /dev/null +++ b/src/Http/Transformers/Transformer.php @@ -0,0 +1,80 @@ + '…']); + * } + * } + */ +abstract class Transformer implements ResourceTransformer +{ + /** + * Resource class, model class, interface, or '*' this transformer applies to. + * + * @var string|array|null + */ + protected static $target; + + /** + * Order relative to other transformers for the same resource. Lower runs first; + * higher runs later and can override earlier output. + * + * @var int + */ + protected static $priority = 0; + + /** + * Channels this transformer applies to (`http`, `webhook`, `broadcast`). Null for all. + * + * @var array|null + */ + protected static $contexts; + + /** + * Restrict to `internal` (console) or `public` (API) requests. Null for both. + * + * @var string|null + */ + protected static $only; + + public static function target(): string|array + { + $target = static::$target; + + if ($target === null || $target === '' || $target === []) { + throw new \LogicException(static::class . ' must define a static $target property or override target().'); + } + + return $target; + } + + /** + * Registration options, overridable by options passed to `ResourceTransformerRegistry::register()`. + * + * @return array{priority: int, contexts: array|null, only: string|null} + */ + public static function options(): array + { + return [ + 'priority' => (int) static::$priority, + 'contexts' => static::$contexts, + 'only' => static::$only, + ]; + } +} diff --git a/src/Providers/CoreServiceProvider.php b/src/Providers/CoreServiceProvider.php index 4953f4b8..37732a92 100644 --- a/src/Providers/CoreServiceProvider.php +++ b/src/Providers/CoreServiceProvider.php @@ -2,10 +2,12 @@ namespace Fleetbase\Providers; +use Fleetbase\Contracts\ResourceTransformer; use Fleetbase\Models\Setting; use Fleetbase\Support\EnvironmentMapper; use Fleetbase\Support\NotificationRegistry; use Fleetbase\Support\Reporting\ReportSchemaRegistry; +use Fleetbase\Support\ResourceTransformerRegistry; use Fleetbase\Support\Telemetry; use Fleetbase\Support\Utils; use Illuminate\Console\Scheduling\Schedule; @@ -103,6 +105,14 @@ class CoreServiceProvider extends ServiceProvider \Fleetbase\Console\Commands\TelemetryPing::class, ]; + /** + * Resource transformers to register, as classes implementing + * `Fleetbase\Contracts\ResourceTransformer` or `class => options` entries. + * + * @var array + */ + public $transformers = []; + /** * Register any application services. * @@ -145,6 +155,11 @@ public function register() return new ReportSchemaRegistry(); }); + // Resource transformer registry: extensions register transformers against it during boot. + $this->app->singleton(ResourceTransformerRegistry::class, function () { + return new ResourceTransformerRegistry(); + }); + // OAuth services. // // scoped() rather than singleton(): Octane keeps workers alive across requests and @@ -204,6 +219,8 @@ public function boot() }); $this->registerObservers(); $this->registerExpansionsFrom(); + $this->registerTransformers(); + $this->registerTransformersFrom(); $this->registerMiddleware(); $this->registerNotifications(); $this->loadRoutesFrom(__DIR__ . '/../routes.php'); @@ -316,6 +333,83 @@ function ($ns) use ($className) { } } + /** + * Register the resource transformers declared by the service provider's `$transformers` property. + * + * @param array|null $transformers overrides the property when given + */ + public function registerTransformers(?array $transformers = null): void + { + $transformers ??= $this->transformers; + + if (empty($transformers)) { + return; + } + + ResourceTransformerRegistry::register($transformers); + } + + /** + * Discover and register resource transformers from a directory. + * + * Mirrors `registerExpansionsFrom()`: every instantiable class in the directory that implements + * `Fleetbase\Contracts\ResourceTransformer` is registered. Unless `$namespace` is given, the class + * namespace is resolved as `{PackageNamespace}\Http\Transformers\` from the package's composer.json. + * + * @param string|array|null $from directory (or directories) to scan; defaults to this package's `src/Http/Transformers` + * @param string|null $namespace explicit namespace of the classes in the directory + */ + public function registerTransformersFrom($from = null, $namespace = null): void + { + if (is_array($from)) { + foreach ($from as $frm) { + $this->registerTransformersFrom($frm, $namespace); + } + + return; + } + + $from ??= __DIR__ . '/../Http/Transformers'; + + try { + $files = new \DirectoryIterator($from); + } catch (\UnexpectedValueException $e) { + // no transformers + return; + } + + if ($namespace !== null) { + $namespaces = [rtrim($namespace, '\\') . '\\']; + } else { + $namespaces = ['Fleetbase\\Http\\Transformers\\']; + $packageNamespace = $this->findPackageNamespace($from); + if ($packageNamespace) { + $namespaces[] = $packageNamespace . '\\Http\\Transformers\\'; + } + } + + foreach ($files as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $className = $file->getBasename('.php'); + $resolved = Arr::first($namespaces, fn ($ns) => Utils::classExists($ns . $className)); + + if (!$resolved) { + continue; + } + + $class = ltrim($resolved . $className, '\\'); + + if (!is_a($class, ResourceTransformer::class, true) || (new \ReflectionClass($class))->isAbstract()) { + continue; + } + + ResourceTransformerRegistry::register($class); + } + } + private function registerNotifications() { NotificationRegistry::register([ diff --git a/src/Support/Find.php b/src/Support/Find.php index d092e3a9..6b9820f5 100644 --- a/src/Support/Find.php +++ b/src/Support/Find.php @@ -20,59 +20,76 @@ class Find */ public static function httpResourceForModel(Model $model, ?string $namespace = null, ?int $version = 1): ?string { - // Create a unique cache key based on the model, namespace, and version. - $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version); static $cache = []; + + // A model may name its resource explicitly; otherwise it is resolved by convention. + $explicit = method_exists($model, 'getResource') ? $model->getResource() : null; + $cacheKey = md5(get_class($model) . '|' . ($namespace ?? '') . '|' . $version . '|' . (is_string($explicit) ? $explicit : '')); + if (isset($cache[$cacheKey])) { - return $cache[$cacheKey]; + $cached = $cache[$cacheKey]; + + // Convention-resolved models with a dedicated `Internal\` resource depend on the request audience, + // so both candidates are cached and the request decides per call. Everything else never touches the request. + if (is_array($cached)) { + return Http::isInternalRequest() ? $cached['internal'] : $cached['public']; + } + + return $cached; } - $resourceNamespace = null; $defaultResourceNS = $coreResourceNS = '\\Fleetbase\\Http\\Resources\\'; $packageName = static::getModelPackage($model); if ($packageName) { $defaultResourceNS = '\\Fleetbase\\' . $packageName . '\\Http\\Resources\\'; } - $baseNamespace = $namespace ? $namespace . '\\Http\\Resources\\' : $defaultResourceNS; - $modelName = Utils::classBasename($model); + $fallback = $coreResourceNS . 'FleetbaseResource'; - if (method_exists($model, 'getResource')) { - $resourceNamespace = $model->getResource(); - } + if ($explicit !== null) { + $resolved = is_string($explicit) && Utils::classExists($explicit) ? $explicit : $fallback; - if ($resourceNamespace === null) { - $internal = Http::isInternalRequest(); + $cache[$cacheKey] = $resolved; - if ($internal) { - $baseNamespace .= 'Internal\\'; - } + return $resolved; + } - $resourceNamespace = $baseNamespace . "v{$version}\\" . $modelName; + $baseNamespace = $namespace ? $namespace . '\\Http\\Resources\\' : $defaultResourceNS; + $modelName = (string) Utils::classBasename($model); - // Fallback to public resource if internal version isn’t found. - if (!Utils::classExists($resourceNamespace)) { - $resourceNamespace = str_replace('Internal\\', '', $resourceNamespace); - } + $public = static::httpResourceByConvention($baseNamespace, $modelName, $version, false, $fallback); + $internal = static::httpResourceByConvention($baseNamespace, $modelName, $version, true, $fallback); - // Fallback to non-versioned namespace. - if (!Utils::classExists($resourceNamespace)) { - $resourceNamespace = str_replace("v{$version}\\", '', $resourceNamespace); - } + if ($public === $internal) { + $cache[$cacheKey] = $public; + + return $public; } - try { - if (!Utils::classExists($resourceNamespace)) { - throw new \Exception('Missing resource'); - } - } catch (\Error|\Exception $e) { - $resourceNamespace = $coreResourceNS . 'FleetbaseResource'; + $cache[$cacheKey] = ['internal' => $internal, 'public' => $public]; + + return Http::isInternalRequest() ? $internal : $public; + } + + /** + * Resolve a resource class by naming convention: `{base}[Internal\]v{version}\{Model}`, falling back to the + * public resource, then the unversioned resource, then the given default. + */ + protected static function httpResourceByConvention(string $baseNamespace, string $modelName, ?int $version, bool $internal, string $fallback): string + { + $candidate = $baseNamespace . ($internal ? 'Internal\\' : '') . "v{$version}\\" . $modelName; + + // Fallback to public resource if internal version isn't found. + if (!Utils::classExists($candidate)) { + $candidate = str_replace('Internal\\', '', $candidate); } - // Cache the resolved class name. - $cache[$cacheKey] = $resourceNamespace; + // Fallback to non-versioned namespace. + if (!Utils::classExists($candidate)) { + $candidate = str_replace("v{$version}\\", '', $candidate); + } - return $cache[$cacheKey]; + return Utils::classExists($candidate) ? $candidate : $fallback; } /** diff --git a/src/Support/ResourceTransformerContext.php b/src/Support/ResourceTransformerContext.php new file mode 100644 index 00000000..551a2c35 --- /dev/null +++ b/src/Support/ResourceTransformerContext.php @@ -0,0 +1,118 @@ + + */ + private array $attributes = []; + + /** + * Registration ids whose `prepare()` already ran for this context. + * + * @var array + */ + private array $prepared = []; + + public function __construct( + public readonly Request $request, + public readonly string $channel = self::HTTP, + public readonly bool $internal = false, + ) { + } + + public function isHttp(): bool + { + return $this->channel === self::HTTP; + } + + public function isWebhook(): bool + { + return $this->channel === self::WEBHOOK; + } + + public function isBroadcast(): bool + { + return $this->channel === self::BROADCAST; + } + + public function isInternal(): bool + { + return $this->internal; + } + + public function isPublic(): bool + { + return !$this->internal; + } + + public function get(string $key, mixed $default = null): mixed + { + return array_key_exists($key, $this->attributes) ? $this->attributes[$key] : $default; + } + + public function set(string $key, mixed $value): self + { + $this->attributes[$key] = $value; + + return $this; + } + + public function has(string $key): bool + { + return array_key_exists($key, $this->attributes); + } + + public function forget(string $key): self + { + unset($this->attributes[$key]); + + return $this; + } + + /** + * Get a value, computing and storing it on first access. + */ + public function remember(string $key, \Closure $resolver): mixed + { + if (!$this->has($key)) { + $this->set($key, $resolver($this)); + } + + return $this->get($key); + } + + /** + * @return array + */ + public function all(): array + { + return $this->attributes; + } + + public function markPrepared(string $registrationId): void + { + $this->prepared[$registrationId] = true; + } + + public function isPrepared(string $registrationId): bool + { + return isset($this->prepared[$registrationId]); + } +} diff --git a/src/Support/ResourceTransformerRegistry.php b/src/Support/ResourceTransformerRegistry.php index 2edbfe5c..3c692a5f 100644 --- a/src/Support/ResourceTransformerRegistry.php +++ b/src/Support/ResourceTransformerRegistry.php @@ -2,85 +2,616 @@ namespace Fleetbase\Support; -use Illuminate\Database\Eloquent\Model; -use Illuminate\Support\Str; +use Fleetbase\Contracts\PreparesResourceTransformation; +use Fleetbase\Contracts\ResourceTransformer; +use Illuminate\Container\Container; +use Illuminate\Http\Request; +use Illuminate\Http\Resources\Json\JsonResource; +use Illuminate\Support\Collection; +/** + * Registry of resource transformers. + * + * Any extension can register a transformer for an HTTP resource class, an Eloquent model class, an + * interface, or every resource ('*'). `FleetbaseResource::resolve()` consults the registry for every + * resource it serializes, so nothing in the resource or model has to change. Transformers chain in + * priority order (lower first; later ones can override earlier output), can be restricted to a + * channel (http, webhook, broadcast) or audience (internal, public), and can batch-load data once per + * collection through `PreparesResourceTransformation`. + * + * The registry is bound as a container singleton by `CoreServiceProvider`. Use the static helpers or + * `app(ResourceTransformerRegistry::class)`: + * + * ResourceTransformerRegistry::register(UserBadgeTransformer::class); + * ResourceTransformerRegistry::register(fn (array $data) => $data + ['flag' => true], ['target' => User::class]); + * + * @phpstan-type TransformerOptions array{target?: string|array, priority?: int, contexts?: array|null, only?: string|null, id?: string} + * @phpstan-type TransformerRegistration array{id: string, transformer: string|callable|object, targets: array, priority: int, contexts: array|null, only: string|null, order: int} + */ class ResourceTransformerRegistry { + public const WILDCARD = '*'; + public const ONLY_INTERNAL = 'internal'; + public const ONLY_PUBLIC = 'public'; + + /** + * @var array + */ + protected array $registrations = []; + + /** + * Ordered registration ids per "resourceClass|modelClass" key. + * + * @var array> + */ + protected array $matchCache = []; + + /** + * Instantiated class-string transformers. + * + * @var array + */ + protected array $instances = []; + + protected int $sequence = 0; + + /** + * Used when the container has no binding (early boot, bare test containers). + */ + protected static ?self $fallback = null; + + /** + * Resolve the active registry: the container singleton when bound, else a process-local fallback. + */ + public static function instance(): self + { + $container = Container::getInstance(); + + if ($container->bound(static::class)) { + $instance = $container->make(static::class); + if ($instance instanceof self) { + return $instance; + } + } + + return static::$fallback ??= new self(); + } + + /** + * Register one transformer, or many. + * + * Accepts a class implementing `ResourceTransformer`, an instance, a closure/callable (requires + * `options['target']`), or an array of any of those; array entries may be `[transformer, options]` + * pairs or `class => options` map entries. + * + * @param string|callable|object|array $transformer + * @param TransformerOptions $options + */ + public static function register(string|callable|object|array $transformer, array $options = []): void + { + if (is_array($transformer) && !is_callable($transformer)) { + static::instance()->addMany($transformer); + + return; + } + + static::instance()->add($transformer, $options); + } + + public static function forget(string $transformer): bool + { + return static::instance()->remove($transformer); + } + + /** + * Remove every registration from the active registry and the fallback. + */ + public static function reset(): void + { + static::instance()->flush(); + static::$fallback?->flush(); + } + /** - * Array to store registered transformers. + * Register a single transformer and return its registration id (the class name for classes). * - * @var array + * @param TransformerOptions $options + * + * @throws \InvalidArgumentException */ - public static $transformers = []; + public function add(string|callable|object $transformer, array $options = []): string + { + $registration = $this->normalize($transformer, $options); + + $existing = $this->registrations[$registration['id']] ?? null; + + $registration['order'] = $existing !== null ? $existing['order'] : $this->sequence++; - public static function register($transformerClass, array $options = []): void + $this->registrations[$registration['id']] = $registration; + + unset($this->instances[$registration['id']]); + $this->matchCache = []; + + return $registration['id']; + } + + /** + * Register many transformers. + * + * @param array $transformers + * + * @return array registration ids + */ + public function addMany(array $transformers): array { - if (is_array($transformerClass)) { - foreach ($transformerClass as $transformerClassElement) { - if (is_array($transformerClassElement) && count($transformerClassElement) === 2) { - static::register($transformerClassElement[0], $transformerClassElement[1]); - } elseif (is_string($transformerClassElement)) { - static::register($transformerClassElement); - } else { - throw new \Exception('Attempted to register invalid notification.'); + $ids = []; + + foreach ($transformers as $key => $entry) { + if (is_string($key)) { + /** @var TransformerOptions $entryOptions */ + $entryOptions = is_array($entry) ? $entry : []; + $ids[] = $this->add($key, $entryOptions); + continue; + } + + if (is_array($entry) && !is_callable($entry)) { + if (array_is_list($entry) && count($entry) === 2 && is_array($entry[1]) && (is_string($entry[0]) || is_callable($entry[0]) || is_object($entry[0]))) { + /** @var TransformerOptions $entryOptions */ + $entryOptions = $entry[1]; + $ids[] = $this->add($entry[0], $entryOptions); + continue; } + + throw new \InvalidArgumentException('Attempted to register invalid resource transformer: expected a class, callable, instance or [transformer, options] pair.'); } + if (is_string($entry) || is_callable($entry) || is_object($entry)) { + $ids[] = $this->add($entry); + continue; + } + + throw new \InvalidArgumentException('Attempted to register invalid resource transformer: ' . get_debug_type($entry)); + } + + return $ids; + } + + public function remove(string $idOrClass): bool + { + $id = ltrim($idOrClass, '\\'); + + if (!isset($this->registrations[$id])) { + return false; + } + + unset($this->registrations[$id], $this->instances[$id]); + $this->matchCache = []; + + return true; + } + + public function flush(): void + { + $this->registrations = []; + $this->instances = []; + $this->matchCache = []; + $this->sequence = 0; + } + + public function has(string $idOrClass): bool + { + return isset($this->registrations[ltrim($idOrClass, '\\')]); + } + + /** + * @return array in registration order + */ + public function all(): array + { + $registrations = array_values($this->registrations); + usort($registrations, fn (array $a, array $b) => $a['order'] <=> $b['order']); + + return $registrations; + } + + /** + * @return array + */ + public function ids(): array + { + return array_keys($this->registrations); + } + + public function isEmpty(): bool + { + return $this->registrations === []; + } + + /** + * Registrations applicable to a resource class (and optionally the model it wraps), in execution order. + * + * @return array + */ + public function matching(string $resourceClass, ?string $modelClass = null): array + { + if ($this->registrations === []) { + return []; + } + + $resourceClass = ltrim($resourceClass, '\\'); + $modelClass = $modelClass !== null ? ltrim($modelClass, '\\') : null; + $key = $resourceClass . '|' . ($modelClass ?? ''); + + if (!isset($this->matchCache[$key])) { + $matches = array_filter( + $this->registrations, + fn (array $registration) => $this->targetsMatch($registration['targets'], $resourceClass, $modelClass) + ); + + usort($matches, fn (array $a, array $b) => [$a['priority'], $a['order']] <=> [$b['priority'], $b['order']]); + + $this->matchCache[$key] = array_column($matches, 'id'); + } + + return array_map(fn (string $id) => $this->registrations[$id], $this->matchCache[$key]); + } + + /** + * Cheap guard: does anything apply to this resource at all? + */ + public function hasTransformersFor(JsonResource|string $resource, ?string $modelClass = null): bool + { + if ($this->registrations === []) { + return false; + } + + if ($resource instanceof JsonResource) { + $modelClass = is_object($resource->resource) ? get_class($resource->resource) : $modelClass; + $resource = get_class($resource); + } + + return $this->matching($resource, $modelClass) !== []; + } + + /** + * Create a context for one resolve. + * + * @throws \InvalidArgumentException + */ + public function newContext(Request $request, string $channel = ResourceTransformerContext::HTTP): ResourceTransformerContext + { + if (!in_array($channel, ResourceTransformerContext::CHANNELS, true)) { + throw new \InvalidArgumentException('Unknown resource transformer channel: ' . $channel); + } + + return new ResourceTransformerContext($request, $channel, Http::isInternalRequest($request)); + } + + /** + * Run `prepare()` on every applicable `PreparesResourceTransformation` transformer, once per context. + * + * @param iterable $models + */ + public function prepare(string $resourceClass, iterable $models, ResourceTransformerContext $context): void + { + if ($this->registrations === []) { return; } - static::$transformers[] = [ - 'definition' => static::fixClassName($transformerClass), - 'target' => static::fixClassName(static::getTransformerClassProperty($transformerClass, 'target', data_get($options, 'target', null))), - ]; + $models = Collection::make($models)->filter(fn ($model) => $model !== null)->values(); + if ($models->isEmpty()) { + return; + } + + $first = $models->first(); + $modelClass = is_object($first) ? get_class($first) : null; + + foreach ($this->matching($resourceClass, $modelClass) as $registration) { + if ($context->isPrepared($registration['id']) || !$this->applies($registration, $context)) { + continue; + } + + if (is_string($registration['transformer']) && !is_a($registration['transformer'], PreparesResourceTransformation::class, true)) { + continue; + } + + $instance = $this->resolveInstance($registration); + + if ($instance instanceof PreparesResourceTransformation) { + $context->markPrepared($registration['id']); + $instance->prepare($models, $context->request, $context); + } + } + } + + /** + * Chain every applicable transformer over the serialized data. + * + * @param array $data + * + * @return array + * + * @throws \UnexpectedValueException when a callable transformer does not return an array + */ + public function apply(array $data, JsonResource $resource, ResourceTransformerContext $context): array + { + if ($this->registrations === []) { + return $data; + } + + $modelClass = is_object($resource->resource) ? get_class($resource->resource) : null; + + foreach ($this->matching(get_class($resource), $modelClass) as $registration) { + if (!$this->applies($registration, $context)) { + continue; + } + + $data = $this->invoke($registration, $data, $resource, $context); + } + + return $data; + } + + /** + * Convenience for an already-serialized payload: builds the context and prepares a single model. + * + * @param array $data + * + * @return array + */ + public function transform(array $data, JsonResource $resource, Request $request, string $channel = ResourceTransformerContext::HTTP, ?ResourceTransformerContext $context = null): array + { + if (!$this->hasTransformersFor($resource)) { + return $data; + } + + if ($context === null) { + $context = $this->newContext($request, $channel); + $this->prepare(get_class($resource), [$resource->resource], $context); + } + + return $this->apply($data, $resource, $context); + } + + /** + * @param TransformerRegistration $registration + */ + protected function applies(array $registration, ResourceTransformerContext $context): bool + { + if ($registration['contexts'] !== null && !in_array($context->channel, $registration['contexts'], true)) { + return false; + } + + if ($registration['only'] === self::ONLY_INTERNAL && !$context->internal) { + return false; + } + + if ($registration['only'] === self::ONLY_PUBLIC && $context->internal) { + return false; + } + + return true; + } + + /** + * @param TransformerRegistration $registration + * @param array $data + * + * @return array + */ + protected function invoke(array $registration, array $data, JsonResource $resource, ResourceTransformerContext $context): array + { + $instance = $this->resolveInstance($registration); + + if ($instance instanceof ResourceTransformer) { + return $instance->transform($data, $resource, $context->request, $context); + } + + if (!is_callable($instance)) { + throw new \UnexpectedValueException('Resource transformer "' . $registration['id'] . '" is not invokable.'); + } + + $result = $instance($data, $resource, $context->request, $context); + + if (!is_array($result)) { + throw new \UnexpectedValueException('Resource transformer "' . $registration['id'] . '" must return an array, got ' . get_debug_type($result) . '.'); + } + + /** @var array $result */ + return $result; } - private static function getTransformerClassProperty(string $transformerClass, string $property, $defaultValue = null) + /** + * @param TransformerRegistration $registration + * + * @return object|callable + */ + protected function resolveInstance(array $registration): mixed { - if (!Utils::classExists($transformerClass) || !property_exists($transformerClass, $property)) { - return $defaultValue; + $transformer = $registration['transformer']; + + if (!is_string($transformer)) { + return $transformer; } - $properties = get_class_vars($transformerClass); + if (!isset($this->instances[$registration['id']])) { + $instance = Container::getInstance()->make($transformer); + if (!is_object($instance)) { + throw new \UnexpectedValueException('Unable to instantiate resource transformer ' . $transformer . '.'); + } + $this->instances[$registration['id']] = $instance; + } - return data_get($properties, $property, $defaultValue); + return $this->instances[$registration['id']]; } - public static function resolveByTarget($targetClass) + /** + * @param array $targets + */ + protected function targetsMatch(array $targets, string $resourceClass, ?string $modelClass): bool { - foreach (static::$transformers as $transformer) { - if (isset($transformer['target']) && static::fixClassName($transformer['target']) === static::fixClassName($targetClass)) { - return $transformer['definition']; + foreach ($targets as $target) { + if ($target === self::WILDCARD) { + return true; + } + + if (is_a($resourceClass, $target, true)) { + return true; + } + + if ($modelClass !== null && is_a($modelClass, $target, true)) { + return true; } } - return null; + return false; } - public static function transform(Model $model, array $data = []): array + /** + * Validate a transformer and its options into a registration (without `order`). + * + * @param TransformerOptions $options + * + * @return TransformerRegistration + * + * @throws \InvalidArgumentException + */ + protected function normalize(string|callable|object $transformer, array $options): array { - $resourceClass = Find::httpResourceForModel($model); - if ($resourceClass) { - $transformerClass = static::resolveByTarget($resourceClass); - if ($transformerClass && method_exists($transformerClass, 'output')) { - return $transformerClass::output($model, $data); + $classOptions = []; + + if (is_string($transformer)) { + $class = ltrim($transformer, '\\'); + + if (!Utils::classExists($class)) { + throw new \InvalidArgumentException('Attempted to register invalid resource transformer: class ' . $class . ' does not exist.'); + } + + /** @var class-string $class */ + $reflection = new \ReflectionClass($class); + + if (!$reflection->isInstantiable() || !$reflection->implementsInterface(ResourceTransformer::class)) { + throw new \InvalidArgumentException('Attempted to register invalid resource transformer: ' . $class . ' must be an instantiable class implementing ' . ResourceTransformer::class . '.'); } + + /** @var class-string $class */ + $targets = $class::target(); + $classOptions = static::classOptions($class); + $id = $class; + $transformer = $class; + } elseif ($transformer instanceof ResourceTransformer) { + $targets = $transformer::target(); + $classOptions = static::classOptions(get_class($transformer)); + $id = $options['id'] ?? get_class($transformer); + } elseif (is_callable($transformer)) { + $targets = $options['target'] ?? null; + $id = $options['id'] ?? static::callableId($transformer); + } else { + throw new \InvalidArgumentException('Attempted to register invalid resource transformer: ' . get_debug_type($transformer)); } - return $data; + if (isset($options['target'])) { + $targets = $options['target']; + } + + $options = array_merge($classOptions, $options); + + $targets = $this->normalizeTargets($targets, $id); + + $priority = $options['priority'] ?? 0; + if (!is_int($priority) && !(is_string($priority) && is_numeric($priority))) { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" priority must be an integer.'); + } + + $contexts = $options['contexts'] ?? null; + if ($contexts !== null) { + if (!is_array($contexts) || $contexts === []) { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" contexts must be a non-empty array or null.'); + } + $contexts = array_values(array_unique(array_map(fn ($channel) => is_string($channel) ? $channel : '', $contexts))); + foreach ($contexts as $channel) { + if (!in_array($channel, ResourceTransformerContext::CHANNELS, true)) { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" has an unknown context "' . $channel . '". Expected one of: ' . implode(', ', ResourceTransformerContext::CHANNELS) . '.'); + } + } + } + + $only = $options['only'] ?? null; + if ($only !== null && !in_array($only, [self::ONLY_INTERNAL, self::ONLY_PUBLIC], true)) { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" only must be "internal", "public" or null.'); + } + + return [ + 'id' => $id, + 'transformer' => $transformer, + 'targets' => $targets, + 'priority' => (int) $priority, + 'contexts' => $contexts, + 'only' => $only, + 'order' => 0, + ]; } - public static function fixClassName($className) + /** + * @return array + * + * @throws \InvalidArgumentException + */ + protected function normalizeTargets(mixed $targets, string $id): array { - if (is_string($className)) { - if (Str::startsWith($className, '\\')) { - return $className; + if ($targets === null || $targets === '' || $targets === []) { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" has no target. Provide a resource class, model class, interface or "*".'); + } + + $normalized = []; + + foreach (is_array($targets) ? $targets : [$targets] as $target) { + if (!is_string($target) || $target === '') { + throw new \InvalidArgumentException('Resource transformer "' . $id . '" has an invalid target: ' . get_debug_type($target) . '.'); } - return '\\' . $className; + $normalized[] = $target === self::WILDCARD ? self::WILDCARD : ltrim($target, '\\'); + } + + return array_values(array_unique($normalized)); + } + + protected static function callableId(callable $callable): string + { + if ($callable instanceof \Closure) { + return 'closure:' . spl_object_id($callable); + } + + if (is_object($callable)) { + return get_class($callable) . ':' . spl_object_id($callable); + } + + if (is_array($callable)) { + $target = is_object($callable[0]) ? get_class($callable[0]) . ':' . spl_object_id($callable[0]) : (string) $callable[0]; + + return 'callable:' . $target . '::' . (string) $callable[1]; + } + + return 'callable:' . (is_string($callable) ? $callable : get_debug_type($callable)); + } + + /** + * Options declared on the transformer class through a static `options()` method, if any. + * + * @param class-string $class + * + * @return array + */ + protected static function classOptions(string $class): array + { + $callable = [$class, 'options']; + + if (!is_callable($callable)) { + return []; } - return $className; + $options = call_user_func($callable); + + return is_array($options) ? $options : []; } } diff --git a/src/Support/Utils.php b/src/Support/Utils.php index b9f1ab9b..a26b916f 100644 --- a/src/Support/Utils.php +++ b/src/Support/Utils.php @@ -1439,7 +1439,7 @@ public static function ordinalNumber($number, $locale = 'en_US') public static function serializeJsonResource(JsonResource $resource) { $request = request(); - $data = $resource->toArray($request); + $data = $resource->resolve($request); foreach ($data as $key => $value) { if ($value instanceof JsonResource) { diff --git a/tests/Pest.php b/tests/Pest.php index cae14328..df6f950c 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -540,6 +540,9 @@ function bind_test_container(array $config = []): Container $container->instance('request', Request::create('/int/v1/test', 'GET')); + // fresh resource transformer registry per test so registrations never leak between tests + $container->instance(Fleetbase\Support\ResourceTransformerRegistry::class, new Fleetbase\Support\ResourceTransformerRegistry()); + // activity() works in every test; files that assert on it bind their own fake TestActivityLogger::$logged = []; $container->instance(Spatie\Activitylog\PendingActivityLog::class, new TestPendingActivityLog()); diff --git a/tests/Unit/EventsAndExceptionsTest.php b/tests/Unit/EventsAndExceptionsTest.php index c85d8c27..e4ccefee 100644 --- a/tests/Unit/EventsAndExceptionsTest.php +++ b/tests/Unit/EventsAndExceptionsTest.php @@ -810,3 +810,112 @@ public function toArray($request): array ->and(session()->has('company'))->toBeFalse() ->and(session()->has('user'))->toBeFalse(); }); + +class EventsAndExceptionsTransformableWebhookResource extends Fleetbase\Http\Resources\FleetbaseResource +{ + public function toWebhookPayload(): array + { + return [ + 'id' => 'custom-payload-id', + 'status' => 'ready', + ]; + } + + public function toArray($request): array + { + return ['id' => 'array-payload-id']; + } +} + +class EventsAndExceptionsTransformableArrayResource extends Fleetbase\Http\Resources\FleetbaseResource +{ + public function toArray($request): array + { + return ['id' => 'array-payload-id', 'absent' => $this->when(false, 'never')]; + } +} + +function events_and_exceptions_transformer_event(FleetbaseModel $record, JsonResource $resource): EventsAndExceptionsLifecycleEvent +{ + return EventsAndExceptionsLifecycleEvent::fake([ + 'modelName' => 'order', + 'modelClassNamespace' => FleetbaseModel::class, + 'modelClassName' => 'Order', + 'modelHumanName' => 'order', + 'modelRecordName' => null, + 'modelUuid' => 'record-uuid', + 'namespace' => '\\Fleetbase', + 'version' => 1, + 'eventName' => 'ready', + 'sentAt' => '2026-07-17 14:00:00', + 'eventId' => 'event_payload', + 'apiVersion' => 'v1', + 'requestMethod' => 'PATCH', + 'apiCredential' => 'console', + 'apiSecret' => 'internal', + 'apiKey' => null, + 'apiEnvironment' => 'live', + 'isSandbox' => false, + 'data' => [], + 'userSession' => null, + 'companySession' => 'company-uuid', + ], $record, $resource); +} + +test('resource lifecycle events apply channel aware resource transformers to webhook and broadcast payloads', function () { + bind_test_container(['api.version' => 'v1']); + Fleetbase\Support\ResourceTransformerRegistry::reset(); + + $record = new FleetbaseModel(); + $record->setRawAttributes([ + 'uuid' => 'record-uuid', + 'company_uuid' => 'company-uuid', + ], true); + + $webhookResource = new EventsAndExceptionsTransformableWebhookResource($record); + $arrayResource = new EventsAndExceptionsTransformableArrayResource($record); + + // nothing registered: payloads are untouched + expect(events_and_exceptions_transformer_event($record, $webhookResource)->getEventData()['data'])->toBe([ + 'id' => 'custom-payload-id', + 'status' => 'ready', + ]) + ->and(events_and_exceptions_transformer_event($record, $arrayResource)->getEventData()['data'])->toBe([ + 'id' => 'array-payload-id', + ]); + + Fleetbase\Support\ResourceTransformerRegistry::register([ + [fn (array $data) => $data + ['via_webhook' => true], ['target' => Fleetbase\Http\Resources\FleetbaseResource::class, 'contexts' => ['webhook'], 'id' => 'webhook']], + [fn (array $data) => $data + ['via_broadcast' => true], ['target' => Fleetbase\Http\Resources\FleetbaseResource::class, 'contexts' => ['broadcast'], 'id' => 'broadcast']], + [fn (array $data) => $data + ['via_http' => true], ['target' => Fleetbase\Http\Resources\FleetbaseResource::class, 'contexts' => ['http'], 'id' => 'http']], + [fn (array $data) => $data + ['everywhere' => true], ['target' => FleetbaseModel::class, 'id' => 'model']], + ]); + + $webhookEvent = events_and_exceptions_transformer_event($record, $webhookResource); + $arrayEvent = events_and_exceptions_transformer_event($record, $arrayResource); + + expect($webhookEvent->getEventData()['data'])->toBe([ + 'id' => 'custom-payload-id', + 'status' => 'ready', + 'via_webhook' => true, + 'everywhere' => true, + ]) + ->and($webhookEvent->broadcastWith()['data'])->toBe([ + 'id' => 'custom-payload-id', + 'status' => 'ready', + 'via_broadcast' => true, + 'everywhere' => true, + ]) + ->and($arrayEvent->getEventData()['data'])->toBe([ + 'id' => 'array-payload-id', + 'via_webhook' => true, + 'everywhere' => true, + ]) + ->and($arrayEvent->broadcastWith()['data'])->toBe([ + 'id' => 'array-payload-id', + 'via_broadcast' => true, + 'everywhere' => true, + ]); + + Fleetbase\Support\ResourceTransformerRegistry::reset(); +}); diff --git a/tests/Unit/Http/FleetbaseResourceCollectionTest.php b/tests/Unit/Http/FleetbaseResourceCollectionTest.php index 35f7b188..1c0bb707 100644 --- a/tests/Unit/Http/FleetbaseResourceCollectionTest.php +++ b/tests/Unit/Http/FleetbaseResourceCollectionTest.php @@ -448,3 +448,26 @@ function fleetbase_resource_collection_request(): Request 'filter' => 'explicit', ]); }); + +class FleetbaseResourceCollectionConditionalPlainResource extends JsonResource +{ + public function toArray($request = null): array + { + return [ + 'id' => $this->resource['id'], + 'absent' => $this->when(false, 'never'), + ]; + } +} + +test('resource collection resolves plain json resource items so conditional values are filtered', function () { + $request = fleetbase_resource_collection_request(); + + $collection = new FleetbaseResourceCollectionTestPlainItems([ + new FleetbaseResourceCollectionConditionalPlainResource(['id' => 'conditional-1']), + ]); + + expect($collection->toArray($request))->toBe([ + ['id' => 'conditional-1'], + ]); +}); diff --git a/tests/Unit/Http/FleetbaseResourceTransformersTest.php b/tests/Unit/Http/FleetbaseResourceTransformersTest.php new file mode 100644 index 00000000..d03d430a --- /dev/null +++ b/tests/Unit/Http/FleetbaseResourceTransformersTest.php @@ -0,0 +1,275 @@ + $this->resource->name]; + } +} + +/** + * Hand-built toArray() that never calls parent::toArray() nor the registry — like most Fleetbase resources. + */ +class FrtResource extends FleetbaseResource +{ + public function toArray($request) + { + return [ + 'id' => $this->resource->id, + 'secret' => 'shh', + 'owner' => $this->resource->owner ? new FrtOwnerResource($this->resource->owner) : null, + 'absent' => $this->when(false, 'never'), + ]; + } +} + +class FrtChildResource extends FrtResource +{ +} + +class FrtPlainResource extends JsonResource +{ + public function toArray($request) + { + return [ + 'id' => $this->resource->id, + 'secret' => 'shh', + 'owner' => $this->resource->owner ? new FrtOwnerResource($this->resource->owner) : null, + 'absent' => $this->when(false, 'never'), + ]; + } +} + +class FrtWebhookResource extends FleetbaseResource +{ + public function toArray($request) + { + return ['id' => $this->resource->id, 'from' => 'toArray']; + } + + public function toWebhookPayload(): array + { + return ['id' => $this->resource->id, 'from' => 'webhook']; + } +} + +class FrtBatchTransformer extends Transformer implements PreparesResourceTransformation +{ + protected static $target = FrtModel::class; + + public static int $prepared = 0; + public static array $preparedWith = []; + + public function prepare(Collection $models, Request $request, ResourceTransformerContext $context): void + { + static::$prepared++; + static::$preparedWith[] = $models->count(); + $context->set('lookup', $models->mapWithKeys(fn ($model) => [$model->id => strtoupper((string) $model->id)])->all()); + } + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['code' => $context->get('lookup')[$resource->resource->id] ?? null]; + } +} + +function frt_request(string $uri = 'int/v1/widgets'): Request +{ + bind_test_container(); + ResourceTransformerRegistry::reset(); + + $uri = ltrim($uri, '/'); + $request = Request::create('/' . $uri, 'GET'); + $route = new Route('GET', $uri, []); + $request->setRouteResolver(fn () => $route); + Container::getInstance()->instance('request', $request); + + return $request; +} + +function frt_model(string $id = 'w1', ?string $owner = 'Ann'): FrtModel +{ + return new FrtModel(['id' => $id, 'owner' => $owner ? new FrtModel(['name' => $owner]) : null]); +} + +beforeEach(function () { + FrtBatchTransformer::$prepared = 0; + FrtBatchTransformer::$preparedWith = []; +}); + +test('resolve output is unchanged when no transformers are registered', function () { + $request = frt_request(); + $model = frt_model(); + + $fleetbase = (new FrtResource($model))->resolve($request); + $plain = (new FrtPlainResource($model))->resolve($request); + + expect(json_encode($fleetbase))->toBe(json_encode($plain)) + ->and($fleetbase)->toHaveKeys(['id', 'secret', 'owner']) + ->and($fleetbase)->not->toHaveKey('absent') + ->and(json_decode(json_encode(new FrtResource($model)), true))->toBe([ + 'id' => 'w1', + 'secret' => 'shh', + 'owner' => ['name' => 'Ann'], + ]); +}); + +test('transformers apply through resolve without the resource opting in', function () { + $request = frt_request(); + $model = frt_model(); + + ResourceTransformerRegistry::register(fn (array $data) => $data + ['by_resource' => true], ['target' => FrtResource::class]); + ResourceTransformerRegistry::register(fn (array $data) => $data + ['by_model' => true], ['target' => FrtModel::class]); + ResourceTransformerRegistry::register(fn (array $data) => $data + ['by_wildcard' => true], ['target' => '*']); + ResourceTransformerRegistry::register(fn (array $data) => $data + ['by_other' => true], ['target' => FrtWebhookResource::class]); + + $resource = new FrtResource($model); + $resolved = $resource->resolve($request); + $child = (new FrtChildResource($model))->resolve($request); + + expect($resource->toArray($request))->not->toHaveKeys(['by_resource', 'by_model', 'by_wildcard']) + ->and($resolved)->toMatchArray(['id' => 'w1', 'by_resource' => true, 'by_model' => true, 'by_wildcard' => true]) + ->and($resolved)->not->toHaveKey('by_other') + ->and($child)->toMatchArray(['by_resource' => true, 'by_model' => true, 'by_wildcard' => true]); +}); + +test('transformers receive the resource, request and context', function () { + $request = frt_request('v1/widgets'); + $model = frt_model(); + + ResourceTransformerRegistry::register(function (array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context) { + return $data + [ + 'resource_class' => get_class($resource), + 'model_id' => $resource->resource->id, + 'path' => $request->path(), + 'channel' => $context->channel, + 'internal' => $context->internal, + ]; + }, ['target' => FrtResource::class]); + + expect((new FrtResource($model))->resolve($request))->toMatchArray([ + 'resource_class' => FrtResource::class, + 'model_id' => 'w1', + 'path' => 'v1/widgets', + 'channel' => 'http', + 'internal' => false, + ]); +}); + +test('transformer output is filtered for missing and merge values and respects without()', function () { + $request = frt_request(); + $model = frt_model(); + + ResourceTransformerRegistry::register(fn (array $data) => array_merge($data, [ + 'maybe' => new MissingValue(), + 'extra' => 'yes', + 'secret' => 'overwritten', + new MergeValue(['merged' => true]), + ]), ['target' => FrtResource::class]); + + $resolved = (new FrtResource($model))->resolve($request); + $without = (new FrtResource($model))->without(['secret', 'extra'])->resolve($request); + + expect($resolved)->toMatchArray(['extra' => 'yes', 'secret' => 'overwritten', 'merged' => true]) + ->and($resolved)->not->toHaveKey('maybe') + ->and($without)->toMatchArray(['merged' => true]) + ->and($without)->not->toHaveKeys(['secret', 'extra', 'maybe']); +}); + +test('nested resources are transformed when the parent is json encoded', function () { + $request = frt_request(); + $model = frt_model(); + + ResourceTransformerRegistry::register(fn (array $data) => $data + ['owner_flag' => true], ['target' => FrtOwnerResource::class]); + + $encoded = json_decode(json_encode((new FrtResource($model))->resolve($request)), true); + + expect($encoded['owner'])->toBe(['name' => 'Ann', 'owner_flag' => true]) + ->and($encoded)->not->toHaveKey('owner_flag'); +}); + +test('collections transform every item and prepare once', function () { + $request = frt_request(); + $models = [frt_model('a'), frt_model('b'), frt_model('c')]; + + ResourceTransformerRegistry::register(FrtBatchTransformer::class); + + $items = FrtResource::collection($models)->without('secret')->toArray($request); + + expect(FrtBatchTransformer::$prepared)->toBe(1) + ->and(FrtBatchTransformer::$preparedWith)->toBe([3]) + ->and(array_column($items, 'code'))->toBe(['A', 'B', 'C']) + ->and($items[0])->not->toHaveKey('secret'); + + // already-wrapped resources share the same context too + $wrapped = (new Fleetbase\Http\Resources\FleetbaseResourceCollection([new FrtResource($models[0]), new FrtResource($models[1])]))->toArray($request); + + expect(FrtBatchTransformer::$prepared)->toBe(2) + ->and(FrtBatchTransformer::$preparedWith)->toBe([3, 2]) + ->and(array_column($wrapped, 'code'))->toBe(['A', 'B']); + + // a singular resolve prepares with just its own model + expect((new FrtResource($models[2]))->resolve($request)['code'])->toBe('C') + ->and(FrtBatchTransformer::$preparedWith)->toBe([3, 2, 1]); +}); + +test('collections skip the transformer pass entirely when nothing matches', function () { + $request = frt_request(); + + ResourceTransformerRegistry::register(fn (array $data) => $data + ['x' => 1], ['target' => FrtWebhookResource::class]); + + $items = FrtResource::collection([frt_model('a')])->toArray($request); + + expect($items[0])->toBe(['id' => 'a', 'secret' => 'shh', 'owner' => $items[0]['owner']]) + ->and($items[0])->not->toHaveKey('x') + ->and($items[0]['owner'])->toBeInstanceOf(FrtOwnerResource::class); +}); + +test('resolveFor and transformPayload apply channel specific transformers', function () { + $request = frt_request(); + $model = frt_model(); + + ResourceTransformerRegistry::register(fn (array $data) => $data + ['webhook_only' => true], ['target' => FrtWebhookResource::class, 'contexts' => ['webhook']]); + ResourceTransformerRegistry::register(fn (array $data) => $data + ['http_only' => true], ['target' => FrtWebhookResource::class, 'contexts' => ['http']]); + ResourceTransformerRegistry::register(fn (array $data) => $data + ['everywhere' => true], ['target' => FrtWebhookResource::class]); + + $resource = new FrtWebhookResource($model); + + expect($resource->resolve($request))->toBe(['id' => 'w1', 'from' => 'toArray', 'http_only' => true, 'everywhere' => true]) + ->and($resource->resolveFor(ResourceTransformerContext::WEBHOOK, $request))->toBe(['id' => 'w1', 'from' => 'toArray', 'webhook_only' => true, 'everywhere' => true]) + ->and($resource->resolveFor(ResourceTransformerContext::BROADCAST))->toBe(['id' => 'w1', 'from' => 'toArray', 'everywhere' => true]) + ->and($resource->transformPayload($resource->toWebhookPayload(), ResourceTransformerContext::WEBHOOK))->toBe(['id' => 'w1', 'from' => 'webhook', 'webhook_only' => true, 'everywhere' => true]) + ->and($resource->transformPayload(['raw' => true]))->toBe(['raw' => true, 'http_only' => true, 'everywhere' => true]); +}); + +test('transformPayload returns the payload untouched when nothing applies', function () { + frt_request(); + + $resource = new FrtWebhookResource(frt_model()); + $payload = ['id' => 'w1', 'keep' => new MissingValue()]; + + // no filtering side effects when no transformer ran + expect($resource->transformPayload($payload, ResourceTransformerContext::WEBHOOK))->toBe($payload); +}); diff --git a/tests/Unit/Providers/CoreProviderTransformersTest.php b/tests/Unit/Providers/CoreProviderTransformersTest.php new file mode 100644 index 00000000..a6f3d661 --- /dev/null +++ b/tests/Unit/Providers/CoreProviderTransformersTest.php @@ -0,0 +1,173 @@ + true]; + } +} + +class CptPropertyTransformer extends Transformer +{ + protected static $target = '*'; + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['property' => true]; + } +} + +abstract class CptAbstractTransformer extends Transformer +{ + protected static $target = '*'; +} + +class CptNotTransformer +{ +} + +class CptProvider extends CoreServiceProvider +{ + /** + * Config files need a full application (base_path()); the registry binding is what is under test. + */ + protected function mergeConfigFrom($path, $key) + { + } +} + +function cpt_provider(): CoreServiceProvider +{ + $container = bind_test_container(['app.env' => 'testing']); + ResourceTransformerRegistry::reset(); + + return new CptProvider($container); +} + +function cpt_transformers_dir(string $suffix, string $namespace): string +{ + $base = sys_get_temp_dir() . '/fleetbase-core-provider-transformers-' . $suffix; + $path = $base . '/src/Http/Transformers'; + + if (!is_dir($path)) { + mkdir($path, 0777, true); + } + + file_put_contents($base . '/composer.json', json_encode([ + 'autoload' => [ + 'psr-4' => [ + $namespace . '\\' => 'src/', + ], + ], + ])); + + foreach (['CptDiscoveredTransformer', 'CptAbstractTransformer', 'CptNotTransformer', 'CptMissingTransformer'] as $class) { + file_put_contents($path . '/' . $class . '.php', "forgetInstance(ResourceTransformerRegistry::class); + expect($container->bound(ResourceTransformerRegistry::class))->toBeFalse(); + + $provider->register(); + + $registry = $container->make(ResourceTransformerRegistry::class); + + expect($registry)->toBeInstanceOf(ResourceTransformerRegistry::class) + ->and($container->make(ResourceTransformerRegistry::class))->toBe($registry) + ->and(ResourceTransformerRegistry::instance())->toBe($registry); +}); + +test('core service provider registers transformers declared on the transformers property', function () { + $provider = cpt_provider(); + $registry = ResourceTransformerRegistry::instance(); + + $provider->registerTransformers(); + expect($registry->isEmpty())->toBeTrue(); + + $provider->transformers = [ + CptPropertyTransformer::class, + [CptDiscoveredTransformer::class, ['priority' => 2, 'contexts' => ['webhook']]], + ]; + $provider->registerTransformers(); + + expect($registry->ids())->toBe([CptPropertyTransformer::class, CptDiscoveredTransformer::class]) + ->and($registry->all()[1]['priority'])->toBe(2) + ->and($registry->all()[1]['contexts'])->toBe(['webhook']); +}); + +test('core service provider registers transformers passed explicitly to registerTransformers', function () { + $provider = cpt_provider(); + $registry = ResourceTransformerRegistry::instance(); + + $provider->registerTransformers([ + [fn (array $data) => $data + ['closure' => true], ['target' => '*', 'id' => 'explicit']], + ]); + + expect($registry->ids())->toBe(['explicit']); +}); + +test('core service provider discovers transformers from a package directory', function () { + $path = cpt_transformers_dir('package', 'Fleetbase\\TransformerProviderTest'); + + foreach (['CptDiscoveredTransformer', 'CptAbstractTransformer', 'CptNotTransformer'] as $class) { + $alias = 'Fleetbase\\TransformerProviderTest\\Http\\Transformers\\' . $class; + if (!class_exists($alias, false)) { + class_alias($class, $alias); + } + } + + $provider = cpt_provider(); + $registry = ResourceTransformerRegistry::instance(); + + $provider->registerTransformersFrom($path); + $provider->registerTransformersFrom($path . '/missing-directory'); + $provider->registerTransformersFrom(); + + expect($registry->ids())->toBe(['Fleetbase\\TransformerProviderTest\\Http\\Transformers\\CptDiscoveredTransformer']) + ->and($registry->all()[0]['targets'])->toBe(['*']); +}); + +test('core service provider discovers transformers with an explicit namespace and multiple paths', function () { + $pathOne = cpt_transformers_dir('explicit-one', 'Fleetbase\\TransformerProviderExplicitOne'); + $pathTwo = cpt_transformers_dir('explicit-two', 'Fleetbase\\TransformerProviderExplicitTwo'); + + $provider = cpt_provider(); + $registry = ResourceTransformerRegistry::instance(); + + // root-namespace classes: the same class is discovered from both paths and registered once + $provider->registerTransformersFrom([$pathOne, $pathTwo], ''); + + expect($registry->ids())->toBe([CptDiscoveredTransformer::class]) + ->and($registry->has(CptAbstractTransformer::class))->toBeFalse() + ->and($registry->has(CptNotTransformer::class))->toBeFalse(); +}); + +test('core service provider boot registers transformers after expansions and before middleware', function () { + $source = file_get_contents((new ReflectionClass(CoreServiceProvider::class))->getFileName()); + + $bootStart = strpos($source, 'public function boot()'); + $boot = substr($source, $bootStart, strpos($source, 'public function registerExpansionsFrom') - $bootStart); + + expect(strpos($boot, '$this->registerExpansionsFrom();'))->toBeLessThan(strpos($boot, '$this->registerTransformers();')) + ->and(strpos($boot, '$this->registerTransformers();'))->toBeLessThan(strpos($boot, '$this->registerTransformersFrom();')) + ->and(strpos($boot, '$this->registerTransformersFrom();'))->toBeLessThan(strpos($boot, '$this->registerMiddleware();')); +}); diff --git a/tests/Unit/Support/FindHttpResourceForModelTest.php b/tests/Unit/Support/FindHttpResourceForModelTest.php new file mode 100644 index 00000000..fba2dfe6 --- /dev/null +++ b/tests/Unit/Support/FindHttpResourceForModelTest.php @@ -0,0 +1,64 @@ +setRouteResolver(fn () => $route); + Container::getInstance()->instance('request', $request); + + return $request; + } + + test('find resolves internal and public http resources independently of resolution order', function () { + bind_test_container(); + + $model = new FindWidget(); + $namespace = '\\Fleetbase\\Tests\\FindFixtures'; + + find_fixture_request('int/v1/find-widgets'); + $internal = Find::httpResourceForModel($model, $namespace); + + find_fixture_request('v1/find-widgets'); + $public = Find::httpResourceForModel($model, $namespace); + + find_fixture_request('int/v1/find-widgets'); + $internalAgain = Find::httpResourceForModel($model, $namespace); + + expect($internal)->toBe('\\Fleetbase\\Tests\\FindFixtures\\Http\\Resources\\Internal\\v1\\FindWidget') + ->and($public)->toBe('\\Fleetbase\\Tests\\FindFixtures\\Http\\Resources\\v1\\FindWidget') + ->and($internalAgain)->toBe($internal); + }); +} diff --git a/tests/Unit/Support/ResourceTransformerRegistryTest.php b/tests/Unit/Support/ResourceTransformerRegistryTest.php index ba61e540..6c423520 100644 --- a/tests/Unit/Support/ResourceTransformerRegistryTest.php +++ b/tests/Unit/Support/ResourceTransformerRegistryTest.php @@ -1,108 +1,430 @@ $this->resource->id ?? null, + 'name' => $this->resource->name ?? null, + ]; } } -class ResourceTransformerRegistryResource extends JsonResource +class RtrChildResource extends RtrResource { } -class ResourceTransformerRegistryTransformer +class RtrOtherResource extends FleetbaseResource { - public static string $target = ResourceTransformerRegistryResource::class; +} - public static function output(Model $model, array $data = []): array +class RtrAppendTransformer extends Transformer +{ + protected static $target = RtrResource::class; + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array { - return array_merge($data, [ - 'transformed' => true, - 'model' => $model::class, - ]); + return $data + ['appended' => true]; + } +} + +class RtrModelTargetTransformer extends Transformer +{ + protected static $target = RtrModel::class; + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['model_targeted' => $context->channel]; + } +} + +class RtrInterfaceTransformer extends Transformer +{ + protected static $target = RtrTaggable::class; + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['tagged' => true]; + } +} + +class RtrWildcardOptionsTransformer extends Transformer +{ + protected static $target = '*'; + protected static $priority = 5; + protected static $contexts = [ResourceTransformerContext::WEBHOOK]; + protected static $only = 'internal'; + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data + ['wildcard' => true]; + } +} + +class RtrOrderTransformer implements ResourceTransformer +{ + public function __construct(private string $label) + { + } + + public static function target(): string|array + { + return [RtrResource::class]; + } + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + $data['order'] = ($data['order'] ?? '') . $this->label; + + return $data; + } +} + +abstract class RtrAbstractTransformer extends Transformer +{ + protected static $target = RtrResource::class; +} + +class RtrNotATransformer +{ + public static $target = RtrResource::class; + + public static function output($model, $data) + { + return $data; } } -class ResourceTransformerRegistryOptionTransformer +class RtrPreparingTransformer extends Transformer implements PreparesResourceTransformation { - public static function output(Model $model, array $data = []): array + protected static $target = RtrModel::class; + + public static int $prepared = 0; + public static array $preparedCount = []; + + public function prepare(Collection $models, Request $request, ResourceTransformerContext $context): void + { + static::$prepared++; + static::$preparedCount[] = $models->count(); + $context->set('names', $models->map(fn ($model) => $model->name)->all()); + } + + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array { - return array_merge($data, ['option_transformer' => true]); + return $data + ['prepared_names' => $context->get('names')]; } } -class ResourceTransformerRegistryNoOutputTransformer +function rtr_request(string $uri): Request { - public static string $target = ResourceTransformerRegistryResource::class; + $uri = ltrim($uri, '/'); + $request = Request::create('/' . $uri, 'GET'); + $route = new Route('GET', $uri, []); + $request->setRouteResolver(fn () => $route); + Container::getInstance()->instance('request', $request); + + return $request; } beforeEach(function () { - ResourceTransformerRegistry::$transformers = []; + bind_test_container(); + ResourceTransformerRegistry::reset(); + RtrPreparingTransformer::$prepared = 0; + RtrPreparingTransformer::$preparedCount = []; }); -test('resource transformer registry normalizes class names and resolves by target', function () { - ResourceTransformerRegistry::register(ResourceTransformerRegistryTransformer::class); +test('registry registers classes, instances, closures and batches with stable ids', function () { + $registry = ResourceTransformerRegistry::instance(); - expect(ResourceTransformerRegistry::$transformers)->toBe([ - [ - 'definition' => '\\' . ResourceTransformerRegistryTransformer::class, - 'target' => '\\' . ResourceTransformerRegistryResource::class, - ], - ]) - ->and(ResourceTransformerRegistry::resolveByTarget(ResourceTransformerRegistryResource::class)) - ->toBe('\\' . ResourceTransformerRegistryTransformer::class) - ->and(ResourceTransformerRegistry::resolveByTarget('\\' . ResourceTransformerRegistryResource::class)) - ->toBe('\\' . ResourceTransformerRegistryTransformer::class) - ->and(ResourceTransformerRegistry::resolveByTarget(JsonResource::class))->toBeNull(); + $closure = fn (array $data) => $data + ['closure' => true]; + + expect($registry->add(RtrAppendTransformer::class))->toBe(RtrAppendTransformer::class) + ->and($registry->add('\\' . RtrModelTargetTransformer::class))->toBe(RtrModelTargetTransformer::class) + ->and($registry->add(new RtrOrderTransformer('a'), ['id' => 'order-a']))->toBe('order-a') + ->and($registry->add($closure, ['target' => RtrResource::class, 'id' => 'closure-a']))->toBe('closure-a') + ->and($registry->add(fn (array $data) => $data, ['target' => RtrResource::class]))->toStartWith('closure:') + ->and($registry->has(RtrAppendTransformer::class))->toBeTrue() + ->and($registry->has('\\' . RtrAppendTransformer::class))->toBeTrue() + ->and($registry->has('order-a'))->toBeTrue() + ->and($registry->has(RtrOtherResource::class))->toBeFalse() + ->and(array_column($registry->all(), 'id'))->toHaveCount(5) + ->and($registry->all()[0]['id'])->toBe(RtrAppendTransformer::class) + ->and($registry->all()[0]['targets'])->toBe([RtrResource::class]) + ->and($registry->all()[0]['priority'])->toBe(0) + ->and($registry->all()[0]['contexts'])->toBeNull() + ->and($registry->all()[0]['only'])->toBeNull(); + + $registry->flush(); + expect($registry->isEmpty())->toBeTrue(); + + $ids = $registry->addMany([ + RtrAppendTransformer::class, + [RtrModelTargetTransformer::class, ['priority' => 3]], + RtrInterfaceTransformer::class => ['only' => 'public'], + [new RtrOrderTransformer('b'), ['id' => 'order-b']], + ]); + + expect($ids)->toBe([RtrAppendTransformer::class, RtrModelTargetTransformer::class, RtrInterfaceTransformer::class, 'order-b']) + ->and($registry->all()[1]['priority'])->toBe(3) + ->and($registry->all()[2]['only'])->toBe('public'); }); -test('resource transformer registry supports options batch registration and rejects invalid batch entries', function () { +test('registry batch registration through the static helper accepts every supported form', function () { ResourceTransformerRegistry::register([ - ResourceTransformerRegistryTransformer::class, - [ResourceTransformerRegistryOptionTransformer::class, ['target' => ResourceTransformerRegistryResource::class]], + RtrAppendTransformer::class, + [RtrModelTargetTransformer::class, ['priority' => 3]], + RtrInterfaceTransformer::class => ['only' => 'public'], + [fn (array $data) => $data, ['target' => '*', 'id' => 'closure-batch']], ]); + ResourceTransformerRegistry::register(fn (array $data) => $data, ['target' => RtrModel::class, 'id' => 'closure-single']); - expect(ResourceTransformerRegistry::$transformers)->toHaveCount(2) - ->and(ResourceTransformerRegistry::$transformers[1])->toBe([ - 'definition' => '\\' . ResourceTransformerRegistryOptionTransformer::class, - 'target' => '\\' . ResourceTransformerRegistryResource::class, - ]); + $registry = ResourceTransformerRegistry::instance(); - ResourceTransformerRegistry::register([[ResourceTransformerRegistryTransformer::class]]); -})->throws(Exception::class, 'Attempted to register invalid notification.'); + expect($registry->ids())->toBe([ + RtrAppendTransformer::class, + RtrModelTargetTransformer::class, + RtrInterfaceTransformer::class, + 'closure-batch', + 'closure-single', + ]) + ->and($registry->all()[1]['priority'])->toBe(3) + ->and($registry->all()[2]['only'])->toBe('public') + ->and($registry->all()[3]['targets'])->toBe(['*']) + ->and(ResourceTransformerRegistry::forget('closure-batch'))->toBeTrue() + ->and(ResourceTransformerRegistry::forget('closure-batch'))->toBeFalse() + ->and($registry->has('closure-batch'))->toBeFalse(); -test('resource transformer registry transforms model data when a matching output transformer exists', function () { - ResourceTransformerRegistry::register(ResourceTransformerRegistryTransformer::class); + ResourceTransformerRegistry::reset(); + expect($registry->isEmpty())->toBeTrue(); +}); - $model = new ResourceTransformerRegistryModel(); +test('registry re-registering a class replaces its options and keeps its original order', function () { + $registry = ResourceTransformerRegistry::instance(); - expect(ResourceTransformerRegistry::transform($model, ['existing' => 'value']))->toBe([ - 'existing' => 'value', - 'transformed' => true, - 'model' => ResourceTransformerRegistryModel::class, - ]); + $registry->add(RtrAppendTransformer::class, ['priority' => 1]); + $registry->add(RtrModelTargetTransformer::class); + $registry->add(RtrAppendTransformer::class, ['priority' => 9, 'only' => 'internal']); + + expect($registry->all())->toHaveCount(2) + ->and($registry->all()[0]['id'])->toBe(RtrAppendTransformer::class) + ->and($registry->all()[0]['priority'])->toBe(9) + ->and($registry->all()[0]['only'])->toBe('internal'); +}); + +test('registry rejects invalid registrations', function () { + $registry = ResourceTransformerRegistry::instance(); + + expect(fn () => $registry->add('Fleetbase\\Missing\\Transformer'))->toThrow(InvalidArgumentException::class, 'does not exist') + ->and(fn () => $registry->add(RtrNotATransformer::class))->toThrow(InvalidArgumentException::class, 'must be an instantiable class implementing') + ->and(fn () => $registry->add(RtrAbstractTransformer::class))->toThrow(InvalidArgumentException::class, 'must be an instantiable class implementing') + ->and(fn () => $registry->add(fn (array $data) => $data))->toThrow(InvalidArgumentException::class, 'has no target') + ->and(fn () => $registry->add(RtrAppendTransformer::class, ['only' => 'admins']))->toThrow(InvalidArgumentException::class, 'only must be') + ->and(fn () => $registry->add(RtrAppendTransformer::class, ['contexts' => ['email']]))->toThrow(InvalidArgumentException::class, 'unknown context') + ->and(fn () => $registry->add(RtrAppendTransformer::class, ['contexts' => []]))->toThrow(InvalidArgumentException::class, 'contexts must be') + ->and(fn () => $registry->add(RtrAppendTransformer::class, ['priority' => 'high']))->toThrow(InvalidArgumentException::class, 'priority must be') + ->and(fn () => $registry->add(RtrAppendTransformer::class, ['target' => [123]]))->toThrow(InvalidArgumentException::class, 'invalid target') + ->and(fn () => $registry->addMany([[RtrAppendTransformer::class]]))->toThrow(InvalidArgumentException::class, 'Attempted to register invalid resource transformer') + ->and(fn () => $registry->addMany([123]))->toThrow(InvalidArgumentException::class, 'Attempted to register invalid resource transformer') + ->and(fn () => $registry->newContext(Request::create('/'), 'email'))->toThrow(InvalidArgumentException::class, 'Unknown resource transformer channel') + ->and($registry->isEmpty())->toBeTrue(); +}); + +test('registry matches targets by resource class hierarchy, model class hierarchy, interface and wildcard', function () { + $registry = ResourceTransformerRegistry::instance(); + + $registry->add(RtrAppendTransformer::class); + $registry->add(RtrModelTargetTransformer::class); + $registry->add(RtrInterfaceTransformer::class); + $registry->add(RtrWildcardOptionsTransformer::class); + + $ids = fn (string $resource, ?string $model = null) => array_column($registry->matching($resource, $model), 'id'); + + expect($ids(RtrResource::class))->toBe([RtrAppendTransformer::class, RtrWildcardOptionsTransformer::class]) + ->and($ids('\\' . RtrChildResource::class))->toBe([RtrAppendTransformer::class, RtrWildcardOptionsTransformer::class]) + ->and($ids(RtrOtherResource::class))->toBe([RtrWildcardOptionsTransformer::class]) + ->and($ids(RtrOtherResource::class, RtrModel::class))->toBe([RtrModelTargetTransformer::class, RtrWildcardOptionsTransformer::class]) + ->and($ids(RtrOtherResource::class, RtrChildModel::class))->toBe([RtrModelTargetTransformer::class, RtrInterfaceTransformer::class, RtrWildcardOptionsTransformer::class]) + ->and($ids(RtrChildResource::class, RtrChildModel::class))->toBe([RtrAppendTransformer::class, RtrModelTargetTransformer::class, RtrInterfaceTransformer::class, RtrWildcardOptionsTransformer::class]) + ->and($ids(JsonResource::class, 'Fleetbase\\Missing\\Model'))->toBe([RtrWildcardOptionsTransformer::class]) + ->and($registry->hasTransformersFor(RtrOtherResource::class))->toBeTrue() + ->and($registry->hasTransformersFor(new RtrOtherResource(new RtrChildModel())))->toBeTrue(); + + $registry->remove(RtrWildcardOptionsTransformer::class); + + expect($ids(RtrOtherResource::class))->toBe([]) + ->and($registry->hasTransformersFor(RtrOtherResource::class))->toBeFalse() + ->and($registry->hasTransformersFor(new RtrOtherResource(['id' => 1])))->toBeFalse() + ->and($registry->hasTransformersFor(new RtrOtherResource(new RtrModel())))->toBeTrue(); + + // the match cache is invalidated on registration + $registry->add(fn (array $data) => $data, ['target' => RtrOtherResource::class, 'id' => 'late']); + expect($ids(RtrOtherResource::class))->toBe(['late']); +}); + +test('registry orders transformers by ascending priority then registration order', function () { + $registry = ResourceTransformerRegistry::instance(); + + $registry->add(new RtrOrderTransformer('c'), ['id' => 'c', 'priority' => 10]); + $registry->add(new RtrOrderTransformer('a'), ['id' => 'a', 'priority' => -1]); + $registry->add(new RtrOrderTransformer('b1'), ['id' => 'b1']); + $registry->add(new RtrOrderTransformer('b2'), ['id' => 'b2']); + + $context = $registry->newContext(rtr_request('v1/widgets')); + + expect(array_column($registry->matching(RtrResource::class), 'id'))->toBe(['a', 'b1', 'b2', 'c']) + ->and($registry->apply([], new RtrResource(new RtrModel()), $context))->toBe(['order' => 'ab1b2c']); +}); + +test('registry applies channel and audience filters', function () { + $registry = ResourceTransformerRegistry::instance(); + + $registry->add(RtrWildcardOptionsTransformer::class); + $registry->add(fn (array $data) => $data + ['public_only' => true], ['target' => '*', 'id' => 'public', 'only' => 'public']); + $registry->add(fn (array $data) => $data + ['http_broadcast' => true], ['target' => '*', 'id' => 'hb', 'contexts' => ['http', 'broadcast']]); + + $resource = new RtrResource(new RtrModel()); + + $internal = rtr_request('int/v1/widgets'); + $public = rtr_request('v1/widgets'); + + $internalHttp = $registry->newContext($internal, ResourceTransformerContext::HTTP); + $internalWebhook = $registry->newContext($internal, ResourceTransformerContext::WEBHOOK); + $publicWebhook = $registry->newContext($public, ResourceTransformerContext::WEBHOOK); + $publicBroadcast = $registry->newContext($public, ResourceTransformerContext::BROADCAST); + + expect($internalHttp->isInternal())->toBeTrue() + ->and($internalHttp->isHttp())->toBeTrue() + ->and($publicWebhook->isPublic())->toBeTrue() + ->and($publicWebhook->isWebhook())->toBeTrue() + ->and($publicBroadcast->isBroadcast())->toBeTrue() + ->and($registry->apply([], $resource, $internalHttp))->toBe(['http_broadcast' => true]) + ->and($registry->apply([], $resource, $internalWebhook))->toBe(['wildcard' => true]) + ->and($registry->apply([], $resource, $publicWebhook))->toBe(['public_only' => true]) + ->and($registry->apply([], $resource, $publicBroadcast))->toBe(['public_only' => true, 'http_broadcast' => true]); +}); + +test('registry rejects callable transformers that do not return arrays', function () { + $registry = ResourceTransformerRegistry::instance(); + $registry->add(fn (array $data) => 'nope', ['target' => '*', 'id' => 'bad']); + + $context = $registry->newContext(rtr_request('v1/widgets')); + + expect(fn () => $registry->apply([], new RtrResource(new RtrModel()), $context)) + ->toThrow(UnexpectedValueException::class, 'Resource transformer "bad" must return an array'); +}); + +test('registry prepares transformers once per context and hands data to transform', function () { + $registry = ResourceTransformerRegistry::instance(); + $registry->add(RtrPreparingTransformer::class); + $registry->add(RtrAppendTransformer::class); + + $models = [new RtrModel(['name' => 'one']), new RtrModel(['name' => 'two'])]; + $context = $registry->newContext(rtr_request('v1/widgets')); + + $registry->prepare(RtrResource::class, $models, $context); + $registry->prepare(RtrResource::class, $models, $context); + + expect(RtrPreparingTransformer::$prepared)->toBe(1) + ->and(RtrPreparingTransformer::$preparedCount)->toBe([2]) + ->and($context->isPrepared(RtrPreparingTransformer::class))->toBeTrue() + ->and($context->get('names'))->toBe(['one', 'two']) + ->and($registry->apply(['id' => 1], new RtrResource($models[0]), $context))->toBe([ + 'id' => 1, + 'prepared_names' => ['one', 'two'], + 'appended' => true, + ]); + + // prepare() with nothing to prepare is a no-op + $registry->prepare(RtrResource::class, [null], $registry->newContext(rtr_request('v1/widgets'))); + expect(RtrPreparingTransformer::$prepared)->toBe(1); + + // the transform() convenience builds a context and prepares the single wrapped model + $resource = new RtrResource(new RtrModel(['name' => 'solo'])); + expect($registry->transform(['id' => 2], $resource, rtr_request('v1/widgets')))->toBe([ + 'id' => 2, + 'prepared_names' => ['solo'], + 'appended' => true, + ]) + ->and(RtrPreparingTransformer::$preparedCount)->toBe([2, 1]) + ->and($registry->transform(['id' => 3], new RtrOtherResource(['id' => 3]), rtr_request('v1/widgets')))->toBe(['id' => 3]); }); -test('resource transformer registry returns original data without a matching callable transformer', function () { - $model = new ResourceTransformerRegistryModel(); +test('registry instance prefers the container binding and falls back when unbound', function () { + $bound = ResourceTransformerRegistry::instance(); + $bound->add(RtrAppendTransformer::class); + + expect(ResourceTransformerRegistry::instance())->toBe($bound) + ->and(Container::getInstance()->make(ResourceTransformerRegistry::class))->toBe($bound); + + Container::setInstance(new FleetbaseTestContainer()); + + $fallback = ResourceTransformerRegistry::instance(); + $fallback->add(RtrModelTargetTransformer::class); + + expect($fallback)->not->toBe($bound) + ->and(ResourceTransformerRegistry::instance())->toBe($fallback) + ->and($fallback->has(RtrAppendTransformer::class))->toBeFalse(); + + ResourceTransformerRegistry::reset(); + expect($fallback->isEmpty())->toBeTrue(); + + bind_test_container(); + expect(ResourceTransformerRegistry::instance())->not->toBe($fallback); +}); - expect(ResourceTransformerRegistry::transform($model, ['untouched' => true]))->toBe(['untouched' => true]); +test('transformer context stores, remembers and forgets attributes', function () { + $context = new ResourceTransformerContext(Request::create('/'), ResourceTransformerContext::BROADCAST, true); - ResourceTransformerRegistry::register(ResourceTransformerRegistryNoOutputTransformer::class); + expect($context->has('a'))->toBeFalse() + ->and($context->get('a', 'default'))->toBe('default') + ->and($context->set('a', 1)->get('a'))->toBe(1) + ->and($context->remember('b', fn (ResourceTransformerContext $ctx) => $ctx->get('a') + 1))->toBe(2) + ->and($context->remember('b', fn () => 99))->toBe(2) + ->and($context->all())->toBe(['a' => 1, 'b' => 2]) + ->and($context->forget('a')->has('a'))->toBeFalse() + ->and($context->channel)->toBe('broadcast') + ->and($context->internal)->toBeTrue() + ->and($context->isPrepared('x'))->toBeFalse(); - expect(ResourceTransformerRegistry::transform($model, ['still' => 'same']))->toBe(['still' => 'same']); + $context->markPrepared('x'); + expect($context->isPrepared('x'))->toBeTrue(); }); -test('resource transformer registry fixes class names only for strings', function () { - expect(ResourceTransformerRegistry::fixClassName(ResourceTransformerRegistryTransformer::class)) - ->toBe('\\' . ResourceTransformerRegistryTransformer::class) - ->and(ResourceTransformerRegistry::fixClassName('\\' . ResourceTransformerRegistryTransformer::class)) - ->toBe('\\' . ResourceTransformerRegistryTransformer::class) - ->and(ResourceTransformerRegistry::fixClassName(null))->toBeNull(); +test('abstract transformer base requires a target and exposes options', function () { + expect(RtrWildcardOptionsTransformer::target())->toBe('*') + ->and(RtrWildcardOptionsTransformer::options())->toBe(['priority' => 5, 'contexts' => ['webhook'], 'only' => 'internal']) + ->and(RtrAppendTransformer::options())->toBe(['priority' => 0, 'contexts' => null, 'only' => null]) + ->and(fn () => (new class extends Transformer { + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + return $data; + } + })::target())->toThrow(LogicException::class, 'must define a static $target property'); }); From 65be9b304f4a45161a92d8ddfb24b3819c810712 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 14:39:01 +0800 Subject: [PATCH 2/3] release: v1.6.68 --- RELEASE.md | 30 +++++++++++++++++++++--------- composer.json | 4 ++-- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/RELEASE.md b/RELEASE.md index 79a30cec..149eb425 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1,14 +1,26 @@ -# v1.6.67 — API keys are generated randomly +# v1.6.68 — Resource transformers apply to every resource -## Security +## Added -- **API keys created in the same second were identical, across organizations.** A key was derived from its creation time and row id, but the id is never loaded after insert (the primary key is the uuid), so every key created in the same second got the same value. API authentication resolves a key to the first matching credential, so a key issued to one organization could authenticate as another's. Keys are now 32 random characters from the CSPRNG, for new keys and for rolled keys. (#283) +- **Agnostic resource transformers.** Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'` (subclasses match), and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascending `priority` and can be scoped by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). (#285) +- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (a once-per-collection `prepare()` hook for batch loading, so transformers never add N+1 queries), `Fleetbase\Support\ResourceTransformerContext`, and the `Fleetbase\Http\Transformers\Transformer` base class. +- Closure transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`. +- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom(__DIR__ . '/../Http/Transformers')` for declarative and directory-based registration from extensions, mirroring expansions. + +## Changed + +- `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag. +- `ResourceLifecycleEvent` payloads, chat participant broadcasts, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers reach them and conditional `MissingValue`s are no longer emitted as `{}`. +- `Find::httpResourceForModel()` caches internal and public resolutions separately, consulting the request only when a model has a dedicated `Internal` resource. + +## Removed + +- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly. + +## Dependencies + +- `fleetbase/laravel-mysql-spatial` `^1.0.3`. The spatial `MysqlConnection` no longer connects to MySQL when the connection object is built, so resolving `DB::connection()` during boot (for example `artisan package:discover` during `composer install`) no longer requires a reachable database. ## Upgrade Steps -- Check for existing duplicate keys and roll every credential that shares one, in both the live and sandbox databases: - ```sql - SELECT `key`, COUNT(*) AS credentials, COUNT(DISTINCT company_uuid) AS orgs - FROM api_credentials WHERE deleted_at IS NULL - GROUP BY `key` HAVING COUNT(*) > 1; - ``` +- Extensions that registered a legacy transformer must implement `Fleetbase\Contracts\ResourceTransformer` (or extend `Fleetbase\Http\Transformers\Transformer`) and register it through `$transformers` or `registerTransformersFrom()`. See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated. diff --git a/composer.json b/composer.json index af7478ac..8aa81946 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.67", + "version": "1.6.68", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", @@ -21,7 +21,7 @@ "php": "^8.1", "aws/aws-sdk-php-laravel": "^3.7", "fleetbase/countries": "^0.8.3", - "fleetbase/laravel-mysql-spatial": "^1.0.2", + "fleetbase/laravel-mysql-spatial": "^1.0.3", "fleetbase/twilio": "^5.0.1", "giggsey/libphonenumber-for-php": "^8.13", "google/apiclient": "^2.18", From 56163c680294e9733fff84a29d3aa735e5adbec3 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sat, 3 Oct 2026 15:02:42 +0800 Subject: [PATCH 3/3] test(resources): bring transformer registry and resources to full coverage CI enforces 100% line and method coverage. Covers the remaining branches: the registry's empty-registration short circuits, invokable objects and array callables as transformers (and their generated ids), rejecting non-callable objects, collections wrapping items in non-resource classes, collections with no resolvable item resource class, and resolving without a bound request. Two defensive throws that normalize() makes unreachable are marked @codeCoverageIgnore with an explanation, and callableId() drops a branch that could never run because string transformers are always treated as classes. --- src/Support/ResourceTransformerRegistry.php | 16 ++++-- .../Http/FleetbaseResourceCollectionTest.php | 57 +++++++++++++++++++ .../FleetbaseResourceTransformersTest.php | 8 +++ .../ResourceTransformerRegistryTest.php | 49 ++++++++++++++++ 4 files changed, 124 insertions(+), 6 deletions(-) diff --git a/src/Support/ResourceTransformerRegistry.php b/src/Support/ResourceTransformerRegistry.php index 3c692a5f..dfd667b8 100644 --- a/src/Support/ResourceTransformerRegistry.php +++ b/src/Support/ResourceTransformerRegistry.php @@ -407,9 +407,12 @@ protected function invoke(array $registration, array $data, JsonResource $resour return $instance->transform($data, $resource, $context->request, $context); } + // @codeCoverageIgnoreStart + // normalize() only admits ResourceTransformer classes/instances and callables, so this cannot be reached. if (!is_callable($instance)) { throw new \UnexpectedValueException('Resource transformer "' . $registration['id'] . '" is not invokable.'); } + // @codeCoverageIgnoreEnd $result = $instance($data, $resource, $context->request, $context); @@ -436,9 +439,12 @@ protected function resolveInstance(array $registration): mixed if (!isset($this->instances[$registration['id']])) { $instance = Container::getInstance()->make($transformer); + // @codeCoverageIgnoreStart + // The container returns an instance for a concrete class string; guard against custom bindings. if (!is_object($instance)) { throw new \UnexpectedValueException('Unable to instantiate resource transformer ' . $transformer . '.'); } + // @codeCoverageIgnoreEnd $this->instances[$registration['id']] = $instance; } @@ -586,13 +592,11 @@ protected static function callableId(callable $callable): string return get_class($callable) . ':' . spl_object_id($callable); } - if (is_array($callable)) { - $target = is_object($callable[0]) ? get_class($callable[0]) . ':' . spl_object_id($callable[0]) : (string) $callable[0]; + // Only array callables remain: strings are registered as transformer classes, never as callables. + /** @var array{0: object|string, 1: string} $callable */ + $target = is_object($callable[0]) ? get_class($callable[0]) . ':' . spl_object_id($callable[0]) : $callable[0]; - return 'callable:' . $target . '::' . (string) $callable[1]; - } - - return 'callable:' . (is_string($callable) ? $callable : get_debug_type($callable)); + return 'callable:' . $target . '::' . $callable[1]; } /** diff --git a/tests/Unit/Http/FleetbaseResourceCollectionTest.php b/tests/Unit/Http/FleetbaseResourceCollectionTest.php index 1c0bb707..48aeb2e5 100644 --- a/tests/Unit/Http/FleetbaseResourceCollectionTest.php +++ b/tests/Unit/Http/FleetbaseResourceCollectionTest.php @@ -471,3 +471,60 @@ public function toArray($request = null): array ['id' => 'conditional-1'], ]); }); + +class FleetbaseResourceCollectionArrayableCollects +{ + public function __construct(private array $item) + { + } + + public function toArray($request = null): array + { + return $this->item + ['wrapped_by' => 'arrayable']; + } +} + +class FleetbaseResourceCollectionBareCollects +{ + public function __construct(array $item) + { + foreach ($item as $key => $value) { + $this->{$key} = $value; + } + } +} + +test('resource collection wraps raw items with non-resource collects classes', function () { + $request = fleetbase_resource_collection_request(); + + $arrayable = (new FleetbaseResourceCollectionMutableCollects([ + ['id' => 'arrayable-collects', 'secret' => 'no'], + ]))->forceCollects(FleetbaseResourceCollectionArrayableCollects::class)->without('secret'); + + $bare = (new FleetbaseResourceCollectionMutableCollects([ + ['id' => 'bare-collects', 'secret' => 'no'], + ]))->forceCollects(FleetbaseResourceCollectionBareCollects::class)->without('secret'); + + expect($arrayable->toArray($request))->toBe([ + ['id' => 'arrayable-collects', 'wrapped_by' => 'arrayable'], + ]) + ->and($bare->toArray($request))->toBe([ + ['id' => 'bare-collects'], + ]); +}); + +test('resource collection skips the transformer pass when items carry no resource class', function () { + $request = fleetbase_resource_collection_request(); + + Fleetbase\Support\ResourceTransformerRegistry::register(fn (array $data) => $data + ['transformed' => true], ['target' => '*']); + + $collection = new FleetbaseResourceCollectionTestPlainItems([ + ['id' => 'plain-array'], + ]); + + expect($collection->toArray($request))->toBe([ + ['id' => 'plain-array'], + ]); + + Fleetbase\Support\ResourceTransformerRegistry::reset(); +}); diff --git a/tests/Unit/Http/FleetbaseResourceTransformersTest.php b/tests/Unit/Http/FleetbaseResourceTransformersTest.php index d03d430a..b2279453 100644 --- a/tests/Unit/Http/FleetbaseResourceTransformersTest.php +++ b/tests/Unit/Http/FleetbaseResourceTransformersTest.php @@ -273,3 +273,11 @@ function frt_model(string $id = 'w1', ?string $owner = 'Ann'): FrtModel // no filtering side effects when no transformer ran expect($resource->transformPayload($payload, ResourceTransformerContext::WEBHOOK))->toBe($payload); }); + +test('resolving without a request fails loudly when the container holds no request', function () { + frt_request(); + Container::getInstance()->instance('request', new stdClass()); + + expect(fn () => (new FrtResource(frt_model()))->resolveFor(ResourceTransformerContext::WEBHOOK)) + ->toThrow(RuntimeException::class, 'Unable to resolve the current request'); +}); diff --git a/tests/Unit/Support/ResourceTransformerRegistryTest.php b/tests/Unit/Support/ResourceTransformerRegistryTest.php index 6c423520..c097d7d8 100644 --- a/tests/Unit/Support/ResourceTransformerRegistryTest.php +++ b/tests/Unit/Support/ResourceTransformerRegistryTest.php @@ -112,6 +112,24 @@ abstract class RtrAbstractTransformer extends Transformer protected static $target = RtrResource::class; } +class RtrInvokableTransformer +{ + public function __invoke(array $data): array + { + return $data + ['invoked' => true]; + } + + public function handle(array $data): array + { + return $data + ['handled' => true]; + } + + public static function handleStatic(array $data): array + { + return $data + ['static' => true]; + } +} + class RtrNotATransformer { public static $target = RtrResource::class; @@ -428,3 +446,34 @@ public function transform(array $data, JsonResource $resource, Request $request, } })::target())->toThrow(LogicException::class, 'must define a static $target property'); }); + +test('registry derives ids for invokable objects and array callables', function () { + $registry = ResourceTransformerRegistry::instance(); + $invokable = new RtrInvokableTransformer(); + + $invokableId = $registry->add($invokable, ['target' => RtrResource::class]); + $methodId = $registry->add([$invokable, 'handle'], ['target' => RtrResource::class]); + $staticId = $registry->add([RtrInvokableTransformer::class, 'handleStatic'], ['target' => RtrResource::class]); + + $context = $registry->newContext(rtr_request('v1/widgets')); + + expect($invokableId)->toBe(RtrInvokableTransformer::class . ':' . spl_object_id($invokable)) + ->and($methodId)->toBe('callable:' . RtrInvokableTransformer::class . ':' . spl_object_id($invokable) . '::handle') + ->and($staticId)->toBe('callable:' . RtrInvokableTransformer::class . '::handleStatic') + ->and($registry->apply([], new RtrResource(new RtrModel()), $context))->toBe(['invoked' => true, 'handled' => true, 'static' => true]) + ->and(fn () => $registry->add(new stdClass(), ['target' => RtrResource::class]))->toThrow(InvalidArgumentException::class, 'stdClass'); +}); + +test('registry short-circuits when nothing is registered', function () { + $registry = ResourceTransformerRegistry::instance(); + $resource = new RtrResource(new RtrModel(['name' => 'empty'])); + $context = $registry->newContext(rtr_request('v1/widgets')); + + expect($registry->matching(RtrResource::class, RtrModel::class))->toBe([]) + ->and($registry->apply(['id' => 1], $resource, $context))->toBe(['id' => 1]) + ->and($registry->hasTransformersFor($resource))->toBeFalse(); + + $registry->prepare(RtrResource::class, [$resource->resource], $context); + + expect($context->all())->toBe([]); +});