Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
68 changes: 68 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
22 changes: 22 additions & 0 deletions src/Contracts/PreparesResourceTransformation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace Fleetbase\Contracts;

use Fleetbase\Support\ResourceTransformerContext;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;

/**
* Optional companion to `ResourceTransformer` for transformers that need to batch-load data.
*
* `prepare()` is called once per resolve with every model about to be serialized (a single model
* for a singular resource, all items for a collection) before any `transform()` call. Stash the
* loaded data on the context and read it back in `transform()` to avoid N+1 queries.
*/
interface PreparesResourceTransformation
{
/**
* @param Collection<int, mixed> $models
*/
public function prepare(Collection $models, Request $request, ResourceTransformerContext $context): void;
}
35 changes: 35 additions & 0 deletions src/Contracts/ResourceTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Fleetbase\Contracts;

use Fleetbase\Support\ResourceTransformerContext;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/**
* A resource transformer decorates the serialized output of an HTTP resource without the
* resource or its model having to know about it. Transformers are registered with the
* `ResourceTransformerRegistry` and applied by `FleetbaseResource::resolve()` to HTTP
* responses, nested resources, collection items, webhook payloads and broadcast payloads.
*/
interface ResourceTransformer
{
/**
* The class(es) this transformer applies to.
*
* A target may be an HTTP resource class, an Eloquent model class, an interface implemented
* by either, or `'*'` to match every resource. Subclasses of a target also match.
*
* @return string|array<int, string>
*/
public static function target(): string|array;

/**
* Transform the serialized resource data.
*
* @param array<string, mixed> $data the data produced by the resource's `toArray()`, after filtering
*
* @return array<string, mixed>
*/
public function transform(array $data, JsonResource $resource, Request $request, ResourceTransformerContext $context): array;
}
3 changes: 2 additions & 1 deletion src/Events/ChatParticipantAdded.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()),
];
}
}
3 changes: 2 additions & 1 deletion src/Events/ChatParticipantRemoved.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()),
];
}
}
23 changes: 18 additions & 5 deletions src/Events/ResourceLifecycleEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -133,7 +135,7 @@ public function broadcastAs(): string
*/
public function broadcastWith(): array
{
return $this->getEventData();
return $this->getEventData(ResourceTransformerContext::BROADCAST);
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Http/Controllers/Internal/v1/UserController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
113 changes: 112 additions & 1 deletion src/Http/Resources/FleetbaseResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
*/
Expand All @@ -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<string, mixed>
*/
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<string, mixed>
*/
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<string, mixed> $data
* @param Request|null $request
*
* @return array<string, mixed>
*/
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.
*
Expand Down Expand Up @@ -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<string, mixed> $data
*
* @return array<string, mixed>
*/
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.
*/
Expand Down
Loading
Loading