Skip to content

Resolve resource links to the controller exposing the domain class - #16272

Open
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:fix/resource-link-controller-lookup
Open

Resolve resource links to the controller exposing the domain class#16272
codeconsole wants to merge 5 commits into
apache:8.0.xfrom
codeconsole:fix/resource-link-controller-lookup

Conversation

@codeconsole

@codeconsole codeconsole commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

A resource link derives its controller from the domain class name, so a controller not named after its domain class is never found:

class PeopleController extends RestfulController<Person> {
    PeopleController() { super(Person) }
}
<g:link resource="${person}">          <%-- targeted "person", which does not exist --%>

DefaultLinkGenerator now keeps a domain-class-to-controller index alongside the existing namespace index, built lazily and guarded by the same artefact-array identity check, so it costs nothing on the common path and rebuilds when the set of controllers changes.

A controller named after the domain class always wins. The index is consulted only when no such controller is registered, so an application following the naming convention is unaffected — including one that has both a plain BookController and a BookRestfulController extends RestfulController<Book>.

A controller's domain class is found by walking its supertypes for a generic type argument the mapping context recognises as a persistent entity. Superclasses and interfaces are both walked, so a base class or a Groovy trait at any depth works, without this class depending on the REST controller hierarchy:

class PeopleController extends RestfulController<Person> {}            // resolved
class WidgetsController extends WidgetControllerBase {}                // resolved through the base
class TagsController implements ResourceHolder<Tag> {}                 // resolved through a trait

A supertype declaring more than one persistent entity is ambiguous and is skipped rather than guessed at, with the walk continuing upwards. If no controller declares the domain class, or more than one does, the link falls back to the domain class name.

Applies only when resource is given a domain instance and GORM is configured; passing a controller name directly (resource: "book") is unchanged.

An association still held as a lazy proxy is rendered from the domain class rather than the instance, to avoid loading it. A domain class passed to the resource attribute resolves through the same rule, so a proxied and a loaded association produce the same href.

A resource link derived its controller from the domain class name via
PersistentEntity.getDecapitalizedName(), so a controller not named after
its domain class was never found. Adopting plural controller names, the
convention Rails uses and the one that makes wildcard REST mappings
practical, meant <g:link resource="${person}"> targeted a non-existent
"person" controller.

DefaultLinkGenerator now keeps a lazily built domain-class-to-controller
index alongside the existing namespace index, guarded by the same
artefact array identity check so it costs nothing on the common path and
rebuilds on a development-mode reload. A controller's domain class is
resolved by walking its superclass hierarchy for a generic type argument
the mapping context recognises as a persistent entity, so it works for
any generic REST base class at any depth without this class depending on
that hierarchy.

Resolution only applies when exactly one controller declares the domain
class; an ambiguous or absent declaration falls back to the previous
behaviour, keeping the change additive.
@codecov

codecov Bot commented Aug 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.70968% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 54.7733%. Comparing base (c1b532a) to head (4555348).
⚠️ Report is 192 commits behind head on 8.0.x.

Files with missing lines Patch % Lines
...org/grails/web/mapping/DefaultLinkGenerator.groovy 88.5246% 2 Missing and 5 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@                Coverage Diff                 @@
##                8.0.x     #16272        +/-   ##
==================================================
+ Coverage     54.7365%   54.7733%   +0.0368%     
- Complexity      20470      20523        +53     
==================================================
  Files            2103       2103                
  Lines          101077     101135        +58     
  Branches        17928      17944        +16     
==================================================
+ Hits            55326      55395        +69     
+ Misses          37876      37863        -13     
- Partials         7875       7877         +2     
Files with missing lines Coverage Δ
...ls/rest/render/util/AbstractLinkingRenderer.groovy 55.6962% <100.0000%> (ø)
...org/grails/web/mapping/DefaultLinkGenerator.groovy 83.3333% <88.5246%> (+2.6882%) ⬆️

... and 3 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The previous commit consulted the domain-class index before the naming
convention, so an application with both a BookController and a
BookRestfulController extending RestfulController<Book> had every
resource link retargeted at the latter. That is not additive, as its
commit message claimed, and it is not confined to g:link: the HAL, Atom
and vnd.error renderers all build hrefs through link(resource: instance),
so REST response bodies moved too. BookFunctionalSpec in the hibernate7
test app caught it on every JDK and indy combination.

The naming convention now wins whenever a controller with that name is
registered, reusing the namespace index as the lookup, so resolution only
applies where the previous behaviour pointed at a controller that does
not exist.

Two further corrections to the walk:

Interfaces are now traversed as well as superclasses. Groovy traits
compile to an interface, so a domain class declared by a trait was
invisible and the claim that any generic base class works at any depth
was not accurate.

A supertype declaring more than one persistent entity is skipped rather
than guessed at, and the walk continues upwards. A base parameterised on
a parent and a child resource previously indexed the controller under
whichever came first in declaration order.

Three of the specs proved nothing and have been replaced. The reset test
swapped in a new GrailsApplication, whose artefact array is a different
object, so the identity guard rebuilt the index whether or not the reset
ran. One assertion compared against a bare expression in a given: block,
where Spock applies no implicit condition. A third asserted only that a
link did not contain '/people/', which held for the toString() of the
fixture. The replacements fail without this change, and cover the
precedence rule, interface resolution and the ambiguous base class.

Adds a spec exercising the real RestfulController hierarchy in
grails-test-suite-uber, including the bounded type parameter shape
RestfulServiceController uses, which the stand-in fixtures did not reach.
An association rendered while still a lazy proxy took a different path
from one already loaded: AbstractLinkingRenderer passes the instance when
initialised, but the domain class name when it is a proxy, to avoid
loading it. Only the instance path resolved the controller, so the same
association rendered /authors/2 or /author/2 depending on the session's
fetch state.

A domain class passed to the resource attribute now resolves through the
same rule as an instance, and the renderer passes the associated entity's
class rather than its decapitalised name. Both paths agree, and the class
form keeps the naming convention precedence, so it is additive on the
same terms.

A string passed to the resource attribute is still treated as a literal
controller name, since it cannot be distinguished from one.
@testlens-app

testlens-app Bot commented Sep 2, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 4555348
▶️ Tests: 81061 executed
⚪️ Checks: 91/91 completed


Learn more about TestLens at testlens.app/docs.

private volatile Map<String, Set<String>> controllerNamespacesByName
private volatile GrailsClass[] cachedControllers

private volatile Map<String, Set<String>> controllerNamesByDomainClass

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should discuss this further on the weekly or mailing list. This assumes all applications use the same design here and it's formalizing something that may not be true for all applications. This is also a major shift, not necessarily a bad one, but it will require another milestone. Thus this should go into 8.1 or later.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — worth the weekly or the mailing list before this goes anywhere. No objection to holding it for a later milestone.

@bito-code-review

Copy link
Copy Markdown

The concern regarding the potential impact of this change on applications that do not follow the assumed naming conventions is noted. Since this introduces a shift in how resource links are resolved, it is reasonable to consider whether this should be targeted for a future milestone like 8.1 to allow for further discussion and validation across different application architectures.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants