Resolve links to the most specific controller, namespace and URL mapping - #16272
Conversation
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 Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16272 +/- ##
==================================================
+ Coverage 57.8440% 58.6084% +0.7644%
- Complexity 22858 24307 +1449
==================================================
Files 2137 2175 +38
Lines 104666 107343 +2677
Branches 18808 19567 +759
==================================================
+ Hits 60543 62912 +2369
+ Misses 35761 35575 -186
- Partials 8362 8856 +494
🚀 New features to boost your workflow:
|
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.
|
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. |
…s domain RestfulController takes its resource class as a constructor argument, so a controller does not have to be named after the domain class it serves. Its API responses build the Location header from the controller's own name, but its HTML form responses call redirect instance, which derives the target from the domain class. On 8.0.x a MoviesController extending RestfulController<Film> answers an API save with /movies/show/1 and an HTML form save with /film/show/1, a controller that does not exist. Covers save and update from an HTML form and the API Location header, through ControllerUnitTest and a real mapping context rather than stubbed URL mappings. The two HTML features fail against the 8.0.x link generator; all three pass with resolution in place.
Both controller indexes in DefaultLinkGenerator were cached as two volatile fields, the index and the artefact array it was built from, written one after the other. Two requests racing a controller reload could interleave those writes so that an index built from the old controllers ended up stored beside the new array. The identity check then passed on every later call and the stale index was served until the set of controllers changed again. Each index is now published with its array as a single immutable holder through one volatile reference, so a reader always sees a matching pair and a stale pair fails the identity check and is rebuilt. The namespace index had the same shape before this branch copied it for the domain class index; both are fixed. The interleaving cannot be forced through the public API, so there is no deterministic test of the race itself. The existing specs covering rebuild on re-registration and after resetControllerNamespaceCache() pass unchanged.
controllerNameForResource was protected only so that a subclass could override the resolution policy, which nothing does. Making it private leaves the change with no new API: the link attributes, configuration and public and protected surface of DefaultLinkGenerator are unchanged.
…main A resource link resolved to the controller named after the domain class, or, failing that, the only controller declaring it. Where two controllers serve one domain class, such as an AssessmentController and a ManageAssessmentController in the manage namespace, every link and redirect for an Assessment went to AssessmentController, including the redirect after ManageAssessmentController saved one. That is the case reported in apache#14272, and the reason applications re-implement RestfulController's save, update and delete to redirect to themselves. The link now targets the most specific controller serving the domain class, a controller serving it when it is named after it or declares it as a generic type argument: 1. the controller handling the request, if it serves the domain class and is in the namespace the link targets 2. the only controller serving it in that namespace 3. the controller named after the domain class 4. the only controller declaring it The targeted namespace is the explicit namespace attribute, else the request's. A controller's redirect always names its own namespace, even a null one, so the explicit namespace does not bypass the first step. A level with more than one candidate falls through rather than guessing, and a link built outside a request, with no namespace, uses only the last two, as before. An explicit controller attribute still wins. The target now depends on the request context, so CachingLinkGenerator folds the request controller and namespace into the key of every resource link without an explicit controller, rather than only when a namespaced controller exists or no namespace was given. It also reads a resource nested in a url map, which was never folded. hasNamespacedControllers() was only there for the old condition and is removed; it has not been in a GA release. An application with one controller per domain class generates the same links as before. Where two controllers serve one domain class, a link to it rendered by one of them now stays there; the controller attribute still sends it elsewhere.
…t fit
When a link named an HTTP method and a mapping for that method existed
for the action but needed parameters the link did not supply, the
reverse lookup retried with any method and then discarded the result.
The link fell through to the $controller wildcard, so given
"/book-list/$page"(controller: 'book', action: 'list')
get "/books/featured/$slug"(controller: 'book', action: 'list')
a GET link to book/list with a page generated /book/list?page=2 rather
than /book-list/2, and in an application without the wildcard mapping,
a URL nothing serves. Every resource link and redirect names GET, so it
reached those too.
The retried lookup is now used.
When a link named a controller defined in more than one namespace without saying which, namespace inference preferred the non-namespaced controller over the one in the request's own namespace. A link to "user" rendered in the admin namespace went to the public UserController even where an admin one existed, and a link to "book" inside a v2 API went to the root BookController whenever there was one. The guide called same-named controllers in several namespaces a discouraged design, but administration areas and versioned APIs are what namespaces are for. A controller name now resolves the way code resolves a name, nearest scope first: the request's namespace if it defines the controller, then the default namespace, then the namespace of the only controller with that name. The current controller keeps its request namespace as before, and a plugin target is still not inferred. A name defined only in namespaces that are neither the default nor the request's is still ambiguous and still infers no namespace, but it is now reported with a warning, once per controller name until the controller cache is reset, naming the namespaces that define it.
Link generation relies on the reverse lookup preferring the most specific mapping for a target, but only some of that was tested. Pins it: a mapping naming the controller and action wins over the $controller wildcard and a redirect mapping is never used; a mapping naming the action wins over one capturing it; the mapping consuming the supplied parameters wins, and one needing a parameter that was not supplied is skipped; a mapping for the link's method wins over one for any method. Where two mappings are equally specific, such as /books/$id and /library/book/$id for the same action, the choice does not depend on the order they are declared in; the mapping attribute selects one explicitly. Each ordering-sensitive case is run with both declaration orders.
Namespace inference for a controller name and controller resolution for a resource link applied the same idea, nearest first, through two sets of rules with different tie-breaks. The resource rule looked in the request's namespace and then jumped to the controller named after the domain class in whatever namespace it lived, so outside a request, or from a namespace serving nothing for the domain class, a link passed over a controller in the default namespace for one named after the domain class in admin. Both now choose among their candidates with the same chain, from the nearest scope to the furthest: the controller handling the request, if it is a candidate in the targeted namespace; the candidate in that namespace; the candidate in the default namespace; the candidate in any namespace. A scope holding more than one candidate takes the one named after the domain class and is otherwise ambiguous, so the next scope is tried. Outside a request, the targeted namespace is the default one. For a controller name the candidates are the controllers with that name and the result is unchanged. For a domain class they are the controllers named after it or declaring it, and the default namespace now comes before a namespace further away.
A redirect naming no namespace had the issuing controller's namespace added to its arguments, which the link generator then treated as an explicit namespace. A redirect to a controller defined only in another namespace was forced into the issuing one, producing a URL for a controller that does not exist, while a link to the same controller from the same page inferred the right namespace. The two disagreed. A redirect to any other controller the application defines, or to a domain instance, is now resolved the way a link is, from the issuing controller's namespace: that namespace is set on the request while the redirect is issued and restored afterwards, so a target it defines is found there as before and a target it does not define is found where it is. A redirect to the issuing controller itself, or to a controller the application does not define, still passes the issuing namespace explicitly, so self-redirects and unit tests that construct controllers directly behave exactly as they did. The cross-namespace cases live in two specifications of their own, as controllers registered in a unit test stay registered for its whole specification.
CachingLinkGenerator keyed a link on its attributes and then tried to fold in each part of the request its resolution might read: the current controller for an action-only link, an inferred namespace, and, for a resource link, the request's controller and namespace under synthetic keys. Each change to resolution needed a matching change to that folding, and it had already fallen behind: a link naming no HTTP method is resolved for the current request's method, which was never folded, so a link rendered during a POST was served from the cache during a PUT with the URL for the POST mapping. DefaultLinkGenerator now resolves a link's target in one place, without changing its attributes: the controller, action and namespace, the HTTP method, and the id and parent resources of a resource. link() builds the URL from that, and the cache keys on it alongside the attributes, so the key covers whatever the resolution reads and the folding is gone. The resolution is reached through a package-private method, adding no API.
|
Thanks @codeconsole. Second round, at head Run locally at the head with
All five points from the last round are resolved:
1. The new upgrade-note example does not change in a
|
webRequest.controllerNamespace not set |
set to 'admin' in the test |
|
|---|---|---|
g.createLink / createLink / grailsLinkGenerator.link, 8.0.x |
/other/list |
/other/list |
| same, this PR | /other/list |
/admin/other/list |
redirect(controller: 'other', ...), both |
/admin/other/list |
/admin/other/list |
So the change shows where the request actually carries the namespace:
- at runtime: a page rendered by a namespaced controller, linking to a name no controller registers
- in a test that sets
webRequest.controllerNamespaceitself
A plain ControllerUnitTest is not one of those places. Suggested wording:
A name no registered controller has stays in the namespace the link is made from, where a link previously dropped the namespace; a redirect to such a name has always stayed in it. A page rendered by a controller in the
adminnamespace that links withcontroller: 'other'now generates/admin/other/listwhen noOtherControlleris registered, and so does a unit test that setswebRequest.controllerNamespaceitself. Passnamespaceto target another namespace.
That also names the admin namespace the example relies on, which the current sentence leaves the reader to infer. I would drop "Register the other controller in the test". The obvious way to do that is mockController(OtherController), which also switches webRequest.controllerName to other, so later links that name no controller in the same test would move with it.
This also makes the behaviour change smaller than I made it sound last round, which helps the list discussion.
Verified as correct
resolveLinkTargetwithnamesControllerand an entity instance goes to thehasIdbranch. There,resourceonly feeds the parent-resource tokens, which a class property name never has, so nothing changes for a proxy instance.ControllerIndex.EMPTYalso carries the two sets, but nothing can report against it: no names, no serving controllers.- The generated-controller templates in
grails-scaffolding/src/main/templates/scaffolding/still redirect withaction: "show"after the8.0.xmerges that reworked scaffolding generation, and no other controller template in the repository usesredirect ${propertyName}.
The upgrade note said a ControllerUnitTest of a namespaced controller now generates a link to an unregistered controller inside the namespace. It does not: the test harness sets the request's controller name but not its namespace, so such a link resolves from the default namespace as before. The change shows where the request carries the namespace, on a page a namespaced controller renders or in a test that sets it, and the note now says so.
|
Thanks @matrei, and good catch on the harness. It's corrected in |
matrei
left a comment
There was a problem hiding this comment.
Thanks @codeconsole. Third round, at head 5c4195c5a9. The only commit since the last round is the upgrade-note correction, so the code is identical to d63aac9d10, and the local runs from the last round still apply. CI is green on the head. The PR still merges cleanly into the current 8.0.x (fb94fe087e). The nine commits there since the merge base are the 8.0.0-RC1 release and #16407, and neither touches link generation, redirects or the docs this PR changes.
The corrected note reads right. Both additions are improvements: the action in the example, and the clause saying a ControllerUnitTest still generates /other/list. I checked the claim behind that clause again. GrailsWebUnitTest.mockController sets webRequest.controllerName and nothing else (GrailsWebUnitTest.groovy:146), and nothing in the testing support sets controllerNamespace. So a link in a ControllerUnitTest is resolved from the default namespace unless the test sets the namespace itself, as the note now says.
No further findings from me.
One FYI, since 8.0.0-RC1 shipped after the last round, from 8.0.x without this PR:
hasNamespacedControllers()is in RC1 as well as in M3 to M6, so my round-one "shipped only inv8.0.0-M3toM6" is out of date. The description's "has not been in a GA release" is still accurate.- The behaviour change now lands between RC1 and GA. That seems worth stating when this goes to the list, given the earlier discussion about timing.
jdaugherty
left a comment
There was a problem hiding this comment.
I ran resource-link scenarios in the namespaces, scaffolding-fields, views-functional-tests and hyphenated apps against this head and against the merge base (334007a8b5). The fixes hold in a running app: links and redirects for a renamed RestfulController, HAL self and association links from JSON views and from the HAL renderers, the fields plugin's association and "Add" links under a renamed @Scaffold controller, the link cache across HTTP methods, and the any-method mapping fallback. The existing specs in those four apps still pass.
Three problems are inline. One smaller gap: the rest-api profile's artifact template (grails-profiles/rest-api/templates/artifacts/RestfulController.groovy) generates a raw extends RestfulController with super(Domain). This resolution only reads generic type arguments, so a controller created from that template and later renamed still gets links to its old name. Moving the template to RestfulController<Domain> would match what the docs now tell users to do.
| String targetNamespace = explicitNamespace ? namespace : currentNamespace | ||
| String currentController = requestStateLookupStrategy.controllerName | ||
| if (currentController != null) { | ||
| ControllerRef current = new ControllerRef(currentController, currentNamespace) |
There was a problem hiding this comment.
Bug: under the hyphenated URL converter this scope never matches a multi-word controller name. With grails.web.url.converter: hyphenated, requestStateLookupStrategy.controllerName is the URL form (tour-desk), while controllerNamespace stays logical (backOffice) and the index holds logical names (tourDesk). current therefore never equals a candidate, and wherever this scope is what should decide, the link or redirect goes to a different controller.
Repro, with the hyphenated converter and a /$namespace/$controller/$action?/$id?(.$format)? mapping:
class CityGuidesController extends RestfulController<TourGuide> { // default namespace
CityGuidesController() { super(TourGuide) }
}
class TourDeskController extends RestfulController<TourGuide> {
static namespace = 'backOffice'
TourDeskController() { super(TourGuide) }
}
class GuideLedgerController extends RestfulController<TourGuide> {
static namespace = 'backOffice'
GuideLedgerController() { super(TourGuide) }
}| 8.0.x | this PR | |
|---|---|---|
form POST to /back-office/tour-desk/save, Location |
/backOffice/tour-guide/show/2 |
/city-guides/show/2 |
createLink(resource: guide, action: 'show') rendered by TourDeskController |
/tour-guide/show/1 |
/city-guides/show/1 |
same, rendered by GuideLedgerController |
/tour-guide/show/1 |
/city-guides/show/1 |
With this scope skipped, backOffice holds two candidates and neither is named after the domain class, so the default namespace wins. A user saving on TourDeskController lands on CityGuidesController's show page. Any multi-word controller that shares a scope with another controller serving the same domain class is affected.
Matching the request's name against grailsUrlConverter.toUrlElement(candidate.name) as well as the logical name put all three on tour-desk and guide-ledger in a local run. Could you fix this and cover it under the hyphenated converter, in LinkGeneratorResourceControllerSpec and in the hyphenated functional app?
Separately, and on 8.0.x as well: the namespace segment is generated in its logical form (/backOffice/tour-desk/show/1 after that change). It routes, as /back-office/... does, but a test expecting the hyphenated form will see it.
There was a problem hiding this comment.
Fixed in 97bbb16. The requesting controller now matches a candidate by its logical name or by that name as the URL converter writes it, in the resource scope chain and in the current-controller check of getDefaultNamespace, which had the same gap for plugin targets. Covered in LinkGeneratorResourceControllerSpec (your three controllers under HyphenatedUrlConverter, with real reverse mappings) and LinkGeneratorNamespaceInferenceSpec. Your HyphenatedLinkResolutionSpec is in f56bf2d and all 7 pass. The namespace segment is unchanged, so it still expects /backOffice/.
| String action) { | ||
| String actionElement = action != null && grailsUrlConverter != null ? grailsUrlConverter.toUrlElement(action) : action | ||
| Set<ControllerRef> serving = new HashSet<>() | ||
| for (Set<ControllerRef> candidates in [index.byName.get(derivedName), index.byDomainClass.get(entity.name)]) { |
There was a problem hiding this comment.
Two domain classes with the same simple name link to each other's controllers. The candidates here are the union of the controllers named after the entity's decapitalized name and the controllers declaring the entity, so a controller named item that declares a different Item still counts:
// namespaces/catalog/ItemController.groovy, default namespace
class ItemController extends RestfulController<namespaces.catalog.Item> { ... }
// namespaces/archive/ItemController.groovy
class ItemController extends RestfulController<namespaces.archive.Item> {
static namespace = 'archive'
...
}show link to |
rendered by | 8.0.x and this PR | should be |
|---|---|---|---|
an archive.Item |
a default-namespace controller | /item/show/1 |
/archive/item/show/1 |
an archive.Item |
catalog.ItemController |
/item/show/1 |
/archive/item/show/1 |
a catalog.Item |
archive.ItemController |
/archive/item/show/1 |
/item/show/1 |
These don't 404. /item/show/1 renders the catalog item that happens to share the id, so the user sees a different record without any error. It isn't new, but this PR defines the target as the controllers serving the domain class, and the index already records which class each controller declares. Could a by-name candidate that declares a different domain class be dropped? The scaffolding example app has this layout (com.example.User and com.example.community.User, each with its own UserController).
There was a problem hiding this comment.
| * than guessed at, so a base class parameterised on both a parent and a child resource does not | ||
| * index the controller under the wrong one.</p> | ||
| */ | ||
| private String domainClassNameFor(Class<?> controllerClass, MappingContext context) { |
There was a problem hiding this comment.
A controller that declares the domain class for another purpose takes that class's links for its whole namespace. This walk counts any superclass, interface or trait with a single entity type argument. nearestController then tries the request's namespace before the default one, so such a controller outranks the controller named after the domain class on every page in its namespace, not just on its own pages. The action check stops helping as soon as it has the action.
abstract class ReportBase<T> {} // src/main/groovy
class GadgetReportController extends ReportBase<Gadget> {
static namespace = 'admin'
def show(Long id) { render "Gadget Report ${id}" }
}
class GadgetController extends RestfulController<Gadget> { // default namespace
GadgetController() { super(Gadget) }
}from admin/PageController |
8.0.x | this PR |
|---|---|---|
createLink(resource: gadget, action: 'show') |
/gadget/show/1 |
/admin/gadgetReport/show/1 |
redirect gadget |
/admin/gadget/show/1 |
/admin/gadgetReport/show/1 |
8.0.x gets the link right, and nothing flags the change: the report's show receives the gadget's id and renders. A report, export or audit controller built on a generic base or trait parameterised on a domain class is an ordinary shape. Could the inference be limited to controllers that actually serve the resource (RestfulController, @Scaffold, or an explicit opt-in), or could the controller named after the domain class win over one that only declares it, outside the declaring controller's own pages?
There was a problem hiding this comment.
Fixed in 8b871cc. A controller serves a domain class only when it is named after it or extends RestfulController parameterised on it: directly, through a base such as RestfulServiceController, or through @Scaffold / static scaffold, which the injector compiles to RestfulController<Domain>. Generic bases and traits no longer count. RestfulController is matched by class name, since grails-rest-transforms depends on this module. In your LinkResolutionSpec (e8f0a33) the two GadgetReport rows now pass. I changed the row for the report's own page to expect /gadget/show/{gadget}, since the report no longer serves Gadget.
|
Suggested functional tests: link resolution in the Could you add these to this PR?
At The 5 that fail on this head:
Apply from the repository root with
|
|
Suggested functional tests: fields plugin links under a renamed scaffold, in Could you add these to this PR? All 140 tests in the app pass at Apply from the repository root with
|
|
Suggested functional tests: HAL links for renamed controllers, in Could you add these to this PR? All 56 tests in the app pass at Apply from the repository root with
|
|
Suggested functional tests: resource links under the hyphenated URL converter, in Could you add these to this PR? At The expectations pin the namespace segment as it is generated, Apply from the repository root with
|
A controller counted as serving a domain class when any superclass, interface or trait of it had that class as its only entity type argument. A report or export controller built on a generic base, and defining a show action of its own, therefore took the links to that class from every page in its namespace, ahead of the controller named after it, and received the id of a record it was never meant to show. A controller now serves a domain class when it is named after it or extends RestfulController parameterised on it, directly, through a base such as RestfulServiceController, or through @scaffold, which makes it extend one. RestfulController is named rather than referenced, since grails-rest-transforms depends on this module.
A controller named after a domain class counted as serving it even when it extended RestfulController parameterised on another domain class of the same simple name, from another package. With a catalog Item and an archive Item, each served by an ItemController in its own namespace, a link to either could resolve to the other's controller, which rendered whichever record had the same id, without any error. The index now records the domain class each controller serves, and a controller named after the entity that serves a different class is no longer a candidate.
Under the hyphenated URL converter a request mapped from its URL holds the controller name as the URL wrote it, tour-desk for TourDeskController, while the controller index and the namespace use the logical names. The controller handling the request therefore never matched a candidate, so the nearest scope was skipped: with two backOffice controllers serving a domain class alongside one in the default namespace, a form saved on /back-office/tour-desk redirected to the default namespace controller, and links rendered on either backOffice page went there too. The request's controller now matches a candidate by its logical name or by that name as the URL converter writes it, both in the resource scope chain and where a link naming the requesting controller keeps the request namespace.
The rest-api profile's create-restful-controller template extended the raw RestfulController and passed the domain class only to its constructor. A resource link finds the controller serving a domain class from RestfulController's type argument, so a controller generated from the template and later renamed still received links under its old name. The template now extends RestfulController<Domain>, as do the REST guide examples that showed the raw form.
LinkResolutionSpec, as suggested in review, renders links from pages in the default, admin, manage and archive namespaces, issues redirects and form saves, and requests each target to check it serves the linked record: a renamed RestfulController, two controllers serving one domain class in different namespaces, a controller name defined in the default and admin namespaces, a report built on a generic base, two domain classes with the same simple name, an association, and links naming no HTTP method next to an any-method mapping. The report's own page links to the controller serving the domain class, as a report built on a generic base does not serve it.
RenamedScaffoldLinksSpec, as suggested in review, serves Sailor through @scaffold(Sailor) DeckhandsController with no SailorController, and pins the one-to-many display links, the Add link of the one-to-many input, the to-one link back from the renamed controller, and the redirect after a form save.
RenamedControllerHalLinksSpec, as suggested in review, serves Magazine through JSON views and Journal through the HAL renderers, both referencing a Publisher, with no controller named after any of the three, and pins the instance and collection self links on both paths and the renderer's association link.
HyphenatedLinkResolutionSpec, as suggested in review, replaces TourGuideLinkSpec and its fixtures. It covers the same three controllers serving TourGuide, two of them in backOffice, and adds links from a page in the default namespace and to a multi-word action, alongside the links from each backOffice controller's own page and the redirect after a form save. The namespace segment is expected as generated, /backOffice/.
|
Added the four suggested specs, all passing:
690ed39: the rest-api |
✅ All tests passed ✅🏷️ Commit: f56bf2d Learn more about TestLens at testlens.app/docs. |
|
Thanks @codeconsole. Fourth round, at head Run locally at the head with
All three fixes hold.
Two nits, both about wording: 1. The namesake rule is broader than its doc and comment say
For the comment: 2. The
|
|
Looks like this isn't breaking anymore, so I'm going to merge / consider it approved. |
Apache Grails tagged 8.0.0-RC2 on 2026-09-29. RC1 has since been promoted to Maven Central, but RC2 is again in the ASF staging group only while its release vote runs, so the TEMP staging-repository entries in build.gradle stay and their comments are retargeted at RC2. RC2's BOM keeps Spring Boot 4.1.1 and Groovy 5.1.3; the cloud.wondrify asset-pipeline plugin follows the BOM to 5.2.0-RC3 (on Maven Central). RC2 itself is built on Gradle 9.8.0, the current release, so the wrapper moves from 9.7.1 to 9.8.0 to stay on the combination Grails tests. This is alignment rather than a requirement: RC2 still assembles on 9.7.1. .gitlab-ci.yml moves to bbb-build:grails-8--2026-10-02-140644, rebuilt today with the Grails 8.0.0-RC2 CLI. It also picks up the toolchain the current v4.0.x-release image has (Go 1.27.0, Node 22.23.2), which the previous grails-8 image predated. Behavior change that comes with RC2 (apache/grails-core#15967): Grails now sends browser-hardening headers on every response by default, so every bbb-web response gains X-Content-Type-Options: nosniff X-Frame-Options: SAMEORIGIN Referrer-Policy: strict-origin-when-cross-origin X-XSS-Protection: 0 The defaults are left in place. Joining through a cross-origin iframe keeps working: the join call answers with a redirect, and the framed document is the client served by nginx, which carries no such header. What changes is that a document rendered by bbb-web itself (for example the XML of /bigbluebutton/api) is no longer displayed inside a cross-origin frame. A deployment that needs that can opt out per header in /etc/bigbluebutton/bbb-web.properties, e.g. grails.security.headers.frame-options.enabled=false. Checked, nothing to do: the RC2 JSON date/time rendering change (apache/grails-core#16411) concerns grails.converters.JSON and JSON views, while bbb-web builds its JSON with groovy.json.JsonBuilder and ships no gson views; the link-resolution change (apache/grails-core#16272) has nothing to act on, bbb-web's only generated link is one redirect(action:). Verified together with the Tomcat pin in the next commit.
A resource link —
<g:link resource="${book}">,redirect book, or theselflink of a HAL, Atom or JSON:API response — derives its controller from the domain class name. A controller that serves a domain class under another name is never found, and where two controllers serve one domain class, every link and redirect goes to the one named after it.RestfulControllertakes its resource class as a constructor argument precisely so that its name need not match, yet on 8.0.x it disagrees with itself:MoviesControllersaveLocationheader/movies/show/1/movies/show/1/film/show/1/movies/show/1One rule: the nearest controller wins
A link now resolves to the nearest of the controllers it could target. For a
controllerlink those are the controllers with that name; for aresourcelink, the controllers serving the domain class — named after it, or declaring it as a generic type argument (RestfulController<T>,@Scaffold,RestfulServiceController<T>, any generic base class or trait) — that define the action the link targets: the action named, or the one its HTTP method maps to, a link naming neither being taken as aGET. So a controller declaring the domain class for another purpose, such as a report built on a generic base, is not sent links to ashowit does not have, even from its own pages, and a link to an action only it defines reaches it. Scopes are tried from the nearest to the furthest, as code resolves a name:A scope holding more than one candidate takes the one named after the domain class, and is otherwise ambiguous, so the next scope is tried rather than guessing. When no scope settles on one, the controller named after the domain class is assumed, as before, and a warning names the controllers that tied. A controller name no registered controller has stays in the namespace the link is made from. The targeted namespace is the
namespaceattribute when given, otherwise the request's; for a redirect, the namespace of the controller issuing it. An explicitcontrollerattribute still wins.Given an
AssessmentControllerand aManageAssessmentControllerin themanagenamespace, both servingAssessment:Assessmentrendered byAssessmentControllerAssessmentControllerManageAssessmentControllerManageAssessmentControllermanageManageAssessmentControllerAssessmentControllerAnd for a
controllerlink from theadminnamespace, with bothUserControllerandadmin/UserController:Because the rule lives in the link generator, it applies to every site that builds a URL from a domain object or class, including those that cannot pass a
controller:redirect instance, the HAL, Atom and vnd.error renderers, JSON views including the self link of a HAL collection, and the fields plugin's association links, one-to-many "Add" link included. An association still held as a lazy proxy resolves the same way as a loaded one. Controllers generated bygenerate-controllerandgenerate-async-controllernow redirect a form save or update to their ownshowaction, so a generated controller keeps working when renamed.Fixes #14272.
Redirects resolve like links
A redirect naming no namespace had the issuing controller's namespace added as an explicit one, so a redirect to a controller defined only in another namespace was forced into the issuing one, while a link to the same controller from the same page was not. A redirect naming no namespace is now resolved exactly as a link is, from the issuing controller's namespace, so it reaches a controller defined only elsewhere. Every redirect that worked before produces the same URL.
URL mappings: the most specific mapping wins
The reverse lookup already prefers a mapping naming the controller and action over the
$controllerwildcard, a literal action over$action, a mapping for the link's HTTP method over one for any method, and the mapping consuming the supplied parameters; a redirect mapping is never used, and declaration order does not change the choice. This is now pinned by a specification. One case did not hold: when a mapping for the link's method existed for the action but needed other parameters, the any-method retry was discarded and the link fell through to the wildcard, so/book-list/2came out as/book/list?page=2. Every resource link and redirect namesGET, so it reached those too. Fixed.Link cache
CachingLinkGeneratorkeyed a link on its attributes and folded in the parts of the request it expected resolution to read. It now keys on the resolved target — controller, namespace, action and HTTP method — so it covers whatever resolution depends on. That fixes a link naming no HTTP method, which is resolved for the current request's method and was served across methods from the cache. A link is also now cached per encoding: the same link generated in a second encoding came back escaped in the first.Performance
Link generation costs the same as on 8.0.x or less. Measured with a microbenchmark of 15 domain classes and 30 controllers under the default URL mapping, in a bound request, taking the median of 7 rounds, over two runs of each branch:
controllerlink, cache hitcontrollerlink, uncachedresourcelinkresourcelink namingGET, asredirect instancedoesThe cache keys on the resolved target, so a hit resolves the link. Resolution checks Groovy truth without the metaclass dispatch statically compiled code otherwise performs for a string, builds the key without a GString, and returns the only candidate without walking the scopes.
Behaviour change
An application with one controller per domain class and no controller name defined in more than one namespace generates the same links as before. Where two controllers serve one domain class, or a controller name is defined in several namespaces, a link rendered from one of them now stays there, and a redirect to a controller in another namespace reaches it rather than a URL for a controller that does not exist. Pass
controllerornamespaceto target something else explicitly. The upgrade notes and the guide cover both.hasNamespacedControllers(), which existed only for the old cache key and has not been in a GA release, is removed; the new resolution adds no API.Also fixes a race in which a request made during a controller reload could leave a controller index built from the old controllers in place until the next change.
To make a controller not named after a domain class the target of its resource links, declare the domain class on it: extend
RestfulController<Book>, or implement a generic interface or trait of your own parameterised on it.