diff --git a/grails-test-examples/issue-15819/README.md b/grails-test-examples/issue-15819/README.md
new file mode 100644
index 00000000000..bb9abae30b4
--- /dev/null
+++ b/grails-test-examples/issue-15819/README.md
@@ -0,0 +1,44 @@
+
+
+# Response rendering regression (#15819)
+
+Functional coverage based on the
+[end-application reproducer](https://github.com/ruthst00/15819-response-renderer-overloads).
+The application registers a Spring `RequestContextFilter` at order `-100`, after
+Grails' request filter, and serves a small GSP fragment from `/search/results`.
+Keep the fragment small and do not flush the response: committing it can mask
+the unwanted conventional view resolution that caused the original failure.
+
+There is deliberately no `results.gsp` or `upload.gsp`. The tests cover template
+rendering with AJAX and normal GET requests and multipart uploads behind a late
+request-context filter. `/search/upload` reads a `title` form field and a `file`
+upload, and renders the title, original filename and uploaded content in the fragment.
+
+`GrailsDispatcherServletRequestBindingSpec` provides the dispatcher regression
+coverage on `7.0.x`, including multipart resolution, request publication, nested
+dispatches and asynchronous processing. It has a distinct name so merge-up can
+retain the existing dispatcher and hidden-method specs on `8.0.x`.
+
+The tests use real HTTP requests against the embedded application; no browser,
+database, or Docker container is needed. From the repository root, run:
+
+```bash
+./gradlew :grails-test-examples-issue-15819:integrationTest
+```
diff --git a/grails-test-examples/issue-15819/build.gradle b/grails-test-examples/issue-15819/build.gradle
new file mode 100644
index 00000000000..34ceb195574
--- /dev/null
+++ b/grails-test-examples/issue-15819/build.gradle
@@ -0,0 +1,44 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+}
+
+version = '0.1'
+group = 'issue15819'
+
+apply plugin: 'org.apache.grails.gradle.grails-web'
+apply plugin: 'org.apache.grails.gradle.grails-gsp'
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+ implementation 'org.apache.grails:grails-dependencies-starter-web'
+
+ integrationTestImplementation 'org.apache.grails.testing:grails-testing-support-core'
+ integrationTestImplementation 'org.apache.grails:grails-testing-support-http-client'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/issue-15819/grails-app/conf/application.yml b/grails-test-examples/issue-15819/grails-app/conf/application.yml
new file mode 100644
index 00000000000..238a2ce1047
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/conf/application.yml
@@ -0,0 +1,23 @@
+# 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.
+
+grails:
+ profile: web
+ views:
+ gsp:
+ encoding: UTF-8
+info:
+ app:
+ name: issue-15819
diff --git a/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy b/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy
new file mode 100644
index 00000000000..866b97d5561
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/conf/spring/resources.groovy
@@ -0,0 +1,30 @@
+/*
+ * 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 spring
+
+import org.springframework.boot.web.servlet.FilterRegistrationBean
+import org.springframework.web.filter.RequestContextFilter
+
+beans = {
+ // Reproduce an application filter replacing the thread binding after Grails' request filter.
+ lateRequestContextFilter(FilterRegistrationBean) {
+ filter = new RequestContextFilter()
+ order = -100
+ }
+}
diff --git a/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy
new file mode 100644
index 00000000000..10b00978603
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/SearchController.groovy
@@ -0,0 +1,35 @@
+/*
+ * 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 issue15819
+
+import grails.compiler.GrailsCompileStatic
+import org.springframework.web.multipart.MultipartHttpServletRequest
+
+@GrailsCompileStatic
+class SearchController {
+
+ def results() {
+ render(template: 'results', model: [results: ['Grails', 'Groovy', 'Spring Boot']])
+ }
+
+ def upload() {
+ def file = ((MultipartHttpServletRequest) request).getFile('file')
+ render(template: 'results', model: [results: [params.title, file.originalFilename, new String(file.bytes, 'UTF-8')]])
+ }
+}
diff --git a/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy
new file mode 100644
index 00000000000..aa0d490cae5
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/controllers/issue15819/UrlMappings.groovy
@@ -0,0 +1,26 @@
+/*
+ * 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 issue15819
+
+class UrlMappings {
+
+ static mappings = {
+ "/search/$action"(controller: 'search')
+ }
+}
diff --git a/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy b/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy
new file mode 100644
index 00000000000..9f2a683ea8e
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/init/issue15819/Application.groovy
@@ -0,0 +1,32 @@
+/*
+ * 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 issue15819
+
+import groovy.transform.CompileStatic
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+
+@CompileStatic
+class Application extends GrailsAutoConfiguration {
+
+ static void main(String[] args) {
+ GrailsApp.run(Application, args)
+ }
+}
diff --git a/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp b/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp
new file mode 100644
index 00000000000..2738fec67a6
--- /dev/null
+++ b/grails-test-examples/issue-15819/grails-app/views/search/_results.gsp
@@ -0,0 +1,23 @@
+<%--
+ 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.
+--%>
+
diff --git a/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy b/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy
new file mode 100644
index 00000000000..0d718fcb1c6
--- /dev/null
+++ b/grails-test-examples/issue-15819/src/integration-test/groovy/issue15819/ResponseRendererFunctionalSpec.groovy
@@ -0,0 +1,72 @@
+/*
+ * 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 issue15819
+
+import spock.lang.Issue
+import spock.lang.Specification
+import spock.lang.Tag
+import spock.lang.Unroll
+
+import grails.testing.mixin.integration.Integration
+import org.apache.grails.testing.http.client.HttpClientSupport
+import org.apache.grails.testing.http.client.MultipartBody
+
+@Integration
+@Tag('http-client')
+@Issue('https://github.com/apache/grails-core/issues/15819')
+class ResponseRendererFunctionalSpec extends Specification implements HttpClientSupport {
+
+ @Unroll
+ void 'an upload renders only the template fragment with ajax=#ajax'() {
+ given:
+ def multipart = MultipartBody.builder()
+ .addPart('title', 'Upload title')
+ .addPart('file', 'results.txt', 'text/plain', 'Uploaded content')
+ .build()
+
+ when:
+ def response = httpPostMultipart(ajax ? ['X-Requested-With': 'XMLHttpRequest'] : [:], '/search/upload', multipart)
+
+ then:
+ response.assertStatus(200)
+ response.headerValue('Content-Type').startsWith('text/html')
+ response.body().replaceAll(/\s+/, ' ').trim() ==
+ ' - Upload title
- results.txt
- Uploaded content
'
+
+ where:
+ ajax << [false, true]
+ }
+
+ @Unroll
+ void '#action renders only the _results.gsp template fragment with ajax=#ajax'() {
+ when:
+ def response = http(ajax ? ['X-Requested-With': 'XMLHttpRequest'] : [:], "/search/$action")
+
+ then: 'no conventional action view is resolved and no layout or error page is appended'
+ response.assertStatus(200)
+ response.headerValue('Content-Type').startsWith('text/html')
+ response.body().replaceAll(/\s+/, ' ').trim() ==
+ ' - Grails
- Groovy
- Spring Boot
'
+
+ where:
+ action | ajax
+ 'results' | false
+ 'results' | true
+ }
+}
diff --git a/grails-web-mvc/build.gradle b/grails-web-mvc/build.gradle
index dfaace63d46..b98c6bc7643 100644
--- a/grails-web-mvc/build.gradle
+++ b/grails-web-mvc/build.gradle
@@ -42,11 +42,13 @@ dependencies {
api 'org.apache.groovy:groovy'
compileOnly 'jakarta.servlet:jakarta.servlet-api'
+ compileOnly 'org.apache.grails.views:grails-gsp-core'
compileOnly 'org.springframework:spring-test', {
// MockHttpServletRequest/Response/Context used in many classes
}
testImplementation 'jakarta.servlet:jakarta.servlet-api'
+ testImplementation 'org.apache.grails.views:grails-gsp-core'
testImplementation 'org.springframework:spring-test'
testImplementation 'org.apache.groovy:groovy-test-junit5'
diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy
index 8b099390a35..8c96a5fcea3 100644
--- a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy
+++ b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServlet.groovy
@@ -34,6 +34,7 @@ import org.springframework.web.servlet.DispatcherServlet
import grails.util.Holders
import org.grails.web.context.ServletEnvironmentGrailsApplicationDiscoveryStrategy
+import org.grails.web.util.GrailsApplicationAttributes
import org.grails.web.util.WebUtils
/**
@@ -54,18 +55,21 @@ class GrailsDispatcherServlet extends DispatcherServlet implements ServletContex
@Override
protected ServletRequestAttributes buildRequestAttributes(HttpServletRequest request, HttpServletResponse response, RequestAttributes previousAttributes) {
- if (previousAttributes == null || !(previousAttributes instanceof GrailsWebRequest)) {
- return buildGrailsWebRequest(request, response)
- }
- else {
- GrailsWebRequest webRequest = (GrailsWebRequest) previousAttributes
- if (webRequest.isActive()) {
- return webRequest
- }
- else {
- return buildGrailsWebRequest(request, response)
+ if (previousAttributes instanceof GrailsWebRequest) {
+ GrailsWebRequest previousWebRequest = (GrailsWebRequest) previousAttributes
+ if (previousWebRequest.isActive()) {
+ return previousWebRequest
}
}
+ GrailsWebRequest webRequest = buildGrailsWebRequest(request, response)
+ if (previousAttributes != null) {
+ // A later filter replaced the binding. Publish the dispatch's instance so URL mappings
+ // and rendering agree, while preserving the request and response wrappers seen here.
+ // FrameworkServlet passes null on Callable threads, which need their own instance
+ // without replacing the attribute still used by the dispatch.
+ request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, webRequest)
+ }
+ return webRequest
}
protected GrailsWebRequest buildGrailsWebRequest(HttpServletRequest request, HttpServletResponse response) {
diff --git a/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy b/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy
new file mode 100644
index 00000000000..7a953b665b3
--- /dev/null
+++ b/grails-web-mvc/src/test/groovy/org/grails/web/servlet/mvc/GrailsDispatcherServletRequestBindingSpec.groovy
@@ -0,0 +1,362 @@
+/*
+ * 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.servlet.mvc
+
+import java.util.concurrent.Callable
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+
+import jakarta.servlet.DispatcherType
+import jakarta.servlet.FilterChain
+import jakarta.servlet.http.HttpServletRequestWrapper
+import jakarta.servlet.http.HttpServletResponseWrapper
+
+import org.springframework.mock.web.MockHttpServletRequest
+import org.springframework.mock.web.MockHttpServletResponse
+import org.springframework.mock.web.MockMultipartFile
+import org.springframework.mock.web.MockMultipartHttpServletRequest
+import org.springframework.mock.web.MockServletConfig
+import org.springframework.mock.web.MockServletContext
+import org.springframework.core.task.SimpleAsyncTaskExecutor
+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.async.WebAsyncUtils
+import org.springframework.web.context.support.StaticWebApplicationContext
+import org.springframework.web.filter.RequestContextFilter
+import org.springframework.web.multipart.MultipartResolver
+import org.springframework.web.servlet.handler.SimpleUrlHandlerMapping
+import org.springframework.web.servlet.mvc.Controller
+
+import org.grails.web.util.GrailsApplicationAttributes
+import org.grails.web.util.WebUtils
+
+import spock.lang.Issue
+import spock.lang.Specification
+
+class GrailsDispatcherServletRequestBindingSpec extends Specification {
+
+ MockServletContext servletContext = new MockServletContext()
+
+ @Issue('https://github.com/apache/grails-core/issues/15819')
+ void 'a multipart dispatch publishes the request used to read uploaded files after a late filter'() {
+ given:
+ def context = new StaticWebApplicationContext()
+ context.servletContext = servletContext
+ def request = new MockHttpServletRequest(servletContext, 'POST', '/upload')
+ request.contentType = 'multipart/form-data; boundary=test'
+ def response = new MockHttpServletResponse()
+ def resolvedRequest = new MockMultipartHttpServletRequest(servletContext)
+ resolvedRequest.method = 'POST'
+ resolvedRequest.requestURI = '/upload'
+ resolvedRequest.addParameter('title', 'uploaded title')
+ resolvedRequest.addFile(new MockMultipartFile('file', 'test.txt', 'text/plain', 'uploaded content'.bytes))
+ def resolver = Mock(MultipartResolver)
+ context.beanFactory.registerSingleton('multipartResolver', resolver)
+ GrailsWebRequest filterRequest
+ GrailsWebRequest dispatchRequest
+ def controller = { req, res ->
+ dispatchRequest = GrailsWebRequest.lookup()
+ assert dispatchRequest.is(GrailsWebRequest.lookup(req))
+ assert !dispatchRequest.is(filterRequest)
+ assert dispatchRequest.active
+ assert dispatchRequest.params.title == 'uploaded title'
+ def multipartRequest = dispatchRequest.currentRequest
+ assert multipartRequest.is(resolvedRequest)
+ assert multipartRequest.getFile('file').originalFilename == 'test.txt'
+ assert new String(multipartRequest.getFile('file').bytes, 'UTF-8') == 'uploaded content'
+ null
+ } as Controller
+ def mapping = new SimpleUrlHandlerMapping(['/upload': controller], 0)
+ context.beanFactory.registerSingleton('handlerMapping', mapping)
+ context.refresh()
+ mapping.setApplicationContext(context)
+ def servlet = new GrailsDispatcherServlet(context)
+ servlet.init(new MockServletConfig(servletContext))
+ def filter = new GrailsWebRequestFilter()
+ filter.setServletContext(servletContext)
+ filter.setApplicationContext(context)
+
+ when:
+ filter.doFilter(request, response, { req, res ->
+ filterRequest = GrailsWebRequest.lookup()
+ new RequestContextFilter().doFilter(req, res, { lateRequest, lateResponse ->
+ servlet.service(lateRequest, lateResponse)
+ } as FilterChain)
+ } as FilterChain)
+
+ then:
+ 1 * resolver.isMultipart(request) >> true
+ 1 * resolver.resolveMultipart(request) >> resolvedRequest
+ dispatchRequest != null
+ !dispatchRequest.active
+ RequestContextHolder.requestAttributes == null
+
+ cleanup:
+ RequestContextHolder.resetRequestAttributes()
+ servlet?.destroy()
+ context.close()
+ }
+
+ @Issue('https://github.com/apache/grails-core/issues/15819')
+ void 'service selects an active request and restores the previous binding for #binding with #dispatchType'() {
+ given:
+ def context = new StaticWebApplicationContext()
+ context.servletContext = servletContext
+ def request = new MockHttpServletRequest(servletContext, 'GET', '/test')
+ request.dispatcherType = dispatchType
+ def response = new MockHttpServletResponse()
+ def existing = new GrailsWebRequest(request, response, servletContext)
+ existing.params['retained'] = 'value'
+ if (binding != 'missing' && binding != 'threadOnly') {
+ request.setAttribute(GrailsApplicationAttributes.WEB_REQUEST, existing)
+ }
+ if (binding == 'completed' || binding == 'completedAttribute') {
+ existing.requestCompleted()
+ }
+ def previous = binding in ['thread', 'threadOnly', 'completed']
+ ? existing
+ : (binding in ['plain', 'completedAttribute'] ? new ServletRequestAttributes(request, response) : null)
+ RequestContextHolder.setRequestAttributes(previous)
+ GrailsWebRequest seen
+ int completed = 0
+ def controller = { req, res ->
+ seen = GrailsWebRequest.lookup()
+ if (previous != null && binding != 'threadOnly') {
+ assert seen.is(GrailsWebRequest.lookup(req))
+ }
+ assert seen.active
+ seen.registerDestructionCallback('completion', { completed++ } as Runnable, RequestAttributes.SCOPE_REQUEST)
+ null
+ } as Controller
+ def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0)
+ context.beanFactory.registerSingleton('handlerMapping', mapping)
+ context.refresh()
+ mapping.setApplicationContext(context)
+ def servlet = new GrailsDispatcherServlet(context)
+ servlet.init(new MockServletConfig(servletContext))
+
+ when:
+ servlet.service(request, response)
+
+ then:
+ seen != null
+ seen.is(existing) == reuse
+ !reuse || seen.params.retained == 'value'
+ !seen.active
+ completed == 1
+ RequestContextHolder.requestAttributes.is(previous)
+ previous != null || GrailsWebRequest.lookup(request).is(binding == 'attribute' ? existing : null)
+
+ cleanup:
+ RequestContextHolder.resetRequestAttributes()
+ servlet?.destroy()
+ context.close()
+
+ where:
+ binding | dispatchType | reuse
+ 'missing' | DispatcherType.REQUEST | false
+ 'attribute' | DispatcherType.REQUEST | false
+ 'thread' | DispatcherType.REQUEST | true
+ 'threadOnly' | DispatcherType.REQUEST | true
+ 'plain' | DispatcherType.REQUEST | false
+ 'completed' | DispatcherType.REQUEST | false
+ 'completed' | DispatcherType.ASYNC | false
+ 'completed' | DispatcherType.ERROR | false
+ 'completedAttribute' | DispatcherType.ASYNC | false
+ 'completedAttribute' | DispatcherType.ERROR | false
+ }
+
+ @Issue('https://github.com/apache/grails-core/issues/15819')
+ void 'nested #dispatchType through a late filter restores the enclosing Grails request'() {
+ given:
+ def context = new StaticWebApplicationContext()
+ context.servletContext = servletContext
+ def request = new MockHttpServletRequest(servletContext, 'GET', '/test')
+ request.dispatcherType = dispatchType
+ request.setAttribute(dispatchAttribute, '/test')
+ def response = new MockHttpServletResponse()
+ def outer = new GrailsWebRequest(request, response, servletContext)
+ outer.renderView = false
+ WebUtils.storeGrailsWebRequest(outer)
+ GrailsWebRequest inner
+ def controller = { req, res ->
+ inner = GrailsWebRequest.lookup()
+ assert inner.is(GrailsWebRequest.lookup(req))
+ assert inner.active
+ assert !inner.is(outer)
+ null
+ } as Controller
+ def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0)
+ context.beanFactory.registerSingleton('handlerMapping', mapping)
+ context.refresh()
+ mapping.setApplicationContext(context)
+ def servlet = new GrailsDispatcherServlet(context)
+ servlet.init(new MockServletConfig(servletContext))
+ def filter = new GrailsWebRequestFilter()
+ filter.setServletContext(servletContext)
+ filter.setApplicationContext(context)
+
+ when:
+ filter.doFilter(request, response, { req, res ->
+ new RequestContextFilter().doFilter(req, res, { nestedRequest, nestedResponse ->
+ servlet.service(nestedRequest, nestedResponse)
+ } as FilterChain)
+ } as FilterChain)
+
+ then:
+ inner != null
+ !inner.active
+ GrailsWebRequest.lookup().is(outer)
+ GrailsWebRequest.lookup(request).is(outer)
+ outer.active
+ !outer.renderView
+
+ cleanup:
+ RequestContextHolder.resetRequestAttributes()
+ servlet?.destroy()
+ context.close()
+
+ where:
+ dispatchType | dispatchAttribute
+ DispatcherType.INCLUDE | WebUtils.INCLUDE_REQUEST_URI_ATTRIBUTE
+ DispatcherType.FORWARD | WebUtils.FORWARD_REQUEST_URI_ATTRIBUTE
+ }
+
+ @Issue('https://github.com/apache/grails-core/issues/15819')
+ void 'a late filter preserves the request and response wrappers seen by the dispatcher'() {
+ given:
+ def context = new StaticWebApplicationContext()
+ context.servletContext = servletContext
+ def request = new MockHttpServletRequest(servletContext, 'GET', '/test')
+ def response = new MockHttpServletResponse()
+ def wrappedRequest = new HttpServletRequestWrapper(request) {
+ @Override
+ boolean isUserInRole(String role) {
+ role == 'ADMIN'
+ }
+ }
+ def wrappedResponse = new HttpServletResponseWrapper(response)
+ GrailsWebRequest filterRequest
+ GrailsWebRequest dispatchRequest
+ def controller = { req, res ->
+ dispatchRequest = GrailsWebRequest.lookup()
+ assert dispatchRequest.request.is(wrappedRequest)
+ assert dispatchRequest.response.is(wrappedResponse)
+ assert dispatchRequest.request.isUserInRole('ADMIN')
+ assert req.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchRequest)
+ null
+ } as Controller
+ def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0)
+ context.beanFactory.registerSingleton('handlerMapping', mapping)
+ context.refresh()
+ mapping.setApplicationContext(context)
+ def servlet = new GrailsDispatcherServlet(context)
+ servlet.init(new MockServletConfig(servletContext))
+ def filter = new GrailsWebRequestFilter()
+ filter.setServletContext(servletContext)
+ filter.setApplicationContext(context)
+
+ when:
+ filter.doFilter(request, response, { req, res ->
+ filterRequest = GrailsWebRequest.lookup()
+ new RequestContextFilter().doFilter(wrappedRequest, wrappedResponse, { lateRequest, lateResponse ->
+ servlet.service(lateRequest, lateResponse)
+ } as FilterChain)
+ } as FilterChain)
+
+ then:
+ dispatchRequest != null
+ !dispatchRequest.is(filterRequest)
+ RequestContextHolder.requestAttributes == null
+
+ cleanup:
+ RequestContextHolder.resetRequestAttributes()
+ servlet?.destroy()
+ context.close()
+ }
+
+ @Issue('https://github.com/apache/grails-core/issues/15819')
+ void 'a Callable keeps an active request binding after the initial dispatch returns'() {
+ given:
+ def context = new StaticWebApplicationContext()
+ context.servletContext = servletContext
+ def started = new CountDownLatch(1)
+ def proceed = new CountDownLatch(1)
+ def done = new CountDownLatch(1)
+ RequestAttributes dispatchAttributes = null
+ RequestAttributes callableAttributes = null
+ Object probe = null
+ Throwable failure = null
+ def controller = { req, res ->
+ dispatchAttributes = RequestContextHolder.requestAttributes
+ def asyncManager = WebAsyncUtils.getAsyncManager(req)
+ asyncManager.taskExecutor = new SimpleAsyncTaskExecutor()
+ asyncManager.asyncWebRequest = WebAsyncUtils.createAsyncWebRequest(req, res)
+ asyncManager.startCallableProcessing({ ->
+ try {
+ callableAttributes = RequestContextHolder.requestAttributes
+ assert req.getAttribute(GrailsApplicationAttributes.WEB_REQUEST).is(dispatchAttributes)
+ started.countDown()
+ proceed.await(5, TimeUnit.SECONDS)
+ probe = RequestContextHolder.currentRequestAttributes().getAttribute('probe', RequestAttributes.SCOPE_REQUEST)
+ }
+ catch (Throwable t) {
+ failure = t
+ }
+ finally {
+ done.countDown()
+ }
+ 'ok'
+ } as Callable)
+ assert started.await(5, TimeUnit.SECONDS)
+ null
+ } as Controller
+ def mapping = new SimpleUrlHandlerMapping(['/test': controller], 0)
+ context.beanFactory.registerSingleton('handlerMapping', mapping)
+ context.refresh()
+ mapping.setApplicationContext(context)
+ def servlet = new GrailsDispatcherServlet(context)
+ servlet.init(new MockServletConfig(servletContext))
+ def filter = new GrailsWebRequestFilter()
+ filter.setServletContext(servletContext)
+ filter.setApplicationContext(context)
+ def request = new MockHttpServletRequest(servletContext, 'GET', '/test')
+ request.asyncSupported = true
+ request.setAttribute('probe', 'value')
+
+ when:
+ filter.doFilter(request, new MockHttpServletResponse(), { req, res -> servlet.service(req, res) } as FilterChain)
+ proceed.countDown()
+ def finished = done.await(5, TimeUnit.SECONDS)
+
+ then:
+ finished
+ failure == null
+ probe == 'value'
+ !callableAttributes.is(dispatchAttributes)
+
+ cleanup:
+ proceed.countDown()
+ RequestContextHolder.resetRequestAttributes()
+ servlet?.destroy()
+ context.close()
+ }
+
+}
diff --git a/settings.gradle b/settings.gradle
index 83bd87bb709..270891ecddc 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -472,6 +472,7 @@ include(
'grails-test-examples-gsp-layout',
'grails-test-examples-gsp-sitemesh3',
'grails-test-examples-gsp-spring-boot',
+ 'grails-test-examples-issue-15819',
'grails-test-examples-issue-698-domain-save-npe',
'grails-test-examples-hyphenated',
'grails-test-examples-issue-views-182',
@@ -528,6 +529,7 @@ project(':grails-test-examples-plugins-loadafter').projectDir = file('grails-tes
project(':grails-test-examples-plugins-issue11005').projectDir = file('grails-test-examples/plugins/issue11005')
project(':grails-test-examples-issue-11767').projectDir = file('grails-test-examples/issue-11767')
project(':grails-test-examples-issue-15228').projectDir = file('grails-test-examples/issue-15228')
+project(':grails-test-examples-issue-15819').projectDir = file('grails-test-examples/issue-15819')
project(':grails-test-examples-issue-10279').projectDir = file('grails-test-examples/issue-10279')
project(':grails-test-examples-plugins-exploded').projectDir = file('grails-test-examples/plugins/exploded')
project(':grails-test-examples-plugins-issue-11767').projectDir = file('grails-test-examples/plugins/issue-11767')