Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
76401d0
Stop @EnableWebMvc from being automatically added to Grails Applications
codeconsole Jun 22, 2026
b05894e
Keep Grails' GrailsWebRequest bound once WebMvcAutoConfiguration is a…
codeconsole Jun 22, 2026
0a49d58
Remove Boot's defaultViewResolver for all Grails servlet web apps
codeconsole Jun 22, 2026
04622e3
Consolidate defaultViewResolver removal into one place
codeconsole Jun 22, 2026
25a4f2d
docs: document @EnableWebMvc removal in 8.0.x upgrade notes
codeconsole Jun 24, 2026
c312c44
test: guard GrailsWebRequestFilter is a RequestContextFilter so Boot …
codeconsole Jun 24, 2026
7692dbc
test: prove Boot WebMvcAutoConfiguration backs off its RequestContext…
codeconsole Jun 24, 2026
3416993
Merge apache/8.0.x into 7.0.x-remove-EnableWebMvc
codeconsole Jun 26, 2026
12505aa
Address review feedback: remove dependency comments, simplify filter …
codeconsole Jul 6, 2026
8f14cb4
Merge remote-tracking branch 'upstream/8.0.x' into 7.0.x-remove-Enabl…
codeconsole Jul 6, 2026
7c77832
Harden the @EnableWebMvc removal: legacy flag fallback + overridable …
codeconsole Jul 7, 2026
7a914ec
Make i18n and URL-mappings framework beans overridable
codeconsole Jul 7, 2026
a1f4013
Merge branch '8.0.x' into 7.0.x-remove-EnableWebMvc
jdaugherty Jul 7, 2026
232b14b
Merge branch '8.0.x' into feat/overridable-i18n-urlmappings-beans
jdaugherty Jul 7, 2026
84ea817
Declare grails.web.removeDefaultViewResolverBean in Settings
codeconsole Jul 7, 2026
d79feda
Merge branch '7.0.x-remove-EnableWebMvc' into feat/overridable-i18n-u…
codeconsole Jul 7, 2026
6cd34fa
Address review feedback
codeconsole Jul 7, 2026
7ff1cb3
Merge branch 'feat/overridable-i18n-urlmappings-beans' of https://git…
codeconsole Jul 7, 2026
f00f89a
Merge branch '8.0.x' into feat/overridable-i18n-urlmappings-beans
codeconsole Jul 7, 2026
1ea2031
Merge branch '8.0.x' into feat/overridable-i18n-urlmappings-beans
jdaugherty Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions grails-controllers/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ dependencies {

testImplementation 'jakarta.servlet:jakarta.servlet-api'
testImplementation 'org.springframework:spring-test'
testImplementation 'org.springframework.boot:spring-boot-test'
testRuntimeOnly 'org.assertj:assertj-core'

testImplementation 'org.apache.groovy:groovy-test-junit5'
testImplementation 'org.junit.jupiter:junit-jupiter-api'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,13 +109,25 @@ public FilterRegistrationBean<Filter> hiddenHttpMethodFilter() {
return registrationBean;
}

// Exposed as a RequestContextFilter bean (GrailsWebRequestFilter extends it) so that Boot's
// WebMvcAutoConfiguration RequestContextFilter — @ConditionalOnMissingBean(RequestContextFilter) —
// backs off in favour of Grails' GrailsWebRequest binding. Without @EnableWebMvc, Boot's
// WebMvcAutoConfiguration is active and would otherwise register an OrderedRequestContextFilter
// (order -105) that rebinds a plain ServletRequestAttributes between GrailsWebRequestFilter (-150)
// and the Spring Security chain (-100), causing a GrailsWebRequest ClassCastException downstream.
@Bean
@ConditionalOnMissingBean(GrailsWebRequestFilter.class)
public FilterRegistrationBean<Filter> grailsWebRequestFilter(ApplicationContext applicationContext) {
FilterRegistrationBean<Filter> registrationBean = new FilterRegistrationBean<>();
public GrailsWebRequestFilter grailsWebRequest(ApplicationContext applicationContext) {
GrailsWebRequestFilter grailsWebRequestFilter = new GrailsWebRequestFilter();
grailsWebRequestFilter.setApplicationContext(applicationContext);
registrationBean.setFilter(grailsWebRequestFilter);
return grailsWebRequestFilter;
}

@Bean
@ConditionalOnMissingBean(name = "grailsWebRequestFilter")
public FilterRegistrationBean<GrailsWebRequestFilter> grailsWebRequestFilter(GrailsWebRequestFilter grailsWebRequest) {
FilterRegistrationBean<GrailsWebRequestFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(grailsWebRequest);
registrationBean.setDispatcherTypes(EnumSet.of(
DispatcherType.FORWARD,
DispatcherType.INCLUDE,
Expand Down Expand Up @@ -153,6 +165,7 @@ public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApp
}

@Bean
@ConditionalOnMissingBean(GrailsWebMvcConfigurer.class)
public GrailsWebMvcConfigurer webMvcConfig() {
return new GrailsWebMvcConfigurer(resourcesCachePeriod, resourcesEnabled, resourcesPattern);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* 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.plugins.web.controllers;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
import org.springframework.context.EnvironmentAware;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.ImportBeanDefinitionRegistrar;
import org.springframework.core.env.Environment;
import org.springframework.core.type.AnnotationMetadata;

import grails.config.Settings;

/**
* Without the auto-injected {@code @EnableWebMvc}, Spring Boot's
* {@link WebMvcAutoConfiguration} is active and contributes a {@code defaultViewResolver}
* ({@code InternalResourceViewResolver}). For a Grails application that does not use
* JSP/InternalResource views (e.g. a REST/JSON-views app) this catch-all resolver resolves
* any unmatched view name to a servlet forward, producing
* {@code Circular view path [index]: would dispatch back to the current handler URL} errors.
*
* This is the single place the {@code defaultViewResolver} is removed for every Grails servlet
* web application (GSP, JSON/Markup or plain), so Grails' own view resolution is used instead.
* grails-gsp only loads for GSP applications, so the removal cannot live there. Ordered after
* {@link WebMvcAutoConfiguration} so the bean exists by the time the registrar runs.
*
* <p>Disable with {@code grails.web.removeDefaultViewResolverBean=false}. The earlier
* {@code spring.gsp.removeDefaultViewResolverBean} (when removal lived in grails-gsp) is still
* honoured for backward compatibility, but is deprecated.
*/
@AutoConfiguration(after = WebMvcAutoConfiguration.class)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@Import(GrailsViewResolverAutoConfiguration.RemoveDefaultViewResolverRegistrar.class)
public class GrailsViewResolverAutoConfiguration {

static final String REMOVE_PROPERTY = Settings.WEB_REMOVE_DEFAULT_VIEW_RESOLVER_BEAN;
static final String LEGACY_REMOVE_PROPERTY = "spring.gsp.removeDefaultViewResolverBean";

static class RemoveDefaultViewResolverRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware {

private static final Logger LOG = LoggerFactory.getLogger(RemoveDefaultViewResolverRegistrar.class);

private boolean removeDefaultViewResolverBean = true;

@Override
public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) {
if (removeDefaultViewResolverBean && registry.containsBeanDefinition("defaultViewResolver")) {
registry.removeBeanDefinition("defaultViewResolver");
}
}

@Override
public void setEnvironment(Environment environment) {
// Honour the legacy grails-gsp property name for backward compatibility (deprecated).
Boolean legacy = environment.getProperty(LEGACY_REMOVE_PROPERTY, Boolean.class);
if (legacy != null) {
LOG.warn("'{}' is deprecated; use '{}' instead.", LEGACY_REMOVE_PROPERTY, REMOVE_PROPERTY);
}
this.removeDefaultViewResolverBean = environment.getProperty(
REMOVE_PROPERTY, Boolean.class, legacy != null ? legacy : true);
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
org.grails.plugins.web.controllers.ControllersAutoConfiguration
org.grails.plugins.web.controllers.GrailsViewResolverAutoConfiguration
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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.plugins.web.controllers

import java.util.function.Supplier

import grails.core.GrailsApplication

import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.runner.WebApplicationContextRunner
import org.springframework.boot.web.servlet.FilterRegistrationBean
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration
import org.springframework.context.ApplicationContext
import org.springframework.web.filter.RequestContextFilter

import org.grails.web.config.http.GrailsFilters
import org.grails.web.servlet.mvc.GrailsWebRequestFilter

import spock.lang.Specification

class ControllersAutoConfigurationSpec extends Specification {

ApplicationContext applicationContext = Mock(ApplicationContext) {
getBeansOfType(_) >> [:]
}

ControllersAutoConfiguration autoConfiguration = new ControllersAutoConfiguration()

void 'grailsWebRequest filter is a RequestContextFilter so Boot WebMvcAutoConfiguration backs off its own RequestContextFilter'() {
when: 'the Grails request-binding filter bean is created'
GrailsWebRequestFilter filter = autoConfiguration.grailsWebRequest(applicationContext)

then: 'it is exposed as a RequestContextFilter, the type Boot @ConditionalOnMissingBean keys on'
filter != null
filter instanceof RequestContextFilter
}

void 'grailsWebRequestFilter registers the GrailsWebRequestFilter with the Grails request-filter order'() {
given: 'the Grails request-binding filter'
GrailsWebRequestFilter filter = autoConfiguration.grailsWebRequest(applicationContext)

when: 'it is wrapped in a registration bean'
FilterRegistrationBean<GrailsWebRequestFilter> registrationBean = autoConfiguration.grailsWebRequestFilter(filter)

then: 'the same filter instance is registered ahead of the Spring Security chain'
registrationBean.filter.is(filter)
registrationBean.order == GrailsFilters.GRAILS_WEB_REQUEST_FILTER.order
}

void 'Boot WebMvcAutoConfiguration registers its own requestContextFilter when the Grails controllers auto-config is absent'() {
expect: 'the contrast case proves the backoff assertion below is meaningful'
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration))
.run { context ->
assert context.containsBean('requestContextFilter')
}
}

void 'Grails controllers auto-config makes Boot WebMvcAutoConfiguration back off its requestContextFilter'() {
given: 'a GrailsApplication, required by the controllers auto-config'
GrailsApplication grailsApplication = Mock(GrailsApplication) {
getClassLoader() >> getClass().classLoader
}
Supplier<GrailsApplication> grailsApplicationSupplier = () -> grailsApplication

expect: 'Boot does not contribute its OrderedRequestContextFilter, leaving GrailsWebRequest bound'
new WebApplicationContextRunner()
.withBean(GrailsApplication, grailsApplicationSupplier)
.withConfiguration(AutoConfigurations.of(ControllersAutoConfiguration, WebMvcAutoConfiguration))
.run { context ->
assert !context.containsBean('requestContextFilter')
assert context.getBeanNamesForType(GrailsWebRequestFilter).length == 1
}
}

void 'a user-defined grailsWebRequestFilter registration bean makes the auto-configured one back off'() {
given: 'a GrailsApplication, required by the controllers auto-config'
GrailsApplication grailsApplication = Mock(GrailsApplication) {
getClassLoader() >> getClass().classLoader
}
Supplier<GrailsApplication> grailsApplicationSupplier = () -> grailsApplication

and: 'a user-defined registration bean under the auto-configured bean name'
FilterRegistrationBean<GrailsWebRequestFilter> userRegistration = new FilterRegistrationBean<>()
Supplier<FilterRegistrationBean> userRegistrationSupplier = () -> userRegistration

expect: 'the user bean wins and the auto-configuration does not register a second one'
new WebApplicationContextRunner()
.withBean(GrailsApplication, grailsApplicationSupplier)
.withBean('grailsWebRequestFilter', FilterRegistrationBean, userRegistrationSupplier)
.withConfiguration(AutoConfigurations.of(ControllersAutoConfiguration, WebMvcAutoConfiguration))
.run { context ->
assert context.getBean('grailsWebRequestFilter').is(userRegistration)
}
}

void 'a user-defined GrailsWebMvcConfigurer bean makes the auto-configured webMvcConfig back off'() {
given: 'a GrailsApplication, required by the controllers auto-config'
GrailsApplication grailsApplication = Mock(GrailsApplication) {
getClassLoader() >> getClass().classLoader
}
Supplier<GrailsApplication> grailsApplicationSupplier = () -> grailsApplication

and: 'a user-defined web MVC configurer'
def userConfigurer = new ControllersAutoConfiguration.GrailsWebMvcConfigurer(0, false, '/custom/**')
Supplier<ControllersAutoConfiguration.GrailsWebMvcConfigurer> userConfigurerSupplier = () -> userConfigurer

expect: 'the user bean wins and only one GrailsWebMvcConfigurer exists'
new WebApplicationContextRunner()
.withBean(GrailsApplication, grailsApplicationSupplier)
.withBean(ControllersAutoConfiguration.GrailsWebMvcConfigurer, userConfigurerSupplier)
.withConfiguration(AutoConfigurations.of(ControllersAutoConfiguration, WebMvcAutoConfiguration))
.run { context ->
def names = context.getBeanNamesForType(ControllersAutoConfiguration.GrailsWebMvcConfigurer)
assert names.length == 1
assert context.getBean(names[0]).is(userConfigurer)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* 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.plugins.web.controllers

import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.test.context.runner.WebApplicationContextRunner
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration

import spock.lang.Specification

class GrailsViewResolverAutoConfigurationSpec extends Specification {

void 'Boot WebMvcAutoConfiguration contributes a defaultViewResolver when the Grails auto-config is absent'() {
expect: 'the contrast case proves the removal assertions below are meaningful'
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration))
.run { context ->
assert context.containsBean('defaultViewResolver')
}
}

void 'the defaultViewResolver is removed by default'() {
expect:
new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration, GrailsViewResolverAutoConfiguration))
.run { context ->
assert !context.containsBean('defaultViewResolver')
}
}

void 'the defaultViewResolver is kept when grails.web.removeDefaultViewResolverBean=false'() {
expect:
new WebApplicationContextRunner()
.withPropertyValues("${GrailsViewResolverAutoConfiguration.REMOVE_PROPERTY}=false")
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration, GrailsViewResolverAutoConfiguration))
.run { context ->
assert context.containsBean('defaultViewResolver')
}
}

void 'the deprecated spring.gsp.removeDefaultViewResolverBean property is still honoured'() {
expect:
new WebApplicationContextRunner()
.withPropertyValues("${GrailsViewResolverAutoConfiguration.LEGACY_REMOVE_PROPERTY}=false")
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration, GrailsViewResolverAutoConfiguration))
.run { context ->
assert context.containsBean('defaultViewResolver')
}
}

void 'the new property takes precedence over the deprecated one'() {
expect:
new WebApplicationContextRunner()
.withPropertyValues(
"${GrailsViewResolverAutoConfiguration.REMOVE_PROPERTY}=true",
"${GrailsViewResolverAutoConfiguration.LEGACY_REMOVE_PROPERTY}=false")
.withConfiguration(AutoConfigurations.of(WebMvcAutoConfiguration, GrailsViewResolverAutoConfiguration))
.run { context ->
assert !context.containsBean('defaultViewResolver')
}
}
}
6 changes: 6 additions & 0 deletions grails-core/src/main/groovy/grails/config/Settings.groovy
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ interface Settings {
*/
String WEB_SERVLET_PATH = 'grails.web.servlet.path'

/**
* Whether to remove Spring Boot's {@code defaultViewResolver} bean so Grails' own view
* resolution is used. Defaults to true
*/
String WEB_REMOVE_DEFAULT_VIEW_RESOLVER_BEAN = 'grails.web.removeDefaultViewResolverBean'

/**
* The URL of the server
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,6 @@ class ApplicationClassInjector implements GrailsArtefactClassInjector {
addAnnotation('org.springframework.boot.SpringBootConfiguration', classNode)?.with {
GrailsASTUtils.addExpressionToAnnotationMember(it, 'proxyBeanMethods', constX(false))
}
addAnnotation('org.springframework.web.servlet.config.annotation.EnableWebMvc', classNode, 'jakarta.servlet.ServletContext')
addAnnotation('org.springframework.boot.autoconfigure.EnableAutoConfiguration', classNode)?.with { annotation ->
EXCLUDED_AUTO_CONFIGURE_CLASSES.each {
GrailsASTUtils.addExpressionToAnnotationMember(
Expand Down
Loading
Loading