@fleetbase/fleetops-engine 0.6.61 → 0.6.62
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/RELEASE.md +23 -8
- package/addon/services/map-settings.js +4 -1
- package/composer.json +1 -1
- package/extension.json +1 -1
- package/package.json +1 -1
- package/server/migrations/2026_09_05_000001_add_unique_indexes_to_fleet_membership_pivots.php +129 -0
- package/server/src/Exceptions/PublicRelationNotFoundException.php +47 -0
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesFleetOpsApiResources.php +42 -7
- package/server/src/Http/Controllers/Api/v1/Concerns/ResolvesPublicExpansions.php +187 -0
- package/server/src/Http/Controllers/Api/v1/DriverController.php +93 -57
- package/server/src/Http/Controllers/Api/v1/EntityController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/FleetController.php +370 -33
- package/server/src/Http/Controllers/Api/v1/FuelReportController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/IssueController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/OrderController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/ServiceAreaController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/TrackingStatusController.php +1 -1
- package/server/src/Http/Controllers/Api/v1/VehicleController.php +134 -35
- package/server/src/Http/Controllers/Api/v1/ZoneController.php +1 -1
- package/server/src/Http/Filter/Concerns/ResolvesPublicRelationUuids.php +54 -0
- package/server/src/Http/Filter/DriverFilter.php +71 -15
- package/server/src/Http/Filter/FleetFilter.php +32 -33
- package/server/src/Http/Filter/VehicleFilter.php +57 -19
- package/server/src/Http/Requests/Concerns/ScopesPublicRelationRules.php +48 -0
- package/server/src/Http/Requests/CreateDriverRequest.php +100 -9
- package/server/src/Http/Requests/CreateFleetRequest.php +30 -2
- package/server/src/Http/Requests/CreateServiceRateRequest.php +7 -2
- package/server/src/Http/Requests/CreateVehicleRequest.php +150 -31
- package/server/src/Http/Resources/v1/Concerns/ResolvesPublicRelationFields.php +110 -0
- package/server/src/Http/Resources/v1/Driver.php +22 -2
- package/server/src/Http/Resources/v1/Fleet.php +38 -25
- package/server/src/Http/Resources/v1/Vehicle.php +46 -4
- package/server/src/Models/Driver.php +2 -2
- package/server/src/routes.php +8 -0
- package/server/tests/ApiDriverControllerContractsTest.php +284 -13
- package/server/tests/ApiEntityControllerContractsTest.php +1 -1
- package/server/tests/ApiEquipmentControllerContractsTest.php +2 -2
- package/server/tests/ApiFleetControllerContractsTest.php +354 -21
- package/server/tests/ApiFuelReportControllerContractsTest.php +1 -1
- package/server/tests/ApiIssueControllerContractsTest.php +1 -1
- package/server/tests/ApiOrderControllerContractsTest.php +1 -1
- package/server/tests/ApiPartControllerContractsTest.php +2 -2
- package/server/tests/ApiServiceAreaZoneControllerContractsTest.php +2 -2
- package/server/tests/ApiTrackingStatusControllerContractsTest.php +1 -1
- package/server/tests/ApiVehicleControllerContractsTest.php +175 -8
- package/server/tests/ControllerFilterContractsTest.php +132 -13
- package/server/tests/ControllerHelperContractsTest.php +6 -3
- package/server/tests/DriverFilterExecutionTest.php +130 -6
- package/server/tests/ExceptionContractsTest.php +15 -0
- package/server/tests/Feature/Http/Api/DeviceControllerContractsTest.php +1 -1
- package/server/tests/Feature/Http/Api/FleetMembershipMigrationTest.php +140 -0
- package/server/tests/Feature/Http/Api/FleetMembershipTest.php +333 -0
- package/server/tests/Feature/Http/Api/FleetPublicContractTest.php +439 -0
- package/server/tests/Feature/Http/Api/FuelTransactionControllerContractsTest.php +2 -2
- package/server/tests/Feature/Http/Api/NullRelationshipInputTest.php +191 -0
- package/server/tests/Feature/Http/Api/PublicIdentifierFilterTest.php +293 -0
- package/server/tests/Feature/Http/Api/SmallApiControllerHelpersTest.php +7 -1
- package/server/tests/Feature/Http/Api/VehicleControllerTrackingTest.php +3 -1
- package/server/tests/Feature/Http/Api/VehiclePublicContractTest.php +454 -0
- package/server/tests/Feature/Http/Api/WorkOrderControllerContractsTest.php +1 -1
- package/server/tests/FleetHierarchyResourceTest.php +56 -8
- package/server/tests/RequestContractsTest.php +100 -7
- package/server/tests/Unit/Http/Resources/FleetResourceTest.php +50 -6
- package/server/tests/VehicleControllerHelperContractsTest.php +1 -1
package/RELEASE.md
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
> v0.6.
|
|
1
|
+
> v0.6.62 ~ "The public Fleet, Vehicle and Driver APIs now expose what the records actually hold"
|
|
2
2
|
|
|
3
3
|
---
|
|
4
4
|
## Highlights
|
|
5
|
-
|
|
5
|
+
The public v1 API exposed a small subset of what the Fleet, Vehicle and Driver records can hold, and the gaps were silent: a caller sending a field the controller did not copy received a `200` and a response body that looked correct while the value was discarded. This release closes that gap, adds fleet hierarchies and fleet membership to the public API, and makes it possible to record a driver who has no email address or phone number.
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
## Features
|
|
9
|
-
- **
|
|
10
|
-
- **
|
|
9
|
+
- **Fleet create and update accept every safe field.** `name`, `color`, `task`, `status`, and the `service_area`, `zone`, `vendor` and `parent_fleet` relationships. Only `name` and `service_area` were reachable before, so a fleet hierarchy could not be built through the API at all. `"parent_fleet": null` clears a parent; a fleet may not be its own parent, nor sit beneath one of its own descendants.
|
|
10
|
+
- **Fleet membership has public endpoints.** `POST` and `DELETE` on `/v1/fleets/{fleet}/vehicles/{vehicle}` and `/v1/fleets/{fleet}/drivers/{driver}`, all four taking public IDs and sharing one response shape. Assignment is idempotent and restores a soft-deleted membership rather than duplicating it; removal is a safe no-op and touches only the pivot, never the driver, the vehicle, the driver's current vehicle, or any other fleet.
|
|
11
|
+
- **The Vehicle contract covers the whole record.** The input projection accepted 21 of the model's 99 fields; it now accepts all 90 safe ones — identity, odometer and measurement, body, capacity and dimensions, lifecycle and financing, regulatory and engine specifications, structured `specs`/`details`/`meta`, and orchestrator constraints — each with type-appropriate validation. `vendor`, `category`, `warranty` and `photo` resolve from public IDs.
|
|
12
|
+
- **A driver can be recorded without credentials.** `email` and `phone` are optional on create; an operational record may legitimately have neither. Nothing is invented to fill the gap and no invitation is sent when there is nowhere to send one. Such a driver cannot sign in to Navigator until credentials are supplied.
|
|
13
|
+
- **Relationships are readable back as public IDs.** A caller that writes `parent_fleet: "fleet_abc"` can now read the assignment back. Asking for a relation through `?with=` still returns the nested object it always did, and internal console responses are unchanged.
|
|
11
14
|
|
|
12
15
|
---
|
|
13
16
|
## Bug Fixes
|
|
14
|
-
- **`
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
17
|
+
- **`Driver::$fillable` listed `'meta,'`** — a trailing comma inside the string — so driver metadata was never mass assignable and never persisted through the public API.
|
|
18
|
+
- **Driver input was a blocklist, not an allowlist.** Anything nobody had thought to exclude reached `Driver::create()` intact, including `auth_token`, `user_uuid` and `company_uuid`, while `location`, `heading`, `altitude`, `speed` and `meta` were dropped on every write.
|
|
19
|
+
- **Driver photo upload wrote `photo_uuid` to `users`,** which has no such column. `User` guards mass assignment by fillable, so every photo uploaded through the public API was discarded without a word. It now writes `avatar_uuid`.
|
|
20
|
+
- **A partial vehicle update took the vehicle offline.** The create-time `online` default was applied on update too, so a plate correction or an odometer reading silently reset it.
|
|
21
|
+
- **Relationship filters could never match.** `?vendor=`, `?fleet=`, `?driver=` and the fleet hierarchy filters compared caller-supplied public IDs against uuid columns. `FleetFilter::query()` searched a `user` relation Fleet does not have, `DriverFilter::phone()` a `phone` relation that does not exist, and `FleetFilter::zone()` a `zone_uuid` column `zones` does not have — the last three raised rather than filtered.
|
|
22
|
+
- **`VehicleFilter` had no `internal_id` filter,** which is the lookup an importer keys on to decide whether a vehicle already exists.
|
|
23
|
+
- **Cross-company relationship IDs were accepted and silently dropped.** Relationship inputs were validated with unscoped `exists` rules, so another organization's public ID passed validation and then resolved to nothing. They are now scoped at validation and again at resolution, and a cross-company ID is answered exactly as a missing one — so a response cannot be used to probe another organization's data.
|
|
24
|
+
- **The Fleet resource ran four `count()` queries per fleet on every public request** and discarded the results, because `when()` evaluates a plain value argument eagerly.
|
|
25
|
+
- **The Fleet webhook payload gated `parent_fleet` on `serviceArea`,** so a subfleet with no service area never reported its parent.
|
|
17
26
|
|
|
18
27
|
---
|
|
19
28
|
## Testing
|
|
20
|
-
- Coverage held at 100%
|
|
29
|
+
- Coverage held at 100% on all three metrics — 34670/34670 statements, 4428/4428 methods, 530/530 classes.
|
|
30
|
+
- New database-backed coverage of the fleet membership pivots: idempotent assignment, restore of a soft-deleted membership, repeated removal as a no-op, and preservation of the driver's vehicle and of unrelated fleet memberships.
|
|
31
|
+
- Data-driven parity tests prove every newly exposed Fleet, Vehicle and Driver field is accepted and persisted, and that tenancy, authentication and generated columns still are not.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
## Continuous Integration
|
|
35
|
+
- The PHP, Ember and Postman contract workflows now run on pull requests targeting `release/v*`. `release.yml` learned that branch name in v0.6.61's follow-up, but the three check workflows did not — so a PR retargeted onto a release branch ran no checks at all.
|
|
21
36
|
|
|
22
37
|
---
|
|
23
38
|
## Need help?
|
|
@@ -18,7 +18,10 @@ export default class MapSettingsService extends Service {
|
|
|
18
18
|
@service fetch;
|
|
19
19
|
@tracked settings = { ...DEFAULT_SETTINGS };
|
|
20
20
|
@tracked isLoaded = false;
|
|
21
|
-
|
|
21
|
+
// Internal request deduplication state is not rendered. Keeping this tracked
|
|
22
|
+
// makes load() read and then dirty the same tag when called during component
|
|
23
|
+
// construction, which Ember correctly rejects as a render-time mutation.
|
|
24
|
+
loadPromise = null;
|
|
22
25
|
|
|
23
26
|
get mapProvider() {
|
|
24
27
|
return this.settings.mapProvider ?? 'leaflet';
|
package/composer.json
CHANGED
package/extension.json
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
use Illuminate\Database\Migrations\Migration;
|
|
4
|
+
use Illuminate\Database\Schema\Blueprint;
|
|
5
|
+
use Illuminate\Support\Facades\DB;
|
|
6
|
+
use Illuminate\Support\Facades\Schema;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One logical membership per (fleet, vehicle) and (fleet, driver) pair, enforced
|
|
10
|
+
* by the database rather than by a read-then-write in the controller.
|
|
11
|
+
*
|
|
12
|
+
* The public membership endpoints are idempotent by checking for an existing
|
|
13
|
+
* pivot and creating one only if there is none. That holds for sequential calls
|
|
14
|
+
* and fails for concurrent ones: two requests can both find nothing and both
|
|
15
|
+
* insert, leaving a fleet with the same vehicle twice. An importer that retries
|
|
16
|
+
* a timed-out request is exactly the caller that produces this.
|
|
17
|
+
*
|
|
18
|
+
* The index deliberately covers soft-deleted rows too. Unlike the SKU and
|
|
19
|
+
* provider-transaction keys — where a tombstone must free the key, and does so
|
|
20
|
+
* through a generated column that is NULL when deleted — a removed membership
|
|
21
|
+
* here must keep occupying its key, because re-assigning restores that row
|
|
22
|
+
* rather than inserting a second one. Letting a tombstone free the key would
|
|
23
|
+
* reintroduce the duplicate it is meant to prevent.
|
|
24
|
+
*
|
|
25
|
+
* Rows with a NULL fleet_uuid or subject_uuid are left alone: MySQL permits any
|
|
26
|
+
* number of NULLs in a unique index, and such a row is orphaned data rather than
|
|
27
|
+
* a membership.
|
|
28
|
+
*/
|
|
29
|
+
return new class extends Migration {
|
|
30
|
+
/**
|
|
31
|
+
* The pivots, and the column naming the member on each.
|
|
32
|
+
*
|
|
33
|
+
* @var array<string, array{0: string, 1: string}>
|
|
34
|
+
*/
|
|
35
|
+
private array $pivots = [
|
|
36
|
+
'fleet_vehicles' => ['vehicle_uuid', 'fleet_vehicles_fleet_vehicle_unique'],
|
|
37
|
+
'fleet_drivers' => ['driver_uuid', 'fleet_drivers_fleet_driver_unique'],
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run the migrations.
|
|
42
|
+
*
|
|
43
|
+
* @return void
|
|
44
|
+
*/
|
|
45
|
+
public function up()
|
|
46
|
+
{
|
|
47
|
+
foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
|
|
48
|
+
if (!Schema::hasTable($table)) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
$this->removeDuplicateMemberships($table, $memberColumn);
|
|
53
|
+
|
|
54
|
+
if (!$this->indexExists($table, $indexName)) {
|
|
55
|
+
Schema::table($table, function (Blueprint $blueprint) use ($memberColumn, $indexName) {
|
|
56
|
+
$blueprint->unique(['fleet_uuid', $memberColumn], $indexName);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reverse the migrations.
|
|
64
|
+
*
|
|
65
|
+
* @return void
|
|
66
|
+
*/
|
|
67
|
+
public function down()
|
|
68
|
+
{
|
|
69
|
+
foreach ($this->pivots as $table => [$memberColumn, $indexName]) {
|
|
70
|
+
if (Schema::hasTable($table) && $this->indexExists($table, $indexName)) {
|
|
71
|
+
Schema::table($table, function (Blueprint $blueprint) use ($indexName) {
|
|
72
|
+
$blueprint->dropUnique($indexName);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Collapse every duplicated pair down to the one row worth keeping.
|
|
80
|
+
*
|
|
81
|
+
* An active row wins over a tombstone, because that is the membership the
|
|
82
|
+
* fleet actually has today. Among equals the lowest id wins, so the outcome
|
|
83
|
+
* is deterministic and a re-run is a no-op. When every row for a pair is
|
|
84
|
+
* soft-deleted, one is still kept — removing them all would turn a later
|
|
85
|
+
* re-assignment into a new row and lose the original membership's history.
|
|
86
|
+
*
|
|
87
|
+
* Only redundant pivot rows are removed. No fleet, vehicle or driver is
|
|
88
|
+
* touched, and no surviving membership changes state.
|
|
89
|
+
*/
|
|
90
|
+
private function removeDuplicateMemberships(string $table, string $memberColumn): void
|
|
91
|
+
{
|
|
92
|
+
$duplicatePairs = DB::table($table)
|
|
93
|
+
->select('fleet_uuid', $memberColumn)
|
|
94
|
+
->whereNotNull('fleet_uuid')
|
|
95
|
+
->whereNotNull($memberColumn)
|
|
96
|
+
->groupBy('fleet_uuid', $memberColumn)
|
|
97
|
+
->havingRaw('COUNT(*) > 1')
|
|
98
|
+
->get();
|
|
99
|
+
|
|
100
|
+
foreach ($duplicatePairs as $pair) {
|
|
101
|
+
$pair = (array) $pair;
|
|
102
|
+
|
|
103
|
+
$rows = DB::table($table)
|
|
104
|
+
->where('fleet_uuid', $pair['fleet_uuid'])
|
|
105
|
+
->where($memberColumn, $pair[$memberColumn])
|
|
106
|
+
// Active rows first, then oldest first.
|
|
107
|
+
->orderByRaw('CASE WHEN deleted_at IS NULL THEN 0 ELSE 1 END')
|
|
108
|
+
->orderBy('id')
|
|
109
|
+
->pluck('id');
|
|
110
|
+
|
|
111
|
+
$redundant = $rows->slice(1)->values();
|
|
112
|
+
|
|
113
|
+
if ($redundant->isNotEmpty()) {
|
|
114
|
+
DB::table($table)->whereIn('id', $redundant->all())->delete();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private function indexExists(string $table, string $index): bool
|
|
120
|
+
{
|
|
121
|
+
$database = DB::connection()->getDatabaseName();
|
|
122
|
+
|
|
123
|
+
return DB::table('information_schema.statistics')
|
|
124
|
+
->where('table_schema', $database)
|
|
125
|
+
->where('table_name', $table)
|
|
126
|
+
->where('index_name', $index)
|
|
127
|
+
->exists();
|
|
128
|
+
}
|
|
129
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Exceptions;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thrown when a public relationship input — `vendor`, `parent_fleet`, `zone`,
|
|
7
|
+
* and friends — names a resource that does not exist inside the authenticated
|
|
8
|
+
* company.
|
|
9
|
+
*
|
|
10
|
+
* A cross-company identifier is deliberately indistinguishable from a missing
|
|
11
|
+
* one: both raise this, so the response cannot be used to probe whether some
|
|
12
|
+
* other organization holds a given public id.
|
|
13
|
+
*/
|
|
14
|
+
class PublicRelationNotFoundException extends \Exception
|
|
15
|
+
{
|
|
16
|
+
/**
|
|
17
|
+
* The request key that failed to resolve, e.g. `parent_fleet`.
|
|
18
|
+
*/
|
|
19
|
+
private string $relation;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The public identifier that was supplied for that key.
|
|
23
|
+
*/
|
|
24
|
+
private ?string $identifier;
|
|
25
|
+
|
|
26
|
+
public function __construct(string $relation, ?string $identifier = null, ?\Throwable $previous = null)
|
|
27
|
+
{
|
|
28
|
+
$this->relation = $relation;
|
|
29
|
+
$this->identifier = $identifier;
|
|
30
|
+
|
|
31
|
+
parent::__construct(
|
|
32
|
+
sprintf('No %s resource found for the identifier provided.', str_replace('_', ' ', $relation)),
|
|
33
|
+
0,
|
|
34
|
+
$previous
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
public function getRelation(): string
|
|
39
|
+
{
|
|
40
|
+
return $this->relation;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public function getIdentifier(): ?string
|
|
44
|
+
{
|
|
45
|
+
return $this->identifier;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;
|
|
4
4
|
|
|
5
|
+
use Fleetbase\FleetOps\Exceptions\PublicRelationNotFoundException;
|
|
5
6
|
use Fleetbase\FleetOps\Models\Contact;
|
|
6
7
|
use Fleetbase\FleetOps\Models\Device;
|
|
7
8
|
use Fleetbase\FleetOps\Models\Driver;
|
|
@@ -23,16 +24,20 @@ use Illuminate\Validation\ValidationException;
|
|
|
23
24
|
|
|
24
25
|
trait ResolvesFleetOpsApiResources
|
|
25
26
|
{
|
|
26
|
-
protected function resolveUuid(string $modelClass, ?string $id): ?string
|
|
27
|
+
protected function resolveUuid(string $modelClass, ?string $id, ?string $companyUuid = null): ?string
|
|
27
28
|
{
|
|
28
29
|
if (empty($id)) {
|
|
29
30
|
return null;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
return $this->resolveModel($modelClass, $id)->uuid;
|
|
33
|
+
return $this->resolveModel($modelClass, $id, $companyUuid)->uuid;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
|
|
36
|
+
/**
|
|
37
|
+
* @param string|null $companyUuid the company to scope the lookup to; defaults
|
|
38
|
+
* to the session company
|
|
39
|
+
*/
|
|
40
|
+
protected function resolveModel(string $modelClass, string $id, ?string $companyUuid = null): Model
|
|
36
41
|
{
|
|
37
42
|
$instance = new $modelClass();
|
|
38
43
|
$query = $modelClass::query()->where(function ($query) use ($id, $instance) {
|
|
@@ -47,8 +52,10 @@ trait ResolvesFleetOpsApiResources
|
|
|
47
52
|
}
|
|
48
53
|
});
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
$companyUuid = $companyUuid ?? session('company');
|
|
56
|
+
|
|
57
|
+
if ($companyUuid && $this->modelHasColumn($instance, 'company_uuid')) {
|
|
58
|
+
$query->where($instance->qualifyColumn('company_uuid'), $companyUuid);
|
|
52
59
|
}
|
|
53
60
|
|
|
54
61
|
$model = $query->first();
|
|
@@ -114,17 +121,45 @@ trait ResolvesFleetOpsApiResources
|
|
|
114
121
|
return preg_match('/(^uuid$|_uuid$|Uuid$|UUID$)/', $key) === 1;
|
|
115
122
|
}
|
|
116
123
|
|
|
117
|
-
protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request): void
|
|
124
|
+
protected function applyPublicIdRelation(array &$input, string $requestKey, string $column, string $modelClass, $request, ?string $companyUuid = null): void
|
|
118
125
|
{
|
|
119
126
|
if (!$request->exists($requestKey)) {
|
|
120
127
|
return;
|
|
121
128
|
}
|
|
122
129
|
|
|
123
130
|
$input[$column] = filled($request->input($requestKey))
|
|
124
|
-
? $this->resolveUuid($modelClass, $request->input($requestKey))
|
|
131
|
+
? $this->resolveUuid($modelClass, $request->input($requestKey), $companyUuid)
|
|
125
132
|
: null;
|
|
126
133
|
}
|
|
127
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Apply a set of public-ID relationship inputs in one pass.
|
|
137
|
+
*
|
|
138
|
+
* `$map` is keyed by the public request key and holds `[column, modelClass]`,
|
|
139
|
+
* e.g. `['parent_fleet' => ['parent_fleet_uuid', Fleet::class]]`. A key that is
|
|
140
|
+
* absent from the request is left untouched; a key sent empty clears the column.
|
|
141
|
+
*
|
|
142
|
+
* Resolution failures are rethrown as a PublicRelationNotFoundException so the
|
|
143
|
+
* caller can say which input was at fault rather than answering with a bare
|
|
144
|
+
* "not found" that names no field.
|
|
145
|
+
*
|
|
146
|
+
* @param array<string, array{0: string, 1: class-string}> $map
|
|
147
|
+
*
|
|
148
|
+
* @throws PublicRelationNotFoundException
|
|
149
|
+
*/
|
|
150
|
+
protected function applyPublicIdRelations(array &$input, array $map, $request, ?string $companyUuid = null): void
|
|
151
|
+
{
|
|
152
|
+
foreach ($map as $requestKey => [$column, $modelClass]) {
|
|
153
|
+
try {
|
|
154
|
+
$this->applyPublicIdRelation($input, $requestKey, $column, $modelClass, $request, $companyUuid);
|
|
155
|
+
} catch (ModelNotFoundException $exception) {
|
|
156
|
+
$identifier = $request->input($requestKey);
|
|
157
|
+
|
|
158
|
+
throw new PublicRelationNotFoundException($requestKey, is_scalar($identifier) ? (string) $identifier : null, $exception);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
128
163
|
protected function allowedMorphTypes(): array
|
|
129
164
|
{
|
|
130
165
|
return [
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
namespace Fleetbase\FleetOps\Http\Controllers\Api\v1\Concerns;
|
|
4
|
+
|
|
5
|
+
use Illuminate\Http\Request;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Normalises the public `with` / `expand` parameters into an allowlisted set of
|
|
9
|
+
* real Eloquent relation names, and writes the result back onto the request.
|
|
10
|
+
*
|
|
11
|
+
* Core's `HasApiModelBehavior` reads `with`/`expand` itself and hands the value
|
|
12
|
+
* straight to `$result->load(...)`, with no allowlist and no mapping. Two things
|
|
13
|
+
* follow from that. A caller can name any string and reach `load()`, and an
|
|
14
|
+
* unknown one raises `RelationNotFoundException` — a 500 for a typo. And the
|
|
15
|
+
* public name is not always the relation name: `?with=subfleets` normalises to
|
|
16
|
+
* `subfleets`, while the relation is `subFleets`, so the documented expansion
|
|
17
|
+
* has never worked.
|
|
18
|
+
*
|
|
19
|
+
* Rewriting the request once, up front, fixes both at the only point where the
|
|
20
|
+
* query builder and the resource are guaranteed to agree on the list.
|
|
21
|
+
*/
|
|
22
|
+
trait ResolvesPublicExpansions
|
|
23
|
+
{
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the requested expansions and rewrite the request with them.
|
|
26
|
+
*
|
|
27
|
+
* @param array<string, string> $allowed public name => Eloquent relation name
|
|
28
|
+
*
|
|
29
|
+
* @return array<int, string> the resolved relation names, in request order
|
|
30
|
+
*/
|
|
31
|
+
protected function applyPublicExpansions(Request $request, array $allowed): array
|
|
32
|
+
{
|
|
33
|
+
$resolved = $this->resolvePublicExpansions($request, $allowed);
|
|
34
|
+
|
|
35
|
+
// Both keys are rewritten: Core reads `with` or `expand`, whichever is
|
|
36
|
+
// present, so leaving the other one holding raw input would put the
|
|
37
|
+
// unmapped name back in front of load().
|
|
38
|
+
$request->merge(['with' => $resolved]);
|
|
39
|
+
|
|
40
|
+
if ($request->exists('expand')) {
|
|
41
|
+
$request->merge(['expand' => $resolved]);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return $resolved;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @param array<string, string> $allowed
|
|
49
|
+
*
|
|
50
|
+
* @return array<int, string>
|
|
51
|
+
*/
|
|
52
|
+
protected function resolvePublicExpansions(Request $request, array $allowed): array
|
|
53
|
+
{
|
|
54
|
+
$requested = $this->publicExpansionInput($request);
|
|
55
|
+
$resolved = [];
|
|
56
|
+
|
|
57
|
+
foreach ($requested as $relation) {
|
|
58
|
+
$mapped = $this->mapPublicExpansion($relation, $allowed);
|
|
59
|
+
|
|
60
|
+
// An unsupported expansion is ignored rather than rejected. A 422
|
|
61
|
+
// would turn a harmless unknown name into a failed request for
|
|
62
|
+
// every generated client that sends a relation this version does
|
|
63
|
+
// not have yet, and the alternative — passing it through — is a
|
|
64
|
+
// 500 from Eloquent.
|
|
65
|
+
if ($mapped !== null && !in_array($mapped, $resolved, true)) {
|
|
66
|
+
$resolved[] = $mapped;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return $resolved;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Every accepted input shape flattened to one list of trimmed, non-empty paths.
|
|
75
|
+
*
|
|
76
|
+
* `?with=vendor`, `?with[]=vendor`, `?with=vendor,driver` and the `expand`
|
|
77
|
+
* alias of each all arrive here and leave identical.
|
|
78
|
+
*
|
|
79
|
+
* @return array<int, string>
|
|
80
|
+
*/
|
|
81
|
+
protected function publicExpansionInput(Request $request): array
|
|
82
|
+
{
|
|
83
|
+
$raw = $request->input('with');
|
|
84
|
+
|
|
85
|
+
if ($raw === null || $raw === '' || $raw === []) {
|
|
86
|
+
$raw = $request->input('expand');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if ($raw === null) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
$values = [];
|
|
94
|
+
foreach (is_array($raw) ? $raw : [$raw] as $entry) {
|
|
95
|
+
if (is_array($entry)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
foreach (explode(',', (string) $entry) as $part) {
|
|
100
|
+
$part = trim($part);
|
|
101
|
+
|
|
102
|
+
if ($part !== '') {
|
|
103
|
+
$values[] = $part;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return $values;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Map one public path onto its Eloquent relation path, or null if unsupported.
|
|
113
|
+
*
|
|
114
|
+
* Each dotted segment is resolved against the allowlist for the level it sits
|
|
115
|
+
* at, so `subfleets.drivers` is checked as `subfleets` then as `drivers` —
|
|
116
|
+
* a nested path cannot smuggle in a relation the top level would refuse.
|
|
117
|
+
*
|
|
118
|
+
* @param array<string, string> $allowed
|
|
119
|
+
*/
|
|
120
|
+
protected function mapPublicExpansion(string $relation, array $allowed): ?string
|
|
121
|
+
{
|
|
122
|
+
$segments = explode('.', $relation);
|
|
123
|
+
$mapped = [];
|
|
124
|
+
|
|
125
|
+
foreach ($segments as $index => $segment) {
|
|
126
|
+
$segment = trim($segment);
|
|
127
|
+
|
|
128
|
+
if ($segment === '') {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
$candidates = $index === 0 ? $allowed : $this->nestedExpansionAllowList();
|
|
133
|
+
$key = $this->matchExpansionKey($segment, $candidates);
|
|
134
|
+
|
|
135
|
+
if ($key === null) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
$mapped[] = $candidates[$key];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return implode('.', $mapped);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Relations reachable underneath an already-allowed relation.
|
|
147
|
+
*
|
|
148
|
+
* Deliberately shallow. Subfleets carry drivers and vehicles, which is the
|
|
149
|
+
* documented nested case; nothing here re-opens the tree, so
|
|
150
|
+
* `subfleets.subfleets` is refused and an expansion cannot recurse.
|
|
151
|
+
*
|
|
152
|
+
* @return array<string, string>
|
|
153
|
+
*/
|
|
154
|
+
protected function nestedExpansionAllowList(): array
|
|
155
|
+
{
|
|
156
|
+
return [
|
|
157
|
+
'drivers' => 'drivers',
|
|
158
|
+
'vehicles' => 'vehicles',
|
|
159
|
+
];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Match a requested segment against the allowlist, spelling-insensitively.
|
|
164
|
+
*
|
|
165
|
+
* `service_area`, `serviceArea` and `ServiceArea` all name the same relation,
|
|
166
|
+
* and so do `subfleets`, `subFleets` and `sub_fleets`. Comparing on
|
|
167
|
+
* case-folded, separator-stripped forms accepts every spelling a client or a
|
|
168
|
+
* generated SDK might produce without widening what is actually allowed.
|
|
169
|
+
*
|
|
170
|
+
* @param array<string, string> $candidates
|
|
171
|
+
*/
|
|
172
|
+
private function matchExpansionKey(string $segment, array $candidates): ?string
|
|
173
|
+
{
|
|
174
|
+
foreach ($candidates as $key => $relation) {
|
|
175
|
+
if ($this->expansionLookupKey($key) === $this->expansionLookupKey($segment)) {
|
|
176
|
+
return $key;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private function expansionLookupKey(string $value): string
|
|
184
|
+
{
|
|
185
|
+
return strtolower(str_replace(['_', '-'], '', $value));
|
|
186
|
+
}
|
|
187
|
+
}
|