From 76401d04d2dfcf7260b07c499dd86a16c18ef983 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 22 Jun 2026 00:42:33 -0600 Subject: [PATCH 01/12] Stop @EnableWebMvc from being automatically added to Grails Applications @EnableWebMvc is a Spring Framework annotation that is not meant to be combined with Spring Boot. Importing it (via DelegatingWebMvcConfiguration) disables Boot's WebMvcAutoConfiguration MVC setup (which is @ConditionalOnMissingBean(WebMvcConfigurationSupport)) and registers MVC infrastructure beans in the user-configuration phase, ahead of all auto-configuration. A concrete symptom is the localeResolver bean: WebMvcConfigurationSupport registers an AcceptHeaderLocaleResolver in the user phase, so grails-i18n's @AutoConfigureBefore(WebMvcAutoConfiguration) SessionLocaleResolver can only ever override it (triggering a bean-definition-override) rather than supersede it cleanly. Remove the auto-injected @EnableWebMvc so Boot's WebMvcAutoConfiguration drives MVC setup. --- .../grails/compiler/injection/ApplicationClassInjector.groovy | 1 - 1 file changed, 1 deletion(-) diff --git a/grails-core/src/main/groovy/org/grails/compiler/injection/ApplicationClassInjector.groovy b/grails-core/src/main/groovy/org/grails/compiler/injection/ApplicationClassInjector.groovy index 763bdbe7611..0d748dd9a5b 100644 --- a/grails-core/src/main/groovy/org/grails/compiler/injection/ApplicationClassInjector.groovy +++ b/grails-core/src/main/groovy/org/grails/compiler/injection/ApplicationClassInjector.groovy @@ -143,7 +143,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( From b05894e5e76650aa575696dab359d8df83c8c2ab Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 22 Jun 2026 00:59:38 -0600 Subject: [PATCH 02/12] Keep Grails' GrailsWebRequest bound once WebMvcAutoConfiguration is active With @EnableWebMvc no longer auto-injected, Boot's WebMvcAutoConfiguration becomes active and registers an OrderedRequestContextFilter (order -105). That runs between GrailsWebRequestFilter (-150) and the Spring Security chain (-100) and rebinds a plain ServletRequestAttributes, so security-chain code that expects a GrailsWebRequest fails with a ClassCastException (HTTP 500 on every request). Boot's filter is @ConditionalOnMissingBean({RequestContextListener, RequestContextFilter}), so make GrailsWebRequestFilter extend RequestContextFilter (it already overrides shouldNotFilterAsyncDispatch/ErrorDispatch identically and binds a richer request context) and expose it as a bean that the FilterRegistrationBean wraps. Boot then backs off in favour of Grails' request-context binding. --- .../ControllersAutoConfiguration.java | 17 ++++++++++++++--- .../web/servlet/mvc/GrailsWebRequestFilter.java | 4 ++-- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index 9d560ae0b85..cd079b7def2 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -108,13 +108,24 @@ public FilterRegistrationBean 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 grailsWebRequestFilter(ApplicationContext applicationContext) { - FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + public GrailsWebRequestFilter grailsWebRequest(ApplicationContext applicationContext) { GrailsWebRequestFilter grailsWebRequestFilter = new GrailsWebRequestFilter(); grailsWebRequestFilter.setApplicationContext(applicationContext); - registrationBean.setFilter(grailsWebRequestFilter); + return grailsWebRequestFilter; + } + + @Bean + public FilterRegistrationBean grailsWebRequestFilter(GrailsWebRequestFilter grailsWebRequest) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(grailsWebRequest); registrationBean.setDispatcherTypes(EnumSet.of( DispatcherType.FORWARD, DispatcherType.INCLUDE, diff --git a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsWebRequestFilter.java b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsWebRequestFilter.java index e938f1a28ce..820ee1a00bc 100644 --- a/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsWebRequestFilter.java +++ b/grails-web-mvc/src/main/groovy/org/grails/web/servlet/mvc/GrailsWebRequestFilter.java @@ -30,7 +30,7 @@ import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.i18n.LocaleContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.filter.RequestContextFilter; import grails.web.mvc.FlashScope; import org.grails.web.util.WebUtils; @@ -41,7 +41,7 @@ * @author Graeme Rocher * @since 0.4 */ -public class GrailsWebRequestFilter extends OncePerRequestFilter implements ApplicationContextAware { +public class GrailsWebRequestFilter extends RequestContextFilter implements ApplicationContextAware { Collection paramListenerBeans; /* (non-Javadoc) From 0a49d580a04ca8e717184d00d8d8b7911ba0174a Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 22 Jun 2026 09:43:11 -0600 Subject: [PATCH 03/12] Remove Boot's defaultViewResolver for all Grails servlet web apps With @EnableWebMvc no longer auto-injected, Spring Boot's WebMvcAutoConfiguration is active and contributes a defaultViewResolver (InternalResourceViewResolver). For a Grails app that does not use JSP/InternalResource views (e.g. a REST/JSON-views app) this catch-all resolves any unmatched view name to a servlet forward, yielding "Circular view path [index]: would dispatch back to the current handler URL" (HTTP 500). grails-gsp already strips this bean for GSP apps via its own registrar, but a JSON-only app never loads grails-gsp. Add a core auto-configuration (ordered after WebMvcAutoConfiguration) that removes defaultViewResolver for every Grails servlet web application, so Grails' own view resolution is used. Disable with grails.web.removeDefaultViewResolverBean=false. Fixes the graphql, issue-views-182 and views-functional-tests example apps. --- .../GrailsViewResolverAutoConfiguration.java | 69 +++++++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 2 files changed, 70 insertions(+) create mode 100644 grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java new file mode 100644 index 00000000000..5c6da543356 --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java @@ -0,0 +1,69 @@ +/* + * 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.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; + +/** + * 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. + * + *

{@code grails-gsp} already removes this bean for GSP applications via its own registrar, + * but a JSON-only application never loads {@code grails-gsp}. This core auto-configuration + * removes the {@code defaultViewResolver} for every Grails servlet web application so Grails' + * own view resolution (GSP, JSON/Markup views) is used instead. Ordered after + * {@link WebMvcAutoConfiguration} so the bean exists by the time the registrar runs. + * + *

Disable with {@code grails.web.removeDefaultViewResolverBean=false}. + */ +@AutoConfiguration(after = WebMvcAutoConfiguration.class) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) +@Import(GrailsViewResolverAutoConfiguration.RemoveDefaultViewResolverRegistrar.class) +public class GrailsViewResolverAutoConfiguration { + + static class RemoveDefaultViewResolverRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware { + + 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) { + this.removeDefaultViewResolverBean = environment.getProperty( + "grails.web.removeDefaultViewResolverBean", Boolean.class, true); + } + } +} diff --git a/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 070ecd4bead..a0c2247bfdd 100644 --- a/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/grails-controllers/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1 +1,2 @@ org.grails.plugins.web.controllers.ControllersAutoConfiguration +org.grails.plugins.web.controllers.GrailsViewResolverAutoConfiguration From 04622e3a9cfbd920089329aa6436c6c90f050ca9 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 22 Jun 2026 10:44:25 -0600 Subject: [PATCH 04/12] Consolidate defaultViewResolver removal into one place GrailsViewResolverAutoConfiguration (grails-controllers) already removes Boot's defaultViewResolver for every Grails servlet web app, so grails-gsp's registrar no longer needs to do it too. Drop the duplicate defaultViewResolver removal (and its spring.gsp.removeDefaultViewResolverBean flag) from grails-gsp, leaving only the GSP-specific behaviour: replacing the viewResolver bean with an alias to gspViewResolver. Rename the registrar to ReplaceViewResolverRegistrar to reflect its remaining role. No behaviour change: GSP apps still have defaultViewResolver removed (now via the core auto-configuration) and gspViewResolver aliased as viewResolver. --- .../GrailsViewResolverAutoConfiguration.java | 7 +++--- .../grails/gsp/boot/GspAutoConfiguration.java | 25 ++++++++----------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java index 5c6da543356..49b127f5d26 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java @@ -36,10 +36,9 @@ * any unmatched view name to a servlet forward, producing * {@code Circular view path [index]: would dispatch back to the current handler URL} errors. * - *

