Infer controller namespace for link generation when unambiguous#15791
Infer controller namespace for link generation when unambiguous#15791jamesfredley wants to merge 2 commits into
Conversation
Grails controllers can declare a namespace, but URL-generating tags and methods only applied the request namespace when a link targeted the current controller. Links to any other controller dropped the namespace unless it was passed explicitly, so the same controller/action attributes produced namespaced URLs in some places and non-namespaced URLs in others. Link generation now infers the namespace from the target controller so links work from controller and action alone: - a self-link uses the current request namespace - when exactly one controller has the name, that controller's namespace is used - when the same name is defined in multiple namespaces (a discouraged design), the non-namespaced controller is preferred, then the current namespace; any remaining ambiguity must be resolved with an explicit namespace The shared LinkGenerator.getDefaultNamespace resolver is applied consistently to g:link, g:createLink, createLink(), g:form, g:formActionSubmit, g:paginate, g:sortableColumn, g:include, redirect() and chain(), and is folded into the CachingLinkGenerator cache key (including nested url maps and resource links) so links generated from different request namespaces never collide. An explicit namespace always wins; use namespace="" in GSP markup or namespace: null in a method call to target a non-namespaced controller. Adds unit, taglib, controller and functional (Geb) tests covering every surface plus a custom application tag library, and documents the behaviour in grails-doc. Assisted-by: claude-code:claude-4.8-opus
There was a problem hiding this comment.
Pull request overview
This pull request updates Grails’ URL/link generation so that when namespace is not explicitly provided, the framework infers the effective namespace from the target controller (and request context where relevant). This makes namespaced URL generation consistent across link tags/helpers and controller redirects/chains, while still allowing opt-out via namespace="" (GSP) / namespace: null (Groovy).
Changes:
- Add a shared
LinkGenerator.getDefaultNamespace(controller, pluginName)contract and implement it inDefaultLinkGenerator, backed by a lazily rebuilt controller-name→namespaces index. - Wire namespace inference and explicit-namespace precedence into
DefaultLinkGenerator,CachingLinkGeneratorcache key construction,chain(), and relevant GSP taglib paths (g:include,g:sortableColumn). - Add comprehensive unit/functional tests and update user documentation (guide, tag refs, controller refs, and upgrade notes) describing the new inference and opt-out behavior.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| grails-web-url-mappings/src/main/groovy/grails/web/mapping/LinkGenerator.java | Introduces the shared getDefaultNamespace(controller, pluginName) contract (defaulting to null). |
| grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultLinkGenerator.groovy | Implements namespace inference, explicit blank→null normalization, and a cached controller namespace index with reset hook. |
| grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/CachingLinkGenerator.java | Updates cache-key construction to include inferred namespace and request context for resource-link shapes; resets namespace index on cache clear. |
| grails-controllers/src/main/groovy/grails/artefact/controller/support/ResponseRedirector.groovy | Updates chain() to honor explicit namespace (including blank/null opt-out) and infer when omitted. |
| grails-gsp/plugin/src/main/groovy/org/grails/plugins/web/taglib/UrlMappingTagLib.groovy | Updates g:include and g:sortableColumn namespace handling to align with inference/explicit precedence. |
| grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/root/RootNamespaceControllers.groovy | Adds root controllers for inference test fixtures. |
| grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/admin/AdminNamespaceControllers.groovy | Adds admin-namespaced controllers for inference test fixtures. |
| grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/nsinference/sales/SalesNamespaceControllers.groovy | Adds sales-namespaced controller for inference test fixtures. |
| grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/LinkGeneratorNamespaceInferenceSpec.groovy | Adds exhaustive unit tests for resolver/link behavior, cache collision regressions, plugin guard, and blank namespace normalization. |
| grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/root/RootNamespaceControllers.groovy | Adds root controllers for taglib inference tests. |
| grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/admin/AdminNamespaceControllers.groovy | Adds admin controllers for taglib inference tests. |
| grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/nsinference/sales/SalesNamespaceControllers.groovy | Adds sales controller for taglib inference tests. |
| grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/NamespaceInferenceTagLibSpec.groovy | Adds unit tests covering URL-generating GSP tags and opt-out semantics. |
| grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/beta/NamespacedController.groovy | Adds controller actions to exercise chain() namespace inference/override behavior. |
| grails-test-suite-uber/src/test/groovy/org/grails/web/servlet/mvc/RedirectMethodTests.groovy | Extends redirect/chain tests to cover inferred namespace, explicit namespace, and explicit-null opt-out. |
| grails-test-examples/namespaces/grails-app/controllers/UrlMappings.groovy | Adds a frontend namespace mapping for functional coverage. |
| grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/PageController.groovy | Adds actions rendering link/tag examples and exercising redirect/chain behaviors. |
| grails-test-examples/namespaces/grails-app/controllers/namespaces/admin/BookController.groovy | Adds a namespaced Book controller used by functional specs. |
| grails-test-examples/namespaces/grails-app/views/page/namespaceLinks.gsp | Adds a view that renders multiple URL-generating tags covering inference and opt-out. |
| grails-test-examples/namespaces/grails-app/views/book/index.gsp | Adds a simple view used by functional navigation assertions. |
| grails-test-examples/namespaces/grails-app/taglib/namespaces/AppLinkTagLib.groovy | Adds an app taglib that internally builds links (functional coverage of app-provided taglibs). |
| grails-test-examples/namespaces/src/integration-test/groovy/namespaces/NamespaceViewRenderingSpec.groovy | Adds functional (Geb) assertions covering tag URLs, redirects, chains, and opt-out behavior end-to-end. |
| grails-doc/src/en/ref/Tags - GSP/link.adoc | Documents namespace inference + opt-out for g:link. |
| grails-doc/src/en/ref/Tags - GSP/createLink.adoc | Documents namespace inference + opt-out for g:createLink and fixes a typo in an example comment. |
| grails-doc/src/en/ref/Tags - GSP/form.adoc | Documents namespace inference + opt-out for g:form. |
| grails-doc/src/en/ref/Tags - GSP/paginate.adoc | Documents namespace inference + opt-out for g:paginate. |
| grails-doc/src/en/ref/Tags - GSP/sortableColumn.adoc | Documents namespace inference + opt-out for g:sortableColumn. |
| grails-doc/src/en/ref/Tags - GSP/include.adoc | Documents namespace inference + opt-out for g:include. |
| grails-doc/src/en/ref/Controllers/redirect.adoc | Updates redirect docs to reflect automatic namespace resolution + opt-out. |
| grails-doc/src/en/ref/Controllers/chain.adoc | Updates chain docs to reflect automatic namespace resolution + opt-out. |
| grails-doc/src/en/ref/Controllers/namespace.adoc | Adds documentation that namespace can be resolved from the target controller when omitted. |
| grails-doc/src/en/guide/theWebLayer/urlmappings/namespacedControllers.adoc | Updates guide to describe the new resolution rules and discouraged ambiguity cases. |
| grails-doc/src/en/guide/introduction/whatsNew.adoc | Adds “Namespace-Aware Link Generation” release note section. |
| grails-doc/src/en/guide/upgrading/upgrading80x.adoc | Adds upgrade note explaining new behavior and opt-out syntax. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
|
The method signature for |
✅ All tests passed ✅Test SummaryCI / Functional Tests (Java 21, indy=false) > :grails-test-examples-scaffolding:integrationTest
🏷️ Commit: a52e874 Learn more about TestLens at testlens.app. |
| // Honor an explicit namespace so a blank one (namespace="" or namespace: null) opts out to | ||
| // the non-namespaced controller; otherwise infer the namespace for the target controller so | ||
| // g:include stays consistent with link generation. | ||
| if (attrs.containsKey('namespace')) { |
There was a problem hiding this comment.
The existing pattern is to actually fetch the key and only execute the code if it's set. The below controller namespace default code will be skipped if namespace = ''
| } | ||
|
|
||
| "/frontend/$controller/$action?/$id?(.$format)?"{ | ||
| namespace = "frontend" |
There was a problem hiding this comment.
namespaces in spring boot can be set by path / header / configured statically. I'm guessing this support is static only?
| if (namespace == null) { | ||
| if (controller == requestStateLookupStrategy.controllerName) { | ||
| namespace = requestStateLookupStrategy.controllerNamespace | ||
| // An explicit namespace attribute always wins so that a blank one (namespace="" or |
There was a problem hiding this comment.
This code looks copied from another area. Why are we implementing the same logic multiple times?
Summary
Grails controllers can declare a
namespace, but URL generation only applied the request namespace when a link targeted the current controller. A link to any other controller silently dropped the namespace unless it was passed explicitly, so the samecontroller/actionattributes produced namespaced URLs in some places and non-namespaced URLs in others. This was inconsistent acrossg:link,g:createLink,g:form,g:paginate,g:sortableColumn,g:include,redirect()andchain().Link generation now infers the namespace from the target controller so links "just work" from
controllerandactionalone, with nonamespaceattribute required in the normal case.Behaviour
When no
namespaceis supplied, a sharedLinkGenerator.getDefaultNamespace(controller, plugin)resolver decides:namespace.An explicit
namespacealways wins. To target a non-namespaced controller, usenamespace=""in GSP markup ornamespace: nullin a Groovy method call (a blank value is normalised tonullso it matches non-namespaced reverse mappings).Dispatch alignment
The change does not alter request dispatch (which controller actually serves a URL); it makes link generation produce the URL that dispatch already resolves to. The table below links
controller="car" action="list"with no explicitnamespace, for the controllers namedcarthat exist in the app.car/car/listdispatches to (unchanged)car/car/list/car/list(unchanged)admin)admincar(via the no-namespace dispatch fallback)/car/list- namespace dropped, so the URL is non-canonical and inconsistent with explicitly-namespaced links/admin/car/list- canonicaladmincar, links tocar)admin)car/admin/car/list(already worked)/admin/car/list(unchanged)admincar- deterministic; a non-namespaced controller wins the null-namespace key (AbstractGrailsControllerUrlMappings)/car/list-> root/car/list-> root (now matches dispatch). Usenamespace="admin"for the namespaced oneadminandsales(no root)/car/list-> whichever won the collision/car/list; an explicitnamespaceis required to choose reliablyExplicit selection always works regardless of the above:
namespace="admin"->/admin/car/list, andnamespace=""(GSP) /namespace: null(Groovy) ->/car/list(non-namespaced).Implementation
LinkGenerator, implemented inDefaultLinkGenerator, backed by a lazily-built controller-name -> namespaces index that re-builds when the registered controllers change (array-identity invalidation; reset on reload viaCachingLinkGenerator.clearCache()).DefaultLinkGenerator.link(),CachingLinkGeneratorcache-key construction (folding the resolved namespace for top-level and nestedurlshapes and the request context for resource links, so links from different request namespaces never collide), controllerchain(), and theg:include/g:sortableColumntags.Tests
LinkGeneratorNamespaceInferenceSpec- exhaustive resolver +link()cases, cache-collision regressions (url-map and resource), plugin guard, and blank-namespace normalisation.NamespaceInferenceTagLibSpec- every URL-generating GSP tag.RedirectMethodTests-chain()namespace cases.grails-test-examples/namespacesfunctional (Geb) specs covering every tag, a redirect, a chain, the opt-out, and a custom application tag library that builds links internally.Docs
grails-docupdated (guide + tag/controller references + What's New + 8.0 upgrade notes) to describe the automatic resolution and thenamespace=""/namespace: nullopt-out.