From 0f09606b2cab73119a02020dc8becce6e12b77e8 Mon Sep 17 00:00:00 2001 From: ruthes00 Date: Thu, 24 Sep 2026 15:16:23 -0400 Subject: [PATCH 1/2] Fixed issue where GrailsExceptionResolver masks original exception with GrailsWebRequest cast failure. --- .../errors/GrailsExceptionResolverSpec.groovy | 42 +++++ .../web/mapping/AbstractUrlMappingInfo.java | 10 +- .../web/mapping/DefaultUrlMappingInfo.java | 6 +- .../AbstractUrlMappingInfoSafeCastSpec.groovy | 150 ++++++++++++++++++ 4 files changed, 205 insertions(+), 3 deletions(-) create mode 100644 grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy index 15f6dd8bfdc..4a2f98e2cc8 100644 --- a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy +++ b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy @@ -33,6 +33,8 @@ import org.springframework.context.ApplicationContext import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes import org.springframework.web.context.request.async.AsyncRequestTimeoutException import org.springframework.web.util.WebUtils import org.springframework.web.context.WebApplicationContext @@ -546,4 +548,44 @@ class GrailsExceptionResolverSpec extends Specification { then: 'the guard only suppresses re-entry, so both are forwarded' forwards.size() == 2 } + + void "resolveViewOrForward does not mask the original exception when a plain ServletRequestAttributes is bound"() { + given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound in RequestContextHolder' + def request = new MockHttpServletRequest('GET', '/fail') + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + + and: 'a UrlMappingInfo whose getControllerName() triggers a closure-based name resolution' + def info = Mock(UrlMappingInfo) + info.getViewName() >> null + info.getControllerName() >> 'errors' + def urlMappings = Mock(UrlMappingsHolder) + urlMappings.match(_ as String) >> null + urlMappings.matchStatusCode(500, _ as Throwable) >> null + urlMappings.matchStatusCode(500) >> info + + and: 'a resolver that records forwards without actually dispatching' + def forwards = [] + def resolver = new GrailsExceptionResolver() { + + @Override + protected void forwardRequest(UrlMappingInfo forwarded, HttpServletRequest req, + HttpServletResponse res, ModelAndView mv, String uri) { + forwards << uri + } + } + def response = new MockHttpServletResponse() + def originalException = new RuntimeException('original application exception') + + when: 'the original exception is resolved while only a plain ServletRequestAttributes is bound' + resolver.resolveViewOrForward(originalException, urlMappings, request, response, new ModelAndView()) + + then: 'no ClassCastException is thrown — the original exception is not masked' + noExceptionThrown() + + and: 'the error handler forward was attempted' + forwards.size() == 1 + + cleanup: + RequestContextHolder.resetRequestAttributes() + } } diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java index 1d46432d381..dc47c7fb97f 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java @@ -125,7 +125,9 @@ else if (value instanceof RuntimeConstraintEvaluator) { return evaluateCapturedName((RuntimeConstraintEvaluator) value); } else { - GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes(); + org.springframework.web.context.request.RequestAttributes attrs = + RequestContextHolder.getRequestAttributes(); + GrailsWebRequest webRequest = attrs instanceof GrailsWebRequest ? (GrailsWebRequest) attrs : null; return evaluateNameForValue(value, webRequest); } } @@ -141,6 +143,9 @@ protected String evaluateNameForValue(Object value, GrailsWebRequest webRequest) String name; if (value instanceof Closure) { + if (webRequest == null) { + return null; + } Closure callable = (Closure) value; final Closure cloned = (Closure) callable.clone(); cloned.setDelegate(webRequest); @@ -149,6 +154,9 @@ protected String evaluateNameForValue(Object value, GrailsWebRequest webRequest) name = result != null ? result.toString() : null; } else if (value instanceof Map) { + if (webRequest == null) { + return null; + } Map httpMethods = (Map) value; name = (String) httpMethods.get(HiddenHttpMethod.effectiveMethod(webRequest.getRequest())); } diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java index 56f4e631828..ea48e4a6d8b 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java @@ -193,8 +193,10 @@ public String getControllerName() { } public String getActionName() { - var webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes(); - var name = evaluateNameForValue(actionName, webRequest); + org.springframework.web.context.request.RequestAttributes attrs = + RequestContextHolder.getRequestAttributes(); + GrailsWebRequest webRequest = attrs instanceof GrailsWebRequest ? (GrailsWebRequest) attrs : null; + String name = evaluateNameForValue(actionName, webRequest); return urlConverter.toUrlElement(name); } diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy new file mode 100644 index 00000000000..0b49955b9ae --- /dev/null +++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy @@ -0,0 +1,150 @@ +/* + * 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 org.grails.web.mapping + +import grails.core.DefaultGrailsApplication +import grails.core.GrailsApplication +import grails.web.mapping.UrlMapping +import grails.web.mapping.UrlMappingInfo +import org.grails.support.MockApplicationContext +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import spock.lang.Specification + +/** + * Verifies that {@link AbstractUrlMappingInfo} and {@link DefaultUrlMappingInfo} do not throw a + * {@link ClassCastException} when {@link RequestContextHolder} holds a plain + * {@link ServletRequestAttributes} instead of a {@link org.grails.web.servlet.mvc.GrailsWebRequest}. + * + *

This scenario arises when {@link org.grails.web.errors.GrailsExceptionResolver} resolves an + * exception: Spring's {@code DispatcherServlet} may have bound a {@code ServletRequestAttributes} + * before the Grails filter had a chance to upgrade it to a {@code GrailsWebRequest}. The unconditional + * cast that previously existed in {@code evaluateNameForValue} and {@code getActionName} would then + * throw a {@code ClassCastException}, masking the original application exception. + * + * @see Issue #16129 + */ +class AbstractUrlMappingInfoSafeCastSpec extends Specification { + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + private static UrlMapping closureActionMapping() { + MockApplicationContext ctx = new MockApplicationContext() + ctx.registerMockBean(GrailsApplication.APPLICATION_ID, new DefaultGrailsApplication()) + new DefaultUrlMappingEvaluator(ctx).evaluateMappings { + '/book'(controller: 'book', action: { request.method == 'GET' ? 'show' : 'save' }) + }.first() + } + + private static UrlMapping staticMapping() { + MockApplicationContext ctx = new MockApplicationContext() + ctx.registerMockBean(GrailsApplication.APPLICATION_ID, new DefaultGrailsApplication()) + new DefaultUrlMappingEvaluator(ctx).evaluateMappings { + '/book'(controller: 'book', action: 'show') + }.first() + } + + void 'evaluateNameForValue does not throw ClassCastException when RequestContextHolder holds a plain ServletRequestAttributes'() { + given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound' + def request = new MockHttpServletRequest('GET', '/book') + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + + and: 'a mapping whose action is a closure (requires a GrailsWebRequest to evaluate)' + UrlMapping mapping = closureActionMapping() + UrlMappingInfo info = mapping.match('/book') + + when: 'action name is resolved while only a plain ServletRequestAttributes is bound' + String actionName = info.actionName + + then: 'no ClassCastException is thrown; the action gracefully returns null' + noExceptionThrown() + actionName == null + } + + void 'getActionName does not throw ClassCastException when RequestContextHolder holds a plain ServletRequestAttributes'() { + given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound' + def request = new MockHttpServletRequest('GET', '/book') + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + + and: 'a mapping whose action is a closure' + UrlMapping mapping = closureActionMapping() + UrlMappingInfo info = mapping.match('/book') + + when: + String actionName = info.actionName + + then: + noExceptionThrown() + actionName == null + } + + void 'evaluateNameForValue does not throw ClassCastException when RequestContextHolder is empty'() { + given: 'no request attributes are bound at all' + RequestContextHolder.resetRequestAttributes() + + and: 'a mapping whose action is a closure' + UrlMapping mapping = closureActionMapping() + UrlMappingInfo info = mapping.match('/book') + + when: + String actionName = info.actionName + + then: + noExceptionThrown() + actionName == null + } + + void 'static string action names are resolved correctly regardless of RequestContextHolder state'() { + given: 'a plain ServletRequestAttributes is bound' + def request = new MockHttpServletRequest('GET', '/book') + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + + and: 'a mapping with a static string action name' + UrlMapping mapping = staticMapping() + UrlMappingInfo info = mapping.match('/book') + + when: + String actionName = info.actionName + + then: 'static names are always resolved correctly' + noExceptionThrown() + actionName == 'show' + } + + void 'static string controller names are resolved correctly regardless of RequestContextHolder state'() { + given: 'a plain ServletRequestAttributes is bound' + def request = new MockHttpServletRequest('GET', '/book') + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + + and: 'a mapping with a static string controller name' + UrlMapping mapping = staticMapping() + UrlMappingInfo info = mapping.match('/book') + + when: + String controllerName = info.controllerName + + then: + noExceptionThrown() + controllerName == 'book' + } +} From 912be076d0a28e59e03e6e22f989b0e9d30ec7ec Mon Sep 17 00:00:00 2001 From: James Daugherty Date: Fri, 25 Sep 2026 02:24:44 -0400 Subject: [PATCH 2/2] Resolve error handlers and mapping names without a bound GrailsWebRequest A DispatcherServlet other than the Grails one - the one MockMvc runs, for example - binds a plain ServletRequestAttributes over the GrailsWebRequest that GrailsWebRequestFilter bound. Guarding the casts alone left GrailsExceptionResolver forwarding to a controller error handler with a null GrailsWebRequest, so the original exception was still replaced. - URL mapping names, reverse mappings and the default URL creator find the GrailsWebRequest the filter stored on the request when the bound attributes are plain - Without one, a name closure is not called and resolves to null, and a map of HTTP methods is keyed by the method of the bound request. With no attributes bound at all, a closure is still called without a delegate - GrailsExceptionResolver forwards to a controller error handler only when the request has a GrailsWebRequest, and an error handler that cannot be resolved no longer replaces the exception being resolved: it is logged and the /error view renders the original exception - The default URL creator no longer prefixes URLs with "null" when there is no context path Fixes #16129 --- .../urlmappings/mappingToResponseCodes.adoc | 2 + .../web/errors/GrailsExceptionResolver.java | 43 ++-- .../errors/GrailsExceptionResolverSpec.groovy | 166 ++++++++++++--- .../web/mapping/AbstractUrlMappingInfo.java | 36 +++- .../grails/web/mapping/DefaultUrlCreator.java | 11 +- .../web/mapping/DefaultUrlMappingInfo.java | 7 +- .../grails/web/mapping/RegexUrlMapping.java | 3 +- .../grails/web/mapping/UrlMappingUtils.java | 21 ++ .../AbstractUrlMappingInfoSafeCastSpec.groovy | 150 ------------- ...UrlMappingInfoRequestAttributesSpec.groovy | 199 ++++++++++++++++++ 10 files changed, 425 insertions(+), 213 deletions(-) delete mode 100644 grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy create mode 100644 grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/UrlMappingInfoRequestAttributesSpec.groovy diff --git a/grails-doc/src/en/guide/theWebLayer/urlmappings/mappingToResponseCodes.adoc b/grails-doc/src/en/guide/theWebLayer/urlmappings/mappingToResponseCodes.adoc index 1c779a454af..75481f95318 100644 --- a/grails-doc/src/en/guide/theWebLayer/urlmappings/mappingToResponseCodes.adoc +++ b/grails-doc/src/en/guide/theWebLayer/urlmappings/mappingToResponseCodes.adoc @@ -85,4 +85,6 @@ class ErrorsController { } ---- +NOTE: A problem reaching the error handler does not replace the exception being handled. If the mapping for a status code cannot be resolved for a request, or an error-handling controller cannot be forwarded to because the request did not pass through the Grails filters - a Spring `MockMvc` test that leaves out the application's filters, for example - the problem is logged and the `/error` view renders the original exception instead. + WARNING: If your error-handling controller action throws an exception as well, you'll end up with a `StackOverflowException`. diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java index 649d4bd0372..9e5c87e3962 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java +++ b/grails-web-mvc/src/main/groovy/org/grails/web/errors/GrailsExceptionResolver.java @@ -63,6 +63,7 @@ import org.grails.exceptions.reporting.StackTraceFilterer; import org.grails.web.mapping.DefaultUrlMappingInfo; import org.grails.web.mapping.UrlMappingUtils; +import org.grails.web.servlet.mvc.GrailsWebRequest; import org.grails.web.servlet.mvc.exceptions.GrailsMVCException; import org.grails.web.util.GrailsApplicationAttributes; import org.grails.web.util.WebUtils; @@ -204,29 +205,47 @@ Map extractRequestParamsWithUrlMappingHolder(UrlMappingsHolder urlMappings, Http protected ModelAndView resolveViewOrForward(Exception ex, UrlMappingsHolder urlMappings, HttpServletRequest request, HttpServletResponse response, ModelAndView mv) { - UrlMappingInfo info = matchStatusCode(ex, urlMappings); - - if (info != null) { - Map params = extractRequestParamsWithUrlMappingHolder(urlMappings, request); - if (params != null && !params.isEmpty()) { - Map infoParams = info.getParameters(); - if (infoParams != null) { - params.putAll(info.getParameters()); + UrlMappingInfo info; + boolean mapsToView; + boolean mapsToController; + try { + info = matchStatusCode(ex, urlMappings); + if (info != null) { + Map params = extractRequestParamsWithUrlMappingHolder(urlMappings, request); + if (params != null && !params.isEmpty()) { + Map infoParams = info.getParameters(); + if (infoParams != null) { + params.putAll(info.getParameters()); + } + info = new DefaultUrlMappingInfo(info, params, grailsApplication); } - info = new DefaultUrlMappingInfo(info, params, grailsApplication); } + mapsToView = info != null && info.getViewName() != null; + mapsToController = !mapsToView && info != null && info.getControllerName() != null; + } + catch (RuntimeException e) { + // An error handler that cannot be resolved for this request - a mapping that computes its controller + // from request state Grails did not set up, for example - must not replace the exception being + // resolved, so the default error view renders that exception instead + LOG.error("Unable to resolve the error handler mapped for [{}]: {}", request.getRequestURI(), e.getMessage(), e); + return mv; } try { - if (info != null && info.getViewName() != null) { + if (mapsToView) { resolveView(request, info, mv); } - else if (info != null && info.getControllerName() != null) { + else if (mapsToController) { if (isErrorHandlerForwardInProgress(request)) { LOG.error("The error handler for this request failed as well; not forwarding to it again"); return mv; } String uri = determineUri(request); + if (GrailsWebRequest.lookup(request) == null) { + LOG.warn("Not forwarding [{}] to the error handler it maps to, because the request has no " + + "GrailsWebRequest to dispatch it with; rendering the default error view instead", uri); + return mv; + } if (!response.isCommitted()) { if (response instanceof GrailsResponseMutator) { // prevent further mutation of the request since an error page needs rendered instead @@ -273,7 +292,7 @@ protected boolean isErrorHandlerForwardInProgress(HttpServletRequest request) { protected void forwardRequest(UrlMappingInfo info, HttpServletRequest request, HttpServletResponse response, ModelAndView mv, String uri) throws ServletException, IOException { - info.configure(WebUtils.retrieveGrailsWebRequest()); + info.configure(GrailsWebRequest.lookup(request)); String forwardUrl = UrlMappingUtils.forwardRequestForUrlMappingInfo( request, response, info, mv.getModel(), true); LOG.debug("Matched URI [{}] to URL mapping [{}], forwarding to [{}] with response [{}]", diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy index 4a2f98e2cc8..7fe2756f4f2 100644 --- a/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy +++ b/grails-web-mvc/src/test/groovy/org/grails/web/errors/GrailsExceptionResolverSpec.groovy @@ -19,6 +19,7 @@ package org.grails.web.errors import grails.config.Config +import grails.core.DefaultGrailsApplication import grails.core.GrailsApplication import grails.web.mapping.UrlMappingInfo import grails.web.mapping.UrlMappingsHolder @@ -27,9 +28,16 @@ import org.apache.grails.core.testing.support.LogCapture import org.grails.exceptions.reporting.DefaultStackTraceFilterer import org.apache.grails.core.GrailsBootstrapRegistryInitializer import org.grails.exceptions.reporting.StackTraceFilterer +import org.grails.web.mapping.DefaultUrlMappingEvaluator +import org.grails.web.mapping.DefaultUrlMappingsHolder +import org.grails.web.mapping.mvc.GrailsControllerUrlMappings +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.grails.web.servlet.view.CompositeViewResolver +import org.grails.web.util.WebUtils as GrailsWebUtils import org.springframework.beans.factory.BeanNotOfRequiredTypeException import org.springframework.beans.factory.NoSuchBeanDefinitionException import org.springframework.context.ApplicationContext +import org.springframework.context.support.StaticApplicationContext import org.springframework.mock.web.MockHttpServletRequest import org.springframework.mock.web.MockHttpServletResponse import org.springframework.mock.web.MockServletContext @@ -40,6 +48,9 @@ import org.springframework.web.util.WebUtils import org.springframework.web.context.WebApplicationContext import org.springframework.web.context.support.StaticWebApplicationContext import org.springframework.web.servlet.ModelAndView +import org.springframework.web.servlet.ViewResolver +import org.springframework.web.servlet.view.InternalResourceView +import spock.lang.Issue import spock.lang.Specification import jakarta.servlet.http.HttpServletRequest @@ -47,6 +58,19 @@ import jakarta.servlet.http.HttpServletResponse class GrailsExceptionResolverSpec extends Specification { + private static final String GRAILS = 'a GrailsWebRequest' + private static final String PLAIN_OVER_GRAILS = 'plain attributes over a stored GrailsWebRequest' + private static final String PLAIN = 'plain attributes' + private static final String NOTHING = 'nothing' + + private final MockServletContext servletContext = new MockServletContext() + private StaticWebApplicationContext webContext + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + webContext?.close() + } + void 'async timeouts resolve as 503 without an error stack trace'() { given: def resolverLog = new LogCapture(GrailsExceptionResolver) @@ -504,6 +528,7 @@ class GrailsExceptionResolverSpec extends Specification { } def request = new MockHttpServletRequest('POST', '/upload/upload') def response = new MockHttpServletResponse() + bind(GRAILS, request, response) when: def result = resolver.resolveViewOrForward(new RuntimeException('boom'), urlMappings, request, response, @@ -538,6 +563,7 @@ class GrailsExceptionResolverSpec extends Specification { } def request = new MockHttpServletRequest('POST', '/upload/upload') def response = new MockHttpServletResponse() + bind(GRAILS, request, response) when: 'two errors are resolved in sequence, as an include and its enclosing request would' resolver.resolveViewOrForward(new RuntimeException('boom'), urlMappings, request, response, @@ -549,43 +575,123 @@ class GrailsExceptionResolverSpec extends Specification { forwards.size() == 2 } - void "resolveViewOrForward does not mask the original exception when a plain ServletRequestAttributes is bound"() { - given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound in RequestContextHolder' - def request = new MockHttpServletRequest('GET', '/fail') - RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) + @Issue('https://github.com/apache/grails-core/issues/16129') + void "an error handler mapped to #handler is forwarded to with #attributes bound"() { + given: + def resolver = resolverFor(mappings, true) + def request = new MockHttpServletRequest(servletContext, 'GET', '/fail') + def response = new MockHttpServletResponse() + bind(attributes, request, response) + def original = new IllegalStateException('original') - and: 'a UrlMappingInfo whose getControllerName() triggers a closure-based name resolution' - def info = Mock(UrlMappingInfo) - info.getViewName() >> null - info.getControllerName() >> 'errors' - def urlMappings = Mock(UrlMappingsHolder) - urlMappings.match(_ as String) >> null - urlMappings.matchStatusCode(500, _ as Throwable) >> null - urlMappings.matchStatusCode(500) >> info + when: + def result = resolver.resolveException(request, response, null, original) + + then: 'the error handler is dispatched with the exception being resolved' + result.empty + response.forwardedUrl == '/errors/serverError' + (request.getAttribute(GrailsExceptionResolver.EXCEPTION_ATTRIBUTE) as Throwable).cause.is(original) + + where: + handler | attributes | mappings + 'a controller' | GRAILS | { '500'(controller: 'errors', action: 'serverError') } + 'a controller' | PLAIN_OVER_GRAILS | { '500'(controller: 'errors', action: 'serverError') } + 'a controller closure' | GRAILS | { '500'(controller: { 'errors' }, action: 'serverError') } + 'a controller closure' | PLAIN_OVER_GRAILS | { '500'(controller: { 'errors' }, action: 'serverError') } + 'an HTTP method map' | PLAIN_OVER_GRAILS | { '500'(controller: 'errors', action: [GET: 'serverError']) } + } - and: 'a resolver that records forwards without actually dispatching' - def forwards = [] - def resolver = new GrailsExceptionResolver() { + @Issue('https://github.com/apache/grails-core/issues/16129') + void "the default error view renders the exception when #handler cannot be forwarded to with #attributes bound"() { + given: + def resolver = resolverFor(mappings, controllerMappings) + def request = new MockHttpServletRequest(servletContext, 'GET', '/fail') + def response = new MockHttpServletResponse() + bind(attributes, request, response) + def original = new IllegalStateException('original') - @Override - protected void forwardRequest(UrlMappingInfo forwarded, HttpServletRequest req, - HttpServletResponse res, ModelAndView mv, String uri) { - forwards << uri - } - } + when: + def result = resolver.resolveException(request, response, null, original) + + then: 'the exception being resolved is not replaced by the failure to reach its error handler' + result.viewName == '/error' + response.forwardedUrl == null + (result.model[GrailsExceptionResolver.EXCEPTION_ATTRIBUTE] as Throwable).cause.is(original) + + where: + handler | attributes | controllerMappings | mappings + 'a controller' | PLAIN | true | { '500'(controller: 'errors', action: 'serverError') } + 'a controller' | NOTHING | true | { '500'(controller: 'errors', action: 'serverError') } + 'a controller closure' | PLAIN | true | { '500'(controller: { 'errors' }, action: 'serverError') } + 'a controller closure' | PLAIN | false | { '500'(controller: { 'errors' }, action: 'serverError') } + 'a controller closure' | NOTHING | true | { '500'(controller: { 'errors' }, action: 'serverError') } + 'an HTTP method map' | PLAIN | true | { '500'(controller: 'errors', action: [GET: 'serverError']) } + 'a controller closure that fails' | GRAILS | true | { '500'(controller: { throw new IllegalStateException('broken mapping') }) } + } + + @Issue('https://github.com/apache/grails-core/issues/16129') + void "an error handler mapped to a view renders with #attributes bound"() { + given: + def resolver = resolverFor({ '500'(view: '/serverError') }, true) + def request = new MockHttpServletRequest(servletContext, 'GET', '/fail') def response = new MockHttpServletResponse() - def originalException = new RuntimeException('original application exception') + bind(attributes, request, response) + def original = new IllegalStateException('original') - when: 'the original exception is resolved while only a plain ServletRequestAttributes is bound' - resolver.resolveViewOrForward(originalException, urlMappings, request, response, new ModelAndView()) + when: + def result = resolver.resolveException(request, response, null, original) - then: 'no ClassCastException is thrown — the original exception is not masked' - noExceptionThrown() + then: + (result.view as InternalResourceView).url == '/serverError' + (result.model[GrailsExceptionResolver.EXCEPTION_ATTRIBUTE] as Throwable).cause.is(original) - and: 'the error handler forward was attempted' - forwards.size() == 1 + where: + attributes << [GRAILS, PLAIN_OVER_GRAILS, PLAIN, NOTHING] + } - cleanup: - RequestContextHolder.resetRequestAttributes() + private GrailsExceptionResolver resolverFor(Closure mappings, boolean controllerMappings) { + def grailsApplication = new DefaultGrailsApplication().tap { + initialise() + } + def evaluatorContext = new StaticApplicationContext() + evaluatorContext.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication) + evaluatorContext.refresh() + UrlMappingsHolder urlMappings = new DefaultUrlMappingsHolder( + new DefaultUrlMappingEvaluator(evaluatorContext).evaluateMappings(mappings)) + if (controllerMappings) { + urlMappings = new GrailsControllerUrlMappings(grailsApplication, urlMappings) + } + ViewResolver viewResolver = { String name, Locale locale -> new InternalResourceView(name) } as ViewResolver + + webContext = new StaticWebApplicationContext() + webContext.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, grailsApplication) + webContext.beanFactory.registerSingleton(UrlMappingsHolder.BEAN_ID, urlMappings) + webContext.beanFactory.registerSingleton(CompositeViewResolver.BEAN_NAME, + new CompositeViewResolver(viewResolvers: [viewResolver])) + webContext.refresh() + servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, webContext) + + def resolver = new GrailsExceptionResolver() + resolver.servletContext = servletContext + resolver.grailsApplication = grailsApplication + resolver.exceptionMappings = ['java.lang.Exception': '/error'] as Properties + resolver + } + + private void bind(String attributes, MockHttpServletRequest request, MockHttpServletResponse response) { + switch (attributes) { + case GRAILS: + GrailsWebUtils.storeGrailsWebRequest(new GrailsWebRequest(request, response, servletContext)) + break + case PLAIN_OVER_GRAILS: + GrailsWebUtils.storeGrailsWebRequest(new GrailsWebRequest(request, response, servletContext)) + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)) + break + case PLAIN: + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)) + break + default: + RequestContextHolder.resetRequestAttributes() + } } } diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java index dc47c7fb97f..54f9c24295f 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/AbstractUrlMappingInfo.java @@ -28,7 +28,10 @@ import groovy.lang.Closure; +import jakarta.servlet.http.HttpServletRequest; + import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.util.UriUtils; import grails.util.GrailsStringUtils; @@ -125,13 +128,24 @@ else if (value instanceof RuntimeConstraintEvaluator) { return evaluateCapturedName((RuntimeConstraintEvaluator) value); } else { - org.springframework.web.context.request.RequestAttributes attrs = - RequestContextHolder.getRequestAttributes(); - GrailsWebRequest webRequest = attrs instanceof GrailsWebRequest ? (GrailsWebRequest) attrs : null; - return evaluateNameForValue(value, webRequest); + return evaluateNameForValue(value, UrlMappingUtils.lookupWebRequest()); } } + /** + * Resolves a controller, action, namespace, view or id name held by this instance. A closure is called with + * the given request as its delegate, and a map of HTTP methods to names is keyed by the method of the request. + * + *

Without a {@code webRequest} but with request attributes bound - a request dispatched by a + * {@code DispatcherServlet} other than the Grails one, without {@code GrailsWebRequestFilter} - a closure is + * not called and resolves to null, since there is no Grails request state for it to read, and a map is keyed + * by the method of the bound request. With no request attributes bound at all, a closure is called without a + * delegate.

+ * + * @param value The name held by this instance + * @param webRequest The current request, or null if there is none + * @return The name, or null if it cannot be resolved + */ protected String evaluateNameForValue(Object value, GrailsWebRequest webRequest) { if (value == null) { return null; @@ -143,7 +157,7 @@ protected String evaluateNameForValue(Object value, GrailsWebRequest webRequest) String name; if (value instanceof Closure) { - if (webRequest == null) { + if (webRequest == null && RequestContextHolder.getRequestAttributes() != null) { return null; } Closure callable = (Closure) value; @@ -154,11 +168,12 @@ protected String evaluateNameForValue(Object value, GrailsWebRequest webRequest) name = result != null ? result.toString() : null; } else if (value instanceof Map) { - if (webRequest == null) { + HttpServletRequest request = webRequest != null ? webRequest.getRequest() : currentRequest(); + if (request == null) { return null; } Map httpMethods = (Map) value; - name = (String) httpMethods.get(HiddenHttpMethod.effectiveMethod(webRequest.getRequest())); + name = (String) httpMethods.get(HiddenHttpMethod.effectiveMethod(request)); } else { name = value.toString(); @@ -166,6 +181,13 @@ else if (value instanceof Map) { return name != null ? name.trim() : null; } + private static HttpServletRequest currentRequest() { + if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes) { + return attributes.getRequest(); + } + return null; + } + /** * Resolves a name the mapping captured from the URI - the {@code $controller} token of * {@code "/$controller/$action?"}, for example - from this instance's own parameters, so that the diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlCreator.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlCreator.java index 78dad3e0f01..47cabf6db46 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlCreator.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlCreator.java @@ -26,8 +26,6 @@ import java.util.Iterator; import java.util.Map; -import org.springframework.web.context.request.RequestContextHolder; - import grails.core.GrailsControllerClass; import grails.util.GrailsStringUtils; import grails.util.GrailsWebUtil; @@ -61,7 +59,7 @@ public DefaultUrlCreator(String controller, String action) { public String createURL(Map parameterValues, String encoding) { if (parameterValues == null) parameterValues = Collections.emptyMap(); - GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes(); + GrailsWebRequest webRequest = UrlMappingUtils.lookupWebRequest(); return createURLWithWebRequest(parameterValues, webRequest, true); } @@ -86,8 +84,9 @@ private String createURLWithWebRequest(Map parameterValues, GrailsWebRequest web } FastStringWriter actualUriBuf = new FastStringWriter(); - if (includeContextPath) { - actualUriBuf.append(requestStateLookupStrategy.getContextPath()); + String contextPath = includeContextPath ? requestStateLookupStrategy.getContextPath() : null; + if (contextPath != null) { + actualUriBuf.append(contextPath); } if (actionName != null) { if (actionName.indexOf(SLASH) > -1) { @@ -127,7 +126,7 @@ public String createURL(String controller, String action, String namespace, Stri } private String createURLInternal(String controller, String action, Map parameterValues, boolean includeContextPath) { - GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes(); + GrailsWebRequest webRequest = UrlMappingUtils.lookupWebRequest(); if (parameterValues == null) parameterValues = new HashMap<>(); boolean blankController = GrailsStringUtils.isBlank(controller); diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java index ea48e4a6d8b..17c753d3b26 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/DefaultUrlMappingInfo.java @@ -28,7 +28,6 @@ import org.springframework.context.ApplicationContext; import org.springframework.util.Assert; -import org.springframework.web.context.request.RequestContextHolder; import grails.core.GrailsApplication; import grails.web.CamelCaseUrlConverter; @@ -37,7 +36,6 @@ import grails.web.mapping.UrlMappingData; import grails.web.mapping.UrlMappingInfo; import grails.web.mapping.exceptions.UrlMappingException; -import org.grails.web.servlet.mvc.GrailsWebRequest; /** * Holds information established from a matched URL. @@ -193,10 +191,7 @@ public String getControllerName() { } public String getActionName() { - org.springframework.web.context.request.RequestAttributes attrs = - RequestContextHolder.getRequestAttributes(); - GrailsWebRequest webRequest = attrs instanceof GrailsWebRequest ? (GrailsWebRequest) attrs : null; - String name = evaluateNameForValue(actionName, webRequest); + String name = evaluateNameForValue(actionName); return urlConverter.toUrlElement(name); } diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/RegexUrlMapping.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/RegexUrlMapping.java index ae65a900746..1b6516f3191 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/RegexUrlMapping.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/RegexUrlMapping.java @@ -38,7 +38,6 @@ import org.springframework.util.Assert; import org.springframework.validation.Errors; import org.springframework.validation.MapBindingResult; -import org.springframework.web.context.request.RequestContextHolder; import grails.core.GrailsApplication; import grails.core.GrailsControllerClass; @@ -375,7 +374,7 @@ private String createURLInternal(Map paramValues, String encoding, boolean inclu String contextPath = ""; if (includeContextPath) { - GrailsWebRequest webRequest = (GrailsWebRequest) RequestContextHolder.getRequestAttributes(); + GrailsWebRequest webRequest = UrlMappingUtils.lookupWebRequest(); if (webRequest != null) { contextPath = webRequest.getContextPath(); } diff --git a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/UrlMappingUtils.java b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/UrlMappingUtils.java index ae22313909b..902d1436767 100644 --- a/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/UrlMappingUtils.java +++ b/grails-web-url-mappings/src/main/groovy/org/grails/web/mapping/UrlMappingUtils.java @@ -34,7 +34,9 @@ import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.context.request.WebRequest; import org.springframework.web.context.support.WebApplicationContextUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -66,6 +68,25 @@ public class UrlMappingUtils { private UrlMappingUtils() { } + /** + * Finds the {@link GrailsWebRequest} of the current request. A {@code DispatcherServlet} other than the + * Grails one - the one MockMvc runs, for example - binds a plain {@link ServletRequestAttributes} over + * the {@code GrailsWebRequest} that {@code GrailsWebRequestFilter} bound, so when the bound attributes + * are not a {@code GrailsWebRequest}, the one the filter stored on the request is used. + * + * @return The GrailsWebRequest, or null if the current request has none + */ + static GrailsWebRequest lookupWebRequest() { + RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); + if (attributes instanceof GrailsWebRequest webRequest) { + return webRequest; + } + if (attributes instanceof ServletRequestAttributes servletAttributes) { + return GrailsWebRequest.lookup(servletAttributes.getRequest()); + } + return null; + } + /** * * @return a Map without entries whose key belongs to UrlMapping#KEYWORDS diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy deleted file mode 100644 index 0b49955b9ae..00000000000 --- a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/AbstractUrlMappingInfoSafeCastSpec.groovy +++ /dev/null @@ -1,150 +0,0 @@ -/* - * 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 org.grails.web.mapping - -import grails.core.DefaultGrailsApplication -import grails.core.GrailsApplication -import grails.web.mapping.UrlMapping -import grails.web.mapping.UrlMappingInfo -import org.grails.support.MockApplicationContext -import org.springframework.mock.web.MockHttpServletRequest -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.web.context.request.RequestContextHolder -import org.springframework.web.context.request.ServletRequestAttributes -import spock.lang.Specification - -/** - * Verifies that {@link AbstractUrlMappingInfo} and {@link DefaultUrlMappingInfo} do not throw a - * {@link ClassCastException} when {@link RequestContextHolder} holds a plain - * {@link ServletRequestAttributes} instead of a {@link org.grails.web.servlet.mvc.GrailsWebRequest}. - * - *

This scenario arises when {@link org.grails.web.errors.GrailsExceptionResolver} resolves an - * exception: Spring's {@code DispatcherServlet} may have bound a {@code ServletRequestAttributes} - * before the Grails filter had a chance to upgrade it to a {@code GrailsWebRequest}. The unconditional - * cast that previously existed in {@code evaluateNameForValue} and {@code getActionName} would then - * throw a {@code ClassCastException}, masking the original application exception. - * - * @see Issue #16129 - */ -class AbstractUrlMappingInfoSafeCastSpec extends Specification { - - def cleanup() { - RequestContextHolder.resetRequestAttributes() - } - - private static UrlMapping closureActionMapping() { - MockApplicationContext ctx = new MockApplicationContext() - ctx.registerMockBean(GrailsApplication.APPLICATION_ID, new DefaultGrailsApplication()) - new DefaultUrlMappingEvaluator(ctx).evaluateMappings { - '/book'(controller: 'book', action: { request.method == 'GET' ? 'show' : 'save' }) - }.first() - } - - private static UrlMapping staticMapping() { - MockApplicationContext ctx = new MockApplicationContext() - ctx.registerMockBean(GrailsApplication.APPLICATION_ID, new DefaultGrailsApplication()) - new DefaultUrlMappingEvaluator(ctx).evaluateMappings { - '/book'(controller: 'book', action: 'show') - }.first() - } - - void 'evaluateNameForValue does not throw ClassCastException when RequestContextHolder holds a plain ServletRequestAttributes'() { - given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound' - def request = new MockHttpServletRequest('GET', '/book') - RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) - - and: 'a mapping whose action is a closure (requires a GrailsWebRequest to evaluate)' - UrlMapping mapping = closureActionMapping() - UrlMappingInfo info = mapping.match('/book') - - when: 'action name is resolved while only a plain ServletRequestAttributes is bound' - String actionName = info.actionName - - then: 'no ClassCastException is thrown; the action gracefully returns null' - noExceptionThrown() - actionName == null - } - - void 'getActionName does not throw ClassCastException when RequestContextHolder holds a plain ServletRequestAttributes'() { - given: 'a plain ServletRequestAttributes (not a GrailsWebRequest) is bound' - def request = new MockHttpServletRequest('GET', '/book') - RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) - - and: 'a mapping whose action is a closure' - UrlMapping mapping = closureActionMapping() - UrlMappingInfo info = mapping.match('/book') - - when: - String actionName = info.actionName - - then: - noExceptionThrown() - actionName == null - } - - void 'evaluateNameForValue does not throw ClassCastException when RequestContextHolder is empty'() { - given: 'no request attributes are bound at all' - RequestContextHolder.resetRequestAttributes() - - and: 'a mapping whose action is a closure' - UrlMapping mapping = closureActionMapping() - UrlMappingInfo info = mapping.match('/book') - - when: - String actionName = info.actionName - - then: - noExceptionThrown() - actionName == null - } - - void 'static string action names are resolved correctly regardless of RequestContextHolder state'() { - given: 'a plain ServletRequestAttributes is bound' - def request = new MockHttpServletRequest('GET', '/book') - RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) - - and: 'a mapping with a static string action name' - UrlMapping mapping = staticMapping() - UrlMappingInfo info = mapping.match('/book') - - when: - String actionName = info.actionName - - then: 'static names are always resolved correctly' - noExceptionThrown() - actionName == 'show' - } - - void 'static string controller names are resolved correctly regardless of RequestContextHolder state'() { - given: 'a plain ServletRequestAttributes is bound' - def request = new MockHttpServletRequest('GET', '/book') - RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, new MockHttpServletResponse())) - - and: 'a mapping with a static string controller name' - UrlMapping mapping = staticMapping() - UrlMappingInfo info = mapping.match('/book') - - when: - String controllerName = info.controllerName - - then: - noExceptionThrown() - controllerName == 'book' - } -} diff --git a/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/UrlMappingInfoRequestAttributesSpec.groovy b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/UrlMappingInfoRequestAttributesSpec.groovy new file mode 100644 index 00000000000..084e0c221a5 --- /dev/null +++ b/grails-web-url-mappings/src/test/groovy/org/grails/web/mapping/UrlMappingInfoRequestAttributesSpec.groovy @@ -0,0 +1,199 @@ +/* + * 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 org.grails.web.mapping + +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +import org.springframework.mock.web.MockServletContext +import org.springframework.web.context.request.RequestContextHolder +import org.springframework.web.context.request.ServletRequestAttributes +import spock.lang.Issue + +import grails.util.GrailsWebMockUtil +import grails.web.mapping.AbstractUrlMappingsSpec +import grails.web.mapping.UrlMappingInfo + +/** + * How a matched mapping resolves its names and creates URLs depending on the request attributes bound to the + * thread. Grails binds a {@code GrailsWebRequest}. A {@code DispatcherServlet} other than the Grails one - the + * one MockMvc runs, for example - binds a plain {@link ServletRequestAttributes} over it, and the + * {@code GrailsWebRequest} is then found on the request, where {@code GrailsWebRequestFilter} stored it. Without + * that filter there is no {@code GrailsWebRequest} at all. + */ +@Issue('https://github.com/apache/grails-core/issues/16129') +class UrlMappingInfoRequestAttributesSpec extends AbstractUrlMappingsSpec { + + private static final String GRAILS = 'a GrailsWebRequest' + private static final String PLAIN_OVER_GRAILS = 'plain attributes over a stored GrailsWebRequest' + private static final String PLAIN = 'plain attributes' + private static final String NOTHING = 'nothing' + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + } + + void 'names a mapping states resolve with #attributes bound'() { + given: + bind(attributes, request('GET', '/book/show/42')) + def mappings = getUrlMappingsHolder { + "/book/show/$id"(controller: 'book', action: 'show') + '/about'(view: '/about') + '/old'(uri: '/new') + } + + when: + UrlMappingInfo controllerInfo = mappings.match('/book/show/42') + UrlMappingInfo viewInfo = mappings.match('/about') + UrlMappingInfo uriInfo = mappings.match('/old') + + then: + with(controllerInfo) { + namespace == null + controllerName == 'book' + actionName == 'show' + viewName == null + id == '42' + } + viewInfo.viewName == '/about' + uriInfo.URI == '/new' + + where: + attributes << [GRAILS, PLAIN_OVER_GRAILS, PLAIN, NOTHING] + } + + void 'a name closure reads the GrailsWebRequest with #attributes bound'() { + given: + def request = request('GET', '/book') + request.addParameter('section', 'gallery') + bind(attributes, request) + def mappings = getUrlMappingsHolder { + '/book'(controller: 'book', action: { params.section }) + } + + expect: + mappings.match('/book').actionName == 'gallery' + + where: + attributes << [GRAILS, PLAIN_OVER_GRAILS] + } + + void 'a name closure is not called when the bound attributes have no GrailsWebRequest'() { + given: + bind(PLAIN, request('GET', '/book')) + def mappings = getUrlMappingsHolder { + '/book'(controller: 'book', action: { throw new IllegalStateException('called') }, id: { throw new IllegalStateException('called') }) + } + + when: + UrlMappingInfo info = mappings.match('/book') + + then: + info.actionName == null + info.id == null + } + + void 'a name closure is called without a delegate when no request attributes are bound'() { + given: + def mappings = getUrlMappingsHolder { + '/book'(controller: 'book', action: { 'show' }) + } + + expect: + mappings.match('/book').actionName == 'show' + } + + void 'a map of HTTP methods selects the name for the request method with #attributes bound'() { + given: + bind(attributes, request('POST', '/book')) + def mappings = getUrlMappingsHolder { + '/book'(controller: 'book', action: [GET: 'show', POST: 'save']) + } + + expect: + mappings.match('/book').actionName == expectedAction + + where: + attributes || expectedAction + GRAILS || 'save' + PLAIN_OVER_GRAILS || 'save' + PLAIN || 'save' + NOTHING || null + } + + void 'a mapping creates its URL with the context path of the GrailsWebRequest with #attributes bound'() { + given: + def request = request('GET', '/book/show') + request.contextPath = '/app' + bind(attributes, request) + def mappings = getUrlMappingsHolder { + '/book/show'(controller: 'book', action: 'show') + } + + expect: + mappings.getReverseMapping('book', 'show', [:]).createURL([:], 'utf-8') == expectedUrl + + where: + attributes || expectedUrl + GRAILS || '/app/book/show' + PLAIN_OVER_GRAILS || '/app/book/show' + PLAIN || '/book/show' + NOTHING || '/book/show' + } + + void 'the default URL creator uses the context path of the GrailsWebRequest with #attributes bound'() { + given: + def request = request('GET', '/book/show') + request.contextPath = '/app' + bind(attributes, request) + def urlCreator = new DefaultUrlCreator('book', 'show') + + expect: + urlCreator.createURL([:], 'utf-8') == expectedUrl + urlCreator.createURL('book', 'show', [:], 'utf-8') == expectedUrl + + where: + attributes || expectedUrl + GRAILS || '/app/book/show' + PLAIN_OVER_GRAILS || '/app/book/show' + PLAIN || '/book/show' + NOTHING || '/book/show' + } + + private static MockHttpServletRequest request(String method, String uri) { + new MockHttpServletRequest(new MockServletContext(), method, uri) + } + + private static void bind(String attributes, MockHttpServletRequest request) { + def response = new MockHttpServletResponse() + switch (attributes) { + case GRAILS: + GrailsWebMockUtil.bindMockWebRequest(request.servletContext, request, response) + break + case PLAIN_OVER_GRAILS: + GrailsWebMockUtil.bindMockWebRequest(request.servletContext, request, response) + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)) + break + case PLAIN: + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request, response)) + break + default: + RequestContextHolder.resetRequestAttributes() + } + } +}