Skip to content

Resolve links to the most specific controller, namespace and URL mapping - #16272

Merged
jdaugherty merged 45 commits into
apache:8.0.xfrom
codeconsole:fix/resource-link-controller-lookup
Sep 29, 2026
Merged

jdaugherty merged 45 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 — <g:link resource="${book}">, redirect book, or the self link 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.

RestfulController takes its resource class as a constructor argument precisely so that its name need not match, yet on 8.0.x it disagrees with itself:

class MoviesController extends RestfulController<Film> {
    MoviesController() { super(Film) }
}
MoviesController save 8.0.x This PR
API request, Location header /movies/show/1 /movies/show/1
HTML form, redirect /film/show/1 /movies/show/1

One rule: the nearest controller wins

A link now resolves to the nearest of the controllers it could target. For a controller link those are the controllers with that name; for a resource link, 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 a GET. So a controller declaring the domain class for another purpose, such as a report built on a generic base, is not sent links to a show it 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:

  1. the controller handling the request, if it is a candidate in the targeted namespace
  2. the candidate in that namespace
  3. the candidate in the default namespace
  4. 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 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 namespace attribute when given, otherwise the request's; for a redirect, the namespace of the controller issuing it. An explicit controller attribute still wins.

Given an AssessmentController and a ManageAssessmentController in the manage namespace, both serving Assessment:

Link to an Assessment rendered by Targets
AssessmentController AssessmentController
ManageAssessmentController ManageAssessmentController
another controller in manage ManageAssessmentController
any other controller, or no request AssessmentController

And for a controller link from the admin namespace, with both UserController and admin/UserController:

<g:link controller="user" action="list"/>   <%-- was /user/list, now /admin/user/list --%>

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 by generate-controller and generate-async-controller now redirect a form save or update to their own show action, 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 $controller wildcard, 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/2 came out as /book/list?page=2. Every resource link and redirect names GET, so it reached those too. Fixed.

Link cache

CachingLinkGenerator keyed 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:

8.0.x This PR
controller link, cache hit 242–278 ns 268–280 ns
controller link, uncached 526–583 ns 494–554 ns
resource link 545–605 ns 504–596 ns
resource link naming GET, as redirect instance does 706–727 ns 560–581 ns

The 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 controller or namespace to 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.

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 85.51724% with 42 lines in your changes missing coverage. Please review.
✅ Project coverage is 58.6084%. Comparing base (334007a) to head (f56bf2d).
⚠️ Report is 240 commits behind head on 8.0.x.

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

Impacted file tree graph

@@                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     
Files with missing lines Coverage Δ
.../src/main/groovy/grails/artefact/Controller.groovy 0.0000% <ø> (ø)
...b/grails/plugin/formfields/FormFieldsTagLib.groovy 82.6722% <100.0000%> (+0.0726%) ⬆️
...ls/rest/render/util/AbstractLinkingRenderer.groovy 55.6962% <100.0000%> (ø)
...json/view/api/internal/DefaultHalViewHelper.groovy 53.8235% <100.0000%> (ø)
.../main/groovy/grails/web/mapping/LinkGenerator.java 81.8182% <ø> (ø)
...y/org/grails/web/mapping/CachingLinkGenerator.java 81.0127% <100.0000%> (-0.3599%) ⬇️
...g/grails/web/mapping/DefaultUrlMappingsHolder.java 72.5738% <100.0000%> (+1.8987%) ⬆️
...org/grails/web/mapping/DefaultLinkGenerator.groovy 82.5688% <84.7273%> (+1.9236%) ⬆️

... and 120 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.
@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.

…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.
@codeconsole codeconsole changed the title Resolve resource links to the controller exposing the domain class Resolve resource links to the most specific controller serving the domain class Sep 24, 2026
…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.
@codeconsole codeconsole changed the title Resolve resource links to the most specific controller serving the domain class Resolve links to the most specific controller, namespace and URL mapping Sep 24, 2026
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.
@matrei

matrei commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Thanks @codeconsole. Second round, at head d63aac9d10. That covers the seven commits from e87bbf388f to e81621a6c2 plus two merges of 8.0.x, which are clean. CI is green on the head.

Run locally at the head with --no-build-cache after cleanTest:

Module Tests Result
grails-web-url-mappings 311 pass
grails-controllers 211 (1 skipped) pass
grails-scaffolding 79 pass

All five points from the last round are resolved:

  1. Explicit controller on a resource link: namesController now skips resolveResourceTarget in both the instance and the Class branch. The namespace pin no longer checks controllerAttribute == null, since resourceTarget is only set when the link was resolved. The blank-versus-absent Pamphlets case pins it well: the name alone is ambiguous there and only the resolution can pick print.
  2. Reported-once sets: they now live on ControllerIndex, and the new specs reload a controller on a plain DefaultLinkGenerator without calling resetControllerNamespaceCache(). Those specs would fail on the previous head.
  3. Unknown controller name: agreed on keeping it. The link/redirect symmetry argument holds. There is one problem with the new wording, though; see below.
  4. resolveNamespace now returns String, and the GString spec reads the namespace through GrailsWebRequest.controllerNamespace, as link generation does.
  5. LogCapture: slf4j-simple is gone and Logback is the only binding on testRuntimeClasspath. The currentControllerIndex() rename is a good catch.

1. The new upgrade-note example does not change in a ControllerUnitTest

upgrading80x.adoc:793 now says that in unit tests of a namespaced controller, g.createLink(controller: 'other', action: 'list') "now generates /admin/other/list rather than /other/list". That came from my point 3 last round, and I got it wrong. The test harness never puts the controller's namespace on the request: mockController sets controllerName but not controllerNamespace. So the link is resolved from the default namespace and comes out the same as before. I checked this with a throwaway spec on this head and on 8.0.x. It used an admin-namespaced controller, no OtherController registered, and a UrlMappings with a /$namespace/$controller/... mapping:

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.controllerNamespace itself

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 admin namespace that links with controller: 'other' now generates /admin/other/list when no OtherController is registered, and so does a unit test that sets webRequest.controllerNamespace itself. Pass namespace to 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

  • resolveLinkTarget with namesController and an entity instance goes to the hasId branch. There, resource only feeds the parent-resource tokens, which a class property name never has, so nothing changes for a proxy instance.
  • ControllerIndex.EMPTY also 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 with action: "show" after the 8.0.x merges that reworked scaffolding generation, and no other controller template in the repository uses redirect ${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.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Thanks @matrei, and good catch on the harness. It's corrected in 5c4195c5a9 with your wording, plus two small additions. The example link names its action, since the URL it shows ends in /list. And a clause says that a ControllerUnitTest, which leaves the request namespace unset, still generates /other/list, since the previous sentence claimed the opposite. "Register the other controller in the test" is gone.

@matrei matrei left a comment

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.

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 in v8.0.0-M3 to M6" 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 jdaugherty left a comment

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.

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)

@jdaugherty jdaugherty Sep 28, 2026 •

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.

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.

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.

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)]) {

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.

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).

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.

Fixed in d5b714c. The index records the domain class each controller serves, and a controller named after the entity that serves a different class is no longer a candidate. Covered in LinkGeneratorResourceControllerSpec, and the three Item rows of your LinkResolutionSpec (e8f0a33) now pass.

