diff --git a/example/grails-app/views/_requestState.gsp b/example/grails-app/views/_requestState.gsp new file mode 100644 index 0000000..7f49023 --- /dev/null +++ b/example/grails-app/views/_requestState.gsp @@ -0,0 +1,8 @@ + + +

${request.getAttribute('foo')}

+

${params.q}

+

${params.id}

+

${session.getAttribute('user')}

+ + diff --git a/example/grails-app/views/_taglib.gsp b/example/grails-app/views/_taglib.gsp new file mode 100644 index 0000000..054f4b1 --- /dev/null +++ b/example/grails-app/views/_taglib.gsp @@ -0,0 +1,5 @@ + + + + + diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy new file mode 100644 index 0000000..324c832 --- /dev/null +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/ProductionEnvironmentSpec.groovy @@ -0,0 +1,29 @@ +package grails.plugins.rendering.document + +import grails.util.Environment +import org.grails.web.servlet.WrappedResponseHolder +import org.springframework.web.context.request.RequestContextHolder +import spock.lang.Specification + +abstract class ProductionEnvironmentSpec extends Specification { + + private String previousEnv + + def setup() { + previousEnv = System.getProperty(Environment.KEY) + System.setProperty(Environment.KEY, Environment.PRODUCTION.name) + assert Environment.current == Environment.PRODUCTION + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } + + def cleanup() { + if (previousEnv == null) { + System.clearProperty(Environment.KEY) + } else { + System.setProperty(Environment.KEY, previousEnv) + } + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } +} diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy new file mode 100644 index 0000000..700ab5e --- /dev/null +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/RenderEnvironmentSpec.groovy @@ -0,0 +1,219 @@ +package grails.plugins.rendering.document + +import grails.core.GrailsApplication +import grails.testing.mixin.integration.Integration +import grails.util.GrailsWebMockUtil +import org.grails.web.servlet.WrappedResponseHolder +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.context.support.StaticApplicationContext +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse +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.servlet.DispatcherServlet +import org.springframework.web.servlet.i18n.FixedLocaleResolver +import org.springframework.web.servlet.support.RequestContextUtils + +@Integration +class RenderEnvironmentSpec extends ProductionEnvironmentSpec { + + GrailsApplication grailsApplication + + def "bind a web request when one does not exist"() { + given: + def out = new StringWriter() + GrailsWebRequest bound = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, out) { + bound = GrailsWebRequest.lookup() + } + + then: + bound != null + bound.out.is(out) + } + + def "leave no request bound afterwards"() { + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + + then: + RequestContextHolder.requestAttributes == null + WrappedResponseHolder.wrappedResponse == null + } + + def "reuse the callers request and restore it afterwards"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + original.controllerName = 'book' + def callerOut = new StringWriter() + original.out = callerOut + def callerLocaleResolver = new FixedLocaleResolver(Locale.ITALIAN) + original.currentRequest.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, callerLocaleResolver) + def layoutBufferingResponse = new MockHttpServletResponse() + WrappedResponseHolder.wrappedResponse = layoutBufferingResponse + def renderOut = new StringWriter() + GrailsWebRequest bound = null + Writer boundOut = null + String controllerName = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, renderOut) { RenderEnvironment env -> + bound = GrailsWebRequest.lookup() + boundOut = bound.out + controllerName = env.controllerName + } + + then: + bound.is(original) + boundOut.is(renderOut) + controllerName == 'book' + RequestContextHolder.requestAttributes.is(original) + original.out.is(callerOut) + original.currentRequest.getAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE).is(callerLocaleResolver) + WrappedResponseHolder.wrappedResponse.is(layoutBufferingResponse) + } + + def "restore state when the block throws"() { + given: + def callerOut = new StringWriter() + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + original.out = callerOut + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { + throw new IllegalStateException('boom') + } + + then: + thrown(IllegalStateException) + RequestContextHolder.requestAttributes.is(original) + original.out.is(callerOut) + } + + def "restore the outer renders writer after a nested render"() { + given: + def outerOut = new StringWriter() + Writer afterInner = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, outerOut) { + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + afterInner = GrailsWebRequest.lookup().out + } + + then: + afterInner.is(outerOut) + RequestContextHolder.requestAttributes == null + } + + def "render locale is #expected when explicit=#explicit and request=#requestLocale"() { + given: + if (requestLocale) { + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + (original.currentRequest as MockHttpServletRequest).addPreferredLocale(requestLocale) + } + Locale seen = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), explicit) { + seen = RequestContextUtils.getLocale(GrailsWebRequest.lookup().currentRequest) + } + + then: + seen == expected + + where: + explicit | requestLocale | expected + Locale.FRENCH | Locale.GERMAN | Locale.FRENCH + null | Locale.GERMAN | Locale.GERMAN + null | null | Locale.default + } + + def "expose the application context path outside of a web request"() { + given: + String contextPath = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + contextPath = GrailsWebRequest.lookup().contextPath + }) + + then: + contextPath == '/rendering' + } + + def "inherit the locale of a plain servlet request"() { + given: + def request = new MockHttpServletRequest() + request.addPreferredLocale(Locale.JAPANESE) + def plain = new ServletRequestAttributes(request) + RequestContextHolder.requestAttributes = plain + Locale seen = null + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + seen = RequestContextUtils.getLocale(GrailsWebRequest.lookup().currentRequest) + }) + + then: + seen == Locale.JAPANESE + RequestContextHolder.requestAttributes.is(plain) + } + + def "run request destruction callbacks registered during the render"() { + given: + boolean destroyed = false + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter(), { + GrailsWebRequest.lookup().registerDestructionCallback('probe', { destroyed = true } as Runnable, + RequestAttributes.SCOPE_REQUEST) + }) + + then: + destroyed + } + + def "leave the caller's request destruction callbacks alone"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + boolean destroyed = false + original.registerDestructionCallback('probe', { destroyed = true } as Runnable, RequestAttributes.SCOPE_REQUEST) + + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) {} + + then: + !destroyed + } + + def "restore state when a destruction callback throws"() { + when: + RenderEnvironment.with(grailsApplication.mainContext, new StringWriter()) { + GrailsWebRequest.lookup().registerDestructionCallback('probe', { throw new IllegalStateException('boom') } as Runnable, RequestAttributes.SCOPE_REQUEST) + } + + then: + thrown(IllegalStateException) + RequestContextHolder.requestAttributes == null + WrappedResponseHolder.wrappedResponse == null + } + + def "bind a web request for a non web application context"() { + given: + def out = new StringWriter() + GrailsWebRequest bound = null + + when: + RenderEnvironment.with(new StaticApplicationContext(), out) { + bound = GrailsWebRequest.lookup() + } + + then: + bound.out.is(out) + RequestContextHolder.requestAttributes == null + } +} diff --git a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy index b31b8c7..3ca3e95 100644 --- a/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy +++ b/example/src/integration-test/groovy/grails/plugins/rendering/document/XhtmlDocumentServiceSpec.groovy @@ -15,18 +15,90 @@ */ package grails.plugins.rendering.document +import grails.core.GrailsApplication import grails.testing.mixin.integration.Integration -import spock.lang.Specification +import grails.util.GrailsWebMockUtil +import org.grails.web.servlet.mvc.GrailsWebRequest +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.web.context.request.RequestContextHolder +import org.w3c.dom.Document -/** - * The xhtmlDocumentService is exercised in the rendering - * service tests. This is here for testing future functionality. - */ @Integration -class XhtmlDocumentServiceSpec extends Specification { +class XhtmlDocumentServiceSpec extends ProductionEnvironmentSpec { + + XhtmlDocumentService xhtmlDocumentService + GrailsApplication grailsApplication + + def "render a taglib template outside web request"() { + when: + Document document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'from a job']) + + then: + hiddenFieldValue(document) == 'from a job' + RequestContextHolder.requestAttributes == null + } + + def "render taglib template on a background thread"() { + given: + Document document = null + Throwable error = null + + when: + def thread = Thread.start { + try { + document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'from a thread']) + } catch (Throwable t) { + error = t + } + } + thread.join(30_000) + + then: + !thread.alive + error == null + hiddenFieldValue(document) == 'from a thread' + } - def xhtmlDocumentService + def "does not write into current request's output"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + def requestOut = new StringWriter() + original.out = requestOut + + when: + Document document = xhtmlDocumentService.createDocument(template: '/taglib', model: [value: 'in a request']) + + then: + hiddenFieldValue(document) == 'in a request' + original.out.is(requestOut) + requestOut.toString() == '' + RequestContextHolder.requestAttributes.is(original) + } + + def "renders template that reads caller's request state"() { + given: + GrailsWebRequest original = GrailsWebMockUtil.bindMockWebRequest(grailsApplication.mainContext) + MockHttpServletRequest request = original.currentRequest as MockHttpServletRequest + request.setAttribute('foo', 'bar') + request.addParameter('q', 'query') + request.session.setAttribute('user', 'alice') + original.params.id = '42' + + when: + Document document = xhtmlDocumentService.createDocument(template: '/requestState') + + then: + paragraphs(document) == ['bar', 'query', '42', 'alice'] + original.currentResponse.contentType == null + RequestContextHolder.requestAttributes.is(original) + } + + private static String hiddenFieldValue(Document document) { + document.getElementsByTagName('input').item(0).getAttribute('value') + } - def emptyScenario() { + private static List paragraphs(Document document) { + def nodes = document.getElementsByTagName('p') + (0.. throwOnGet = { String name -> false } + Closure throwOnSet = { String name, Object value -> false } + int setThrows + + ScriptedRequest(MockServletContext ctx) { super(ctx) } + + @Override + Object getAttribute(String name) { + if (armed && throwOnGet(name)) { + throw new IllegalStateException("boom-get $name") + } + super.getAttribute(name) + } + + @Override + void setAttribute(String name, Object value) { + if (armed && throwOnSet(name, value)) { + throw new IllegalStateException("boom-set #${++setThrows}") + } + super.setAttribute(name, value) + } + + Object raw(String name) { super.getAttribute(name) } + } + + MockServletContext servletContext = new MockServletContext() + GenericWebApplicationContext ctx = new GenericWebApplicationContext(servletContext) + StringWriter callerOut = new StringWriter() + AcceptHeaderLocaleResolver callerResolver = new AcceptHeaderLocaleResolver() + MockHttpServletResponse callerWrapped = new MockHttpServletResponse() + ScriptedRequest callerRequest + + def setup() { + ctx.refresh() + callerRequest = new ScriptedRequest(servletContext) + def callerWebRequest = new GrailsWebRequest(callerRequest, new MockHttpServletResponse(), servletContext, ctx) + callerRequest.setAttribute(GrailsApplicationAttributes.OUT, callerOut) + callerRequest.setAttribute(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE, callerResolver) + RequestContextHolder.setRequestAttributes(callerWebRequest) + WrappedResponseHolder.wrappedResponse = callerWrapped + } + + def cleanup() { + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + } + + private static boolean thrownFrom(Throwable throwable, String method) { + throwable.stackTrace.find { + it.className == RenderEnvironment.class.name + }?.methodName == method + } + + def "surface the init failure and leave nothing bound when there is no caller request"() { + given: + RequestContextHolder.resetRequestAttributes() + WrappedResponseHolder.wrappedResponse = null + + when: + RenderEnvironment.with(ctx, null) {} + + then: + def ex = thrown(NullPointerException) + thrownFrom(ex, 'init') + RequestContextHolder.requestAttributes == null + WrappedResponseHolder.wrappedResponse == null + } + + def "surface the init failure and keep the caller's request state"() { + when: + RenderEnvironment.with(ctx, null) {} + + then: + def ex = thrown(NullPointerException) + thrownFrom(ex, 'init') + callerRequest.raw(GrailsApplicationAttributes.OUT).is(callerOut) + callerRequest.raw(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE).is(callerResolver) + WrappedResponseHolder.wrappedResponse.is(callerWrapped) + } + + def "keep a plain caller binding when init fails"() { + given: + def plain = new ServletRequestAttributes(new MockHttpServletRequest()) + RequestContextHolder.setRequestAttributes(plain) + + when: + RenderEnvironment.with(ctx, null) {} + + then: + def ex = thrown(NullPointerException) + thrownFrom(ex, 'init') + RequestContextHolder.requestAttributes.is(plain) + } + + def "keep caller's state when reading its #attribute fails"() { + given: + callerRequest.throwOnGet = { String name -> name == attribute } + callerRequest.armed = true + + when: + RenderEnvironment.with(ctx, new StringWriter(), Locale.FRENCH) {} + + then: + def ex = thrown(IllegalStateException) + ex.message == "boom-get $attribute" + callerRequest.raw(GrailsApplicationAttributes.OUT).is(callerOut) + callerRequest.raw(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE).is(callerResolver) + WrappedResponseHolder.wrappedResponse.is(callerWrapped) + + where: + attribute << [GrailsApplicationAttributes.OUT, DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE] + } + + def "restore the caller's state when setting the render writer fails"() { + given: + callerRequest.throwOnSet = { String name, Object value -> name == GrailsApplicationAttributes.OUT && callerRequest.setThrows == 0 } + callerRequest.armed = true + + when: + RenderEnvironment.with(ctx, new StringWriter(), Locale.FRENCH) {} + + then: + def ex = thrown(IllegalStateException) + ex.message == 'boom-set #1' + callerRequest.raw(DispatcherServlet.LOCALE_RESOLVER_ATTRIBUTE).is(callerResolver) + callerRequest.raw(GrailsApplicationAttributes.OUT).is(callerOut) + WrappedResponseHolder.wrappedResponse.is(callerWrapped) + } + + def "keep the outer render's writer when a nested init fails"() { + given: + def outerOut = new StringWriter() + Object outInsideOuterAfterNestedFailure = null + + when: + RenderEnvironment.with(ctx, outerOut, Locale.FRENCH) { + try { + RenderEnvironment.with(ctx, null) {} + } catch (NullPointerException ignored) { + } + outInsideOuterAfterNestedFailure = callerRequest.raw(GrailsApplicationAttributes.OUT) + } + + then: + outInsideOuterAfterNestedFailure.is(outerOut) + callerRequest.raw(GrailsApplicationAttributes.OUT).is(callerOut) + } +}