Skip to content

Infer controller namespace for link generation when unambiguous#15791

Open
jamesfredley wants to merge 2 commits into
8.0.xfrom
fix/namespace-aware-link-generation
Open

Infer controller namespace for link generation when unambiguous#15791
jamesfredley wants to merge 2 commits into
8.0.xfrom
fix/namespace-aware-link-generation

Conversation

@jamesfredley

@jamesfredley jamesfredley commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

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 same controller/action attributes produced namespaced URLs in some places and non-namespaced URLs in others. This was inconsistent across g:link, g:createLink, g:form, g:paginate, g:sortableColumn, g:include, redirect() and chain().

Link generation now infers the namespace from the target controller so links "just work" from controller and action alone, with no namespace attribute required in the normal case.

Behaviour

When no namespace is supplied, a shared LinkGenerator.getDefaultNamespace(controller, plugin) resolver decides:

  1. Self-link (target is the controller currently handling the request) -> the current request namespace.
  2. Exactly one controller has the name -> that controller's namespace (namespaced or not). This is the normal case - nothing to specify.
  3. The same name is defined in multiple namespaces (a discouraged design) -> the non-namespaced controller is preferred (this matches the way dispatch already resolves the collision), then the current namespace; any remaining ambiguity must be resolved with an explicit namespace.

An explicit namespace always wins. To target a non-namespaced controller, use namespace="" in GSP markup or namespace: null in a Groovy method call (a blank value is normalised to null so 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 explicit namespace, for the controllers named car that exist in the app.

Controllers named car Which controller /car/list dispatches to (unchanged) Old link generation New link generation
One, non-namespaced (root) root car /car/list /car/list (unchanged)
One, namespaced (admin) the admin car (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 - canonical
Self-link (page is served by admin car, links to car) current (admin) car /admin/car/list (already worked) /admin/car/list (unchanged)
Two: root and admin root car - deterministic; a non-namespaced controller wins the null-namespace key (AbstractGrailsControllerUrlMappings) /car/list -> root /car/list -> root (now matches dispatch). Use namespace="admin" for the namespaced one
Two: admin and sales (no root) non-deterministic - last controller registered wins the null-namespace fallback (the code comments this as non-deterministic) /car/list -> whichever won the collision current namespace if the target exists in it, otherwise /car/list; an explicit namespace is required to choose reliably

Explicit selection always works regardless of the above: namespace="admin" -> /admin/car/list, and namespace="" (GSP) / namespace: null (Groovy) -> /car/list (non-namespaced).

Implementation

  • New shared resolver on LinkGenerator, implemented in DefaultLinkGenerator, backed by a lazily-built controller-name -> namespaces index that re-builds when the registered controllers change (array-identity invalidation; reset on reload via CachingLinkGenerator.clearCache()).
  • Wired into DefaultLinkGenerator.link(), CachingLinkGenerator cache-key construction (folding the resolved namespace for top-level and nested url shapes and the request context for resource links, so links from different request namespaces never collide), controller chain(), and the g:include / g:sortableColumn tags.
  • Plugin-targeted links are not inferred from the application's own controllers.

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/namespaces functional (Geb) specs covering every tag, a redirect, a chain, the opt-out, and a custom application tag library that builds links internally.

Docs

grails-doc updated (guide + tag/controller references + What's New + 8.0 upgrade notes) to describe the automatic resolution and the namespace="" / namespace: null opt-out.

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
Copilot AI review requested due to automatic review settings June 29, 2026 22:33

Copilot AI 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.

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 in DefaultLinkGenerator, backed by a lazily rebuilt controller-name→namespaces index.
  • Wire namespace inference and explicit-namespace precedence into DefaultLinkGenerator, CachingLinkGenerator cache 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.

@bito-code-review

Copy link
Copy Markdown

The method signature for getDefaultNamespace has been updated to getDefaultNamespace(String controller, String pluginName) to support namespace inference in link generation. The documentation and tests have been updated to reflect this change, ensuring that link generation correctly resolves namespaces automatically when the namespace attribute is omitted.

@testlens-app

testlens-app Bot commented Jun 30, 2026

Copy link
Copy Markdown

✅ All tests passed ✅

⚠️ TestLens detected flakiness ⚠️

Test Summary

CI / Functional Tests (Java 21, indy=false) > :grails-test-examples-scaffolding:integrationTest

Test Runs
UserControllerSpec > User list ❌ ✅

🏷️ Commit: a52e874
▶️ Tests: 17370 executed
⚪️ Checks: 46/46 completed


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

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.

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"

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.

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

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.

This code looks copied from another area. Why are we implementing the same logic multiple times?

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants