Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion grails-core/src/main/groovy/grails/config/Settings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 2 additions & 4 deletions grails-doc/src/en/guide/upgrading/upgrading71x.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
----
6 changes: 2 additions & 4 deletions grails-test-examples/app4/grails-app/conf/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,8 @@ grails:
profile: web
codegen:
defaultPackage: app4
web:
url:
mapping:
validateWildcards: true
urlmapping:
validateWildcards: true
info:
app:
name: '@info.app.name@'
Expand Down
6 changes: 2 additions & 4 deletions grails-test-examples/app5/grails-app/conf/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,8 @@ grails:
profile: web
codegen:
defaultPackage: app5
web:
url:
mapping:
validateWildcards: false
urlmapping:
validateWildcards: false
spring:
groovy:
template:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -210,12 +210,10 @@ abstract class AbstractGrailsControllerUrlMappings implements UrlMappings {

protected UrlMappingInfo[] collectControllerMappings(UrlMappingInfo[] infos) {
def webRequest = GrailsWebRequest.lookup()
List<UrlMappingInfo> wildcardActionMatches = []
List<UrlMappingInfo> otherMatches = []
boolean hasLiteralControllerMatch = false
List<UrlMappingInfo> matches = []
for (UrlMappingInfo info : infos) {
if (info.redirectInfo) {
otherMatches.add(info)
matches.add(info)
continue
}
if (webRequest != null) {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
Expand Down Expand Up @@ -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 {

Expand Down
Loading