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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `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.
- Untyped relation methods can no longer be requested by a client (see Security). Give the method a return type (`public function driver(): BelongsTo`) or list its name in `protected $withable = [...]`.
- A relation requested in another case (`subfleets`) loads under the declared method name (`subFleets`), the key resources read.
- `without` on create, update and public list responses adds to the model's hidden attributes (`makeHidden`) instead of replacing them (`setHidden`), so it can no longer un-hide the model's defaults.

### Security
- Request `with` / `expand` / `with_count` / `count` names, and a template query's stored `with` list, reach Eloquent only when they name a real relation. That means a method or Expandable closure that declares an Eloquent Relation return type (a union counts when one member is a Relation), or a name the model lists in a new `$withable` array. Each segment of a nested path is checked against the related model, and the path is cut at the first segment that fails. Nothing after a `morphTo` is kept, and a `morphTo` is never counted. Anything else is dropped without an error. Previously Eloquent called any requested public method with no required arguments on a blank model: `with[]=saveInstance` inserted an empty row, and fleetops' `Order::dispatch()` saved an order and queued its events. See `Fleetbase\Support\RequestRelations`.

### Removed
- `HasApiModelBehavior::normalizeRequestRelations()` (protected, no callers outside the trait).
- 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
4 changes: 3 additions & 1 deletion src/Models/TemplateQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Fleetbase\Casts\Json;
use Fleetbase\Services\TemplateRenderService;
use Fleetbase\Support\RequestRelations;
use Fleetbase\Traits\HasApiModelBehavior;
use Fleetbase\Traits\HasPublicId;
use Fleetbase\Traits\HasUuid;
Expand Down Expand Up @@ -180,7 +181,8 @@ public function execute(): \Illuminate\Support\Collection
}

// Eager-load relationships
$with = $this->getAttribute('with');
// The stored list is user-editable, so it is held to the same relation check as a request's `with`
$with = RequestRelations::eagerLoads($model, $this->getAttribute('with'));
if (!empty($with)) {
$query->with($with);
}
Expand Down
205 changes: 205 additions & 0 deletions src/Support/RequestRelations.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
<?php

namespace Fleetbase\Support;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Str;

/**
* Restricts client-supplied relation names (`with`, `expand`, `with_count`, `count`) to real relations.
*
* Eloquent resolves an eager load or a count by calling `$model->newInstance()->{$name}()`, so an
* unchecked name runs any public method that needs no arguments on a blank model: `with[]=saveInstance`
* inserts an empty row, and fleetops' `Order::dispatch()` saves an order and queues its events.
*
* A name is accepted when the method (or the Expandable closure registered under that name) declares an
* Eloquent Relation return type, or when the model lists it in a `$withable` array. Each segment of a
* nested path is checked against the model the previous segment relates to, and the path is cut at the
* first segment that fails. Nothing after a morphTo is kept, since its related model is only known per
* result. Rejected names are dropped without an error.
*/
class RequestRelations
{
/**
* Most paths read from one request.
*/
public const MAX_PATHS = 50;

/**
* Most segments kept in one nested path.
*/
public const MAX_DEPTH = 5;

/**
* Get the eager load paths a request may apply to a model.
*
* @param mixed $relations a comma-separated string or a list of dot paths
*
* @return string[] the accepted paths, with each segment named as the model's method
*/
public static function eagerLoads(Model $model, mixed $relations): array
{
$paths = [];
foreach (static::split($relations) as $path) {
$current = $model;
$segments = [];
foreach (explode('.', $path) as $segment) {
$method = static::relationMethod($current, $segment);
$relation = $method ? static::relation($current, $method) : null;
if (!$relation) {
break;
}

$segments[] = $method;
if ($relation instanceof MorphTo || count($segments) >= static::MAX_DEPTH) {
break;
}

$current = $relation->getRelated();
}

if ($segments) {
$paths[] = implode('.', $segments);
}
}

return array_values(array_unique($paths));
}

/**
* Get the relation counts a request may apply to a model.
*
* @param mixed $relations a comma-separated string or a list of relation names
*
* @return string[] the accepted relation names, named as the model's methods
*/
public static function counts(Model $model, mixed $relations): array
{
$names = [];
foreach (static::split($relations) as $name) {
$method = str_contains($name, '.') ? null : static::relationMethod($model, $name);
if ($method === null) {
continue;
}

$relation = static::relation($model, $method);
if ($relation && !$relation instanceof MorphTo) {
$names[] = $method;
}
}

return array_values(array_unique($names));
}

/**
* Resolve a requested segment to the name of a relation method on the model.
*
* The segment is tried camel-cased, as given, then snake-cased. A method is returned as declared, so
* `subfleets` resolves to `subFleets` and the relation is loaded under the key resources read.
*/
public static function relationMethod(Model $model, string $segment): ?string
{
$segment = trim($segment);
if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) {
return null;
}

$withable = array_map('strtolower', static::withable($model));
foreach (array_unique([Str::camel($segment), $segment, Str::snake($segment)]) as $name) {
$callable = static::callable($model, $name);
if (!$callable) {
continue;
}

$name = $callable instanceof \ReflectionMethod ? $callable->getName() : $name;
if (in_array(strtolower($name), $withable, true) || static::declaresRelation($callable->getReturnType())) {
return $name;
}
}

return null;
}

/**
* Flatten every accepted input shape to a list of trimmed, non-empty paths.
*
* @return string[]
*/
protected static function split(mixed $relations): array
{
if (is_string($relations)) {
$relations = explode(',', $relations);
}
if (!is_array($relations)) {
return [];
}

$paths = array_map('trim', array_filter($relations, 'is_string'));

return array_slice(array_values(array_unique(array_filter($paths, fn (string $path) => $path !== ''))), 0, static::MAX_PATHS);
}

/**
* Get the method, or the Expandable closure, a relation name calls, when it can be called with no arguments.
*/
protected static function callable(Model $model, string $name): ?\ReflectionFunctionAbstract
{
if (method_exists($model, $name)) {
$method = new \ReflectionMethod($model, $name);

return $method->isPublic() && !$method->isStatic() && $method->getNumberOfRequiredParameters() === 0 ? $method : null;
}

// isExpansion() only holds for a registered Closure
if (method_exists($model, 'isExpansion') && $model::isExpansion($name)) {
$closure = new \ReflectionFunction($model::getExpansionClosure($name));

return $closure->getNumberOfRequiredParameters() === 0 ? $closure : null;
}

return null;
}

/**
* Whether a declared return type names an Eloquent relation (`BelongsTo`, `?HasMany`, `BelongsTo|Builder`).
*/
protected static function declaresRelation(?\ReflectionType $type): bool
{
$types = $type instanceof \ReflectionUnionType ? $type->getTypes() : [$type];
foreach ($types as $member) {
if ($member instanceof \ReflectionNamedType && !$member->isBuiltin() && is_a($member->getName(), Relation::class, true)) {
return true;
}
}

return false;
}

/**
* Get the relation names a model allows beyond those with a declared Relation return type.
*
* @return string[]
*/
protected static function withable(Model $model): array
{
$withable = (fn () => property_exists($this, 'withable') ? $this->withable : [])->call($model);

return is_array($withable) ? array_values(array_filter($withable, 'is_string')) : [];
}

/**
* Build the relation the same way Eloquent does for an eager load, or null when it is not one.
*/
protected static function relation(Model $model, string $method): ?Relation
{
try {
$relation = Relation::noConstraints(fn () => $model->newInstance()->{$method}());
} catch (\Throwable) {
return null;
}

return $relation instanceof Relation ? $relation : null;
}
}
Loading
Loading