* 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) {

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.

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?

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.

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.

@jdaugherty

Copy link
Copy Markdown
Contributor

Suggested functional tests: link resolution in the namespaces app

Could you add these to this PR? LinkResolutionSpec renders links from pages in the default, admin, manage and archive namespaces, issues redirects and form saves with redirects turned off, and then requests each target to check it serves the linked record. It covers:

  • a RestfulController not named after its domain class (MoviesController<Film>): links, the redirect after a form save, and the JSON Location
  • two controllers serving one domain class in different namespaces (AssessmentController, manage/ManageAssessmentController), each link shape (action, method, a class, g:link, g:form, an explicit controller)
  • a controller name defined in the default namespace and in admin (AuthorController), a name no controller has, and a redirect from admin to a controller defined only in the default namespace
  • a report built on a generic base (admin/GadgetReportController extends ReportBase<Gadget>)
  • two domain classes with the same simple name, each with its own ItemController
  • a link to an association (screening.film)
  • a link naming no HTTP method, rendered by a GET, a POST and a GET again, and an any-method mapping (/home-list/$page) next to a GET-only mapping for the same action that needs other parameters

At 5c4195c5a9 the app's 61 tests pass except 5 rows. 27 of the spec's 55 fail on 8.0.x.

The 5 that fail on this head:

  • catalog.Item and archive.Item each have an ItemController declaring them, and a link to one reaches the other's controller (3 rows). /item/show/1 then renders the catalog item that shares the id. Dropping a by-name candidate that declares a different domain class makes all 3 pass in a local run.
  • admin/GadgetReportController takes the Gadget link and redirect gadget from admin/PageController (2 rows). These rows expect GadgetController, and the row for the report's own page expects it to keep linking to its own show; adjust them if this is settled differently.

Apply from the repository root with git apply; it applies cleanly to 5c4195c5a9.

namespaces patch (25 files)
diff --git a/grails-test-examples/namespaces/build.gradle b/grails-test-examples/namespaces/build.gradle
index 9e34eb7479..f3b0f73e24 100644
--- a/grails-test-examples/namespaces/build.gradle
+++ b/grails-test-examples/namespaces/build.gradle
@@ -61,6 +61,7 @@ dependencies {
 
 
     integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
+    integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client'
 }
 
 apply {
diff --git a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy b/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
index 7b385ab86f..27215ec2b8 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
+++ b/grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy
@@ -37,6 +37,18 @@ class UrlMappings {
             namespace = "frontend"
         }
 
+        "/manage/$controller/$action?/$id?(.$format)?"{
+            namespace = "manage"
+        }
+
+        "/archive/$controller/$action?/$id?(.$format)?"{
+            namespace = "archive"
+        }
+
+        "/home-submit"(controller: "home", action: "target", method: "POST")
+        "/home-list/$page"(controller: "home", action: "list")
+        "/home-by/$category"(controller: "home", action: "list", method: "GET")
+
         "/"(view:"/index")
         "500"(view:'/error')
     }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/AssessmentController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/AssessmentController.groovy
new file mode 100644
index 0000000000..ac056ffb96
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/AssessmentController.groovy
@@ -0,0 +1,35 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+import grails.rest.RestfulController
+
+class AssessmentController extends RestfulController<Assessment> {
+
+    LinkFixtureService linkFixtureService
+
+    AssessmentController() {
+        super(Assessment)
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/AuthorController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/AuthorController.groovy
new file mode 100644
index 0000000000..b5be646af9
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/AuthorController.groovy
@@ -0,0 +1,27 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+class AuthorController {
+
+    def index() {
+        render 'Root Author'
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/GadgetController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/GadgetController.groovy
new file mode 100644
index 0000000000..21eee9ac2b
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/GadgetController.groovy
@@ -0,0 +1,29 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+import grails.rest.RestfulController
+
+class GadgetController extends RestfulController<Gadget> {
+
+    GadgetController() {
+        super(Gadget)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/HomeController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/HomeController.groovy
new file mode 100644
index 0000000000..f1ed80c995
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/HomeController.groovy
@@ -0,0 +1,45 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+class HomeController {
+
+    LinkFixtureService linkFixtureService
+
+    def index() {
+        render 'Home'
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+
+    def methodLinks() {
+        render view: '/links/methodLinks'
+    }
+
+    def target() {
+        render 'Target'
+    }
+
+    def list() {
+        render "List ${params.page ?: ''}${params.category ?: ''}"
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/MoviesController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/MoviesController.groovy
new file mode 100644
index 0000000000..ee45ca6f9c
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/MoviesController.groovy
@@ -0,0 +1,29 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+import grails.rest.RestfulController
+
+class MoviesController extends RestfulController<Film> {
+
+    MoviesController() {
+        super(Film)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/AuthorController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/AuthorController.groovy
new file mode 100644
index 0000000000..c4cdad8bb2
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/AuthorController.groovy
@@ -0,0 +1,29 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.admin
+
+class AuthorController {
+
+    static namespace = 'admin'
+
+    def index() {
+        render 'Admin Author'
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/GadgetReportController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/GadgetReportController.groovy
new file mode 100644
index 0000000000..9d4202c285
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/GadgetReportController.groovy
@@ -0,0 +1,39 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.admin
+
+import namespaces.Gadget
+import namespaces.LinkFixtureService
+import namespaces.ReportBase
+
+class GadgetReportController extends ReportBase<Gadget> {
+
+    static namespace = 'admin'
+
+    LinkFixtureService linkFixtureService
+
+    def show(Long id) {
+        render "Gadget Report ${id}"
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
index c455fbccdd..2544b879e2 100644
--- a/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy
@@ -19,10 +19,15 @@
 
 package namespaces.admin
 
+import namespaces.Gadget
+import namespaces.LinkFixtureService
+
 class PageController {
 
     static namespace = "admin"
 
+    LinkFixtureService linkFixtureService
+
     def index() {
         render view: "/page/index", model: [pageTitle: "Admin Page"]
     }
@@ -46,4 +51,20 @@ class PageController {
     def redirectToRootReport() {
         redirect controller: "report", action: "index", namespace: null
     }
+
+    def resourceLinks() {
+        render view: "/links/resourceLinks", model: linkFixtureService.model(params)
+    }
+
+    def redirectToHome() {
+        redirect controller: "home", action: "index"
+    }
+
+    def redirectToAuthor() {
+        redirect controller: "author", action: "index"
+    }
+
+    def redirectToGadget(Long id) {
+        redirect Gadget.get(id)
+    }
 }
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/archive/ItemController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/archive/ItemController.groovy
new file mode 100644
index 0000000000..2fb82a0342
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/archive/ItemController.groovy
@@ -0,0 +1,38 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.archive
+
+import grails.rest.RestfulController
+import namespaces.LinkFixtureService
+
+class ItemController extends RestfulController<Item> {
+
+    static namespace = 'archive'
+
+    LinkFixtureService linkFixtureService
+
+    ItemController() {
+        super(Item)
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/catalog/ItemController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/catalog/ItemController.groovy
new file mode 100644
index 0000000000..b5027cad23
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/catalog/ItemController.groovy
@@ -0,0 +1,36 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.catalog
+
+import grails.rest.RestfulController
+import namespaces.LinkFixtureService
+
+class ItemController extends RestfulController<Item> {
+
+    LinkFixtureService linkFixtureService
+
+    ItemController() {
+        super(Item)
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/DashboardController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/DashboardController.groovy
new file mode 100644
index 0000000000..d23fcb66a9
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/DashboardController.groovy
@@ -0,0 +1,38 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.manage
+
+import namespaces.Film
+import namespaces.LinkFixtureService
+
+class DashboardController {
+
+    static namespace = 'manage'
+
+    LinkFixtureService linkFixtureService
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+
+    def redirectToFilm(Long id) {
+        redirect Film.get(id)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/ManageAssessmentController.groovy b/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/ManageAssessmentController.groovy
new file mode 100644
index 0000000000..ce1d70862b
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/controllers/namespaces/manage/ManageAssessmentController.groovy
@@ -0,0 +1,39 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.manage
+
+import grails.rest.RestfulController
+import namespaces.Assessment
+import namespaces.LinkFixtureService
+
+class ManageAssessmentController extends RestfulController<Assessment> {
+
+    static namespace = 'manage'
+
+    LinkFixtureService linkFixtureService
+
+    ManageAssessmentController() {
+        super(Assessment)
+    }
+
+    def links() {
+        render view: '/links/resourceLinks', model: linkFixtureService.model(params)
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/Assessment.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/Assessment.groovy
new file mode 100644
index 0000000000..2b055aeacf
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/Assessment.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+class Assessment {
+
+    String title
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/Film.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/Film.groovy
new file mode 100644
index 0000000000..c68647b221
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/Film.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+class Film {
+
+    String title
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/Gadget.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/Gadget.groovy
new file mode 100644
index 0000000000..a757dec5dc
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/Gadget.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+class Gadget {
+
+    String name
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/Screening.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/Screening.groovy
new file mode 100644
index 0000000000..aa3ac05f2e
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/Screening.groovy
@@ -0,0 +1,24 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package namespaces
+
+class Screening {
+
+    Film film
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/archive/Item.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/archive/Item.groovy
new file mode 100644
index 0000000000..7d62ce53f6
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/archive/Item.groovy
@@ -0,0 +1,33 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.archive
+
+/**
+ * Shares its simple name, and so its decapitalized name, with {@link namespaces.catalog.Item}.
+ */
+class Item {
+
+    String name
+
+    static mapping = {
+        autoImport false
+        table 'archive_item'
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/domain/namespaces/catalog/Item.groovy b/grails-test-examples/namespaces/grails-app/domain/namespaces/catalog/Item.groovy
new file mode 100644
index 0000000000..70de2f00b2
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/domain/namespaces/catalog/Item.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces.catalog
+
+class Item {
+
+    String name
+}
diff --git a/grails-test-examples/namespaces/grails-app/services/namespaces/LinkFixtureService.groovy b/grails-test-examples/namespaces/grails-app/services/namespaces/LinkFixtureService.groovy
new file mode 100644
index 0000000000..ea26eb9a7f
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/services/namespaces/LinkFixtureService.groovy
@@ -0,0 +1,37 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+import grails.gorm.transactions.ReadOnly
+
+class LinkFixtureService {
+
+    @ReadOnly
+    Map model(Map params) {
+        [
+                assessment : Assessment.get(params.assessmentId as Long),
+                film       : Film.get(params.filmId as Long),
+                gadget     : Gadget.get(params.gadgetId as Long),
+                catalogItem: namespaces.catalog.Item.get(params.catalogItemId as Long),
+                archiveItem: namespaces.archive.Item.get(params.archiveItemId as Long),
+                screening  : Screening.get(params.screeningId as Long)
+        ]
+    }
+}
diff --git a/grails-test-examples/namespaces/grails-app/views/links/methodLinks.gsp b/grails-test-examples/namespaces/grails-app/views/links/methodLinks.gsp
new file mode 100644
index 0000000000..0748857336
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/views/links/methodLinks.gsp
@@ -0,0 +1,28 @@
+<%--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you under the Apache License, Version 2.0 (the
+  ~  "License"); you may not use this file except in compliance
+  ~  with the License.  You may obtain a copy of the License at
+  ~
+  ~    https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~  Unless required by applicable law or agreed to in writing,
+  ~  software distributed under the License is distributed on an
+  ~  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~  KIND, either express or implied.  See the License for the
+  ~  specific language governing permissions and limitations
+  ~  under the License.
+  --%>
+<!doctype html>
+<html>
+<head>
+    <title>Method Links</title>
+</head>
+<body>
+<a id="targetLink" href="${createLink(controller: 'home', action: 'target')}">Target</a>
+<a id="pagedListLink" href="${createLink(controller: 'home', action: 'list', params: [page: 2])}">Page 2</a>
+</body>
+</html>
diff --git a/grails-test-examples/namespaces/grails-app/views/links/resourceLinks.gsp b/grails-test-examples/namespaces/grails-app/views/links/resourceLinks.gsp
new file mode 100644
index 0000000000..d68e2455f4
--- /dev/null
+++ b/grails-test-examples/namespaces/grails-app/views/links/resourceLinks.gsp
@@ -0,0 +1,44 @@
+<%--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you under the Apache License, Version 2.0 (the
+  ~  "License"); you may not use this file except in compliance
+  ~  with the License.  You may obtain a copy of the License at
+  ~
+  ~    https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~  Unless required by applicable law or agreed to in writing,
+  ~  software distributed under the License is distributed on an
+  ~  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~  KIND, either express or implied.  See the License for the
+  ~  specific language governing permissions and limitations
+  ~  under the License.
+  --%>
+<%@ page import="namespaces.Assessment" %>
+<!doctype html>
+<html>
+<head>
+    <title>Resource Links</title>
+</head>
+<body>
+<a id="assessmentShowLink" href="${createLink(resource: assessment, action: 'show')}">Assessment</a>
+<a id="assessmentEditLink" href="${createLink(resource: assessment, action: 'edit')}">Edit Assessment</a>
+<a id="assessmentNoActionLink" href="${createLink(resource: assessment)}">Assessment, no action</a>
+<a id="assessmentGetLink" href="${createLink(resource: assessment, method: 'GET')}">Assessment, GET</a>
+<a id="assessmentExplicitLink" href="${createLink(resource: assessment, action: 'show', controller: 'assessment')}">Assessment, explicit controller</a>
+<a id="assessmentClassLink" href="${createLink(resource: Assessment, action: 'show', id: assessment.id)}">Assessment, by class</a>
+<a id="assessmentIndexLink" href="${createLink(resource: Assessment, action: 'index')}">Assessments</a>
+<g:link elementId="assessmentTagLink" resource="${assessment}" action="show">Assessment, tag</g:link>
+<g:form name="assessmentForm" resource="${assessment}" action="update" method="PUT"></g:form>
+<a id="filmShowLink" href="${createLink(resource: film, action: 'show')}">Film</a>
+<a id="gadgetShowLink" href="${createLink(resource: gadget, action: 'show')}">Gadget</a>
+<a id="catalogItemShowLink" href="${createLink(resource: catalogItem, action: 'show')}">Catalog item</a>
+<a id="archiveItemShowLink" href="${createLink(resource: archiveItem, action: 'show')}">Archive item</a>
+<a id="screeningFilmLink" href="${createLink(resource: screening?.film, action: 'show')}">Screening film</a>
+<a id="authorLink" href="${createLink(controller: 'author', action: 'index')}">Author</a>
+<a id="homeLink" href="${createLink(controller: 'home', action: 'index')}">Home</a>
+<a id="unknownLink" href="${createLink(controller: 'nowhere', action: 'list')}">Unknown</a>
+</body>
+</html>
diff --git a/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/LinkResolutionSpec.groovy b/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/LinkResolutionSpec.groovy
new file mode 100644
index 0000000000..99e6f751b1
--- /dev/null
+++ b/grails-test-examples/namespaces/src/integration-test/groovy/namespaces/LinkResolutionSpec.groovy
@@ -0,0 +1,216 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package namespaces
+
+import java.net.http.HttpClient
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import spock.lang.Shared
+import spock.lang.Specification
+import spock.lang.Tag
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.TestHttpResponse
+
+/**
+ * Pins where links and redirects built from a controller name or a domain instance land in a running
+ * application, across namespaces, controllers not named after their domain class, and controllers
+ * sharing a name.
+ */
+@Integration(applicationClass = Application)
+@Tag('http-client')
+class LinkResolutionSpec extends Specification implements HttpClientSupport {
+
+    @Shared
+    Map<String, Long> ids = [:]
+
+    void setup() {
+        if (!ids) {
+            Assessment.withNewTransaction {
+                ids.assessment = new Assessment(title: 'Quarterly').save(flush: true, failOnError: true).id
+                Film film = new Film(title: 'Jaws').save(flush: true, failOnError: true)
+                ids.film = film.id
+                ids.screening = new Screening(film: film).save(flush: true, failOnError: true).id
+                ids.gadget = new Gadget(name: 'Widget').save(flush: true, failOnError: true).id
+                ids.catalogItem = new namespaces.catalog.Item(name: 'Catalog').save(flush: true, failOnError: true).id
+                ids.archiveItem = new namespaces.archive.Item(name: 'Archive').save(flush: true, failOnError: true).id
+            }
+        }
+    }
+
+    @Unroll
+    void "#element rendered by #page targets #expected"(String page, String element, String expected, String lands) {
+        when: 'the page is rendered'
+        TestHttpResponse response = http("${page}?${fixtureQuery()}")
+
+        then:
+        response.statusCode() == 200
+
+        when:
+        String target = linkTarget(response.body() as String, element)
+
+        then: 'the link targets the expected controller'
+        target == contextPath + fill(expected)
+
+        and: 'the target serves the linked resource'
+        lands == null || bodyAt(target).contains(lands)
+
+        where:
+        page                             | element                  || expected                                       | lands
+        '/home/links'                    | 'assessmentShowLink'     || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/home/links'                    | 'assessmentEditLink'     || '/assessment/edit/{assessment}'                | null
+        '/home/links'                    | 'assessmentNoActionLink' || '/assessment/index/{assessment}'               | null
+        '/home/links'                    | 'assessmentGetLink'      || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/home/links'                    | 'assessmentClassLink'    || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/home/links'                    | 'assessmentIndexLink'    || '/assessment/index'                            | null
+        '/home/links'                    | 'assessmentTagLink'      || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/home/links'                    | 'assessmentForm'         || '/assessment/update/{assessment}'              | null
+        '/home/links'                    | 'filmShowLink'           || '/movies/show/{film}'                          | 'Jaws'
+        '/home/links'                    | 'gadgetShowLink'         || '/gadget/show/{gadget}'                        | 'Widget'
+        '/home/links'                    | 'catalogItemShowLink'    || '/item/show/{catalogItem}'                     | 'Catalog'
+        '/home/links'                    | 'archiveItemShowLink'    || '/archive/item/show/{archiveItem}'             | 'Archive'
+        '/home/links'                    | 'screeningFilmLink'      || '/movies/show/{film}'                          | 'Jaws'
+        '/home/links'                    | 'authorLink'             || '/author/index'                                | 'Root Author'
+        '/home/links'                    | 'unknownLink'            || '/nowhere/list'                                | null
+        '/assessment/links'              | 'assessmentShowLink'     || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/assessment/links'              | 'assessmentNoActionLink' || '/assessment/index/{assessment}'                 | null
+        '/assessment/links'              | 'assessmentIndexLink'    || '/assessment/index'                            | null
+        '/manage/manageAssessment/links' | 'assessmentShowLink'     || '/manage/manageAssessment/show/{assessment}'   | 'Quarterly'
+        '/manage/manageAssessment/links' | 'assessmentEditLink'     || '/manage/manageAssessment/edit/{assessment}'   | null
+        '/manage/manageAssessment/links' | 'assessmentNoActionLink' || '/manage/manageAssessment/index/{assessment}'  | null
+        '/manage/manageAssessment/links' | 'assessmentGetLink'      || '/manage/manageAssessment/show/{assessment}'   | 'Quarterly'
+        '/manage/manageAssessment/links' | 'assessmentClassLink'    || '/manage/manageAssessment/show/{assessment}'   | 'Quarterly'
+        '/manage/manageAssessment/links' | 'assessmentIndexLink'    || '/manage/manageAssessment/index'               | null
+        '/manage/manageAssessment/links' | 'assessmentTagLink'      || '/manage/manageAssessment/show/{assessment}'   | 'Quarterly'
+        '/manage/manageAssessment/links' | 'assessmentForm'         || '/manage/manageAssessment/update/{assessment}' | null
+        '/manage/manageAssessment/links' | 'assessmentExplicitLink' || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/manage/dashboard/links'        | 'assessmentShowLink'     || '/manage/manageAssessment/show/{assessment}'   | 'Quarterly'
+        '/manage/dashboard/links'        | 'filmShowLink'           || '/movies/show/{film}'                          | 'Jaws'
+        '/manage/dashboard/links'        | 'authorLink'             || '/author/index'                                | 'Root Author'
+        '/manage/dashboard/links'        | 'unknownLink'            || '/manage/nowhere/list'                         | null
+        '/admin/page/resourceLinks'      | 'assessmentShowLink'     || '/assessment/show/{assessment}'                | 'Quarterly'
+        '/admin/page/resourceLinks'      | 'filmShowLink'           || '/movies/show/{film}'                          | 'Jaws'
+        '/admin/page/resourceLinks'      | 'gadgetShowLink'         || '/gadget/show/{gadget}'                        | 'Widget'
+        '/admin/page/resourceLinks'      | 'authorLink'             || '/admin/author/index'                          | 'Admin Author'
+        '/admin/page/resourceLinks'      | 'homeLink'               || '/home/index'                                  | 'Home'
+        '/admin/page/resourceLinks'      | 'unknownLink'            || '/admin/nowhere/list'                          | null
+        '/admin/gadgetReport/links'      | 'gadgetShowLink'         || '/admin/gadgetReport/show/{gadget}'            | 'Gadget Report'
+        '/archive/item/links'            | 'archiveItemShowLink'    || '/archive/item/show/{archiveItem}'             | 'Archive'
+        '/archive/item/links'            | 'catalogItemShowLink'    || '/item/show/{catalogItem}'                     | 'Catalog'
+        '/item/links'                    | 'catalogItemShowLink'    || '/item/show/{catalogItem}'                     | 'Catalog'
+        '/item/links'                    | 'archiveItemShowLink'    || '/archive/item/show/{archiveItem}'             | 'Archive'
+    }
+
+    @Unroll
+    void "#description redirects to #expected"(String description, String path, String form, String expected, String lands) {
+        given: 'a client that reports redirects rather than following them'
+        HttpClient client = newHttpClientWith { followRedirects(HttpClient.Redirect.NEVER) }
+
+        when:
+        TestHttpResponse response = form != null ?
+                httpPost([:], fill(path), form, 'application/x-www-form-urlencoded', client) :
+                http(fill(path), client)
+
+        then:
+        response.statusCode() == 302
+
+        when:
+        String target = new URI(response.headerValue('Location')).path
+
+        then: 'the redirect targets the expected controller'
+        target ==~ Pattern.quote(contextPath) + fill(expected).split(/\{new\}/, -1).collect { Pattern.quote(it) }.join(/\d+/)
+
+        and: 'the target serves the resource'
+        lands == null || bodyAt(target).contains(lands)
+
+        where:
+        description                                               | path                                      | form           || expected                                   | lands
+        'a form save on a renamed RestfulController'              | '/movies/save'                            | 'title=Alien'  || '/movies/show/{new}'                        | 'Alien'
+        'a form save on a namespaced RestfulController'           | '/manage/manageAssessment/save'           | 'title=Annual' || '/manage/manageAssessment/show/{new}'       | 'Annual'
+        'a form save on the controller named after the domain'    | '/assessment/save'                        | 'title=Weekly' || '/assessment/show/{new}'                    | 'Weekly'
+        'a form save on a namespaced same-named controller'       | '/archive/item/save'                      | 'name=Old'     || '/archive/item/show/{new}'                  | 'Old'
+        'a form save on the default-namespace same-named one'     | '/item/save'                              | 'name=New'     || '/item/show/{new}'                          | 'New'
+        'an admin redirect to a controller only in the default'   | '/admin/page/redirectToHome'              | null           || '/home/index'                               | 'Home'
+        'an admin redirect to a name in the default and admin'    | '/admin/page/redirectToAuthor'            | null           || '/admin/author/index'                       | 'Admin Author'
+        'an admin redirect to a controller only in admin'         | '/admin/page/redirectToBook'              | null           || '/admin/book/index'                         | null
+        'a manage redirect to a film'                             | '/manage/dashboard/redirectToFilm/{film}' | null           || '/movies/show/{film}'                       | 'Jaws'
+        'an admin redirect to a gadget'                           | '/admin/page/redirectToGadget/{gadget}'   | null           || '/gadget/show/{gadget}'                     | 'Widget'
+    }
+
+    void "a JSON save on a renamed RestfulController sets a Location naming it"() {
+        when:
+        TestHttpResponse response = httpPostJson([Accept: 'application/json'], '/movies/save', [title: 'Heat'])
+
+        then:
+        response.statusCode() == 201
+        new URI(response.headerValue('Location')).path ==~ Pattern.quote("${contextPath}/movies/show/") + /\d+/
+    }
+
+    void "a link naming no HTTP method is resolved for each request's method, even once cached"() {
+        when: 'the same page is rendered by a GET, a POST and a GET again'
+        String viaGet = linkTarget(http('/home/methodLinks').body() as String, 'targetLink')
+        String viaPost = linkTarget(httpPost([:], '/home/methodLinks', '', 'application/x-www-form-urlencoded').body() as String, 'targetLink')
+        String viaGetAgain = linkTarget(http('/home/methodLinks').body() as String, 'targetLink')
+
+        then: 'the POST uses the mapping for POST, and neither is served the other from the cache'
+        viaGet == "${contextPath}/home/target"
+        viaPost == "${contextPath}/home-submit"
+        viaGetAgain == viaGet
+    }
+
+    void "a link fitting an any-method mapping is not sent to the wildcard by a GET mapping needing other parameters"() {
+        when:
+        String target = linkTarget(http('/home/methodLinks').body() as String, 'pagedListLink')
+
+        then:
+        target == "${contextPath}/home-list/2"
+        bodyAt(target).contains('List 2')
+    }
+
+    private String fixtureQuery() {
+        ids.collect { key, value -> "${key}Id=${value}" }.join('&')
+    }
+
+    private String fill(String template) {
+        template.replaceAll(/\{(\w+)\}/) { List<String> match ->
+            match[1] == 'new' ? match[0] : String.valueOf(ids[match[1]])
+        }
+    }
+
+    private String getContextPath() {
+        new URI(httpBaseUrl).path
+    }
+
+    private String bodyAt(String path) {
+        URI base = new URI(httpBaseUrl)
+        TestHttpResponse response = http([Accept: 'application/json'], "${base.scheme}://${base.authority}${path}")
+        response.statusCode() == 200 ? response.body() as String : "HTTP ${response.statusCode()}"
+    }
+
+    private static String linkTarget(String html, String element) {
+        Matcher tag = html =~ /<(?:a|form)\b[^>]*\bid="${Pattern.quote(element)}"[^>]*>/
+        assert tag.find(), "no element with id ${element}"
+        Matcher attribute = tag.group() =~ /\b(?:href|action)="([^"]*)"/
+        assert attribute.find(), "no target on ${tag.group()}"
+        attribute.group(1).replace('&amp;', '&')
+    }
+}
diff --git a/grails-test-examples/namespaces/src/main/groovy/namespaces/ReportBase.groovy b/grails-test-examples/namespaces/src/main/groovy/namespaces/ReportBase.groovy
new file mode 100644
index 0000000000..002a7ade17
--- /dev/null
+++ b/grails-test-examples/namespaces/src/main/groovy/namespaces/ReportBase.groovy
@@ -0,0 +1,27 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+
+package namespaces
+
+/**
+ * A generic base a report controller extends for the domain class it reports on, as an application
+ * might for reports that are not the controller serving that domain class.
+ */
+abstract class ReportBase<T> {
+}

@jdaugherty

Copy link
Copy Markdown
Contributor

Suggested functional tests: fields plugin links under a renamed scaffold, in scaffolding-fields

Could you add these to this PR? RenamedScaffoldLinksSpec adds Crew hasMany Sailor, with Sailor served by @Scaffold(Sailor) class DeckhandsController and no SailorController. It 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.

All 140 tests in the app pass at 5c4195c5a9. 3 of the 4 new ones fail on 8.0.x, where those links and the redirect all point at /sailor/....

Apply from the repository root with git apply; it applies cleanly to 5c4195c5a9.

scaffolding-fields patch (6 files)
diff --git a/grails-test-examples/scaffolding-fields/build.gradle b/grails-test-examples/scaffolding-fields/build.gradle
index 328a0e7dc2..ec65fb4b3c 100644
--- a/grails-test-examples/scaffolding-fields/build.gradle
+++ b/grails-test-examples/scaffolding-fields/build.gradle
@@ -53,6 +53,7 @@ dependencies {
 
 
     integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
+    integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client'
 }
 
 apply {
diff --git a/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/CrewController.groovy b/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/CrewController.groovy
new file mode 100644
index 0000000000..7e8f6a38c8
--- /dev/null
+++ b/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/CrewController.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package scaffoldingfields
+
+import grails.plugin.scaffolding.annotation.Scaffold
+
+@Scaffold(Crew)
+class CrewController {
+}
diff --git a/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/DeckhandsController.groovy b/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/DeckhandsController.groovy
new file mode 100644
index 0000000000..a091e4c834
--- /dev/null
+++ b/grails-test-examples/scaffolding-fields/grails-app/controllers/scaffoldingfields/DeckhandsController.groovy
@@ -0,0 +1,28 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package scaffoldingfields
+
+import grails.plugin.scaffolding.annotation.Scaffold
+
+/**
+ * Serves {@link Sailor} under a name other than the domain class's, with no SailorController.
+ */
+@Scaffold(Sailor)
+class DeckhandsController {
+}
diff --git a/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Crew.groovy b/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Crew.groovy
new file mode 100644
index 0000000000..7a16cfc8b2
--- /dev/null
+++ b/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Crew.groovy
@@ -0,0 +1,30 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package scaffoldingfields
+
+class Crew {
+
+    String name
+
+    static hasMany = [sailors: Sailor]
+
+    String toString() {
+        name
+    }
+}
diff --git a/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Sailor.groovy b/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Sailor.groovy
new file mode 100644
index 0000000000..5445bc62d5
--- /dev/null
+++ b/grails-test-examples/scaffolding-fields/grails-app/domain/scaffoldingfields/Sailor.groovy
@@ -0,0 +1,30 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package scaffoldingfields
+
+class Sailor {
+
+    String name
+
+    static belongsTo = [crew: Crew]
+
+    String toString() {
+        name
+    }
+}
diff --git a/grails-test-examples/scaffolding-fields/src/integration-test/groovy/scaffoldingfields/RenamedScaffoldLinksSpec.groovy b/grails-test-examples/scaffolding-fields/src/integration-test/groovy/scaffoldingfields/RenamedScaffoldLinksSpec.groovy
new file mode 100644
index 0000000000..e8f0e0e173
--- /dev/null
+++ b/grails-test-examples/scaffolding-fields/src/integration-test/groovy/scaffoldingfields/RenamedScaffoldLinksSpec.groovy
@@ -0,0 +1,118 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package scaffoldingfields
+
+import java.net.http.HttpClient
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import spock.lang.Shared
+import spock.lang.Specification
+import spock.lang.Tag
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.TestHttpResponse
+
+/**
+ * Pins the association links the fields plugin renders, and the redirect after a save, for a domain class
+ * served by a scaffolded controller not named after it.
+ */
+@Integration
+@Tag('http-client')
+class RenamedScaffoldLinksSpec extends Specification implements HttpClientSupport {
+
+    @Shared
+    Map<String, Long> ids = [:]
+
+    void setup() {
+        if (!ids) {
+            Crew.withNewTransaction {
+                Crew crew = new Crew(name: 'Pequod')
+                crew.addToSailors(new Sailor(name: 'Ishmael'))
+                crew.save(flush: true, failOnError: true)
+                ids.crew = crew.id
+                ids.ishmael = crew.sailors.first().id
+            }
+        }
+    }
+
+    void "a one-to-many association links each element to the controller serving it"() {
+        when:
+        String href = anchorHref(page("/crew/show/${ids.crew}"), 'Ishmael')
+
+        then:
+        href == "${contextPath}/deckhands/show/${ids.ishmael}"
+        bodyAt(href).contains('Ishmael')
+    }
+
+    void "the add link of a one-to-many input targets the controller serving the element"() {
+        when:
+        String href = anchorHref(page("/crew/edit/${ids.crew}"), 'Add Sailor')
+
+        then:
+        href == "${contextPath}/deckhands/create?crew.id=${ids.crew}"
+        bodyAt(href).contains('Create Sailor')
+    }
+
+    void "a to-one association rendered by the renamed controller links to its owner"() {
+        when:
+        String href = anchorHref(page("/deckhands/show/${ids.ishmael}"), 'Pequod')
+
+        then:
+        href == "${contextPath}/crew/show/${ids.crew}"
+    }
+
+    void "a form save on the renamed controller redirects to it"() {
+        given:
+        HttpClient client = newHttpClientWith { followRedirects(HttpClient.Redirect.NEVER) }
+
+        when:
+        TestHttpResponse response = httpPost([:], '/deckhands/save', "name=Starbuck&crew.id=${ids.crew}",
+                'application/x-www-form-urlencoded', client)
+        String location = new URI(response.headerValue('Location') ?: '').path
+
+        then:
+        response.statusCode() == 302
+        location ==~ Pattern.quote("${contextPath}/deckhands/show/") + /\d+/
+        bodyAt(location).contains('Starbuck')
+    }
+
+    private String page(String path) {
+        TestHttpResponse response = http(path)
+        assert response.statusCode() == 200
+        response.body() as String
+    }
+
+    private String getContextPath() {
+        new URI(httpBaseUrl).path
+    }
+
+    private String bodyAt(String path) {
+        URI base = new URI(httpBaseUrl)
+        TestHttpResponse response = http("${base.scheme}://${base.authority}${path}")
+        response.statusCode() == 200 ? response.body() as String : "HTTP ${response.statusCode()}"
+    }
+
+    private static String anchorHref(String html, String text) {
+        Matcher anchor = html =~ /<a\b[^>]*\bhref="([^"]*)"[^>]*>\s*${Pattern.quote(text)}\s*<\/a>/
+        assert anchor.find(), "no link reading ${text}"
+        anchor.group(1).replace('&amp;', '&')
+    }
+}

@jdaugherty

Copy link
Copy Markdown
Contributor

Suggested functional tests: HAL links for renamed controllers, in views-functional-tests

Could you add these to this PR? RenamedControllerHalLinksSpec serves Magazine through JSON views (PeriodicalsController) and Journal through HalJsonRenderer and HalJsonCollectionRenderer, registered in a new resources.groovy (PapersController). Both reference a Publisher served by PublishersController, and no controller is named after any of the three. It pins the instance and collection self links on both paths, and the renderer's association link to publisher.

All 56 tests in the app pass at 5c4195c5a9. All 4 new ones fail on 8.0.x, which renders /magazine/show/1, /magazine/index, /journal/show/1 and /publisher/show/1.

Apply from the repository root with git apply; it applies cleanly to 5c4195c5a9.

views-functional-tests patch (11 files)
diff --git a/grails-test-examples/views-functional-tests/grails-app/conf/spring/resources.groovy b/grails-test-examples/views-functional-tests/grails-app/conf/spring/resources.groovy
new file mode 100644
index 0000000000..b2357c3ac0
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/conf/spring/resources.groovy
@@ -0,0 +1,26 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+import functional.tests.Journal
+import grails.rest.render.hal.HalJsonCollectionRenderer
+import grails.rest.render.hal.HalJsonRenderer
+
+beans = {
+    journalHalRenderer(HalJsonRenderer, Journal)
+    journalHalCollectionRenderer(HalJsonCollectionRenderer, Journal)
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PapersController.groovy b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PapersController.groovy
new file mode 100644
index 0000000000..f4b6914677
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PapersController.groovy
@@ -0,0 +1,34 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+import grails.rest.RestfulController
+
+/**
+ * Serves {@link Journal} through the HAL renderers registered in resources.groovy, under a name other
+ * than the domain class's.
+ */
+class PapersController extends RestfulController<Journal> {
+
+    static responseFormats = ['json', 'hal']
+
+    PapersController() {
+        super(Journal)
+    }
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PeriodicalsController.groovy b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PeriodicalsController.groovy
new file mode 100644
index 0000000000..25f3255c10
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PeriodicalsController.groovy
@@ -0,0 +1,33 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+import grails.rest.RestfulController
+
+/**
+ * Serves {@link Magazine} through JSON views, under a name other than the domain class's.
+ */
+class PeriodicalsController extends RestfulController<Magazine> {
+
+    static responseFormats = ['json', 'hal']
+
+    PeriodicalsController() {
+        super(Magazine)
+    }
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PublishersController.groovy b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PublishersController.groovy
new file mode 100644
index 0000000000..43171935af
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/PublishersController.groovy
@@ -0,0 +1,30 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+import grails.rest.RestfulController
+
+class PublishersController extends RestfulController<Publisher> {
+
+    static responseFormats = ['json', 'hal']
+
+    PublishersController() {
+        super(Publisher)
+    }
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/UrlMappings.groovy b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/UrlMappings.groovy
index 886a14bd2b..5006cc9310 100644
--- a/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/UrlMappings.groovy
+++ b/grails-test-examples/views-functional-tests/grails-app/controllers/functional/tests/UrlMappings.groovy
@@ -46,6 +46,9 @@ class UrlMappings {
         "/teams/deep/$id"(controller: "team", action:"deep")
         "/teams/hal/$id"(controller: "team", action:"hal")
         "/authors"(resources:"author")
+        "/periodicals"(resources:"periodicals")
+        "/papers"(resources:"papers")
+        "/publishers"(resources:"publishers")
         "/api/book/$action?"(controller: 'book', namespace: 'api')
         "/person-inheritance"(controller: 'personInheritance', action: 'index')
         "/person-inheritance/npe"(controller: 'personInheritance', action: 'npe')
diff --git a/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Journal.groovy b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Journal.groovy
new file mode 100644
index 0000000000..09e0c8aa7e
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Journal.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+class Journal {
+
+    String title
+    Publisher publisher
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Magazine.groovy b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Magazine.groovy
new file mode 100644
index 0000000000..1433cbe391
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Magazine.groovy
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+class Magazine {
+
+    String title
+    Publisher publisher
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Publisher.groovy b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Publisher.groovy
new file mode 100644
index 0000000000..d401bf6eba
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/domain/functional/tests/Publisher.groovy
@@ -0,0 +1,24 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+class Publisher {
+
+    String name
+}
diff --git a/grails-test-examples/views-functional-tests/grails-app/views/periodicals/index.gson b/grails-test-examples/views-functional-tests/grails-app/views/periodicals/index.gson
new file mode 100644
index 0000000000..3bda0a74ce
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/views/periodicals/index.gson
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+import functional.tests.Magazine
+
+model {
+    Iterable<Magazine> magazineList
+}
+
+json hal.render(magazineList)
diff --git a/grails-test-examples/views-functional-tests/grails-app/views/periodicals/show.gson b/grails-test-examples/views-functional-tests/grails-app/views/periodicals/show.gson
new file mode 100644
index 0000000000..d563619379
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/grails-app/views/periodicals/show.gson
@@ -0,0 +1,25 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+import functional.tests.Magazine
+
+model {
+    Magazine magazine
+}
+
+json hal.render(magazine)
diff --git a/grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/RenamedControllerHalLinksSpec.groovy b/grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/RenamedControllerHalLinksSpec.groovy
new file mode 100644
index 0000000000..a07ebac53c
--- /dev/null
+++ b/grails-test-examples/views-functional-tests/src/integration-test/groovy/functional/tests/RenamedControllerHalLinksSpec.groovy
@@ -0,0 +1,102 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package functional.tests
+
+import spock.lang.Shared
+import spock.lang.Specification
+import spock.lang.Tag
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.TestHttpResponse
+
+/**
+ * Pins the HAL links rendered for domain classes served by controllers not named after them, through
+ * JSON views and through the HAL renderers.
+ */
+@Integration
+@Tag('http-client')
+class RenamedControllerHalLinksSpec extends Specification implements HttpClientSupport {
+
+    private static final Map<String, String> HAL = [Accept: 'application/hal+json']
+
+    @Shared
+    Map<String, Long> ids = [:]
+
+    void setup() {
+        if (!ids) {
+            Publisher.withNewTransaction {
+                Publisher publisher = new Publisher(name: 'Penguin').save(flush: true, failOnError: true)
+                ids.publisher = publisher.id
+                ids.magazine = new Magazine(title: 'Wired', publisher: publisher).save(flush: true, failOnError: true).id
+                ids.journal = new Journal(title: 'Nature', publisher: publisher).save(flush: true, failOnError: true).id
+            }
+        }
+    }
+
+    void "a JSON view of an instance links it to the controller serving it"() {
+        when:
+        Map json = hal("/periodicals/${ids.magazine}")
+
+        then:
+        path(json._links.self.href) == "/periodicals/${ids.magazine}"
+    }
+
+    void "a JSON view of a collection links it, and each element, to the controller serving them"() {
+        when:
+        Map json = hal('/periodicals')
+
+        then:
+        path(json._links.self.href) == '/periodicals'
+        embedded(json)*._links*.self*.href.collect { path(it) } == ["/periodicals/${ids.magazine}"]
+    }
+
+    void "the HAL renderer links an instance and its lazy association to the controllers serving them"() {
+        when:
+        Map json = hal("/papers/${ids.journal}")
+
+        then:
+        path(json._links.self.href) == "/papers/${ids.journal}"
+        path(json._links.publisher.href) == "/publishers/${ids.publisher}"
+    }
+
+    void "the HAL collection renderer links a collection, and each element, to the controller serving them"() {
+        when:
+        Map json = hal('/papers')
+
+        then:
+        path(json._links.self.href) == '/papers'
+        embedded(json)*._links*.self*.href.collect { path(it) } == ["/papers/${ids.journal}"]
+    }
+
+    private Map hal(String path) {
+        TestHttpResponse response = http(HAL, path)
+        assert response.statusCode() == 200
+        response.json()
+    }
+
+    private static List<Map> embedded(Map json) {
+        def embedded = json._embedded
+        (embedded instanceof Map ? ((Map) embedded).values().flatten() : embedded) as List<Map>
+    }
+
+    private static String path(Object href) {
+        href == null ? null : new URI(href.toString()).path
+    }
+}

@jdaugherty

Copy link
Copy Markdown
Contributor

Suggested functional tests: resource links under the hyphenated URL converter, in hyphenated

Could you add these to this PR? HyphenatedLinkResolutionSpec adds TourGuide, served by CityGuidesController in the default namespace, which also has a showDetails action, and by TourDeskController and GuideLedgerController in backOffice. It pins links from a page in the default namespace, links from each backOffice controller's own page, a multi-word action, and the redirect after a form save.

At 5c4195c5a9, 3 of the 7 fail: the links rendered by TourDeskController and GuideLedgerController, and the save redirect, reach CityGuidesController (/city-guides/show/2 after saving on the tour desk). Under this converter the request's controllerName is the URL form (tour-desk), while the controller index holds tourDesk, so the controller handling the request is never recognised as a candidate. With that comparison also made against grailsUrlConverter.toUrlElement(candidate.name), all 7 pass in a local run. All 7 fail on 8.0.x, which links to /tour-guide/....

The expectations pin the namespace segment as it is generated, /backOffice/.... That form routes, as /back-office/... does.

Apply from the repository root with git apply; it applies cleanly to 5c4195c5a9.

hyphenated patch (8 files)
diff --git a/grails-test-examples/hyphenated/build.gradle b/grails-test-examples/hyphenated/build.gradle
index b4ea82debf..ec2ccd6416 100644
--- a/grails-test-examples/hyphenated/build.gradle
+++ b/grails-test-examples/hyphenated/build.gradle
@@ -70,6 +70,7 @@ dependencies {
 
 
     integrationTestImplementation testFixtures('org.apache.grails:grails-geb')
+    integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client'
 }
 
 apply {
diff --git a/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/CityGuidesController.groovy b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/CityGuidesController.groovy
new file mode 100644
index 0000000000..4f2aefb288
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/CityGuidesController.groovy
@@ -0,0 +1,32 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated
+
+import grails.rest.RestfulController
+
+class CityGuidesController extends RestfulController<TourGuide> {
+
+    CityGuidesController() {
+        super(TourGuide)
+    }
+
+    def showDetails(Long id) {
+        render "City guide details ${id}"
+    }
+}
diff --git a/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/LinkPageController.groovy b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/LinkPageController.groovy
new file mode 100644
index 0000000000..ca4db9b098
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/LinkPageController.groovy
@@ -0,0 +1,26 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated
+
+class LinkPageController {
+
+    def links(Long guideId) {
+        render view: '/links/guideLinks', model: [guide: TourGuide.get(guideId)]
+    }
+}
diff --git a/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/GuideLedgerController.groovy b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/GuideLedgerController.groovy
new file mode 100644
index 0000000000..9accbf0d71
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/GuideLedgerController.groovy
@@ -0,0 +1,35 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated.backoffice
+
+import grails.rest.RestfulController
+import hyphenated.TourGuide
+
+class GuideLedgerController extends RestfulController<TourGuide> {
+
+    static namespace = 'backOffice'
+
+    GuideLedgerController() {
+        super(TourGuide)
+    }
+
+    def links(Long guideId) {
+        render view: '/links/guideLinks', model: [guide: TourGuide.get(guideId)]
+    }
+}
diff --git a/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/TourDeskController.groovy b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/TourDeskController.groovy
new file mode 100644
index 0000000000..3efaa7759b
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/controllers/hyphenated/backoffice/TourDeskController.groovy
@@ -0,0 +1,35 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated.backoffice
+
+import grails.rest.RestfulController
+import hyphenated.TourGuide
+
+class TourDeskController extends RestfulController<TourGuide> {
+
+    static namespace = 'backOffice'
+
+    TourDeskController() {
+        super(TourGuide)
+    }
+
+    def links(Long guideId) {
+        render view: '/links/guideLinks', model: [guide: TourGuide.get(guideId)]
+    }
+}
diff --git a/grails-test-examples/hyphenated/grails-app/domain/hyphenated/TourGuide.groovy b/grails-test-examples/hyphenated/grails-app/domain/hyphenated/TourGuide.groovy
new file mode 100644
index 0000000000..e1de873ba6
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/domain/hyphenated/TourGuide.groovy
@@ -0,0 +1,24 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated
+
+class TourGuide {
+
+    String name
+}
diff --git a/grails-test-examples/hyphenated/grails-app/views/links/guideLinks.gsp b/grails-test-examples/hyphenated/grails-app/views/links/guideLinks.gsp
new file mode 100644
index 0000000000..eee6fbac84
--- /dev/null
+++ b/grails-test-examples/hyphenated/grails-app/views/links/guideLinks.gsp
@@ -0,0 +1,30 @@
+<%--
+  ~  Licensed to the Apache Software Foundation (ASF) under one
+  ~  or more contributor license agreements.  See the NOTICE file
+  ~  distributed with this work for additional information
+  ~  regarding copyright ownership.  The ASF licenses this file
+  ~  to you under the Apache License, Version 2.0 (the
+  ~  "License"); you may not use this file except in compliance
+  ~  with the License.  You may obtain a copy of the License at
+  ~
+  ~    https://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~  Unless required by applicable law or agreed to in writing,
+  ~  software distributed under the License is distributed on an
+  ~  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+  ~  KIND, either express or implied.  See the License for the
+  ~  specific language governing permissions and limitations
+  ~  under the License.
+  --%>
+<%@ page import="hyphenated.TourGuide" %>
+<!doctype html>
+<html>
+<head>
+    <title>Guide Links</title>
+</head>
+<body>
+<a id="guideShowLink" href="${createLink(resource: guide, action: 'show')}">Guide</a>
+<a id="guideDetailsLink" href="${createLink(resource: guide, action: 'showDetails')}">Guide details</a>
+<a id="guideDetailsByClassLink" href="${createLink(resource: TourGuide, action: 'showDetails', id: guide.id)}">Guide details, by class</a>
+</body>
+</html>
diff --git a/grails-test-examples/hyphenated/src/integration-test/groovy/hyphenated/HyphenatedLinkResolutionSpec.groovy b/grails-test-examples/hyphenated/src/integration-test/groovy/hyphenated/HyphenatedLinkResolutionSpec.groovy
new file mode 100644
index 0000000000..6f054d4afa
--- /dev/null
+++ b/grails-test-examples/hyphenated/src/integration-test/groovy/hyphenated/HyphenatedLinkResolutionSpec.groovy
@@ -0,0 +1,92 @@
+/*
+ *  Licensed to the Apache Software Foundation (ASF) under one
+ *  or more contributor license agreements.  See the NOTICE file
+ *  distributed with this work for additional information
+ *  regarding copyright ownership.  The ASF licenses this file
+ *  to you under the Apache License, Version 2.0 (the
+ *  "License"); you may not use this file except in compliance
+ *  with the License.  You may obtain a copy of the License at
+ *
+ *    https://www.apache.org/licenses/LICENSE-2.0
+ *
+ *  Unless required by applicable law or agreed to in writing,
+ *  software distributed under the License is distributed on an
+ *  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ *  KIND, either express or implied.  See the License for the
+ *  specific language governing permissions and limitations
+ *  under the License.
+ */
+package hyphenated
+
+import java.net.http.HttpClient
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+
+import spock.lang.Shared
+import spock.lang.Specification
+import spock.lang.Tag
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.TestHttpResponse
+
+/**
+ * Pins where resource links land when the hyphenated URL converter renames controllers, actions and
+ * namespaces in URLs.
+ */
+@Integration(applicationClass = Application)
+@Tag('http-client')
+class HyphenatedLinkResolutionSpec extends Specification implements HttpClientSupport {
+
+    @Shared
+    Long guideId
+
+    void setup() {
+        if (!guideId) {
+            TourGuide.withNewTransaction {
+                guideId = new TourGuide(name: 'Rome').save(flush: true, failOnError: true).id
+            }
+        }
+    }
+
+    @Unroll
+    void "#element rendered by #page targets #expected"(String page, String element, String expected) {
+        when:
+        String target = linkTarget(http("${page}?guideId=${guideId}").body() as String, element)
+
+        then:
+        target == expected.replace('{id}', String.valueOf(guideId))
+
+        where: 'a namespace is generated in its logical form, which routes as the hyphenated one does'
+        page                               | element                   || expected
+        '/link-page/links'                 | 'guideShowLink'           || '/city-guides/show/{id}'
+        '/link-page/links'                 | 'guideDetailsLink'        || '/city-guides/show-details/{id}'
+        '/link-page/links'                 | 'guideDetailsByClassLink' || '/city-guides/show-details/{id}'
+        '/back-office/tour-desk/links'     | 'guideShowLink'           || '/backOffice/tour-desk/show/{id}'
+        '/back-office/tour-desk/links'     | 'guideDetailsLink'        || '/city-guides/show-details/{id}'
+        '/back-office/guide-ledger/links'  | 'guideShowLink'           || '/backOffice/guide-ledger/show/{id}'
+    }
+
+    void "a form save on a namespaced controller not named after the domain redirects to it"() {
+        given:
+        HttpClient client = newHttpClientWith { followRedirects(HttpClient.Redirect.NEVER) }
+
+        when:
+        TestHttpResponse response = httpPost([:], '/back-office/tour-desk/save', 'name=Paris',
+                'application/x-www-form-urlencoded', client)
+        String location = new URI(response.headerValue('Location') ?: '').path
+
+        then:
+        response.statusCode() == 302
+        location ==~ Pattern.quote('/backOffice/tour-desk/show/') + /\d+/
+    }
+
+    private static String linkTarget(String html, String element) {
+        Matcher tag = html =~ /<a\b[^>]*\bid="${Pattern.quote(element)}"[^>]*>/
+        assert tag.find(), "no element with id ${element}"
+        Matcher attribute = tag.group() =~ /\bhref="([^"]*)"/
+        assert attribute.find()
+        attribute.group(1).replace('&amp;', '&')
+    }
+}

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/.
@codeconsole

Copy link
Copy Markdown
Contributor Author

Added the four suggested specs, all passing:

  • e8f0a33: LinkResolutionSpec in namespaces. The report's own page row now expects GadgetController.
  • c466cbb: RenamedScaffoldLinksSpec in scaffolding-fields
  • 446633b: RenamedControllerHalLinksSpec in views-functional-tests
  • f56bf2d: HyphenatedLinkResolutionSpec in hyphenated, replacing the TourGuideLinkSpec I had added in 97bbb16

690ed39: the rest-api create-restful-controller template now generates extends RestfulController<Domain>. The three REST guide examples that showed the raw form were changed too.

@testlens-app

testlens-app Bot commented Sep 29, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: f56bf2d
▶️ Tests: 102918 executed
⚪️ Checks: 90/90 completed


Learn more about TestLens at testlens.app/docs.

@matrei

matrei commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

Thanks @codeconsole. Fourth round, at head f56bf2d950. It covers the eight commits since 5c4195c5a9: the fixes for @jdaugherty's three inline findings, the rest-api template change, and the four functional specs he suggested. There are no merges this time. The PR still merges cleanly into the current 8.0.x (12be92bbb8), and CI is green on the head, including every functional test job that runs the four new specs.

Run locally at the head with --no-build-cache after cleanTest:

Module Tests Result
grails-web-url-mappings 327 pass
grails-controllers 211 (1 skipped) pass
grails-scaffolding 79 pass
grails-views-gson 186 pass
grails-fields 685 (8 skipped) pass

All three fixes hold.

  1. Hyphenated converter (97bbb161c8). The fix depends on the request holding the name and the namespace in different forms, so I traced where each one comes from:

    • DefaultUrlMappingInfo.getControllerName() always runs the name through urlConverter.toUrlElement, so the request holds tour-desk.
    • The namespace goes through the converter too, but UrlMappingsInfoHandlerAdapter then overwrites it with controllerClass.namespace, so it ends up logical (backOffice).

    So the right test is to compare each candidate's logical name, and its toUrlElement form, with the request name, and to compare namespaces directly. Applying the same check to the current-controller shortcut in getDefaultNamespace was a good catch.

  2. Same simple name (d5b714c80a). Recording the served class per controller and dropping a namesake that serves a different class is the minimal fix. The six Item rows in LinkGeneratorResourceControllerSpec cover every direction.

  3. Only RestfulController counts (8b871ccd73). domainClassNameFor walks superclasses only, stops at grails.rest.RestfulController and reads its type argument through ResolvableType. That covers:

    • a direct subclass
    • an intermediate base, including RestfulServiceController<T extends GormEntity<T>>
    • @Scaffold and static scaffold: ScaffoldingControllerInjector sets RestfulController<Domain> together with usingGenerics, so the generic signature is written to the class file. ScaffoldingControllerInjectorSpec pins that, and RenamedScaffoldLinksSpec covers it end to end.

    Matching on the class name, not the class, is the right way around the module dependency. The docs, the upgrade note and the rest-api template now all show the parameterised form, and no raw extends RestfulController examples are left in grails-doc or the profile templates.

Two nits, both about wording:

1. The namesake rule is broader than its doc and comment say

servingControllers drops a controller named after the entity whenever it serves any other domain class, not only one with the same simple name. I checked this with a throwaway spec on this head. With BookController extends RestfulController<Publication> next to BooksController extends RestfulController<Book>, a show link to a Book goes to /books/show/1. Before d5b714c80a it went to /book/show/1, where the name settled the tie. That behaviour is right, since the dropped controller's show would load a Publication. But the new paragraph in restfulMappings.adoc and the inline comment (// Named after the entity, but serving another domain class of the same simple name.) describe only the same-simple-name case. Suggested wording for the doc:

A controller named after the domain class that extends RestfulController parameterised on another domain class serves that other class only, as a UserController serving com.example.community.User does beside a com.example.User.

For the comment: // Named after the entity, but serving another domain class.

2. The isRequestController Javadoc limits the URL form to some mappings

It says the request holds the name "its URL mapping gave it, which a mapping taking it from the URL writes in the URL converter's form". But getControllerName() converts in every case, so a static "/desk"(controller: 'tourDesk') puts tour-desk on the request too. Something like "The request holds the controller name as the URL converter writes it: tour-desk rather than tourDesk under the hyphenated converter" would match what the code handles.

Neither blocks. My approval stands.

@jdaugherty

Copy link
Copy Markdown
Contributor

Looks like this isn't breaking anymore, so I'm going to merge / consider it approved.

@jdaugherty
jdaugherty merged commit 7bbb43f into apache:8.0.x Sep 29, 2026
93 checks passed
antobinary added a commit to antobinary/bigbluebutton that referenced this pull request Oct 2, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Scaffolded system restricts multiple controllers

3 participants