Skip to content
Open
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.
30 changes: 21 additions & 9 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,26 @@
# v1.6.67 — API keys are generated randomly
# v1.6.68 — Resource transformers apply to every resource

## Security
## Added

- **API keys created in the same second were identical, across organizations.** A key was derived from its creation time and row id, but the id is never loaded after insert (the primary key is the uuid), so every key created in the same second got the same value. API authentication resolves a key to the first matching credential, so a key issued to one organization could authenticate as another's. Keys are now 32 random characters from the CSPRNG, for new keys and for rolled keys. (#283)
- **Agnostic resource transformers.** Any extension can decorate the serialized output of any API resource without modifying the resource or its model. Register a transformer against an HTTP resource class, an Eloquent model class, an interface, or `'*'` (subclasses match), and `FleetbaseResource::resolve()` applies it to JSON responses, nested resources, collection items, webhook payloads and broadcast payloads. Transformers chain in ascending `priority` and can be scoped by `contexts` (`http`, `webhook`, `broadcast`) and `only` (`internal`, `public`). (#285)
- `Fleetbase\Contracts\ResourceTransformer`, `Fleetbase\Contracts\PreparesResourceTransformation` (a once-per-collection `prepare()` hook for batch loading, so transformers never add N+1 queries), `Fleetbase\Support\ResourceTransformerContext`, and the `Fleetbase\Http\Transformers\Transformer` base class.
- Closure transformers via `ResourceTransformerRegistry::register(fn (...) => ..., ['target' => ...])`.
- `CoreServiceProvider::$transformers`, `registerTransformers()` and `registerTransformersFrom(__DIR__ . '/../Http/Transformers')` for declarative and directory-based registration from extensions, mirroring expansions.

## Changed

- `FleetbaseResourceCollection` resolves items (instead of calling `toArray()`), sharing one `prepare()` pass per collection. A hand-built collection with a manually set `preserveKeys` now filters item arrays with the item's flag.
- `ResourceLifecycleEvent` payloads, chat participant broadcasts, `Utils::serializeJsonResource()` and the cached internal user payload serialize through `resolve()`, so transformers reach them and conditional `MissingValue`s are no longer emitted as `{}`.
- `Find::httpResourceForModel()` caches internal and public resolutions separately, consulting the request only when a model has a dedicated `Internal` resource.

## Removed

- Legacy duck-typed transformers (`$target` property + static `output($model, $data)`), `ResourceTransformerRegistry::transform(Model, array)`, `resolveByTarget()`, `fixClassName()` and the static `$transformers` array. The `User` resource no longer calls the registry directly.

## Dependencies

- `fleetbase/laravel-mysql-spatial` `^1.0.3`. The spatial `MysqlConnection` no longer connects to MySQL when the connection object is built, so resolving `DB::connection()` during boot (for example `artisan package:discover` during `composer install`) no longer requires a reachable database.

## Upgrade Steps

- Check for existing duplicate keys and roll every credential that shares one, in both the live and sandbox databases:
```sql
SELECT `key`, COUNT(*) AS credentials, COUNT(DISTINCT company_uuid) AS orgs
FROM api_credentials WHERE deleted_at IS NULL
GROUP BY `key` HAVING COUNT(*) > 1;
```
- Extensions that registered a legacy transformer must implement `Fleetbase\Contracts\ResourceTransformer` (or extend `Fleetbase\Http\Transformers\Transformer`) and register it through `$transformers` or `registerTransformersFrom()`. See the README section "Resource transformers". The only known legacy consumer, aws-marketplace, is deprecated and is not updated.
4 changes: 2 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.67",
"version": "1.6.68",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand All @@ -21,7 +21,7 @@
"php": "^8.1",
"aws/aws-sdk-php-laravel": "^3.7",
"fleetbase/countries": "^0.8.3",
"fleetbase/laravel-mysql-spatial": "^1.0.2",
"fleetbase/laravel-mysql-spatial": "^1.0.3",
"fleetbase/twilio": "^5.0.1",
"giggsey/libphonenumber-for-php": "^8.13",
"google/apiclient": "^2.18",
Expand Down
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
Loading
Loading