diff --git a/grails-controllers/build.gradle b/grails-controllers/build.gradle index 69c091b0fd0..230135287d3 100644 --- a/grails-controllers/build.gradle +++ b/grails-controllers/build.gradle @@ -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' 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 ecce85b66e0..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,13 +109,25 @@ 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 + @ConditionalOnMissingBean(name = "grailsWebRequestFilter") + public FilterRegistrationBean grailsWebRequestFilter(GrailsWebRequestFilter grailsWebRequest) { + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + registrationBean.setFilter(grailsWebRequest); registrationBean.setDispatcherTypes(EnumSet.of( DispatcherType.FORWARD, DispatcherType.INCLUDE, @@ -153,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 new file mode 100644 index 00000000000..36db458d35d --- /dev/null +++ b/grails-controllers/src/main/groovy/org/grails/plugins/web/controllers/GrailsViewResolverAutoConfiguration.java @@ -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. + * + *

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); + } + } +} 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 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..8280c26417f --- /dev/null +++ b/grails-controllers/src/test/groovy/org/grails/plugins/web/controllers/ControllersAutoConfigurationSpec.groovy @@ -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 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 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 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-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 */ 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 73fda353f85..bd215309bf4 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 @@ -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( diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc index 0e27e12d8e3..37e6b139e90 100644 --- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc +++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc @@ -1349,3 +1349,64 @@ grails: - Presto - Trident ---- + +==== 31. @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. + +===== 31.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 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 + +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, 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 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` + +**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. + +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-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 d32d50f72c3..0c2d0d6877d 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); } } diff --git a/grails-i18n/build.gradle b/grails-i18n/build.gradle index 6a3eaf34b7f..7f41699e342 100644 --- a/grails-i18n/build.gradle +++ b/grails-i18n/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') -} \ No newline at end of file + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} 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..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 @@ -21,7 +21,9 @@ 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.condition.SearchStrategy; import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.MessageSource; @@ -55,12 +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, search = SearchStrategy.CURRENT) public LocaleResolver localeResolver() { return new SessionLocaleResolver(); } @Bean + @ConditionalOnMissingBean(name = "localeChangeInterceptor", search = SearchStrategy.CURRENT) public LocaleChangeInterceptor localeChangeInterceptor() { ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor(); localeChangeInterceptor.setParamName("lang"); @@ -68,6 +75,7 @@ public LocaleChangeInterceptor localeChangeInterceptor() { } @Bean(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 new file mode 100644 index 00000000000..2a8dd391745 --- /dev/null +++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/I18nAutoConfigurationSpec.groovy @@ -0,0 +1,133 @@ +/* + * 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.GenericApplicationContext +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 '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() + 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 ff471b5ac1b..c7e81c943fc 100644 --- a/grails-url-mappings/build.gradle +++ b/grails-url-mappings/build.gradle @@ -52,6 +52,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' @@ -70,4 +72,5 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') -} \ No newline at end of file + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} 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..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,13 +76,18 @@ 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(CorsFilter.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 +95,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..b13e145351f --- /dev/null +++ b/grails-url-mappings/src/test/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfigurationSpec.groovy @@ -0,0 +1,130 @@ +/* + * 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.springframework.web.cors.UrlBasedCorsConfigurationSource +import org.springframework.web.filter.CorsFilter + +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 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() + 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) + } + } +} 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)