Repository navigation
fix(api): only real relations can be requested through with, expand and counts - #303
Merged
Merged
Conversation
with/expand/with_count/count names reach Eloquent's eager loader, which calls $model->newInstance()->$name() for any public method with no required arguments. The new case uses a model whose dispatch() writes a row (as fleetops' Order::dispatch() does) and requests it through every path: list, find and create; with as array and string, expand, a nested path, with_count, count, and the base Model's saveInstance(). It fails today: dispatch() runs on each path and every request ends in a 500.
…nd counts
Eloquent resolves an eager load or a count by calling
$model->newInstance()->{$name}(), so every request-supplied relation name
ran whatever public no-argument method it named on a blank model:
`with[]=saveInstance` inserted an empty row on any model, and on fleetops
orders `with[]=dispatch` saved an order and queued OrderDispatched.
Fleetbase\Support\RequestRelations now decides which names reach
Eloquent. A segment is accepted when the method, or the Expandable
closure registered under that name, declares an Eloquent Relation return
type (a union counts when one member is a Relation), or when the model
lists it in a new `$withable` array. The method must be public,
non-static, and need no arguments. 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, since its related
model is only known per result, and a morphTo is never counted. Names
come back as declared, so `subfleets` loads as `subFleets`. Rejected
names are dropped without an error.
The guard covers every place a client's names reach Eloquent:
- applyCustomFilters()
- withRelationships() and withCounts()
- the load()/loadCount() after create and update
- mutateModelWithRequest()
- TemplateQuery::execute(), whose stored `with` list is user-editable
withCounts() also accepts an array now; before, it was a TypeError.
`without` in mutateModelWithRequest() now uses makeHidden() instead of
setHidden(). setHidden() replaced the model's own hidden attributes
rather than adding to them.
Fixtures with untyped relations gain return types. The nested-path
expectation drops `childItems.grandChildren`, a relation that never
existed.
The coverage gate needs 100% per file. A typed relation that throws on a blank model is now tested (it is dropped), as is mutateModelWithRequest() on a non-model. The Expandable branch loses a Closure check that could never fail: isExpansion() only holds for a registered Closure.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
List, find, create and update endpoints pass the client's
with/expand/with_count/countnames to Eloquent as eager loads and counts, and nothing checks that the names are relations. To resolve each name, Eloquent'sBuilder::getRelation()runsRelation::noConstraints(fn () => $model->newInstance()->$name()). So a request can run any public method that takes no required arguments, on a blank model:with[]=saveInstanceinserts an empty row.Fleetbase\Models\Model::saveInstance()exists on every Fleetbase model.with[]=dispatchrunsOrder::dispatch(). It saves a blank order and queuesOrderDispatched, which fires webhooks.GET /int/v1/orders?with[]=dispatch, or the public v1 API with an API key.cancel()andnotifyDriverAssigned()work the same way, as do methods on other models across extensions.with_countcalls the method while the query is built, so it runs even when the result set is empty.This reached Eloquent through these paths:
applyCustomFilters()withRelationships()(nested paths were not checked at all)withCounts()load()/loadCount()after create and updatemutateModelWithRequest()TemplateQuery::execute(), whose storedwithlist comes unvalidated from the template builder andtemplate-queriesCRUDThe first commit adds a failing test that shows it. A model whose
dispatch()writes a row is requested through list, find and create; withwithas an array and as a string,expand, a nested path,with_count,count, andsaveInstance. Before the fix,dispatch()ran 3 times per request (list, find, create), wrote junk rows, and every case ended in an exception (a 500):The fix
Fleetbase\Support\RequestRelationsdecides which names reach Eloquent. It reads the method by reflection and never calls an unknown one.A segment is accepted only when the method is public, not static, and needs no arguments, and one of these holds:
Relationreturn type, for exampleBelongsToor?HasMany;Relationmember, for exampleBelongsTo|Builder(fleetopsOrder::driverAssigned(),vehicleAssigned(),facilitator(), andDriver::currentJob(),jobs()use this);Expandableclosure that declares such a return type;protected $withable = [...]array. This is for third-party extensions that can't add return types yet.Nested paths: each segment is checked against the related model, and the path is cut at the first segment that fails, so
children.dispatchloadschildren. Nothing after amorphTois kept, because its related model is only known per result. AmorphTois never counted. Depth is capped at 5 segments and the list at 50 paths.Names come back as declared.
subfleetsnow loads assubFleets, the keyFleetresources read withwhenLoaded('subFleets'). Before, it loaded undersubfleets, which never rendered.Malformed input is dropped: anything outside
[A-Za-z_][A-Za-z0-9_]*per segment, such asrelation:col,col, spaces or non-strings. No client uses the column-select syntax.Rejected names are dropped without an error. This matches the Postman contract for
GET /v1/vehicles/:id?with[]=vendor&with[]=not_a_relation.Also in this PR:
withCounts()now accepts an array; before, an array caused a TypeError.withoutusesmakeHidden()instead ofsetHidden().setHidden()replaced the model's own$hiddenlist, sowithout[]=xcould un-hide password-like columns on responses that serialize the model.normalizeRequestRelations()is removed. It was protected and had no callers outside the trait.#301 adds a similar private helper,
ApiModelCache::relationMethod()/declaresRelationReturn(). #301 is still open, so this PR doesn't depend on it. Once both merge,ApiModelCachecan callRequestRelations::relationMethod()and drop its copy. That's a one-line follow-up I can do on whichever lands second.Relations that stop being requestable
A survey of every client that sends
with/with_count(console, every engine, navigator-app, storefront-app, fleetbase-js, storefront-js, fleetbase-php) found no untyped core-api relation being requested.Company::users(with_counton the admin organizations list) is already typed.The untyped relations that clients request are all in extensions. Each has a companion PR adding return types. Those must merge with or before this one, or the screens below lose the nested data. They won't error.
driverAssigned.vehicle), order payload; public v1 documentedfleets?with=service_area,zone,vendor,parent_fleet,photo,subfleets,drivers,vehiclesanddrivers?with=vehicledev-v0.0.2: c56365d9 already typed all 157 relations. Every pallet consolewithset passesRequestRelationsunchanged (checked against this branch). The released pallet code, 55247cf9 and earlier, is untyped, so pallet must ship fromdev-v0.0.2before or with this release.Companion PRs:
ResolvesPublicRelationFields, which loaded anymethod_existsname vialoadMissing()These are not covered by a companion PR:
UserExpansion::billableCompany, registry-bridgeCategoryExpansion::registryExtensionsand samsaraCompanyExpansion::samsaraCredentialsare untyped Expansion closures. No client requests them aswith, so nothing breaks. Typing them is a follow-up.ServiceItemrequestsasset,issueandsupplier, but those methods don't exist. They're dropped today and stay dropped.Docs / API spec impact
withis documented, withexpandas an alias, on Fleets, Drivers, Vehicles and Trailers. Every documented value stays requestable once the fleetops PR lands: Vehicle and Trailer were already typed, and Fleet andDriver::vehicleare typed by that PR. The "unknown names are ignored" contract is unchanged, so no spec change is needed.content/docs/extension-development/backend/expansions-and-observers.mdx:120-149says an Expansion relation is usable inwith(). It now needs a declared return type on the closure (return function (): HasOne { ... }). The extension model docs should also say that a relation is only client-requestable when it declares aRelationreturn type or is listed in$withable. The API reference pages (fleets.mdx,vehicles.mdx,drivers.mdx, trailer guides) need no change.Not in this PR
NotificationRegistry::resolveNotifiable()turns a storeddynamic:<property>from company notification settings into$subject->{$property}/load($property). That calls the method on the real subject model, and a class definition is passed toapp(). Company-admin input only, and the existing tests rely on unregistered definitions resolving. I've split it into its own task.ResolvesPublicRelationFieldscheckswithwith onlymethod_existsbeforeloadMissing(). The fleetops companion PR fixes it with this helper.Validation
php -d memory_limit=1G vendor/bin/pest, full suite on PHP 8.4: 2079 passed, 0 failed. A rerun of the touched files on the final commits: 79 passed.tests/Unit/Support/RequestRelationsTest.phpcovers:$withable, case-insensitively;save,push,touch,delete,fresh,replicate), on both the root and the related model;morphTo; typed vs untyped Expandable closures; malformed input and the path cap.makeHiddenregression. A TemplateQuery test covers a stored side-effect name.php-cs-fixer fix --dry-runon the changed files: clean.phpstan(level max) on the new file: it flags only the dynamicExpandablestatic calls ($model::isExpansion()), the same pattern the existing code uses. The trait and model already carry many pre-existing level-max findings, so phpstan isn't a gate here.CI note
PHP CI's coverage gate requires 100% coverage of every file.
56f94208bringsRequestRelations.phpand this PR's lines inHasApiModelBehavior.phpto 100%.One gap remains that this PR didn't cause:
src/Support/ApiModelCache.php:519, thereturn []ingetSessionScope()when the request has no session. It came in with #300 and makes PHP CI fail on every branch cut fromrelease/v1.6.69(#301 and the storefront-logging PR as well). I left it alone because #301 rewritesApiModelCacheTest.php, and a fix here would conflict with it. It needs a one-line test there or in #301.