Run GSP in a Spring Boot application without the Grails plugin lifecycle - #16184
codeconsole wants to merge 55 commits into
Conversation
…gistered
CoreGrailsPlugin registers a Groovy aware auto-proxy creator under Spring's
internalAutoProxyCreator bean name, and Spring rejects a creator class it does
not know as soon as anything asks it to register or escalate one - Boot's
AopAutoConfiguration, @EnableAspectJAutoProxy, the aop namespace:
Class name [org.grails.spring.aop.autoproxy.
GroovyAwareInfrastructureAdvisorAutoProxyCreator]
is not a known auto-proxy creator class
The reflection patch that makes them known has sat in a GrailsAutoConfiguration
static initializer since 2015, which held for as long as the plugin lifecycle
ran through that class. Since the lifecycle was retimed it runs from
GrailsPluginLifecycleInitializer for every Spring Boot application with
grails-core on the class path, so an application that is not a Grails
application registers the creator without ever loading GrailsAutoConfiguration,
and fails to start.
Move the patch to GroovyAwareAutoProxyCreators and apply it where the creator is
registered. It is idempotent, so both call sites can apply it, and it warns
rather than leaving the failure to surface later as Spring's error.
Four things stood between a plain Spring Boot application and a decorated GSP, leaving the gsp-spring-boot example unable to start and its runtime test disabled: - the tag library beans were autowired by name, which injected the view resolver eagerly - viewResolver is an alias of gspViewResolver - and defeated the @lazy that RenderSitemeshTagLib declares to break exactly that cycle - <g:applyLayout> resolves its layout through a bean qualified jspViewResolver, the name Grails gives an application's own view resolver, which a standalone application has no bean for; gspViewResolver now answers to that name too, as GrailsSiteMeshViewResolverBeanPostProcessor already documented that it would - that post processor compared the target against the bean name only, so an aliased resolver was never wrapped and no page was decorated - Sitemesh3LayoutTagLib, which carries the grailsLayout namespace the GSP compiler emits for the head, title and body of a decorated page, was not registered, so those capture tags reached the browser as literal markup The SiteMesh 3 layout finder also ignored sitemesh.decorator.default, the key a Spring Boot application configures its default layout with; it is now consulted after the two Grails keys, which keep precedence. Re-enable the example's runtime test, covering decoration as well as rendering.
The example excluded *.gsp from its resources on the assumption that the precompiled GSP classes would serve the views. They cannot: compileGroovyPages keys its view registry by the Grails view convention (/WEB-INF/grails-app/views/...), which a standalone Spring Boot application never searches - it looks under its configured template roots - and only the Grails GSP plugin loads that registry into the page locator at all. The result was a bootJar that started and then answered 404 for every view, having fallen through to a servlet dispatch for a path nothing serves. Ship the sources so the packaged application renders, as bootRun already did from the project directory.
GroovyPagePlugin registered its compiled pages with output.dir('gsp-classes'),
which resolves against the project directory rather than the build directory and
so named a directory that is never written. The pages and the view registry
beside them reached the archives only through the copies the jar and war tasks
make of the compile task's real destination, which left them off the class path
of anything else: an application started with bootRun or exercised by a test
could not load a single page it would ship with.
Add the compile tasks' destinations to the runtime and test runtime class paths
instead. They cannot be registered as source set output, which is what the
`classes` task builds, because compileGroovyPages runs after `classes` and the
two would form a cycle.
Only the Grails GSP plugin read the view registry that the compileGroovyPages build task writes, so a standalone Spring Boot application could not render a view it had compiled: it searched its template roots, found no template where the templates had been left out of the artifact, and fell through to a servlet dispatch for a path nothing serves. Read the registry into the page locator, and search the path a view is registered under - the one below the template root, naming no resource root - alongside the roots themselves. The registry is left unread when a template root on the file system is in play. The locator prefers a compiled view over a template it can find, so reading it during development would serve every page as it stood when the application was built, however often its template was edited.
Now that a standalone application can render the views compiled into it, the example ships those rather than the templates they were built from, which is what it always intended: the *.gsp exclusion is restored, undoing the stopgap in "Ship the GSP sources in the packaged gsp-spring-boot example". serverpath registers each view under its path below the template root, where the default registers it below the /WEB-INF/grails-app/views/ a Grails application looks under, and nothing here ever searches. The new test renders with the template root pointed at the class path, which holds no templates, so only the compiled views can answer it - the same arrangement the packaged application runs under, where every earlier failure in this series was invisible to a test run from the project directory.
GroovyPageCompiler merged the registry it writes into the one an earlier run had left behind, on the stated grounds that only changed pages are added to the mapping. They are not: compileGSP records a page whether or not it had to recompile it, so the mapping already names every page of the run. The merge only preserved entries no run would write again - a page since renamed or removed, or one registered under a different view prefix - each naming a class that is no longer there. They survived a clean, since a merged registry is what the build cache had stored, and were left for the locator to trip over at runtime, where a mapping that resolves to no class costs a failed Class.forName and a warning per render before it falls back.
The template engine bean took a TagLibraryResolver as a required dependency, and the only resolver is contributed by a configuration conditional on grails-web-jsp being present. An application that renders GSP without JSP support therefore did not start at all, failing on a missing bean of a type it had no use for. Take the resolver as an ObjectProvider. A page that uses a JSP tag library still needs the resolver, and could not have used one without JSP support anyway.
The executable jar packages no JSP - src/main/webapp goes into a war, not a jar - and Jasper compiles a JSP from the servlet context, so the /jsp link the layout rendered led to a 500 there while working in a war and under bootRun. Offer the link, and switch the form over, only where the JSP is in the servlet context. A request typed in by hand now leaves the form on GSP rather than failing to render. What settles it is the page being there rather than a document root existing, which an executable jar has - a temporary and empty one - and rather than the JSP libraries being on the class path, which they are in every artifact: the GSP form uses Spring's form tag library, a JSP tag library, and renders nothing without them. That is also why the jar keeps those libraries. Leaving them out would take rewriting the GSP form to drop <form:form>, giving up what it demonstrates.
Two auto-configurations define a bean named codecLookup: the codecs module's, unconditionally and as the primary one, and GSP's stand-in for it, guarded by @ConditionalOnMissingBean. With no order declared between them GSP's was processed first, so its guard found nothing to back off from and the unconditional definition overrode it - leaving an application to enable bean-definition overriding before it would start, for a bean it never wanted twice. Declaring GSP's after the codecs module lets the guard do its work: the module's lookup registers, GSP's backs off, and the stand-in is left to the application that has no codecs module on its class path. Named rather than referenced, since that is the application GSP has to keep working for. The gsp-spring-boot example no longer opts into bean-definition overriding, and starts with no override logged at all, which is what its tests now hold to.
Every tag library is autowired with the lookup that finds it, by the
TagLibraryInvoker trait they all carry. The standalone auto-configuration in
turn built the lookup out of its tag libraries, holding them as inner beans of
its tagLibInstances property - so the lookup depended on beans that depended on
the lookup, and an application had to allow circular references before it would
start:
gspTagLibraryLookup <-> (inner bean) RenderTagLib
Register the tag libraries as beans of the context, leaving the lookup to find
them through the @taglib annotation once they all exist. StandaloneTagLibraryLookup
already looked for them on a context refreshed event; it now does so as a
SmartInitializingSingleton as well, which is before the web server accepts a
request rather than after, so the first page to render cannot outrun the tag
libraries it uses.
An application can still register a tag library under one of these names itself,
and the registrar leaves that one alone.
The gsp-spring-boot example no longer allows circular references. With the
preceding commit it now asks for neither that nor bean-definition overriding -
it renders GSP with nothing but its own configuration - and its tests hold it
there, since either would stop the application from starting.
…is one CoreAutoConfiguration is contributed to every Spring Boot application with grails-core on its class path, and two of its beans are the GrailsApplication read through another type: the class loader it was built with, and its config as ConfigProperties. Both took the application as a bean, so a context without one could not be created at all - it failed asking for a bean of a type it had no use for. Condition the two on a GrailsApplication being there. An application that has one is unchanged; one that has none keeps the beans that never needed it, the placeholder configurer among them, and starts.
GrailsPluginLifecycleInitializer is registered for every Spring Boot application that has grails-core on its class path, so the lifecycle ran for applications that are not Grails applications: one depending on a Grails library - GSP for its views, say - was given a GrailsApplication, a plugin manager and the beans of every plugin found, over the top of what the libraries it did ask for auto-configure for themselves. Stand the phase down unless one of the context's sources is a GrailsApplicationClass. A Grails application is unaffected. A Spring Boot application using a Grails library gets that library's auto-configuration and nothing else - which is what it asked for, and what such an application got before the lifecycle was retimed onto this initializer. The GSP auto-configuration contributes the GrailsApplicationAware post processor that the core plugin contributes to a Grails application, so the beans that read the application through it - the page locator, the tag library lookup, the JSP tag library resolver - are handed it either way.
The configuration that applies the SiteMesh defaults to a context built without SpringApplication - a test context, where the environment post processor that serves every other application does not run - was a BeanDefinitionRegistryPostProcessor with both of its methods empty. That is a way of being instantiated early enough for EnvironmentAware to matter, and it cost every application two warnings a boot about a @configuration class created too early to enhance. It declares no beans, so there is nothing to enhance: say so with proxyBeanMethods = false, and implement the plainer BeanFactoryPostProcessor, which is instantiated just as early and has one method to leave empty rather than two.
The plugin lifecycle stood down unless a source of the context was a Grails application class, which left out an application that GrailsApp launched from sources of another kind - a plain @configuration class, as DevelopmentModeWatchSpec starts one. Being launched by GrailsApp is as much a statement that this is a Grails application as the application class is, and GrailsApp already records it by stashing the sources it was given. Take either as the answer. What stays out is what was meant to: an application that Spring Boot launched, of sources that say nothing about Grails.
Two of its three properties said nothing about GSP. The trace level for web logging is a debugging aid that made every request print a page of Spring internals, and the tag library descriptor scan pattern restated a narrower form of what the GSP JSP integration already defaults to - the Spring form tag library the example's form uses is scanned for either way. What is left is the default layout, which is the one thing an application rendering GSP through SiteMesh has to say.
…t-standalone-8.0.x # Conflicts: # grails-core/src/main/groovy/grails/boot/config/GrailsAutoConfiguration.groovy # grails-doc/src/en/guide/upgrading/upgrading80x.adoc
Jasper compiles a JSP at run time with the Eclipse compiler, 3.3MB of the example's 41.5MB jar. An executable jar packages no JSP to compile - a JSP is served from the servlet context, which a war carries and a jar does not - so none of it is reachable there. Excluding ecj from Jasper itself would take the compiler from the war and from bootRun as well, where a JSP does render: both answer /jsp with a 500 and "No Java compiler available" without it. It is left out of the jar rather than out of the dependency, which is the artifact that cannot use it. Jasper stays in every artifact regardless: the GSP form uses Spring's form tag library, which is a JSP tag library and needs the JSP API that Jasper carries.
GSP arrives with the web tier behind it, and this application uses the part of it that renders a view. It routes and binds with Spring MVC, it has no domain classes, and it answers in HTML, so the rest is packaged and never called: Grails URL mappings and the constraint validation behind them - which carries commons-validator and commons-collections 3.2.2 - Grails data binding, Grails MVC, and Jackson. Each is excluded from the dependency that brings it and named with what it is for, rather than filtered out of the class path as a set, so what the application does not use is a statement about the application. The jar goes from 41.5MB to 34.2MB. The GSP page, both layouts, the form and its validation, and the JSP rendering in the war and under bootRun are unchanged.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## 8.0.x #16184 +/- ##
==================================================
+ Coverage 55.2582% 55.7736% +0.5154%
- Complexity 20976 21186 +210
==================================================
Files 2113 2116 +3
Lines 101632 101869 +237
Branches 18045 18112 +67
==================================================
+ Hits 56160 56816 +656
+ Misses 37422 36916 -506
- Partials 8050 8137 +87
🚀 New features to boost your workflow:
|
A boot archive packages every directory of its class path into its classes directory, and the compiled pages are copied there by the archive already, so every page arrived twice and the copy failed - which is what building any Grails example with views did. They stay on the test runtime class path, which no archive reads, so a test still loads the pages the application ships, the view registry among them. An application run from the build renders its templates as they are edited, which a page compiled ahead of the edit would have stood in the way of.
A Spring Boot application has no plugins to scan for artefacts, so a tag library reaches a page by being a bean of the context. The lookup found the beans marked @taglib, which is how a tag library written for GSP is marked, and passed over the ones marked @ArteFact("TagLib"), which is how a Grails plugin marks the tag libraries it carries - the asset pipeline's <asset:...> among them. Both are now detected, so a tag library out of a plugin can be declared as a bean and used in a page exactly as in a Grails application. Artefacts of other kinds are left alone.
The example now uses a tag library written for Grails - the asset pipeline's <asset:stylesheet> - from a Spring Boot application, which is what a plugin's tag libraries are for an application that installs no plugins: beans of the context, declared in AssetPipelineConfiguration alongside the filter that serves what the pipeline compiled. Bootstrap is an input to the build rather than a library the application ships. It is declared on the assets configuration, the manifest names the one file the page needs, and 246KB of compiled CSS is packaged instead of the 1.8MB webjar. The URL mappings exclusion comes back out: the asset pipeline contributes a link generator, and a link generator reads a mappings holder.
The link generator declared the URL mappings holder as a required dependency, though it reads it in one place: the link to a controller and action. A link to a resource or to a path never touches it, so an application routing with Spring MVC could not have a link generator at all - it had to declare an empty mappings holder for a bean it would never ask a mapping of. The holder is optional now, and a link that does need mappings says so instead of failing on a null. The gsp-spring-boot example drops the empty holder it declared for the asset pipeline's link generator.
The form is Bootstrap's - labels, controls, help text, and the invalid state Spring's form tag library marks a rejected field with - inside a card, under a navbar that carries the light / dark / auto theme menu the Grails welcome page has. The menu is Bootstrap's own colour modes, applied before the page paints by a script in the head, and remembered per browser. Bootstrap's script and its icon font join the stylesheet on the assets configuration. Only what the two manifests compile ships, plus the icon font's own files - the ones its stylesheet asks the browser for by name - and no source maps. Two things the styling turned up: The session id was carried in the URL for a visitor arriving without a cookie, which is how the container rewrites a form action, and Spring MVC answers a path carrying one with a 404 - so submitting the form on a first visit failed. Sessions are tracked by cookie now, where the id belongs. The results view was told nothing of what rendered it, because only the form handler said so, and its heading came out unfinished. Every view is told, by the same interceptor that offers the JSP rendering.
Both sides added sections to the upgrade guide. Upstream's marshalling section reused number 54, which the guide already had, so it is 58 and this branch's two sections follow it as 59 and 60.
…odule The application declared the asset pipeline's tag libraries as beans, with the manifest and filter beside them, to write <asset:...> in its layout. That is wiring the asset pipeline itself is the place for, and none of it is needed to serve what the pipeline compiles: asset-pipeline-spring-boot registers the filter from its own configuration, which the application imports, and the layout links the compiled files by name - the filter resolves each to the digest-named file the build wrote. The compiled assets are packaged with the application's resources rather than only into its archives, so a test run from the project serves the same files the artifact does. Nothing Grails-side is declared for it, and asset-pipeline-grails is off the class path.
…path Both were excluded until the asset pipeline's link generator asked for a URL mappings holder. The example maps no URLs and validates no domain classes, and nothing it depends on asks for either now.
The runtime classpath extends the assets configuration, so a webjar declared there is both compiled from and shipped: 1.8MB of Bootstrap and 1.7MB of icons sat in the artifact behind the compiled files that are what actually gets served, while the build file said Bootstrap was an input to the build rather than a library the application ships. assetDevelopmentRuntime is read by the compiler and is not extended by the runtime classpath, which is what that sentence describes. The artifact keeps the icon font, which the compiled stylesheet asks the browser for by name. Executable jar 41.5MB -> 37.9MB, war 44.8MB -> 41.2MB.
The javadoc claimed the gsp-spring-boot example starts a whole application with an asset pipeline link generator in it. That example has no link generator, and it excludes grails-web-url-mappings, so the configuration's @ConditionalOnClass does not even match there. This specification is the only coverage.
AI ReviewVerified the framework changes against the modules they touch and ran the example. The lifecycle gate, the whole-configuration gate on Bug:
|
Resolved grails-doc/src/en/guide/upgrading/upgrading80x.adoc: both branches appended numbered sections after 57. Kept upstream's three new sections and this branch's two, renumbered so the list stays sequential — 58 non-public bean marshalling, 59 interceptor URI matching, 60 sort and order validation, 61 XML DOCTYPE, 62 plugin lifecycle, 63 sitemesh.decorator.default.
The tag libraries of a Spring Boot application are wired from their own
declarations. Sitemesh3LayoutTagLib declared none for its codec lookup - a Grails
application fills that field in by name - so in a standalone context it was null,
and captureTagContent took its unencoded branch.
The GSP compiler rewrites the <head>, <body> and <title> of every decorated page
to capture tags, so every attribute of those elements was written verbatim: a
layout or page with <body class="${...}"> put the raw value into the response,
where it could close the attribute and open a tag of its own.
The field declares the dependency now, as RenderSitemeshTagLib does. Covered by
the tag library's own specification, which pins both the encoded output and what
an unwired lookup costs, and by an assertion in the example that the context
wires every tag library's lookup - that one fails without the declaration.
The example also pins that the plugin runtime stays out of a standalone context,
which holds today only because the core auto-configuration is evaluated before
the standalone GrailsApplication exists.
Found by Mattias Reichel in review of apache#16184.
CoreGrailsPluginRegistrarSpec asserted that Spring Boot's AOP auto-configuration accepts the registered creator, but Spring's priority list is static and shared by the whole fork: any specification that loaded GrailsAutoConfiguration earlier had already patched it, so the assertion held whether or not the code under test did anything. It now clears the Grails entries first and restores them after. GroovyPageCompilerSpec left generatedGroovyPagesDirectory unset, so the compiler wrote the Groovy it generates under java.io.tmpdir - shared by every parallel fork and never cleaned. It is pointed at the temporary directory of the test. GroovyPagePluginFunctionalSpec asserted the compiled pages are on the test runtime class path but not that they are built before the tests run, which is what the builtBy wiring is for. The non-Grails project asserts both compile tasks are dependencies of test, as the Grails project asserts neither is. Found by Mattias Reichel in review of apache#16184.
GspAutoConfigurationSpec exercised only the method that reads the registry. That the locator bean is given the map, and that it searches the bare path a view is registered under, were covered only by the example application - nothing in the module failed if either were reverted. The registry fixture now names classes that exist, so the specification drives the contributed locator through findPage and gets a compiled script source back. Reverting either the map or the search path fails it. Found by Mattias Reichel in review of apache#16184.
The GSP view resolver was declared under both gspViewResolver and
jspViewResolver, which makes the second an alias. An alias is resolved ahead of a
bean definition of the same name and registering one does not check for a
conflict, so a Spring Boot application that also serves JSP and has a
jspViewResolver of its own would silently get the GSP resolver from
getBean("jspViewResolver") rather than an error.
The alias is registered by the registrar that already handles the viewResolver
name, and only when no bean definition holds jspViewResolver. User configuration
is parsed before auto-configuration, so an application's own resolver is there to
be seen.
Found by Mattias Reichel in review of apache#16184.
Which rendering of the form to serve was a static field, so one visitor's choice of JSP was every visitor's, and JspViewTest had to put it back afterwards. It is a session attribute now, which is what an example should show; the test keeps its cookies instead, so its requests are one visit. The comment on the setter said JspViewController is mapped only where a JSP can be served. It is always mapped - what it checks is the servlet context, at the time of the request. Found by Mattias Reichel in review of apache#16184.
Nothing user-facing covered what this adds: the views compiled at build time and read from the class path, the serverpath the compile task needs, the templates being rendered instead wherever a template root is a file: URL, the empty URL mappings holder, and a tag library declared as a bean being picked up whether it is annotated @taglib or @ArteFact("TagLib"). Adds a guide page under Groovy Server Pages, and replaces the module README, which described sample applications that no longer exist. The SiteMesh upgrade note named an internal class to explain where the property comes from; the sentence says the same thing without it. Found by Mattias Reichel in review of apache#16184.
|
@matrei All addressed, pushed as 3ac93a3..16eb980. Bug — unencoded attribute values (3ac93a3). Confirmed: with the registrar's wiring
Docs (16eb980). The SiteMesh note no longer names Design notes
Process. The 8.0.x-before-RC1 question is still open and is @codeconsole's call, not something these commits settle. |
|
Second round, on 16eb980 against 8.0.x (merge base a5758d6 is the current tip of 8.0.x, so the branch is clean). Reviewed the six commits since the first round, 3ac93a3..16eb980, and ran Every point from the first round is addressed: the encoding defect, the four test gaps, the two doc gaps and both design notes. What is left is in the new guide page, plus one test-hygiene detail. Docs: the tag library example in
|
The AOP feature removes the Grails creators from APC_PRIORITY_LIST, the registrar under test adds them back, and cleanup then added them all again - so every run left a second copy of both classes in a list shared by the whole fork, contrary to what the helper says it does. Cleanup now restores only a creator that is missing, which also still restores them if the code under test did not. Found by Mattias Reichel in review of apache#16184.
The case pinned what captureTagContent writes when a tag library is built with no codec lookup, which turned the absence of a guard into specified behaviour. The lookup is a required dependency, so a context without one fails to start, and StandaloneContextTest already fails if the field is not wired - the stronger contract. The encoded-output case stays. Found by Mattias Reichel in review of apache#16184.
The page showed the asset pipeline's AssetsTagLib being declared as a bean with a
constructor it does not have, and taking an AssetProcessorService that is a
service artefact of a plugin, so an application with no plugin lifecycle has no
bean of it to give. Nothing covered it either: the example serves its assets
through the asset pipeline's Spring Boot module and does not use that tag
library. The example is now a @grails.gsp.TagLib class declared as a bean, the
shape the lookup's specifications test, with a sentence saying a plugin's tag
library may need its collaborators declared too. The TIP names what the example
application does demonstrate.
The build.gradle listing gains subs="attributes+", so {version} is substituted
and the callouts still render, and the Grails GSP plugin id gets its version,
without which it does not resolve in a plain Spring Boot build.
Found by Mattias Reichel in review of apache#16184.
|
@matrei Addressed in 16eb980..76bfb3b. Tag library example (76bfb3b). Replaced with a Rendering (76bfb3b). The
Nit (ec9a9c4). The unencoded-output case is removed. The encoded-output case stays, and |
The Grails compiler already marks both with @ArteFact("TagLib"), which is how a Grails application finds them. The annotation was added so GSP's standalone lookup would find them too, and apache/grails-core#16184 teaches that lookup to read the artefact marker as well - naming the asset pipeline's library as the case. Against the 8.0.0-M6 this build pins, that lookup still reads only @taglib, so the specification that resolves the tag libraries through it is pending on #16184, and alerts once it starts passing. Nothing that works on M6 is lost: standalone GSP does not start there without the UrlMappingsHolder #16184 also contributes. The url case takes the tag library from the context instead of the lookup, so the link generator it guards is still covered on M6.
|
Follow-up on 76bfb3b, found while running the example with Bug: any error or 404 in a standalone application ends in
|
| bean | class | order |
|---|---|---|
gspViewResolver |
GrailsSiteMeshViewResolver |
LOWEST_PRECEDENCE - 20 |
beanNameViewResolver |
BeanNameViewResolver |
LOWEST_PRECEDENCE - 10 |
mvcViewResolver, defaultViewResolver |
ViewResolverComposite, InternalResourceViewResolver |
LOWEST_PRECEDENCE |
BasicErrorController.errorHtml falls back to the view name error, which Boot serves from its whitelabel error bean through BeanNameViewResolver. The GSP resolver is asked first. There is no error.gsp, and spring.gsp.jspEnabled defaults to true (GspAutoConfiguration.java:108, applied at line 290), so GroovyPageViewResolver.createFallbackView (GroovyPageViewResolver.java:258-273) returns a JSTL view for /error without checking that any such JSP exists. That view forwards to /error, the URL being handled, and the servlet container rejects the loop. Boot's whitelabel page is never reached.
GroovyPageViewResolver itself is untouched by this PR (grails-web-gsp has no diff against 8.0.x); what the PR changes is that the standalone module now starts, so this is the first time the path is reachable. A Grails application never hits it because its errors are routed by UrlMappings, not by BasicErrorController.
Two things I checked before suggesting a fix:
spring.gsp.jspEnabled=falsemakes/errorand 404s render the whitelabel page, andform.jspstill renders through Boot'sdefaultViewResolver- but undecorated: the<meta name="layout" content="main"/>inform.jspis honoured only when the JSP is served through the GSP resolver's fallback, which the SiteMesh view resolver wraps. So flipping the default is not free for the example's JSP demonstration.- Adding
templates/error.gspto the example would make the page render, but only hides the module defect: every other standalone application without one gets the container's raw error page instead of Boot's.
Suggested fix, in the module: have the JSP fallback resolve only a JSP that exists. createJstlView can ask servletContext.getResource(url) (the example's JspSupport.canServeJsp does exactly this) and return null otherwise, which lets resolution continue to BeanNameViewResolver and the whitelabel error bean, or to Boot's defaultViewResolver for a real JSP. That keeps JSP decoration in the example and fixes the error page for every consumer. A test in the example - GET /does-not-exist with Accept: text/html is a 404 whose body is the whitelabel page (or an error.gsp if you add one to show it off) - would pin it.
A longer-term item, not for this PR: Boot resolves error/404, error/5xx templates through TemplateAvailabilityProviders, and there is none for .gsp, so a standalone application cannot use per-status GSP error pages the way a Thymeleaf one can.
Any error or 404 a browser asked for ended in the container's own error page, with "Circular view path [error]" in the log. Boot renders its error page through the view named error, and the GSP view resolver is asked first. With no error.gsp and JSP views on, its fallback returned a JSTL view for error without checking that any such JSP exists; that view forwarded to /error, the URL being handled, and the container refused the loop. A client sending no Accept header gets Boot's JSON body instead, which is why no test saw it. The resolver GspAutoConfiguration contributes now falls back to a JSP only where the servlet context has one, and otherwise answers nothing, leaving the name to the resolvers after it - Boot's error page among them. A real JSP resolves as before, so the example's JSP rendering is still decorated by its layout. The check lives in a resolver used only by the standalone module. The shared GroovyPageViewResolver is unchanged, so a Grails application with JSTL present - where its errors are routed by URL mappings, never through Boot's error controller - keeps the fallback it has, including for a JSP that exists only as a precompiled servlet mapping with no resource behind it. ErrorPageTest in the example requests an unmapped path with Accept: text/html and gets Boot's page with a 404; StandaloneGroovyPageViewResolverSpec covers both branches through resolveViewName. Both fail with the check reverted. The guide page lists spring.gsp.jspEnabled with what it now does. Found by Mattias Reichel in review of apache#16184.
|
Reproduced with Fix. Tests. Docs. The guide page's configuration table lists Per-status GSP error pages through a |
…t-standalone-8.0.x
This comment has been minimized.
This comment has been minimized.
matrei
left a comment
There was a problem hiding this comment.
Fifth round, on 2033f0e against 8.0.x (merge base 3067d0a is the current tip of 8.0.x, so the branch is clean). One commit since round four, 5f11919, which is the fix for the error-page defect from that round. CI on the head is green: 89 checks passed, 4 skipped (publish, docs, forge, wrapper).
Ran :grails-gsp-spring-boot:test, :grails-gsp-spring-boot:codeStyle and the gsp-spring-boot example's tests. All pass. Approving.
Verified
- The fix (
StandaloneGroovyPageViewResolver): the JSP fallback is kept in a subclass the standalone module alone constructs;GroovyPageViewResolverandgrails-web-gspare untouched, so a Grails application keeps the fallback it had. The override calls the basecreateJstlView, asks the servlet context for the view's URL (leading slash added where missing,MalformedURLExceptionanswered with "no"), and returnsnullwhen nothing is there. WithsetCache(false)on the base resolver, anullfromloadViewis "unresolved" to the dispatcher and the next resolver is asked -BeanNameViewResolverand Boot's whitelabelerrorbean, as the round-four table showed. - Both tests are load-bearing. With the existence check disabled,
StandaloneGroovyPageViewResolverSpecfails on theerrorcase (a view comes back wherenullis expected) andErrorPageTestfails with Tomcat's ownHTTP Status 404 – Not Foundbody in place of the whitelabel page. Restored afterwards; the tree is clean. - JSP decoration survives:
JspViewTeststill gets theform.jsprendering through the GSP resolver's fallback, wrapped by the SiteMesh layout, because/form.jspis a real servlet-context resource in the example. - The spec tests through the public surface:
resolveViewNameon a resolver built from the auto-configuration's publicgroovyPageLocatorbean method, with aMockServletContextover a@TempDiras the document root and the context closed incleanup. Same construction pattern asGspAutoConfigurationSpec. - Docs: the new
spring.gsp.jspEnabledrow in the configuration table matches the default inGspAutoConfigurationand describes the new behaviour without naming an internal class. The module README needs no change; the resolver's behaviour is described on the guide page. - Style:
codeStyleon the module is clean; the new Java class carries the license header and explicit imports.
Nit, not blocking
StandaloneGroovyPageViewResolver.servletContext() re-derives what WebApplicationObjectSupport.getServletContext() already provides, and the base resolver already relies on that accessor to build every GSP view (GroovyPageViewResolver.java:221). Its two null branches are unreachable in practice, since the resolver cannot render anything without a web context. getServletContext() in place of the helper would drop a dozen lines.
Observation, no change needed
A miss is cached like a hit: CacheEntry.setValue(null) marks the entry initialised, so a name with neither a template nor a JSP is remembered for spring.gsp.view.cacheTimeout while reloading is on, and for the life of the context when it is off. That is the same treatment a missing GSP always had, and it keeps the error lookup cheap, so it is the right trade; noting it only so the next reader does not expect a JSP dropped into a running production deployment to be picked up.
Process
Unchanged: whether this lands on 8.0.x before RC1 is a release-management call still open in the thread, not something in the diff.
|
@matrei @jdaugherty Addressed in bf09ab4. The upgrade note now distinguishes Grails 7 from the earlier Grails 8 milestones: a custom Also addressed the non-blocking nit by replacing the duplicate servlet-context helper with Spring's inherited |
A Spring Boot application that renders its views with GSP could not start, and behind the first failure were four more, each hidden by the one before it. The
gsp-spring-bootexample now starts, renders, decorates and packages, and its runtime test is enabled again.Using GSP from a Spring Boot application
Views render from a plain
SpringApplication, with no Grails application class and no Grails plugins:The whole of the example's
application.propertiesis now the one property that says something about GSP:sitemesh.decorator.default=mainspring.main.allow-circular-referencesandspring.main.allow-bean-definition-overridingare gone. Both were opted into because of how the GSP beans were wired, not because the application wanted either.Views compiled by the
compileGroovyPagesbuild task are rendered from their compiled classes, so an application can ship without its templates:jar { processResources.exclude('**/*.gsp') } compileGroovyPages { source = project.file("${project.projectDir}/src/main/resources/templates") serverpath = '/' // the path a standalone application looks a view up by }Templates on disk still win where they are there to edit, so
bootRunre-renders a template as it changes.Styling with the asset pipeline
The example styles itself with Bootstrap, compiled by the asset pipeline at build time and served by the pipeline's own Spring Boot module. Bootstrap is an input to the build rather than a library the application ships - it goes on the
assetsconfiguration, and the artifact carries the compiled, digest-named stylesheet and script instead of the 1.8MB webjar:Nothing Grails-side is declared for it: no tag library, no
GrailsApplicationwiring, andasset-pipeline-grailsis not on the application's class path.What changed
The Grails plugin lifecycle runs for a Grails application only — one that
GrailsApplaunched, or one with a Grails application class among the sources.GrailsPluginLifecycleInitializeris registered for every Spring Boot application with grails-core on its class path, so an application using a Grails library was given aGrailsApplication, a plugin manager and the beans of every plugin found, over the top of what the libraries it did ask for auto-configure for themselves. It now gets that library's auto-configuration and nothing else. Documented in the 8.0 upgrade notes, with the one case inside a Grails application it changes: a@SpringBootTestthat names configuration classes of its own now names the application class as well.Bean wiring. Tag libraries are beans of the context, found by the lookup once they exist, rather than held inside it — they are autowired with the lookup, so a lookup that held them was a cycle. The GSP codec lookup is configured after the codecs module, so its
@ConditionalOnMissingBeanguard can do its work instead of being overridden. The core plugin's auto-configuration is contributed only where there is aGrailsApplication- the condition is on the plugin class, so the generatedCoreAutoConfigurationis gated as a whole - and a context without one starts rather than failing on a bean of a type it never had.Standalone GSP. The view registry
compileGroovyPageswrites is read into the page locator;<g:applyLayout>resolves its layout; thegrailsLayoutnamespace is registered, so the capture tags the GSP compiler emits no longer reach the browser as markup; the JSP tag library resolver is optional, so GSP renders without JSP support on the class path; and the tag libraries a Grails plugin carries are found alongside the ones marked@TagLib.Auto-proxy creators. The reflection patch that makes Spring recognise the Groovy aware auto-proxy creator had sat in a
GrailsAutoConfigurationstatic initializer since 2015, whileCoreGrailsPluginis what registers the creator. It moved toGroovyAwareAutoProxyCreators, applied where the creator is registered, so it holds for an applicationGrailsApplaunched from sources that do not load that class.An application that maps no URLs can still have a link generator.
DefaultLinkGeneratorrequires the URL mappings holder, as it should.GspAutoConfigurationcontributes an empty one under@ConditionalOnMissingBean(name = "grailsUrlMappingsHolder"), ordered afterUrlMappingsAutoConfigurationso a holder from there wins; only a standalone application has that module on its class path, so a Grails application never sees the bean.The view registry is written whole rather than merged into what an earlier build left behind, which kept naming views since renamed or removed, against classes no longer there.
The compiled pages are on the test class path of a build that is not a Grails build.
GroovyPagePluginregistered them with a path resolved against the project directory rather than the build directory, so an application tested from the build could load none of them. A Grails application's tests render GSP from source and gain nothing from them, so itstesttask is left as it was - the plugin records whetherGrailsGradlePluginis applied and decides when the task graph is built. They are kept off the main runtime class path, which a boot archive packages into its classes directory - where the archive copies them already.sitemesh.decorator.defaultis consulted for the default layout, after both Grails keys. A Grails application that set only the SiteMesh key was decorated with the implicitapplicationlayout and now gets the layout the key names; in the upgrade notes.The JSP compiler is left out of the executable jar — 3.3MB of Eclipse compiler that a jar cannot use, since it packages no JSP to compile. The war and
bootRun, which do serve JSPs, keep it.Limitations
serverpathset to match the template root. The default registers each view below/WEB-INF/grails-app/views/, which is where a Grails application looks and a standalone application never does.Cache-Control. The pipeline's servlet filter rewrites/assets/application.cssto the digest-named file and then decides cacheability from the rewritten name, which is never a manifest key, so it answerspublic, max-age=31536000on a URL whose content changes on redeploy. Measured against the packaged example. The fix belongs in the asset pipeline, not here.grails-coreon the class path for theGrailsApplicationthat the page locator, tag library lookup and JSP tag library resolver read, and with itgrails-datastore-core, javassist, caffeine andjakarta.persistence-api— around 2.6MB of persistence machinery thatGrailsApplication.getMappingContext()makes structural rather than incidental.