feat(#108): resolve concrete Resource/model type from the controller return expression#253
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #253 +/- ##
============================================
- Coverage 92.38% 92.31% -0.08%
- Complexity 5489 5607 +118
============================================
Files 397 398 +1
Lines 14701 14983 +282
============================================
+ Hits 13582 13832 +250
- Misses 1119 1151 +32
|
Implementation decisions (record)
Controlled Vito experiment (clean tree, base
|
| base | PR | |
|---|---|---|
gen_exit / paths |
0 / 311 | 0 / 311 |
| ops that gained a 2xx schema | — | +42 — exactly the #106 prediction (26 under /api, 16 on web /…/json routes outside the survey prefix) |
| ops whose existing schema changed | — | 0 |
resource.response-ambiguous |
42 | 0 |
| other lint rules | path.parameter-undeclared 4, response.no-error 4, server.invalid-url 1, throws.transitive-missing 6 |
byte-identical |
Envelope spot-checks against real bodies: ProjectResource::collection(user()->projects()->get()) → plain {data} ✓; ServerResource::collection($project->servers()->simplePaginate(25)) → {data, links, meta} ✓; both behind $this->authorize(...) guard statements the scan correctly walks past.
Gates
composer test: 2225 passed (5555 assertions). (One first-run parallel flake in the fixture-copy path, the same pre-existing one noted in the feat(#12): infer Resource response schemas from the toArray() array literal #247 review; green on re-run.)composer lint(PHPStan level 8): no errors.vendor/bin/pint --test: passed.examples/api-resourcessnapshot diff is exactly the new annotation-freeGET /api/bookingsoperation; all 24 snapshot tests green.
🤖 Generated with Claude Code
Review #253 — empirical (worktree @
|
| Probe | Result |
|---|---|
early-return guard (if (!$x) return response()->json(…); return X::collection(…)) |
REFUSED via two-method-level-returns — never picks the guard's return; observed in the wild (Bagisto BookingProductController::config, NOTICE present) |
| ternary / match return | REFUSED (unrecognised shape) |
$x = A…; $x = B…; return $x / assign-inside-if-return-outside |
REFUSED (not exactly-once-unconditional) |
closure containing return before the real return |
resolves (closure scope correctly excluded) |
| aliased import / parent-class static call | resolve via NameResolver (BaseResource::collection() → BaseResource, faithful — that is what ::collection collects) |
self::collection() in a non-resource class |
REFUSED |
…->response()->setStatusCode(202) |
REFUSED + NOTICE (and unreachable via the entry condition anyway) |
paginate($request->integer('per_page')) (dynamic arg) |
paginated ✓ |
bare ->toResource() on a model with no conventional resource |
graceful REFUSAL + NOTICE naming the model |
new JsonResource($nonModelParam) |
REFUSED |
| PHPDoc generic vs disagreeing body | PHPDoc wins (also pinned by test) |
| memoisation | refusal NOTICE once per run ✓ |
Wiring checked: the locator's only construction sites (container scoped binding → autowired #[Scoped] reader; create() in the three lint-rule tests) are all updated; per-run caches sit behind the scoped lifecycle (Octane-safe); the model-target $ref path mirrors EloquentModelResponseResolver's qualifyKey(build()) exactly; the two lint-rule null-guards are semantically right (the model-only target legitimately has no resource to inspect). Example/docs/CHANGELOG diff is exactly the feature.
Survey — Layer A, serialized (#233 repair, per-app --only, links verified review-108 on all 11)
Baseline results-pr-251.json verified: generated today against 8566e71 (= this PR's merge base). PR run → results-pr-253.json.
| App | apiOps | respSchemas | completeness % | response-ambiguous |
gen_exit |
|---|---|---|---|---|---|
| AdvisingApp | 31 | 31 → 31 | 96.8 → 96.8 | 0 → 0 | 0 → 0 |
| AureusERP | 90 | 90 → 90 | 91.1 → 91.1 | 0 → 0 | 0 → 0 |
| Bagisto | 41 | 13 → 22 | 29.3 → 51.2 | 0 → 0 | 0 → 0 |
| BookStack | 86 | 86 → 86 | 94.2 → 94.2 | 0 → 0 | 0 → 0 |
| Coolify | 154 | 154 → 154 | 55.8 → 55.8 | 0 → 0 | 0 → 0 |
| InvoiceNinja | 522 | 519 → 519 | 88.3 → 88.3 | 0 → 0 | 0 → 0 |
| Koel | 170 | 170 → 169* | 82.4 → 82.4 | 1 → 0 | 0 → 0 |
| Lychee | 205 | 202 → 202 | 97.6 → 97.6 | 0 → 0 | 0 → 0 |
| Pelican | 158 | 158 → 158 | 96.2 → 96.2 | 1 → 1 | 0 → 0 |
| SpeedtestTracker | 8 | 8 → 8 | 100 → 100 | 0 → 0 | 0 → 0 |
| Vito (corpus tree, annotated scratch) | 144 | 144 → 144 | 87.5 → 87.5 | 25 → 0 | 0 → 0 |
* #254. Only other lint movement anywhere: resource.fields-undeclared +1 on Bagisto and Koel (newly reached resources with unreadable toArray() — correct). All other rules byte-identical per app. No crashes, no stderr.
Prediction (#106, minus the overcount caveat) vs actual:
- Vito: exact. Independently re-ran the controlled clean-tree experiment (scratch stashed, base
8566e71vs PR head, both regenerated): +42 ops gained a substantive 2xx (26 under/api, 16 web/…/json), 0 lost, 0 existing schemas changed, ambiguous 25 → 0 in-tree. Matches the PR-body experiment line for line. The 16 web-route gains sit outside the survey prefix and do not polluteapiOperations/responseSchemas(negative check ✓). On the corpus tree the in-prefix ops were already attribute-annotated (full-spec-proof scratch) and none of their schemas changed — a nice in-the-wild attribute-precedence confirmation. - Bagisto: +14 (9 in-prefix, 5
/adminweb) vs "up to 34". The feat(#12): infer Resource response schemas from the toArray() array literal #247 caveat confirmed: per-op base-vs-PR spec diff shows 14 gained / 0 lost / 0 changed; 31 distinct actions refused with NOTICEs (54× conditional-returns, 30× no-top-level-return-in-window, 102×new JsonResource(<unknown>)across accumulated runs) — OAPI-017 opportunity sizing across 11 OSS apps — prioritises #12 + an under-tracked lever-2 (body-level Resource type resolution) #106 counted call-shape presence anywhere in the body, not single-unconditional-return reachability. The feared Bagisto failure modes did not materialize: no crashes, no exotic-shape misresolutions, NOTICE volume bounded (once per action·run). - Koel: +0 substantive vs +1 — the class resolves, the fields don't (finding 4). Honest outcome.
Spot-checks against source (item schema · envelope · pagination): Bagisto GET /api/products (ProductResource::collection($products) from repository → rich 11+-prop item ✓, plain {data} — under-claims runtime pagination, see finding 2) · GET /api/categories/tree (repository tree → plain {data} ✓ correct) · GET /api/customer/addresses (relation collection → plain {data} ✓ correct) · Koel POST /api/songs/by-ids (collection via overridden SongResource::collection() → still the right item class ✓, plain {data} ✓ — getMany() returns a Collection) · Vito GET /api/projects (->get() → plain ✓) and …/cron-jobs (simplePaginate(25) → {data, links, meta} ✓), both resolving past authorize()/validateRoute() guard statements.
Net: +51 substantive operations corpus-wide (35 in-prefix equivalent on clean trees), zero regressions, exactly the multiplier #106 promised — the #12 reader now fires on collection endpoints it could never reach. Good ship.
🤖 Generated with Claude Code
|
Review polish landed in 543a1e2 — per finding: Finding 1 (base/abstract resource classes accepted) — Fixed. Finding 2 (paginator-preserving chain links flip the envelope) — Fixed. Nit 5 (refusal-note wording) — Fixed; the note now reads "is not the method's only return, so the resource type would be a guess", accurate for both conditional siblings and dead-code double returns. Nit 6 ( Findings 3 (cursor-envelope fidelity) and 4 (resolved-but-empty schema, #254/#255) left as filed follow-ups per the review. Gates: 🤖 Generated with Claude Code |
8566e71 to
e2be90f
Compare
543a1e2 to
f5599bf
Compare
2b2413f to
0fd71c0
Compare
…controller return expression Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…return expression When the signature names only a base resource type (JsonResource, bare ResourceCollection, AnonymousResourceCollection), ResourceClassLocator now consults the new ReturnExpressionResourceReader — a Tier-1 bounded scan of the method's return expression (first 10 statements, conditional contexts refused): - X::collection(...) / X::make(...) / new X(...) name the concrete resource; the two-statement $x = ...; return $x; form resolves through the single unconditional assignment. - ->toResource(X::class) / ->toResourceCollection(X::class) take the literal class argument; bare $model->toResource() on a Model-typed parameter resolves Laravel's own convention (#[UseResource], guessResourceName()). - new JsonResource($model) on a Model-typed parameter becomes a wrapped-model target: the response documents the model schema directly (ResourceTarget gains modelClass). - A @return …Collection<FooResource> docblock generic is read first and wins over the body. - A collection only claims the paginated {data, links, meta} envelope when its source visibly ends in a paginate()-family call; otherwise the new plain {data} envelope (ResourceEnvelopeFactory::unpaginatedCollection) documents it. - #[ResponseResource] continues to win; refusals keep today's behaviour plus one generation-log NOTICE per action and run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The api-resources flavor gains an annotation-free BookingController::index() returning BookingResource::collection(Booking::query()->latest()->paginate()) behind an AnonymousResourceCollection signature — the snapshot diff is exactly the new /api/bookings operation with the resolved item $ref and paginated envelope. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ving chain links
Finding 1: concreteResourceClass() now rejects the base JsonResource itself
and abstract subclasses, so JsonResource::collection(...) / AbstractX::collection(...)
refuse with the NOTICE and keep the resource.response-ambiguous signal alive
instead of silently documenting an empty {data} schema.
Finding 2: endsInPaginatingCall() looks through the paginator-preserving chain
links withQueryString()/appends()/withPath()/fragment() (all return $this and
only tweak generated URLs), so paginate(...)->withQueryString() keeps the
{data, links, meta} envelope; item-mapping through() stays excluded and falls
back to the plain envelope.
Nits: the multiple-returns refusal note no longer claims "conditional" for
dead-code double returns; the resource.response-ambiguous description (rule +
docs/linting.md row) now names all three satisfiers (#[ResponseResource],
#[Collects]/$collects, the return expression).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
02ea4e1 to
817fbed
Compare
Stack
#219 ← #225 ← #230 ← #235 ← #241 ← #247 ← #251 ← this (base:
fix/249-250-model-metadata-gaps).Closes #108.
What
When an action's signature gives only a base Resource type (
JsonResource, bareResourceCollection,AnonymousResourceCollection) — i.e.ResourceClassLocatorfinds nothing concrete today — resolve the concrete resource (or wrapped model) from the method's return expression: a bounded Tier-1 scan per epic #5, no dataflow. Per #106 this unlocks ~77 corpus operations (Vito 42, Bagisto 34, Koel 1) plus the 3->toResource()/new JsonResource($m)ops, feeding the #12toArray()reader through the existingSchemaFromResource::buildRef()seam.Plan
ResourceTargetgains?string $modelClass = null(wrapped-model targets) andbool $paginated = true(collection envelope choice);isAmbiguous()requires both class fields null. Lint rules that read$target->resourceClassafter the ambiguity check get a null-guard for model-only targets.ReturnExpressionResourceReader(Plugins\ApiResources\Support,@internal,#[Scoped], memoised per method so notes fire once per run):@return …<FooResource>generic is read first (no AST parse —DocBlockParser+TypeNodeResolver::genericValueClassalready do this); the body scan only runs when the docblock yields nothing. PHPDoc wins over the body when both resolve.MethodBodyScannerfirst-10-statements +StatementNodeFinderwithSkipConditionalContexts. The first top-levelreturnis the canonical one; a return whose expression is a plain variable resolves through the single unconditional assignment to that variable inside the scanned region (the two-statement case); multiple or conditional assignments refuse.X::collection(arg)(collection of X),X::make(…)/new X(…)(single X) whereXresolves via NameResolver to a concreteJsonResourcesubclass that is not aResourceCollection;->toResource(X::class)/->toResourceCollection(X::class)(explicit class argument is decisive); bare$param->toResource()where$paramis a Model-typed method parameter — the resource class resolves through Laravel's own convention (#[UseResource]attribute, thenguessResourceName()candidates);new JsonResource($param)(exactly the base class) with a Model-typed parameter → wrapped-model target (model component viaEloquentModelToSchema, single{data}envelope).->additional(…)on a matched shape is ignored (resource class stays certain; the extra envelope keys are not modelled). Any other chain link refuses with a note.toResourceCollectionreceiver chain) visibly ends inpaginate/simplePaginate/cursorPaginatekeeps the{data, links, meta}envelope; otherwise the collection documents as a plain{data: […]}envelope — pagination meta is only claimed when statically knowable. Signature/attribute-resolved collections keep today's paginated envelope (no body information; existing behaviour).#[ResponseResource]as the escape hatch.ResourceClassLocatorwidening (the only consumer-visible seam change): after the#[ResponseResource]check and the signature read, an ambiguous collection or an exactly-base singleJsonResourcereturn consults the reader; a null read keeps today's result (ambiguous target / empty base schema). Attribute precedence is pinned end-to-end by test.ResourceResponseResolver: model-target branch + unpaginated-collection envelope branch;ResourceEnvelopeFactory::unpaginatedCollection().tests/Feature/Plugins/ApiResources/ReturnExpressionResolutionTest.php(shapes:::collectionpaginated + unpaginated,::make,new X,->toResource()typed-param + explicit class,new JsonResource($m),@returngeneric incl. PHPDoc-vs-body precedence, two-statement, refused variable, refused conditional, chainedadditional, attribute-wins); unit tests for the reader undertests/Unit/Plugins/ApiResources/. Lint interplay:resource.response-ambiguousno longer fires for body-resolved collections.examples/api-resourcesgains one idiomaticBookingResource::collection()index route with no#[ResponseResource]; snapshot regenerated and diff verified to be exactly the new operation.docs/plugins.mdApiResources section +docs/auto-derivation.mdresponse-body row; CHANGELOG[Unreleased].Scope decisions (recorded as they land)
->toResource()receiver resolution implements the typed-parameter subset (plus the explicit-class-argument forms, which need no receiver type at all); inlineModel::find/…receiver chains degrade with a note — OAPI-017 opportunity sizing across 11 OSS apps — prioritises #12 + an under-tracked lever-2 (body-level Resource type resolution) #106 sized this whole sub-shape at 3 ops.->toResource()resolves the conventional resource (what the runtime actually serialises), not the raw model schema the issue text suggests — more faithful; falls back to refusal when no conventional resource exists (the runtime would throw there anyway).🤖 Generated with Claude Code