From 029aa5e43711283d10c6aa555d9c786b75be93f9 Mon Sep 17 00:00:00 2001 From: Scott Murphy Heiberg Date: Wed, 8 Jul 2026 09:26:22 -0600 Subject: [PATCH] Add method-based doWithSpring(BeanBuilder) plugin hook Introduce a statically-compilable alternative to the closure-returning doWithSpring() hook on the Plugin base class. Plugins can now register beans by overriding doWithSpring(BeanBuilder) and calling the builder directly, instead of returning a closure whose delegate the container wires up at runtime. The new hook is a no-op on the Plugin base class, and DefaultGrailsPlugin dispatches it for Plugin descriptors. A plugin may override either doWithSpring() or doWithSpring(BeanBuilder), but not both: the two forms are alternatives, so defining both throws a PluginException and fails plugin load rather than silently combining or preferring one. The legacy closure-returning form and the legacy closure-property form on non-Plugin descriptors remain fully supported. The hook is intentionally scoped to the Plugin class and is NOT added to the GrailsApplicationLifeCycle interface: that interface is map-coerced in the testing support (GrailsApplicationBuilder builds `[doWithSpring: {...}] as GrailsApplicationLifeCycle`), and Groovy's map-to-interface proxies do not honour Java default methods, so an interface-level hook would break every unit test that builds an application context. Adds PluginDoWithSpringMethodSpec covering the Plugin no-op, direct method-form registration, the DefaultGrailsPlugin dispatch for the method form and the legacy closure form, and the PluginException raised when both forms are defined. Documents the new hook in the plugin runtime- configuration guide. --- .../main/groovy/grails/plugins/Plugin.groovy | 14 ++ .../grails/plugins/DefaultGrailsPlugin.java | 35 +++- .../PluginDoWithSpringMethodSpec.groovy | 153 ++++++++++++++++++ .../hookingIntoRuntimeConfiguration.adoc | 29 ++++ 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 grails-core/src/test/groovy/grails/plugins/PluginDoWithSpringMethodSpec.groovy diff --git a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy index b3ca44ddca1..21335ba6c88 100644 --- a/grails-core/src/main/groovy/grails/plugins/Plugin.groovy +++ b/grails-core/src/main/groovy/grails/plugins/Plugin.groovy @@ -109,6 +109,20 @@ abstract class Plugin implements GrailsApplicationLifeCycle, GrailsApplicationAw @Override Closure doWithSpring() { null } + /** + * Registers Spring beans directly against the supplied {@link BeanBuilder}. This is the statically-compilable + * alternative to {@link #doWithSpring()}: instead of returning a closure whose delegate the container wires up, + * an implementation registers beans against the builder passed as an argument. Subclasses should override. + * + * A plugin should override either this method or {@link #doWithSpring()}, but not both: the two forms are + * alternatives, and defining both causes the plugin to fail to load. + * + * @param beans The {@link BeanBuilder} to register beans against + */ + void doWithSpring(BeanBuilder beans) { + // no-op + } + /** * Invoked in a phase where plugins can add dynamic methods. Subclasses should override */ diff --git a/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java b/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java index 8064850d832..b6839c07823 100644 --- a/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java +++ b/grails-core/src/main/groovy/org/grails/plugins/DefaultGrailsPlugin.java @@ -21,6 +21,7 @@ import java.io.File; import java.io.IOException; import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; @@ -391,13 +392,23 @@ public void doWithRuntimeConfiguration(RuntimeSpringConfiguration springConfig) b.setVariable("resolver", getResolver()); if (plugin instanceof Plugin) { - Closure c = ((Plugin) plugin).doWithSpring(); + Plugin pluginObject = (Plugin) plugin; + // Legacy closure-returning hook: doWithSpring() returns a bean-defining closure + Closure c = pluginObject.doWithSpring(); + // A plugin must define at most one Spring configuration hook. Both forms are explicit + // overrides, so defining both is an authoring error rather than a supported combination. + if (c != null && isDoWithSpringMethodOverridden(pluginObject)) { + throw new PluginException("Plugin [" + this + "] defines both the closure-returning doWithSpring() " + + "and the doWithSpring(BeanBuilder) method. Define only one Spring configuration hook."); + } + BeanBuilder bb = new BeanBuilder(getParentCtx(), springConfig, grailsApplication.getClassLoader()); + bb.setBinding(b); if (c != null) { - BeanBuilder bb = new BeanBuilder(getParentCtx(), springConfig, grailsApplication.getClassLoader()); - bb.setBinding(b); c.setDelegate(bb); bb.invokeMethod("beans", new Object[]{c}); } + // Method-based hook: doWithSpring(BeanBuilder) registers beans directly against the builder + pluginObject.doWithSpring(bb); } else { if (!pluginBean.isReadableProperty(DO_WITH_SPRING)) { return; @@ -416,6 +427,24 @@ public void doWithRuntimeConfiguration(RuntimeSpringConfiguration springConfig) } + /** + * Determines whether the plugin overrides the {@link Plugin#doWithSpring(BeanBuilder)} method, as opposed to + * inheriting the no-op base implementation. Used to detect a plugin that defines both the closure-returning and + * method-based Spring configuration hooks. + * + * @param pluginObject the plugin instance + * @return true if {@code doWithSpring(BeanBuilder)} is declared below {@link Plugin} + */ + private boolean isDoWithSpringMethodOverridden(Plugin pluginObject) { + try { + Method method = pluginObject.getClass().getMethod(DO_WITH_SPRING, BeanBuilder.class); + return method.getDeclaringClass() != Plugin.class; + } + catch (NoSuchMethodException e) { + return false; + } + } + @Override public String getName() { return pluginGrailsClass.getLogicalPropertyName(); diff --git a/grails-core/src/test/groovy/grails/plugins/PluginDoWithSpringMethodSpec.groovy b/grails-core/src/test/groovy/grails/plugins/PluginDoWithSpringMethodSpec.groovy new file mode 100644 index 00000000000..c97ec0cb9a9 --- /dev/null +++ b/grails-core/src/test/groovy/grails/plugins/PluginDoWithSpringMethodSpec.groovy @@ -0,0 +1,153 @@ +/* + * 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 grails.plugins + +import grails.core.DefaultGrailsApplication +import grails.plugins.exceptions.PluginException +import grails.spring.BeanBuilder +import org.grails.plugins.DefaultGrailsPlugin +import org.grails.spring.DefaultRuntimeSpringConfiguration +import org.grails.spring.RuntimeSpringConfiguration +import org.springframework.beans.propertyeditors.ClassEditor +import spock.lang.Specification + +/** + * Tests the method-based {@link Plugin#doWithSpring(BeanBuilder)} hook that complements the closure-returning + * {@link Plugin#doWithSpring()} hook. + */ +class PluginDoWithSpringMethodSpec extends Specification { + + void "a Plugin subclass can register beans by overriding doWithSpring(BeanBuilder)"() { + given: "a plugin that registers a bean through the method-based hook" + def app = new DefaultGrailsApplication() + def plugin = new MethodHookPlugin(grailsApplication: app) + def bb = new BeanBuilder() + + when: "the method-based hook is invoked with the builder" + plugin.doWithSpring(bb) + def ctx = bb.createApplicationContext() + + then: "the bean registered against the builder is present" + ctx.containsBean('methodEditor') + } + + void "the base Plugin doWithSpring(BeanBuilder) is a no-op"() { + given: "a plugin that overrides neither hook" + def app = new DefaultGrailsApplication() + def plugin = new NoOpPlugin(grailsApplication: app) + def bb = new BeanBuilder() + + when: "the method-based hook is invoked" + plugin.doWithSpring(bb) + def ctx = bb.createApplicationContext() + + then: "no beans are registered by the plugin and the closure hook still returns null" + plugin.doWithSpring() == null + ctx.beanDefinitionNames.length == 0 + } + + void "DefaultGrailsPlugin invokes the method-based doWithSpring(BeanBuilder) hook"() { + given: "a plugin descriptor that uses only the method-based hook" + def app = new DefaultGrailsApplication() + def plugin = new DefaultGrailsPlugin(MethodHookGrailsPlugin, app) + RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration() + + when: "runtime configuration is performed" + plugin.doWithRuntimeConfiguration(springConfig) + def ctx = springConfig.applicationContext + + then: "the bean registered via the method hook is present" + ctx.containsBean('methodEditor') + } + + void "DefaultGrailsPlugin throws when a plugin defines both doWithSpring hooks"() { + given: "a plugin descriptor that overrides both hooks" + def app = new DefaultGrailsApplication() + def plugin = new DefaultGrailsPlugin(BothHooksGrailsPlugin, app) + RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration() + + when: "runtime configuration is performed" + plugin.doWithRuntimeConfiguration(springConfig) + + then: "a PluginException is thrown identifying the conflicting hooks" + PluginException e = thrown() + e.message.contains('doWithSpring()') + e.message.contains('doWithSpring(BeanBuilder)') + } + + void "DefaultGrailsPlugin still supports the legacy closure-returning doWithSpring hook"() { + given: "a plugin descriptor that overrides only the closure-returning hook" + def app = new DefaultGrailsApplication() + def plugin = new DefaultGrailsPlugin(LegacyClosureGrailsPlugin, app) + RuntimeSpringConfiguration springConfig = new DefaultRuntimeSpringConfiguration() + + when: "runtime configuration is performed" + plugin.doWithRuntimeConfiguration(springConfig) + def ctx = springConfig.applicationContext + + then: "the bean registered via the closure hook is present" + ctx.containsBean('classEditor') + } +} + +class MethodHookPlugin extends Plugin { + @Override + void doWithSpring(BeanBuilder beans) { + beans.methodEditor(ClassEditor, grailsApplication.classLoader) + } +} + +class NoOpPlugin extends Plugin { +} + +class MethodHookGrailsPlugin extends Plugin { + def version = '1.0' + + @Override + void doWithSpring(BeanBuilder beans) { + beans.methodEditor(ClassEditor, grailsApplication.classLoader) + } +} + +class BothHooksGrailsPlugin extends Plugin { + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + classEditor(ClassEditor, application.classLoader) + } + } + + @Override + void doWithSpring(BeanBuilder beans) { + beans.customEditor(ClassEditor, grailsApplication.classLoader) + } +} + +class LegacyClosureGrailsPlugin extends Plugin { + def version = '1.0' + + @Override + Closure doWithSpring() { + { -> + classEditor(ClassEditor, application.classLoader) + } + } +} diff --git a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc index f7b6593ea5d..bd3b8a7932d 100644 --- a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc +++ b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc @@ -50,6 +50,35 @@ class I18nGrailsPlugin extends Plugin { This plugin configures the Grails `messageSource` bean and a couple of other beans to manage Locale resolution and switching. It using the link:spring.html#theBeanBuilderDSLExplained[Spring Bean Builder] syntax to do so. +Alternatively, you can override the `doWithSpring(BeanBuilder)` method and register beans directly against the supplied `BeanBuilder` instead of returning a closure. This avoids the closure indirection and is the recommended form for statically compiled plugins: + +[source,groovy] +---- +import org.springframework.web.servlet.i18n.CookieLocaleResolver +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import org.springframework.context.support.ReloadableResourceBundleMessageSource +import grails.plugins.* +import grails.spring.BeanBuilder + +class I18nGrailsPlugin extends Plugin { + + def version = "0.1" + + @Override + void doWithSpring(BeanBuilder beans) { + beans.messageSource(ReloadableResourceBundleMessageSource) { + basename = "WEB-INF/grails-app/i18n/messages" + } + beans.localeChangeInterceptor(LocaleChangeInterceptor) { + paramName = "lang" + } + beans.localeResolver(CookieLocaleResolver) + } +} +---- + +A plugin may override either `doWithSpring()` or `doWithSpring(BeanBuilder)`, but not both. The closure-returning `doWithSpring()` remains fully supported for backwards compatibility. Defining both forms in the same plugin is an error: the plugin will fail to load with an exception, since the two forms are alternatives and defining both indicates an incomplete migration between them. Consolidate the bean definitions into a single form. + ==== Customizing the Servlet Environment