{@code grails-gsp} already removes this bean for GSP applications via its own registrar, - * but a JSON-only application never loads {@code grails-gsp}. This core auto-configuration - * removes the {@code defaultViewResolver} for every Grails servlet web application so Grails' - * own view resolution (GSP, JSON/Markup views) is used instead. Ordered after + * 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. * *

Disable with {@code grails.web.removeDefaultViewResolverBean=false}. diff --git a/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java b/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java index 79ec59002fe..b5614a45b4e 100644 --- a/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java +++ b/grails-gsp/spring-boot/src/main/java/grails/gsp/boot/GspAutoConfiguration.java @@ -90,7 +90,7 @@ protected static abstract class AbstractGspConfig { } @Configuration - @Import({TagLibraryLookupRegistrar.class, RemoveDefaultViewResolverRegistrar.class}) + @Import({TagLibraryLookupRegistrar.class, ReplaceViewResolverRegistrar.class}) protected static class GspTemplateEngineAutoConfiguration extends AbstractGspConfig { private static final String LOCAL_DIRECTORY_TEMPLATE_ROOT = "./src/main/resources/templates"; private static final String CLASSPATH_TEMPLATE_ROOT = "classpath:/templates"; @@ -266,26 +266,22 @@ protected GenericBeanDefinition createBeanDefinition(Class beanClass) { } /** - * {@link WebMvcAutoConfiguration} adds defaultViewResolver and viewResolver beans. + * Makes the GSP view resolver the primary {@code viewResolver} for GSP applications by + * replacing the {@code viewResolver} bean ({@link WebMvcAutoConfiguration}'s + * {@code ContentNegotiatingViewResolver}) with an alias to {@code gspViewResolver}. * - * This ImportBeanDefinitionRegistrar removes the defaultViewResolver and replaces - * the viewResolver bean with GSP view resolver by default. - * - * The behavior of this class can be controlled with spring.gsp.removeDefaultViewResolver and - * spring.gsp.replaceViewResolverBean configuration properties. + *

Removal of {@link WebMvcAutoConfiguration}'s {@code defaultViewResolver} + * ({@code InternalResourceViewResolver}) is handled centrally for every Grails servlet web + * application by {@code GrailsViewResolverAutoConfiguration} (grails-controllers), so it is + * not repeated here. * + *

Controlled by the {@code spring.gsp.replaceViewResolverBean} configuration property. */ - protected static class RemoveDefaultViewResolverRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware { - boolean removeDefaultViewResolverBean; + protected static class ReplaceViewResolverRegistrar implements ImportBeanDefinitionRegistrar, EnvironmentAware { boolean replaceViewResolverBean; @Override public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, BeanDefinitionRegistry registry) { - if (removeDefaultViewResolverBean) { - if (registry.containsBeanDefinition("defaultViewResolver")) { - registry.removeBeanDefinition("defaultViewResolver"); - } - } if (replaceViewResolverBean) { if (registry.containsBeanDefinition("viewResolver")) { registry.removeBeanDefinition("viewResolver"); @@ -296,7 +292,6 @@ public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, B @Override public void setEnvironment(Environment environment) { - removeDefaultViewResolverBean = environment.getProperty("spring.gsp.removeDefaultViewResolverBean", Boolean.class, true); replaceViewResolverBean = environment.getProperty("spring.gsp.replaceViewResolverBean", Boolean.class, true); } } From 25a4f2da2c1c29708890eaeb8501a49e18ef3631 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 24 Jun 2026 11:27:43 -0600 Subject: [PATCH 05/12] docs: document @EnableWebMvc removal in 8.0.x upgrade notes --- .../src/en/guide/upgrading/upgrading80x.adoc | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 628b92cac7d..4f9a866f190 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1008,3 +1008,49 @@ grails.gorm.default.constraints = { '*'(nullable: false) } ---- + +==== 27. @EnableWebMvc No Longer Added to Grails Applications + +In earlier versions, Grails added Spring's `@EnableWebMvc` annotation to the generated `Application` class at compile time. +`@EnableWebMvc` is a Spring Framework configuration annotation that is not intended to be combined with Spring Boot: it imports `DelegatingWebMvcConfiguration`, which eagerly registers a fixed set of MVC beans and switches off Spring Boot's `WebMvcAutoConfiguration`. +In a Boot application this produced bean-definition override warnings at startup (for example for `localeResolver` and `internalAutoProxyCreator`) and prevented Boot's conditional, more configurable auto-configuration from taking effect. + +Grails 8 removes the auto-injected `@EnableWebMvc`. +Spring Boot's `WebMvcAutoConfiguration` is now active for Grails servlet web applications, which is the intended Boot behavior and resolves those override warnings. + +**No action is required for most applications.** +Grails adapts internally so the change is transparent. +The notes below explain the two behavioral differences this introduces and the configuration toggles available if you need to control them. + +===== 27.1 Boot's default view resolver is removed automatically + +With `WebMvcAutoConfiguration` active, Boot contributes a catch-all `defaultViewResolver` (an `InternalResourceViewResolver`). +For a Grails application that does not use JSP/`InternalResource` views (for example a REST/JSON-views application) this resolver maps any unmatched view name to a servlet forward, which can surface as: + +[source] +---- +Circular view path [index]: would dispatch back to the current handler URL +---- + +To prevent this, Grails removes Boot's `defaultViewResolver` for every Grails servlet web application so that Grails' own view resolution is used instead. +If you need to keep Boot's `defaultViewResolver` (for example, an application that genuinely relies on JSP/`InternalResource` views), opt out: + +[source,yaml] +.application.yml +---- +grails: + web: + removeDefaultViewResolverBean: false +---- + +NOTE: In GSP applications the GSP view resolver continues to be installed as the primary `viewResolver`, controlled as before by `spring.gsp.replaceViewResolverBean`. +The previous `spring.gsp.removeDefaultViewResolverBean` property has been removed; `defaultViewResolver` removal is now handled centrally for all Grails servlet web applications by the `grails.web.removeDefaultViewResolverBean` property above. + +===== 27.2 GrailsWebRequest binding + +With `WebMvcAutoConfiguration` active, Boot would otherwise register its own `RequestContextFilter` that rebinds a plain `ServletRequestAttributes`, replacing the `GrailsWebRequest` bound earlier in the filter chain. +Downstream code that expects a `GrailsWebRequest` would then fail with a `ClassCastException`. + +Grails 8 registers its request-binding filter as a `RequestContextFilter` bean so that Boot's filter backs off and the `GrailsWebRequest` remains bound for the whole request. +This is handled internally and requires no configuration changes. +Applications that defined their own `GrailsWebRequestFilter` bean continue to override the Grails-provided one. From c312c449cc569c82da48641a5b2cb9123b9f54be Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 24 Jun 2026 11:41:51 -0600 Subject: [PATCH 06/12] test: guard GrailsWebRequestFilter is a RequestContextFilter so Boot backs off --- .../ControllersAutoConfigurationSpec.groovy | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..6dc819cff39 --- /dev/null +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -0,0 +1,59 @@ +/* + * 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.web.servlet.FilterRegistrationBean +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 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 + } +} From 7692dbc571a1a8233f6c5f3e88e87cb7f4ec96d4 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 24 Jun 2026 12:08:38 -0600 Subject: [PATCH 07/12] test: prove Boot WebMvcAutoConfiguration backs off its RequestContextFilter for Grails apps --- grails-controllers/build.gradle | 4 +++ .../ControllersAutoConfigurationSpec.groovy | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/grails-controllers/build.gradle b/grails-controllers/build.gradle index 0640d739597..21c2ec6f8f6 100644 --- a/grails-controllers/build.gradle +++ b/grails-controllers/build.gradle @@ -63,6 +63,10 @@ dependencies { testImplementation 'jakarta.servlet:jakarta.servlet-api' testImplementation 'org.springframework:spring-test' + // WebApplicationContextRunner verifies Boot's WebMvcAutoConfiguration backs off its RequestContextFilter + testImplementation 'org.springframework.boot:spring-boot-test' + // spring-boot-test's AssertableApplicationContext implements AssertJ's AssertProvider, loaded at runtime + testRuntimeOnly 'org.assertj:assertj-core' testImplementation 'org.apache.groovy:groovy-test-junit5' testImplementation 'org.junit.jupiter:junit-jupiter-api' diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index 6dc819cff39..1e67d2e9818 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -19,7 +19,14 @@ 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 @@ -56,4 +63,30 @@ class ControllersAutoConfigurationSpec extends Specification { 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 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 + } + } } From 12505aaa6e90c05bd2aa5c5ec3a5c0c98313d7cc Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Mon, 6 Jul 2026 11:32:18 -0700 Subject: [PATCH 08/12] Address review feedback: remove dependency comments, simplify filter comment --- grails-controllers/build.gradle | 2 -- .../web/controllers/ControllersAutoConfiguration.java | 9 +++------ 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/grails-controllers/build.gradle b/grails-controllers/build.gradle index 21c2ec6f8f6..d050003c2c0 100644 --- a/grails-controllers/build.gradle +++ b/grails-controllers/build.gradle @@ -63,9 +63,7 @@ dependencies { testImplementation 'jakarta.servlet:jakarta.servlet-api' testImplementation 'org.springframework:spring-test' - // WebApplicationContextRunner verifies Boot's WebMvcAutoConfiguration backs off its RequestContextFilter testImplementation 'org.springframework.boot:spring-boot-test' - // spring-boot-test's AssertableApplicationContext implements AssertJ's AssertProvider, loaded at runtime testRuntimeOnly 'org.assertj:assertj-core' testImplementation 'org.apache.groovy:groovy-test-junit5' diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index cd079b7def2..af635da0736 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -108,12 +108,9 @@ public FilterRegistrationBean 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. + // GrailsWebRequestFilter extends RequestContextFilter, so Boot's WebMvcAutoConfiguration + // backs off and does not register a competing RequestContextFilter that would rebind + // a plain ServletRequestAttributes over the GrailsWebRequest. @Bean @ConditionalOnMissingBean(GrailsWebRequestFilter.class) public GrailsWebRequestFilter grailsWebRequest(ApplicationContext applicationContext) { From 7c778321d3a3fd92103bdb9679afd5952adc8e54 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 08:58:13 -0600 Subject: [PATCH 09/12] Harden the @EnableWebMvc removal: legacy flag fallback + overridable beans - Honour the deprecated spring.gsp.removeDefaultViewResolverBean property as a fallback for grails.web.removeDefaultViewResolverBean, logging a deprecation warning when the old name is set - Guard grailsWebRequestFilter and webMvcConfig with @ConditionalOnMissingBean so user-defined beans take precedence - Update the 8.0 upgrade notes and add test coverage for the property matrix and the bean back-off behavior --- .../ControllersAutoConfiguration.java | 11 ++- .../GrailsViewResolverAutoConfiguration.java | 19 ++++- .../ControllersAutoConfigurationSpec.groovy | 44 +++++++++++ ...lsViewResolverAutoConfigurationSpec.groovy | 79 +++++++++++++++++++ .../src/en/guide/upgrading/upgrading80x.adoc | 4 +- 5 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfigurationSpec.groovy diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java index bbae7c1a3d3..5774c175e9f 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/ControllersAutoConfiguration.java @@ -109,9 +109,12 @@ public FilterRegistrationBean hiddenHttpMethodFilter() { return registrationBean; } - // GrailsWebRequestFilter extends RequestContextFilter, so Boot's WebMvcAutoConfiguration - // backs off and does not register a competing RequestContextFilter that would rebind - // a plain ServletRequestAttributes over the GrailsWebRequest. + // 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 GrailsWebRequestFilter grailsWebRequest(ApplicationContext applicationContext) { @@ -121,6 +124,7 @@ public GrailsWebRequestFilter grailsWebRequest(ApplicationContext applicationCon } @Bean + @ConditionalOnMissingBean(name = "grailsWebRequestFilter") public FilterRegistrationBean grailsWebRequestFilter(GrailsWebRequestFilter grailsWebRequest) { FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); registrationBean.setFilter(grailsWebRequest); @@ -161,6 +165,7 @@ public DispatcherServletRegistrationBean dispatcherServletRegistration(GrailsApp } @Bean + @ConditionalOnMissingBean(GrailsWebMvcConfigurer.class) public GrailsWebMvcConfigurer webMvcConfig() { return new GrailsWebMvcConfigurer(resourcesCachePeriod, resourcesEnabled, resourcesPattern); } diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java index 49b127f5d26..a2329e3028e 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java @@ -18,6 +18,9 @@ */ 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; @@ -41,15 +44,22 @@ * 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. * - *

Disable with {@code grails.web.removeDefaultViewResolverBean=false}. + *

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 = "grails.web.removeDefaultViewResolverBean"; + 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 @@ -61,8 +71,13 @@ public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, B @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( - "grails.web.removeDefaultViewResolverBean", Boolean.class, true); + REMOVE_PROPERTY, Boolean.class, legacy != null ? legacy : true); } } } diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy index 1e67d2e9818..8280c26417f 100644 --- a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -89,4 +89,48 @@ class ControllersAutoConfigurationSpec extends Specification { 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 grailsApplicationSupplier = () -> grailsApplication + + and: 'a user-defined registration bean under the auto-configured bean name' + FilterRegistrationBean userRegistration = new FilterRegistrationBean<>() + Supplier 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 grailsApplicationSupplier = () -> grailsApplication + + and: 'a user-defined web MVC configurer' + def userConfigurer = new ControllersAutoConfiguration.GrailsWebMvcConfigurer(0, false, '/custom/**') + Supplier 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) + } + } } diff --git a/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfigurationSpec.groovy b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..77db96d3b85 --- /dev/null +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfigurationSpec.groovy @@ -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') + } + } +} diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index d0e40f713dd..5418373a8bf 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1382,7 +1382,7 @@ grails: ---- NOTE: In GSP applications the GSP view resolver continues to be installed as the primary `viewResolver`, controlled as before by `spring.gsp.replaceViewResolverBean`. -The previous `spring.gsp.removeDefaultViewResolverBean` property has been removed; `defaultViewResolver` removal is now handled centrally for all Grails servlet web applications by the `grails.web.removeDefaultViewResolverBean` property above. +The earlier `spring.gsp.removeDefaultViewResolverBean` property (from when this removal lived in the GSP module) is still honoured for backward compatibility but is **deprecated** — `defaultViewResolver` removal is now handled centrally for all Grails servlet web applications by the `grails.web.removeDefaultViewResolverBean` property above, which takes precedence. Setting the old name logs a deprecation warning at startup. ===== 31.2 GrailsWebRequest binding @@ -1391,4 +1391,4 @@ Downstream code that expects a `GrailsWebRequest` would then fail with a `ClassC Grails 8 registers its request-binding filter as a `RequestContextFilter` bean so that Boot's filter backs off and the `GrailsWebRequest` remains bound for the whole request. This is handled internally and requires no configuration changes. -Applications that defined their own `GrailsWebRequestFilter` bean continue to override the Grails-provided one. +Applications that defined their own `GrailsWebRequestFilter` bean, or their own `grailsWebRequestFilter` filter-registration bean, continue to override the Grails-provided ones. From 7a914eca2e6a786b3dd93f68fcad3758354bce20 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 09:14:06 -0600 Subject: [PATCH 10/12] Make i18n and URL-mappings framework beans overridable Guard framework beans with @ConditionalOnMissingBean so an application- or plugin-defined bean takes precedence cleanly instead of relying on bean-definition overriding: - i18n: localeResolver, localeChangeInterceptor, messageSource - URL mappings: grailsCorsFilter, urlMappingsErrorPageCustomizer, urlMappingsInfoHandlerAdapter The beans still register by default. The localeResolver guard requires the @EnableWebMvc removal (#13863): with @EnableWebMvc present, WebMvcConfigurationSupport contributes its own localeResolver bean that would win the @ConditionalOnMissingBean race and silently replace Grails' SessionLocaleResolver. Adds auto-configuration test coverage for both modules (default registration, property toggles, and user-bean back-off) and documents the change in the 8.0 upgrade notes. --- .../src/en/guide/upgrading/upgrading80x.adoc | 12 ++ grails-i18n/build.gradle | 3 + .../plugins/i18n/I18nAutoConfiguration.java | 4 + .../i18n/I18nAutoConfigurationSpec.groovy | 111 +++++++++++++++++ grails-url-mappings/build.gradle | 3 + .../mapping/UrlMappingsAutoConfiguration.java | 3 + .../UrlMappingsAutoConfigurationSpec.groovy | 112 ++++++++++++++++++ 7 files changed, 248 insertions(+) create mode 100644 grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy create mode 100644 grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 5418373a8bf..4f5e76550a3 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1392,3 +1392,15 @@ Downstream code that expects a `GrailsWebRequest` would then fail with a `ClassC Grails 8 registers its request-binding filter as a `RequestContextFilter` bean so that Boot's filter backs off and the `GrailsWebRequest` remains bound for the whole request. This is handled internally and requires no configuration changes. Applications that defined their own `GrailsWebRequestFilter` bean, or their own `grailsWebRequestFilter` filter-registration bean, continue to override the Grails-provided ones. + +==== 32. More Framework Beans Are Cleanly Overridable + +Several Grails framework beans that were previously registered unconditionally are now guarded with `@ConditionalOnMissingBean`, so an application- or plugin-defined bean of the same name (or type) takes precedence cleanly instead of relying on bean-definition overriding: + +* i18n: `localeResolver`, `localeChangeInterceptor` and `messageSource` +* URL mappings: `grailsCorsFilter`, `urlMappingsErrorPageCustomizer` and `urlMappingsInfoHandlerAdapter` + +**No action is required.** +The framework beans still register by default; this only changes what happens when you define your own. +Previously, defining one of these beans produced a bean-definition override (with a startup warning, or a failure when `spring.main.allow-bean-definition-overriding` is `false`). +Now the framework bean simply backs off. diff --git a/grails-i18n/build.gradle b/grails-i18n/build.gradle index 2472c4e24cc..83fc147075f 100644 --- a/grails-i18n/build.gradle +++ b/grails-i18n/build.gradle @@ -50,6 +50,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' @@ -68,4 +70,5 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') } \ No newline at end of file diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java index 0965fa64258..7c271e7fc84 100644 --- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java +++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java @@ -21,6 +21,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; @@ -56,11 +57,13 @@ public class I18nAutoConfiguration { private int fileCacheSeconds; @Bean(DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME) + @ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME) public LocaleResolver localeResolver() { return new SessionLocaleResolver(); } @Bean + @ConditionalOnMissingBean(name = "localeChangeInterceptor") public LocaleChangeInterceptor localeChangeInterceptor() { ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor(); localeChangeInterceptor.setParamName("lang"); @@ -68,6 +71,7 @@ public LocaleChangeInterceptor localeChangeInterceptor() { } @Bean(AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME) + @ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME) public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPluginManager pluginManager) { PluginAwareResourceBundleMessageSource messageSource = new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager); messageSource.setDefaultEncoding(encoding); diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..052bfd7bb0f --- /dev/null +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy @@ -0,0 +1,111 @@ +/* + * 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.i18n + +import java.util.function.Supplier + +import grails.core.DefaultGrailsApplication +import grails.core.GrailsApplication +import grails.plugins.GrailsPlugin +import grails.plugins.GrailsPluginManager + +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration +import org.springframework.boot.test.context.runner.WebApplicationContextRunner +import org.springframework.context.MessageSource +import org.springframework.context.support.StaticMessageSource +import org.springframework.web.servlet.LocaleResolver +import org.springframework.web.servlet.i18n.FixedLocaleResolver +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import org.springframework.web.servlet.i18n.SessionLocaleResolver + +import org.grails.spring.context.support.PluginAwareResourceBundleMessageSource +import org.grails.web.i18n.ParamsAwareLocaleChangeInterceptor + +import spock.lang.Specification + +class I18nAutoConfigurationSpec extends Specification { + + private WebApplicationContextRunner contextRunner() { + // A real instance: PluginAwareResourceBundleMessageSource casts its GrailsApplication + // to DefaultGrailsApplication, so an interface mock (JDK proxy) fails at startup. + GrailsApplication grailsApplication = new DefaultGrailsApplication() + GrailsPluginManager pluginManager = Mock(GrailsPluginManager) { + getAllPlugins() >> ([] as GrailsPlugin[]) + } + Supplier grailsApplicationSupplier = () -> grailsApplication + Supplier pluginManagerSupplier = () -> pluginManager + new WebApplicationContextRunner() + .withBean(GrailsApplication, grailsApplicationSupplier) + .withBean(GrailsPluginManager, pluginManagerSupplier) + .withConfiguration(AutoConfigurations.of(PropertyPlaceholderAutoConfiguration, I18nAutoConfiguration)) + } + + void 'the Grails i18n beans register by default'() { + expect: + contextRunner().run { context -> + assert context.getBean('localeResolver') instanceof SessionLocaleResolver + assert context.getBean(LocaleChangeInterceptor) instanceof ParamsAwareLocaleChangeInterceptor + assert context.getBean('messageSource') instanceof PluginAwareResourceBundleMessageSource + } + } + + void 'a user-defined localeResolver bean makes the Grails localeResolver back off'() { + given: + LocaleResolver userLocaleResolver = new FixedLocaleResolver(Locale.CANADA) + Supplier userLocaleResolverSupplier = () -> userLocaleResolver + + expect: + contextRunner() + .withBean('localeResolver', LocaleResolver, userLocaleResolverSupplier) + .run { context -> + assert context.getBean('localeResolver').is(userLocaleResolver) + assert context.getBeanNamesForType(LocaleResolver).length == 1 + } + } + + void 'a user-defined localeChangeInterceptor bean makes the Grails localeChangeInterceptor back off'() { + given: + LocaleChangeInterceptor userInterceptor = new LocaleChangeInterceptor() + Supplier userInterceptorSupplier = () -> userInterceptor + + expect: + contextRunner() + .withBean('localeChangeInterceptor', LocaleChangeInterceptor, userInterceptorSupplier) + .run { context -> + assert context.getBean('localeChangeInterceptor').is(userInterceptor) + assert context.getBeanNamesForType(LocaleChangeInterceptor).length == 1 + } + } + + void 'a user-defined messageSource bean makes the Grails messageSource back off'() { + given: + MessageSource userMessageSource = new StaticMessageSource() + Supplier userMessageSourceSupplier = () -> userMessageSource + + expect: + contextRunner() + .withBean('messageSource', MessageSource, userMessageSourceSupplier) + .run { context -> + assert context.getBean('messageSource').is(userMessageSource) + assert context.getBeanNamesForType(PluginAwareResourceBundleMessageSource).length == 0 + } + } +} diff --git a/grails-url-mappings/build.gradle b/grails-url-mappings/build.gradle index 288e426f5b1..5fa694bc4ef 100644 --- a/grails-url-mappings/build.gradle +++ b/grails-url-mappings/build.gradle @@ -51,6 +51,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' @@ -69,4 +71,5 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') } \ No newline at end of file diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java index 1d4edfae6a5..d1eb1234510 100644 --- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java +++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java @@ -76,12 +76,14 @@ public LinkGenerator grailsLinkGenerator() { } @Bean + @ConditionalOnMissingBean(GrailsCorsFilter.class) @ConditionalOnProperty(name = Settings.SETTING_CORS_FILTER, havingValue = "true", matchIfMissing = true) public GrailsCorsFilter grailsCorsFilter(GrailsCorsConfiguration grailsCorsConfiguration) { return new GrailsCorsFilter(grailsCorsConfiguration); } @Bean + @ConditionalOnMissingBean(UrlMappingsErrorPageCustomizer.class) public UrlMappingsErrorPageCustomizer urlMappingsErrorPageCustomizer(ObjectProvider urlMappingsProvider) { UrlMappingsErrorPageCustomizer errorPageCustomizer = new UrlMappingsErrorPageCustomizer(); errorPageCustomizer.setUrlMappings(urlMappingsProvider.getIfAvailable()); @@ -89,6 +91,7 @@ public UrlMappingsErrorPageCustomizer urlMappingsErrorPageCustomizer(ObjectProvi } @Bean + @ConditionalOnMissingBean(UrlMappingsInfoHandlerAdapter.class) public UrlMappingsInfoHandlerAdapter urlMappingsInfoHandlerAdapter() { return new UrlMappingsInfoHandlerAdapter(); } diff --git a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy new file mode 100644 index 00000000000..d8dbd79d4e2 --- /dev/null +++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy @@ -0,0 +1,112 @@ +/* + * 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.mapping + +import java.util.function.Supplier + +import grails.config.Settings +import grails.web.mapping.UrlMappings +import grails.web.mapping.cors.GrailsCorsConfiguration +import grails.web.mapping.cors.GrailsCorsFilter + +import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration +import org.springframework.boot.test.context.runner.WebApplicationContextRunner + +import org.grails.web.mapping.mvc.UrlMappingsInfoHandlerAdapter +import org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer + +import spock.lang.Specification + +class UrlMappingsAutoConfigurationSpec extends Specification { + + private WebApplicationContextRunner contextRunner() { + // grailsLinkGenerator autowires the grailsUrlMappingsHolder bean and + // urlMappingsInfoHandlerAdapter autowires UrlMappings; one mock satisfies both. + UrlMappings urlMappings = Mock(UrlMappings) + Supplier urlMappingsSupplier = () -> urlMappings + new WebApplicationContextRunner() + .withBean('grailsUrlMappingsHolder', UrlMappings, urlMappingsSupplier) + .withConfiguration(AutoConfigurations.of(PropertyPlaceholderAutoConfiguration, UrlMappingsAutoConfiguration)) + } + + void 'the Grails url-mappings beans register by default'() { + expect: + contextRunner().run { context -> + assert context.containsBean('grailsCorsFilter') + assert context.containsBean('urlMappingsErrorPageCustomizer') + assert context.containsBean('urlMappingsInfoHandlerAdapter') + } + } + + void 'the CORS filter is not registered when disabled by property'() { + expect: + contextRunner() + .withPropertyValues("${Settings.SETTING_CORS_FILTER}=false") + .run { context -> + assert !context.containsBean('grailsCorsFilter') + } + } + + void 'a user-defined GrailsCorsFilter bean makes the auto-configured one back off'() { + given: + GrailsCorsFilter userCorsFilter = new GrailsCorsFilter(new GrailsCorsConfiguration()) + Supplier userCorsFilterSupplier = () -> userCorsFilter + + expect: + contextRunner() + .withBean(GrailsCorsFilter, userCorsFilterSupplier) + .run { context -> + def names = context.getBeanNamesForType(GrailsCorsFilter) + assert names.length == 1 + assert context.getBean(names[0]).is(userCorsFilter) + } + } + + void 'a user-defined UrlMappingsErrorPageCustomizer bean makes the auto-configured one back off'() { + given: + UrlMappingsErrorPageCustomizer userCustomizer = new UrlMappingsErrorPageCustomizer() + Supplier userCustomizerSupplier = () -> userCustomizer + + expect: + contextRunner() + .withBean(UrlMappingsErrorPageCustomizer, userCustomizerSupplier) + .run { context -> + def names = context.getBeanNamesForType(UrlMappingsErrorPageCustomizer) + assert names.length == 1 + assert context.getBean(names[0]).is(userCustomizer) + } + } + + void 'a user-defined UrlMappingsInfoHandlerAdapter bean makes the auto-configured one back off'() { + given: + UrlMappingsInfoHandlerAdapter userAdapter = new UrlMappingsInfoHandlerAdapter() + Supplier userAdapterSupplier = () -> userAdapter + + expect: + contextRunner() + .withBean(UrlMappingsInfoHandlerAdapter, userAdapterSupplier) + .run { context -> + def names = context.getBeanNamesForType(UrlMappingsInfoHandlerAdapter) + assert names.length == 1 + assert context.getBean(names[0]).is(userAdapter) + } + } +} From 84ea8179b8d948e0f387ee9f478083a1b3cd761e Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 11:54:12 -0600 Subject: [PATCH 11/12] Declare grails.web.removeDefaultViewResolverBean in Settings Review feedback on the stacked PR #15916: the property is user-facing configuration, so the constant belongs in grails.config.Settings with the other grails.web.* keys rather than a package-private field. --- .../controllers/GrailsViewResolverAutoConfiguration.java | 4 +++- grails-core/src/main/groovy/grails/config/Settings.groovy | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java index a2329e3028e..36db458d35d 100644 --- a/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java @@ -31,6 +31,8 @@ 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} @@ -53,7 +55,7 @@ @Import(GrailsViewResolverAutoConfiguration.RemoveDefaultViewResolverRegistrar.class) public class GrailsViewResolverAutoConfiguration { - static final String REMOVE_PROPERTY = "grails.web.removeDefaultViewResolverBean"; + 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 { diff --git a/grails-core/src/main/groovy/grails/config/Settings.groovy b/grails-core/src/main/groovy/grails/config/Settings.groovy index c298638b38e..43f14707897 100644 --- a/grails-core/src/main/groovy/grails/config/Settings.groovy +++ b/grails-core/src/main/groovy/grails/config/Settings.groovy @@ -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 */ From 6cd34fa69160e06d68f5734135d1812d991729eb Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Tue, 7 Jul 2026 11:57:52 -0600 Subject: [PATCH 12/12] Address review feedback - Scope the i18n @ConditionalOnMissingBean guards to SearchStrategy.CURRENT: DispatcherServlet resolves localeResolver/localeChangeInterceptor from its own context and Boot's MessageSourceAutoConfiguration scopes its messageSource guard the same way, so parent-context beans must not suppress the child's - Broaden the CORS guard to CorsFilter so any user CORS filter registration wins, not only a custom GrailsCorsFilter (two CORS filters never coexist) - Reference the new Settings.WEB_REMOVE_DEFAULT_VIEW_RESOLVER_BEAN constant (declared on the base branch) instead of a string literal - Qualify the upgrade-notes claim: the back-off applies to beans registered before auto-configuration (application @Bean methods); doWithSpring and resources.groovy beans continue to rely on bean-definition overriding - Add trailing newlines to the touched build files - Tests: parent-context hierarchy coverage for the i18n beans and plain CorsFilter back-off coverage --- .../src/en/guide/upgrading/upgrading80x.adoc | 5 ++++- grails-i18n/build.gradle | 2 +- .../plugins/i18n/I18nAutoConfiguration.java | 10 ++++++--- .../i18n/I18nAutoConfigurationSpec.groovy | 22 +++++++++++++++++++ grails-url-mappings/build.gradle | 2 +- .../mapping/UrlMappingsAutoConfiguration.java | 6 ++++- .../UrlMappingsAutoConfigurationSpec.groovy | 18 +++++++++++++++ 7 files changed, 58 insertions(+), 7 deletions(-) diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 4f5e76550a3..024aec9f0d7 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1395,7 +1395,7 @@ Applications that defined their own `GrailsWebRequestFilter` bean, or their own ==== 32. More Framework Beans Are Cleanly Overridable -Several Grails framework beans that were previously registered unconditionally are now guarded with `@ConditionalOnMissingBean`, so an application- or plugin-defined bean of the same name (or type) takes precedence cleanly instead of relying on bean-definition overriding: +Several Grails framework beans that were previously registered unconditionally are now guarded with `@ConditionalOnMissingBean`, so a bean of the same name (or type) defined in an application configuration class (a `@Bean` method) takes precedence cleanly instead of relying on bean-definition overriding: * i18n: `localeResolver`, `localeChangeInterceptor` and `messageSource` * URL mappings: `grailsCorsFilter`, `urlMappingsErrorPageCustomizer` and `urlMappingsInfoHandlerAdapter` @@ -1404,3 +1404,6 @@ Several Grails framework beans that were previously registered unconditionally a The framework beans still register by default; this only changes what happens when you define your own. Previously, defining one of these beans produced a bean-definition override (with a startup warning, or a failure when `spring.main.allow-bean-definition-overriding` is `false`). Now the framework bean simply backs off. + +NOTE: The back-off applies to beans that are registered before auto-configuration conditions are evaluated — `@Bean` methods in application configuration classes. +Beans contributed later, through a plugin's `doWithSpring` or the application's `resources.groovy`, continue to take effect through bean-definition overriding, exactly as in previous releases. diff --git a/grails-i18n/build.gradle b/grails-i18n/build.gradle index ae5c5eb1439..7f41699e342 100644 --- a/grails-i18n/build.gradle +++ b/grails-i18n/build.gradle @@ -72,4 +72,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java index 7c271e7fc84..081e0ac517d 100644 --- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java +++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java @@ -23,6 +23,7 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.SearchStrategy; import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.MessageSource; @@ -56,14 +57,17 @@ public class I18nAutoConfiguration { @Value("${" + Settings.I18N_FILE_CACHE_SECONDS + ":5}") private int fileCacheSeconds; + // SearchStrategy.CURRENT on all three guards: DispatcherServlet resolves these beans from its + // own context, and Boot's MessageSourceAutoConfiguration uses the same scoping — a bean in a + // parent context must not make the child context's bean back off. @Bean(DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME) - @ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME) + @ConditionalOnMissingBean(name = DispatcherServlet.LOCALE_RESOLVER_BEAN_NAME, search = SearchStrategy.CURRENT) public LocaleResolver localeResolver() { return new SessionLocaleResolver(); } @Bean - @ConditionalOnMissingBean(name = "localeChangeInterceptor") + @ConditionalOnMissingBean(name = "localeChangeInterceptor", search = SearchStrategy.CURRENT) public LocaleChangeInterceptor localeChangeInterceptor() { ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor(); localeChangeInterceptor.setParamName("lang"); @@ -71,7 +75,7 @@ public LocaleChangeInterceptor localeChangeInterceptor() { } @Bean(AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME) - @ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME) + @ConditionalOnMissingBean(name = AbstractApplicationContext.MESSAGE_SOURCE_BEAN_NAME, search = SearchStrategy.CURRENT) public MessageSource messageSource(GrailsApplication grailsApplication, GrailsPluginManager pluginManager) { PluginAwareResourceBundleMessageSource messageSource = new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager); messageSource.setDefaultEncoding(encoding); diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy index 052bfd7bb0f..2a8dd391745 100644 --- a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy @@ -30,6 +30,7 @@ import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration import org.springframework.boot.test.context.runner.WebApplicationContextRunner import org.springframework.context.MessageSource +import org.springframework.context.support.GenericApplicationContext import org.springframework.context.support.StaticMessageSource import org.springframework.web.servlet.LocaleResolver import org.springframework.web.servlet.i18n.FixedLocaleResolver @@ -95,6 +96,27 @@ class I18nAutoConfigurationSpec extends Specification { } } + void 'beans in a parent context do not suppress the Grails i18n beans'() { + given: 'a parent context that already has i18n beans (SearchStrategy.CURRENT contract)' + GenericApplicationContext parent = new GenericApplicationContext() + parent.beanFactory.registerSingleton('messageSource', new StaticMessageSource()) + parent.beanFactory.registerSingleton('localeResolver', new FixedLocaleResolver(Locale.CANADA)) + parent.beanFactory.registerSingleton('localeChangeInterceptor', new LocaleChangeInterceptor()) + parent.refresh() + + expect: 'the child context still registers its own Grails i18n beans' + contextRunner() + .withParent(parent) + .run { context -> + assert context.getBeanNamesForType(PluginAwareResourceBundleMessageSource).length == 1 + assert context.getBean('localeResolver') instanceof SessionLocaleResolver + assert context.getBean(LocaleChangeInterceptor) instanceof ParamsAwareLocaleChangeInterceptor + } + + cleanup: + parent.close() + } + void 'a user-defined messageSource bean makes the Grails messageSource back off'() { given: MessageSource userMessageSource = new StaticMessageSource() diff --git a/grails-url-mappings/build.gradle b/grails-url-mappings/build.gradle index 91eb38d33d8..c7e81c943fc 100644 --- a/grails-url-mappings/build.gradle +++ b/grails-url-mappings/build.gradle @@ -73,4 +73,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java index d1eb1234510..0022ea9d398 100644 --- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java +++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java @@ -27,6 +27,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; +import org.springframework.web.filter.CorsFilter; import grails.config.Settings; import grails.util.Environment; @@ -75,8 +76,11 @@ public LinkGenerator grailsLinkGenerator() { return cacheUrls ? new CachingLinkGenerator(serverURL) : new DefaultLinkGenerator(serverURL); } + // Guarded on CorsFilter (the Spring type GrailsCorsFilter extends), not GrailsCorsFilter: + // a user replacing CORS handling with any CorsFilter registration should not end up with + // two CORS filters answering the same requests. @Bean - @ConditionalOnMissingBean(GrailsCorsFilter.class) + @ConditionalOnMissingBean(CorsFilter.class) @ConditionalOnProperty(name = Settings.SETTING_CORS_FILTER, havingValue = "true", matchIfMissing = true) public GrailsCorsFilter grailsCorsFilter(GrailsCorsConfiguration grailsCorsConfiguration) { return new GrailsCorsFilter(grailsCorsConfiguration); diff --git a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy index d8dbd79d4e2..b13e145351f 100644 --- a/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy +++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy @@ -29,6 +29,8 @@ import grails.web.mapping.cors.GrailsCorsFilter import org.springframework.boot.autoconfigure.AutoConfigurations import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration import org.springframework.boot.test.context.runner.WebApplicationContextRunner +import org.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.filter.CorsFilter import org.grails.web.mapping.mvc.UrlMappingsInfoHandlerAdapter import org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer @@ -80,6 +82,22 @@ class UrlMappingsAutoConfigurationSpec extends Specification { } } + void 'a user-defined plain CorsFilter bean also makes the auto-configured GrailsCorsFilter back off'() { + given: 'CORS handling replaced with a plain Spring CorsFilter' + CorsFilter userCorsFilter = new CorsFilter(new UrlBasedCorsConfigurationSource()) + Supplier userCorsFilterSupplier = () -> userCorsFilter + + expect: 'only one CORS filter exists and it is the user one' + contextRunner() + .withBean(CorsFilter, userCorsFilterSupplier) + .run { context -> + assert context.getBeanNamesForType(GrailsCorsFilter).length == 0 + def names = context.getBeanNamesForType(CorsFilter) + assert names.length == 1 + assert context.getBean(names[0]).is(userCorsFilter) + } + } + void 'a user-defined UrlMappingsErrorPageCustomizer bean makes the auto-configured one back off'() { given: UrlMappingsErrorPageCustomizer userCustomizer = new UrlMappingsErrorPageCustomizer()