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..dfd667b8 100644 --- a/src/Support/ResourceTransformerRegistry.php +++ b/src/Support/ResourceTransformerRegistry.php @@ -2,85 +2,620 @@ 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; + } + + $models = Collection::make($models)->filter(fn ($model) => $model !== null)->values(); + if ($models->isEmpty()) { return; } - static::$transformers[] = [ - 'definition' => static::fixClassName($transformerClass), - 'target' => static::fixClassName(static::getTransformerClassProperty($transformerClass, 'target', data_get($options, 'target', null))), - ]; + $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; } - private static function getTransformerClassProperty(string $transformerClass, string $property, $defaultValue = null) + /** + * @param TransformerRegistration $registration + * @param array $data + * + * @return array + */ + protected function invoke(array $registration, array $data, JsonResource $resource, ResourceTransformerContext $context): array { - if (!Utils::classExists($transformerClass) || !property_exists($transformerClass, $property)) { - return $defaultValue; + $instance = $this->resolveInstance($registration); + + if ($instance instanceof ResourceTransformer) { + 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 - $properties = get_class_vars($transformerClass); + $result = $instance($data, $resource, $context->request, $context); - return data_get($properties, $property, $defaultValue); + 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; } - public static function resolveByTarget($targetClass) + /** + * @param TransformerRegistration $registration + * + * @return object|callable + */ + protected function resolveInstance(array $registration): mixed { - foreach (static::$transformers as $transformer) { - if (isset($transformer['target']) && static::fixClassName($transformer['target']) === static::fixClassName($targetClass)) { - return $transformer['definition']; + $transformer = $registration['transformer']; + + if (!is_string($transformer)) { + return $transformer; + } + + 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; } - return null; + return $this->instances[$registration['id']]; } - public static function transform(Model $model, array $data = []): array + /** + * @param array $targets + */ + protected function targetsMatch(array $targets, string $resourceClass, ?string $modelClass): bool { - $resourceClass = Find::httpResourceForModel($model); - if ($resourceClass) { - $transformerClass = static::resolveByTarget($resourceClass); - if ($transformerClass && method_exists($transformerClass, 'output')) { - return $transformerClass::output($model, $data); + 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 $data; + return false; } - public static function fixClassName($className) + /** + * 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 { - if (is_string($className)) { - if (Str::startsWith($className, '\\')) { - return $className; + $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)); + } + + 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) . '.'); + } } + } - return '\\' . $className; + $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 $className; + return [ + 'id' => $id, + 'transformer' => $transformer, + 'targets' => $targets, + 'priority' => (int) $priority, + 'contexts' => $contexts, + 'only' => $only, + 'order' => 0, + ]; + } + + /** + * @return array + * + * @throws \InvalidArgumentException + */ + protected function normalizeTargets(mixed $targets, string $id): array + { + 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) . '.'); + } + + $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); + } + + // 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 . '::' . $callable[1]; + } + + /** + * 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 []; + } + + $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..48aeb2e5 100644 --- a/tests/Unit/Http/FleetbaseResourceCollectionTest.php +++ b/tests/Unit/Http/FleetbaseResourceCollectionTest.php @@ -448,3 +448,83 @@ 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'], + ]); +}); + +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 new file mode 100644 index 00000000..b2279453 --- /dev/null +++ b/tests/Unit/Http/FleetbaseResourceTransformersTest.php @@ -0,0 +1,283 @@ + $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); +}); + +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/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..c097d7d8 100644 --- a/tests/Unit/Support/ResourceTransformerRegistryTest.php +++ b/tests/Unit/Support/ResourceTransformerRegistryTest.php @@ -1,108 +1,479 @@ $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; +} + +class RtrAppendTransformer extends Transformer +{ + protected static $target = RtrResource::class; - public static function output(Model $model, array $data = []): array + public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array + { + 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) { - return array_merge($data, [ - 'transformed' => true, - 'model' => $model::class, - ]); } + + 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 ResourceTransformerRegistryOptionTransformer +class RtrInvokableTransformer { - public static function output(Model $model, array $data = []): array + public function __invoke(array $data): array { - return array_merge($data, ['option_transformer' => true]); + 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; + + public static function output($model, $data) + { + return $data; } } -class ResourceTransformerRegistryNoOutputTransformer +class RtrPreparingTransformer extends Transformer implements PreparesResourceTransformation { - public static string $target = ResourceTransformerRegistryResource::class; + 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 $data + ['prepared_names' => $context->get('names')]; + } +} + +function rtr_request(string $uri): Request +{ + $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(); + + 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(); + + ResourceTransformerRegistry::reset(); + expect($registry->isEmpty())->toBeTrue(); +}); + +test('registry re-registering a class replaces its options and keeps its original order', function () { + $registry = ResourceTransformerRegistry::instance(); + + $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, ]); - ResourceTransformerRegistry::register([[ResourceTransformerRegistryTransformer::class]]); -})->throws(Exception::class, 'Attempted to register invalid notification.'); + // 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 transforms model data when a matching output transformer exists', function () { - ResourceTransformerRegistry::register(ResourceTransformerRegistryTransformer::class); +test('registry instance prefers the container binding and falls back when unbound', function () { + $bound = ResourceTransformerRegistry::instance(); + $bound->add(RtrAppendTransformer::class); - $model = new ResourceTransformerRegistryModel(); + expect(ResourceTransformerRegistry::instance())->toBe($bound) + ->and(Container::getInstance()->make(ResourceTransformerRegistry::class))->toBe($bound); - expect(ResourceTransformerRegistry::transform($model, ['existing' => 'value']))->toBe([ - 'existing' => 'value', - 'transformed' => true, - 'model' => ResourceTransformerRegistryModel::class, - ]); + 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); +}); + +test('transformer context stores, remembers and forgets attributes', function () { + $context = new ResourceTransformerContext(Request::create('/'), ResourceTransformerContext::BROADCAST, true); + + 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(); + + $context->markPrepared('x'); + expect($context->isPrepared('x'))->toBeTrue(); +}); + +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'); }); -test('resource transformer registry returns original data without a matching callable transformer', function () { - $model = new ResourceTransformerRegistryModel(); +test('registry derives ids for invokable objects and array callables', function () { + $registry = ResourceTransformerRegistry::instance(); + $invokable = new RtrInvokableTransformer(); - expect(ResourceTransformerRegistry::transform($model, ['untouched' => true]))->toBe(['untouched' => true]); + $invokableId = $registry->add($invokable, ['target' => RtrResource::class]); + $methodId = $registry->add([$invokable, 'handle'], ['target' => RtrResource::class]); + $staticId = $registry->add([RtrInvokableTransformer::class, 'handleStatic'], ['target' => RtrResource::class]); - ResourceTransformerRegistry::register(ResourceTransformerRegistryNoOutputTransformer::class); + $context = $registry->newContext(rtr_request('v1/widgets')); - expect(ResourceTransformerRegistry::transform($model, ['still' => 'same']))->toBe(['still' => 'same']); + 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('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('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([]); });