diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index d29d890c869..004c66acede 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -171,7 +171,7 @@ interface Settings { * * @since 7.1 */ - String WEB_URL_MAPPING_VALIDATE_WILDCARDS = 'grails.web.url.mapping.validateWildcards' + String URL_MAPPING_VALIDATE_WILDCARDS = 'grails.urlmapping.validateWildcards' /** * Whether to cache links generated by the link generator diff --git a/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc b/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc index e258e455e26..b6d9a3e3435 100644 --- a/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc +++ b/grails-doc/src/en/guide/theWebLayer/urlmappings/embeddedVariables.adoc @@ -102,10 +102,8 @@ To disable this behavior, set in `application.yml`: [source,yaml] ---- grails: - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false ---- You can also resolve the controller name and action name to execute dynamically using a closure: diff --git a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc index 5ec292bdc41..a8511a90435 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading71x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading71x.adoc @@ -719,8 +719,6 @@ If this causes issues with existing URL mappings, you can disable it in `applica [source,yaml] ---- grails: - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false ---- \ No newline at end of file diff --git a/grails-test-examples/app4/grails-app/conf/application.yml b/grails-test-examples/app4/grails-app/conf/application.yml index 04d31232e30..8f7727e02ed 100644 --- a/grails-test-examples/app4/grails-app/conf/application.yml +++ b/grails-test-examples/app4/grails-app/conf/application.yml @@ -18,10 +18,8 @@ grails: profile: web codegen: defaultPackage: app4 - web: - url: - mapping: - validateWildcards: true + urlmapping: + validateWildcards: true info: app: name: '@info.app.name@' diff --git a/grails-test-examples/app5/grails-app/conf/application.yml b/grails-test-examples/app5/grails-app/conf/application.yml index 3fc1a627b37..4dbb6bec2ab 100644 --- a/grails-test-examples/app5/grails-app/conf/application.yml +++ b/grails-test-examples/app5/grails-app/conf/application.yml @@ -23,10 +23,8 @@ grails: profile: web codegen: defaultPackage: app5 - web: - url: - mapping: - validateWildcards: false + urlmapping: + validateWildcards: false spring: groovy: template: diff --git a/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy b/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy index 32b7d1daa05..0454ddbdab4 100644 --- a/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy +++ b/grails-test-examples/app5/src/integration-test/groovy/app5/WildcardValidationDisabledSpec.groovy @@ -25,7 +25,7 @@ import spock.lang.Specification @Integration class WildcardValidationDisabledSpec extends Specification implements HttpClientSupport { - def 'grails.web.url.mapping.validateWildcards false keeps the fallback mapping selected ahead of a valid wildcard controller match'() { + def 'grails.urlmapping.validateWildcards false keeps the fallback mapping selected ahead of a valid wildcard controller match'() { when: 'requesting a path that matches both the fallback mapping and a registered controller' def response = http('/wildcard-disabled/target') diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy index ef831f9654d..8bcdf16a8b4 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/mvc/AbstractGrailsControllerUrlMappings.groovy @@ -60,7 +60,7 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { this.urlMappingsHolderDelegate = urlMappingsHolderDelegate this.urlConverter = urlConverter this.validateWildcardMappings = grailsApplication.config.getProperty( - Settings.WEB_URL_MAPPING_VALIDATE_WILDCARDS, Boolean, true) + Settings.URL_MAPPING_VALIDATE_WILDCARDS, Boolean, true) def controllerArtefacts = grailsApplication.getArtefacts(ControllerArtefactHandler.TYPE) for (GrailsClass gc in controllerArtefacts) { registerController((GrailsControllerClass) gc) @@ -210,12 +210,10 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { protected UrlMappingInfo[] collectControllerMappings(UrlMappingInfo[] infos) { def webRequest = GrailsWebRequest.lookup() - List wildcardActionMatches = [] - List otherMatches = [] - boolean hasLiteralControllerMatch = false + List matches = [] for (UrlMappingInfo info : infos) { if (info.redirectInfo) { - otherMatches.add(info) + matches.add(info) continue } if (webRequest != null) { @@ -225,30 +223,32 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings { ControllerKey controllerKey = new ControllerKey(info.namespace, info.controllerName, info.actionName, info.pluginName) GrailsControllerClass controllerClass = info ? mappingsToGrailsControllerMap.get(controllerKey) : null if (controllerClass) { - def wrapped = new GrailsControllerUrlMappingInfo(controllerClass, info) - if (validateWildcardMappings && info.hasWildcardCaptures()) { - wildcardActionMatches.add(wrapped) - } else { - otherMatches.add(wrapped) - if (!hasLiteralControllerMatch) { - // A literal match has no URL-captured parameters beyond standard routing params - def params = info.parameters - hasLiteralControllerMatch = params == null || params.keySet().every { it in ROUTING_PARAMS } - } - } + matches.add(new GrailsControllerUrlMappingInfo(controllerClass, info)) } else if (!validateWildcardMappings || !info.hasWildcardCaptures()) { - otherMatches.add(info) + matches.add(info) } // else: wildcard-captured values didn't match a registered controller/action — skip } - // Wildcard-resolved matches take priority over parameterized catch-all matches - // (e.g., $controller=feed beats $communitySlug=feed), but NOT over literal path - // matches (e.g., /community or post /invites) which are always more specific - if (hasLiteralControllerMatch) { - (otherMatches + wildcardActionMatches) as UrlMappingInfo[] - } else { - (wildcardActionMatches + otherMatches) as UrlMappingInfo[] + // When wildcard validation is enabled, promote validated wildcard matches + // (e.g., $action? resolving to a real action) only when they have strictly fewer + // non-routing URL captures — meaning they matched a more specific URL pattern. + // Same wildcard status: preserve URL matcher's original order (stable sort). + // When validation is disabled, preserve original URL matcher order entirely. + if (validateWildcardMappings) { + matches.sort(true) { a, b -> + if (a.hasWildcardCaptures() == b.hasWildcardCaptures()) return 0 + int diff = nonRoutingParamCount(a) - nonRoutingParamCount(b) + if (a.hasWildcardCaptures() && diff < 0) return -1 + if (b.hasWildcardCaptures() && diff < 0) return 1 + 0 // preserve original order + } } + matches as UrlMappingInfo[] + } + + private static int nonRoutingParamCount(UrlMappingInfo info) { + def params = info.parameters + params == null ? 0 : (int) params.keySet().count { !(it in ROUTING_PARAMS) } } protected UrlMappingInfo collectControllerMapping(UrlMappingInfo info) { diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy index 3c848fc7dac..5451d0be20a 100644 --- a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy +++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/mvc/WildcardActionValidationSpec.groovy @@ -23,6 +23,7 @@ import org.springframework.mock.web.MockHttpServletRequest import grails.core.DefaultGrailsApplication import grails.util.GrailsWebMockUtil import grails.web.Controller +import grails.web.HyphenatedUrlConverter import grails.web.mapping.AbstractUrlMappingsSpec import grails.web.mapping.UrlMappingInfo import grails.web.mapping.UrlMappingsHolder @@ -179,11 +180,109 @@ class WildcardActionValidationSpec extends AbstractUrlMappingsSpec { } } + void 'prefers explicit method-specific mappings inside parameterized group prefix'() { + when: 'an explicit POST mapping and a wildcard optional action mapping are inside a group with a URL variable' + def mappingInfo = evaluateRequestFor(requestURI: '/users/john/invites', method: 'POST', { + group "/users/$username", { + post "/invites"(controller: 'invite', action: 'create') + "/invites/$action?"(controller: 'invite') + } + }, InviteController) + + then: 'the explicit POST mapping is selected despite the group-level URL variable' + with(mappingInfo) { + controllerName == 'invite' + actionName == 'create' + } + } + + void 'GET to parameterized group routes to wildcard action index, not POST-only create'() { + when: 'a GET request matches both a POST-only mapping and a wildcard optional action mapping inside a group' + def mappingInfo = evaluateRequestFor(requestURI: '/users/john/invites', method: 'GET', { + group "/users/$username", { + post "/invites"(controller: 'invite', action: 'create') + "/invites/$action?"(controller: 'invite') + } + }, InviteController) + + then: 'the wildcard optional action mapping is selected (index), not the POST-only create' + with(mappingInfo) { + controllerName == 'invite' + actionName != 'create' + } + } + + void 'literal group path beats parameterized catch-all even with same controller'() { + when: 'a group with a literal sub-path and a $username catch-all both match, mapping to the same controller' + def mappingInfo = evaluateRequestFor('/users/gallery', { + group "/users", { + group "/gallery", controller: 'topic', { + "/$action?"() + } + } + "/users/$username"(controller: 'topic', action: 'home') + }, TopicController) + + then: 'the literal group path wins because it has a more specific URL pattern' + with(mappingInfo) { + controllerName == 'topic' + actionName != 'home' + } + } + + void 'wildcard action match beats explicit memberId match for hyphenated action URL'() { + given: + def urlConverter = new HyphenatedUrlConverter() + def mappingsHolder = createUrlMappingsHolder(urlConverter, { + group "/users/$username", { + group "/members", controller: 'member', { + "/$action?"() + "/$memberId"(action: 'remove') + } + } + }, MemberController) + + when: 'a hyphenated action URL matches both $action? and $memberId(remove) in a parameterized group' + def webRequest = GrailsWebMockUtil.bindMockWebRequest() + def request = (webRequest.request as MockHttpServletRequest).tap { + requestURI = '/users/john/members/opt-in-prompt' + method = 'GET' + } + def mappingInfo = new UrlMappingsHandlerMapping(mappingsHolder).getHandler(request)?.handler as UrlMappingInfo + + then: 'the $action? mapping wins because it has a more specific URL pattern' + mappingInfo != null + with(mappingInfo) { + controllerName == 'member' + actionName == 'opt-in-prompt' + } + } + + void 'wildcard controller match beats parameterized catch-all for different controllers'() { + when: 'a wildcard $controller match (feed) and a $username catch-all (topic.home) both match' + def mappingInfo = evaluateRequestFor('/users/feed', { + "/users"(controller: 'topic', action: 'home') + group "/users", { + "/$controller/$action?"() + } + "/users/$username"(controller: 'topic', action: 'home') + }, TopicController, FeedController) + + then: 'the wildcard controller match wins because it resolved a real controller' + with(mappingInfo) { + controllerName == 'feed' + } + } + private UrlMappingsHolder createUrlMappingsHolder(boolean validateWildcardMappings = true, Closure mappings, Class... controllerClasses) { + createUrlMappingsHolder(null, validateWildcardMappings, mappings, controllerClasses) + } + + private UrlMappingsHolder createUrlMappingsHolder(grails.web.UrlConverter urlConverter, boolean validateWildcardMappings = true, Closure mappings, Class... controllerClasses) { def grailsApplication = new DefaultGrailsApplication(controllerClasses).tap { initialise() } - new GrailsControllerUrlMappings(grailsApplication, getUrlMappingsHolder(mappings)).tap { + new GrailsControllerUrlMappings(grailsApplication, getUrlMappingsHolder(mappings), urlConverter).tap { it.validateWildcardMappings = validateWildcardMappings } } @@ -219,6 +318,21 @@ class CommunityController { def index() {} } +@Controller +class MemberController { + + def index() {} + def optInPrompt() {} + def optIn() {} + def remove() {} +} + +@Controller +class FeedController { + + def index() {} +} + @Controller class TopicController {