diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle
index c75af3b4ab6..b08c308363c 100644
--- a/build-logic/plugins/build.gradle
+++ b/build-logic/plugins/build.gradle
@@ -44,6 +44,10 @@ dependencies {
testImplementation "org.spockframework:spock-core:${gradleBomDependencyVersions['gradle-spock.version']}"
testImplementation gradleTestKit()
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
+
+ // Fixture sources compiled by GenerateAutoConfigurationImportsTaskSpec need the real
+ // annotation on their classpath so it resolves as a genuine annotation type when scanned.
+ testImplementation "org.springframework.boot:spring-boot-autoconfigure:${gradleBomDependencyVersions['spring-boot.version']}"
}
tasks.named('test') {
@@ -108,5 +112,9 @@ gradlePlugin {
id = 'org.apache.grails.buildsrc.vulnerability-scan'
implementationClass = 'org.apache.grails.buildsrc.VulnerabilityScanPlugin'
}
+ register('autoConfigurationImportsPlugin') {
+ id = 'org.apache.grails.buildsrc.autoconfiguration-imports'
+ implementationClass = 'org.apache.grails.buildsrc.AutoConfigurationImportsPlugin'
+ }
}
}
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AutoConfigurationImportsPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AutoConfigurationImportsPlugin.groovy
new file mode 100644
index 00000000000..02563ad49b3
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/AutoConfigurationImportsPlugin.groovy
@@ -0,0 +1,66 @@
+/*
+ * 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.apache.grails.buildsrc
+
+import groovy.transform.CompileStatic
+
+import org.gradle.api.Plugin
+import org.gradle.api.Project
+import org.gradle.api.file.Directory
+import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.provider.Provider
+import org.gradle.api.tasks.SourceSet
+import org.gradle.api.tasks.TaskProvider
+
+/**
+ * Convention plugin that wires {@link GenerateAutoConfigurationImportsTask} into the {@code main}
+ * source set's output, so a module authoring {@code @AutoConfiguration} classes never has to
+ * hand-maintain {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}.
+ *
+ *
The generated resources directory is registered via {@code sourceSets.main.output.dir(...)}
+ * rather than routed through {@code processResources}, so it lands on the compile/runtime/test
+ * classpath and in the final jar without depending on {@code Copy}-task ordering semantics.
+ */
+@CompileStatic
+class AutoConfigurationImportsPlugin implements Plugin {
+
+ static final String TASK_NAME = 'generateAutoConfigurationImports'
+ static final String GENERATED_RESOURCES_PATH = 'generated/resources/autoConfigurationImports'
+
+ @Override
+ void apply(Project project) {
+ project.pluginManager.apply('java-base')
+
+ SourceSet main = project.extensions.getByType(JavaPluginExtension).sourceSets.getByName('main')
+ Provider generatedDir = project.layout.buildDirectory.dir(GENERATED_RESOURCES_PATH)
+
+ TaskProvider task = project.tasks.register(
+ TASK_NAME, GenerateAutoConfigurationImportsTask) { GenerateAutoConfigurationImportsTask t ->
+ t.group = 'build'
+ t.description = 'Generates META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports ' +
+ 'by scanning compiled classes for @AutoConfiguration'
+ t.classesDirs.from(main.output.classesDirs)
+ t.scanClasspath.from(main.compileClasspath, main.output.classesDirs)
+ t.outputDirectory.set(generatedDir)
+ }
+
+ main.output.dir(Collections. singletonMap('builtBy', task), generatedDir)
+ }
+
+}
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTask.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTask.groovy
new file mode 100644
index 00000000000..51606f2d814
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTask.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.apache.grails.buildsrc
+
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.ConfigurableFileCollection
+import org.gradle.api.file.DirectoryProperty
+import org.gradle.api.tasks.Classpath
+import org.gradle.api.tasks.IgnoreEmptyDirectories
+import org.gradle.api.tasks.InputFiles
+import org.gradle.api.tasks.OutputDirectory
+import org.gradle.api.tasks.PathSensitive
+import org.gradle.api.tasks.PathSensitivity
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Scans this project's own compiled main classes for {@code @AutoConfiguration} and writes
+ * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}, so the
+ * file never needs to be hand-maintained. Mirrors how {@code META-INF/grails-plugin.xml} is already
+ * generated from scanned {@code *GrailsPlugin} classes elsewhere in this build.
+ *
+ * Classes are inspected via a scratch {@link URLClassLoader} over this project's own runtime
+ * classpath, isolated from the Gradle daemon's classpath (parent set to the platform loader) so a
+ * different Spring/Groovy version on the daemon's classpath cannot shadow the project's own.
+ */
+abstract class GenerateAutoConfigurationImportsTask extends DefaultTask {
+
+ static final String IMPORTS_RESOURCE_PATH =
+ 'META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports'
+
+ static final String AUTO_CONFIGURATION_ANNOTATION = 'org.springframework.boot.autoconfigure.AutoConfiguration'
+
+ @InputFiles
+ @IgnoreEmptyDirectories
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getClassesDirs()
+
+ /**
+ * The classpath the scratch classloader resolves annotations/supertypes against. Deliberately
+ * built from the project's compile classpath (dependencies only) rather than its runtime
+ * classpath: the runtime classpath includes the source set's own output, which - once this
+ * task's generated directory is registered on that same output - would make this task depend
+ * on itself.
+ */
+ @Classpath
+ abstract ConfigurableFileCollection getScanClasspath()
+
+ @OutputDirectory
+ abstract DirectoryProperty getOutputDirectory()
+
+ @TaskAction
+ void generate() {
+ SortedSet discovered = scan(classesDirs.files, scanClasspath.files) { String className, Throwable failure ->
+ logger.warn('generateAutoConfigurationImports: could not inspect {} - it will be excluded from ' +
+ 'the generated imports file even if it is genuinely annotated @AutoConfiguration. Cause: {}',
+ className, failure.toString())
+ }
+ File importsFile = outputDirectory.file(IMPORTS_RESOURCE_PATH).get().asFile
+ importsFile.parentFile.mkdirs()
+ importsFile.text = discovered.isEmpty() ? '' : discovered.join('\n') + '\n'
+ }
+
+ /**
+ * Package-private so it is directly unit-testable without running a real Gradle task.
+ *
+ * @param classesDirs directories of compiled {@code .class} files to inspect (a project's own
+ * output only - dependency jars on {@code classpathFiles} are never scanned for candidates)
+ * @param classpathFiles the classpath the scratch classloader resolves supertypes/annotations
+ * against; must include {@code classesDirs} plus every runtime dependency
+ * @param onUnresolvable invoked with (className, failure) for every candidate class that could
+ * not be loaded against {@code classpathFiles}. Defaults to a no-op so existing callers are
+ * unaffected; {@link #generate()} passes a callback that logs a build warning, since a class
+ * that fails to load here is silently excluded from the generated imports file - which, if it
+ * was genuinely a real {@code @AutoConfiguration}, means its beans would never be registered
+ * with no other signal that anything went wrong.
+ * @return the fully-qualified names of every top-level class annotated {@code @AutoConfiguration},
+ * sorted for a deterministic, diff-friendly output file
+ */
+ static SortedSet scan(Set classesDirs, Set classpathFiles,
+ Closure> onUnresolvable = { String className, Throwable failure -> }) {
+ URL[] urls = classpathFiles.collect { it.toURI().toURL() } as URL[]
+ URLClassLoader scanLoader = new URLClassLoader(urls, ClassLoader.systemClassLoader.parent)
+ try {
+ Class> autoConfigurationAnnotation = Class.forName(AUTO_CONFIGURATION_ANNOTATION, false, scanLoader)
+ SortedSet discovered = new TreeSet<>()
+ classesDirs.each { dir -> scanDirectory(dir, scanLoader, autoConfigurationAnnotation, discovered, onUnresolvable) }
+ discovered
+ }
+ finally {
+ scanLoader.close()
+ }
+ }
+
+ private static void scanDirectory(File dir, URLClassLoader scanLoader, Class> autoConfigurationAnnotation,
+ SortedSet discovered, Closure> onUnresolvable) {
+ if (!dir.exists()) {
+ return
+ }
+ dir.eachFileRecurse(groovy.io.FileType.FILES) { file ->
+ if (!file.name.endsWith('.class') || file.name.contains('$')) {
+ return
+ }
+ String relative = dir.toPath().relativize(file.toPath()).toString()
+ String className = relative.replace(File.separator, '.') - '.class'
+ try {
+ Class> candidate = Class.forName(className, false, scanLoader)
+ if (candidate.isAnnotationPresent(autoConfigurationAnnotation)) {
+ discovered << candidate.name
+ }
+ }
+ catch (Throwable unresolvable) {
+ onUnresolvable.call(className, unresolvable)
+ }
+ }
+ }
+
+}
diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTaskSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTaskSpec.groovy
new file mode 100644
index 00000000000..76d6de525c6
--- /dev/null
+++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/GenerateAutoConfigurationImportsTaskSpec.groovy
@@ -0,0 +1,173 @@
+/*
+ * 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.apache.grails.buildsrc
+
+import groovy.io.FileType
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.Phases
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import spock.lang.TempDir
+
+import spock.lang.Specification
+
+/**
+ * Compiles small fixture sources to real {@code .class} files (the same way {@code groovyc} would)
+ * and scans them, rather than mocking the classloading behaviour {@link GenerateAutoConfigurationImportsTask#scan}
+ * depends on.
+ */
+class GenerateAutoConfigurationImportsTaskSpec extends Specification {
+
+ @TempDir
+ File tempDir
+
+ private static void compileFixtures(File srcDir, File destDir) {
+ CompilerConfiguration config = new CompilerConfiguration()
+ config.targetDirectory = destDir
+ CompilationUnit unit = new CompilationUnit(config)
+ srcDir.eachFileRecurse(FileType.FILES) { File source ->
+ if (source.name.endsWith('.groovy')) {
+ unit.addSource(source)
+ }
+ }
+ unit.compile(Phases.OUTPUT)
+ }
+
+ private static File springBootAutoconfigureClasspathEntry() {
+ new File(AutoConfiguration.protectionDomain.codeSource.location.toURI())
+ }
+
+ /**
+ * Compiled fixture classes are themselves Groovy classes (they implement
+ * {@code groovy.lang.GroovyObject}), so the scratch classloader needs the Groovy runtime too -
+ * exactly as a real project's {@code runtimeClasspath} always would.
+ */
+ private static File groovyRuntimeClasspathEntry() {
+ new File(GroovyObject.protectionDomain.codeSource.location.toURI())
+ }
+
+ private static Set testClasspath(File destDir) {
+ [destDir, springBootAutoconfigureClasspathEntry(), groovyRuntimeClasspathEntry()] as Set
+ }
+
+ def "finds a top-level class annotated @AutoConfiguration"() {
+ given:
+ File srcDir = new File(tempDir, 'src')
+ srcDir.mkdirs()
+ new File(srcDir, 'RealAutoConfig.groovy').text = '''
+ package fixture
+
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @AutoConfiguration
+ class RealAutoConfig {
+ }
+ '''
+ File destDir = new File(tempDir, 'classes')
+ destDir.mkdirs()
+ compileFixtures(srcDir, destDir)
+
+ when:
+ SortedSet found = GenerateAutoConfigurationImportsTask.scan([destDir] as Set, testClasspath(destDir))
+
+ then:
+ found == ['fixture.RealAutoConfig'] as SortedSet
+ }
+
+ def "ignores classes with no @AutoConfiguration annotation"() {
+ given:
+ File srcDir = new File(tempDir, 'src')
+ srcDir.mkdirs()
+ new File(srcDir, 'PlainClass.groovy').text = '''
+ package fixture
+
+ class PlainClass {
+ }
+ '''
+ File destDir = new File(tempDir, 'classes')
+ destDir.mkdirs()
+ compileFixtures(srcDir, destDir)
+
+ when:
+ SortedSet found = GenerateAutoConfigurationImportsTask.scan([destDir] as Set, testClasspath(destDir))
+
+ then:
+ found.isEmpty()
+ }
+
+ def "ignores nested/inner classes even when annotated"() {
+ given: "an @AutoConfiguration nested inside a plain outer class"
+ File srcDir = new File(tempDir, 'src')
+ srcDir.mkdirs()
+ new File(srcDir, 'Outer.groovy').text = '''
+ package fixture
+
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ class Outer {
+ @AutoConfiguration
+ static class Nested {
+ }
+ }
+ '''
+ File destDir = new File(tempDir, 'classes')
+ destDir.mkdirs()
+ compileFixtures(srcDir, destDir)
+
+ expect: "compilation really did produce a \$-named class file, proving this isn't a vacuous pass"
+ new File(destDir, 'fixture/Outer$Nested.class').exists()
+
+ when:
+ SortedSet found = GenerateAutoConfigurationImportsTask.scan([destDir] as Set, testClasspath(destDir))
+
+ then:
+ found.isEmpty()
+ }
+
+ def "returns an empty set for a nonexistent classes directory"() {
+ expect:
+ GenerateAutoConfigurationImportsTask.scan(
+ [new File(tempDir, 'does-not-exist')] as Set, [springBootAutoconfigureClasspathEntry()] as Set).isEmpty()
+ }
+
+ def "reports classes that fail to load instead of silently dropping them"() {
+ given: "a compiled class whose superclass is deliberately removed from the classpath afterwards"
+ File srcDir = new File(tempDir, 'src')
+ srcDir.mkdirs()
+ new File(srcDir, 'Missing.groovy').text = 'class Missing {}'
+ new File(srcDir, 'Broken.groovy').text = 'class Broken extends Missing {}'
+ File destDir = new File(tempDir, 'classes')
+ destDir.mkdirs()
+ compileFixtures(srcDir, destDir)
+ assert new File(destDir, 'Missing.class').delete()
+
+ when:
+ List reportedFailures = []
+ SortedSet found = GenerateAutoConfigurationImportsTask.scan(
+ [destDir] as Set, testClasspath(destDir)) { String className, Throwable failure ->
+ reportedFailures << className
+ assert failure != null
+ }
+
+ then: "the unloadable class is excluded from the result but not silently - the callback fires for it"
+ found.isEmpty()
+ reportedFailures == ['Broken']
+ }
+
+}
diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle
index 17f2cede7ba..0c12457ad9e 100644
--- a/gradle/publish-root-config.gradle
+++ b/gradle/publish-root-config.gradle
@@ -34,6 +34,7 @@ def publishedProjects = [
'grails-bom',
'grails-hibernate5-bom',
'grails-hibernate7-bom',
+ 'grails-beans-dsl',
'grails-bootstrap',
'grails-cache',
'grails-codecs',
diff --git a/grails-beans-dsl-example/build.gradle b/grails-beans-dsl-example/build.gradle
new file mode 100644
index 00000000000..dc699287290
--- /dev/null
+++ b/grails-beans-dsl-example/build.gradle
@@ -0,0 +1,58 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'groovy'
+ id 'java-library'
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+ id 'org.apache.grails.buildsrc.autoconfiguration-imports'
+ id 'org.apache.grails.gradle.grails-code-style'
+ id 'org.apache.grails.gradle.grails-jacoco'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+dependencies {
+
+ implementation platform(project(':grails-bom'))
+
+ implementation project(':grails-beans-dsl')
+
+ api 'org.springframework.boot:spring-boot-autoconfigure'
+ api 'org.springframework:spring-context'
+
+ testImplementation 'org.springframework.boot:spring-boot-test'
+ testImplementation 'org.springframework:spring-test'
+ testImplementation 'org.assertj:assertj-core'
+
+ testImplementation 'org.apache.groovy:groovy-test-junit5'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api'
+ testImplementation 'org.junit.platform:junit-platform-suite'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
+ testImplementation 'org.spockframework:spock-core'
+ testRuntimeOnly 'org.slf4j:slf4j-simple'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+}
diff --git a/grails-beans-dsl-example/src/main/groovy/beandsl/example/ExampleBeans.groovy b/grails-beans-dsl-example/src/main/groovy/beandsl/example/ExampleBeans.groovy
new file mode 100644
index 00000000000..1cad31ea9e6
--- /dev/null
+++ b/grails-beans-dsl-example/src/main/groovy/beandsl/example/ExampleBeans.groovy
@@ -0,0 +1,48 @@
+/*
+ * 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 beandsl.example
+
+import org.springframework.boot.autoconfigure.AutoConfiguration
+
+import grails.compiler.beans.GrailsBeans
+
+/**
+ * Spike: a {@code doWithSpring}-style closure DSL that {@link GrailsBeans} compiles into real
+ * {@code @Bean} factory methods at compile time, so this class ships as a plain Spring Boot
+ * {@code @AutoConfiguration} with no closures surviving into the compiled bytecode.
+ */
+@GrailsBeans
+@AutoConfiguration
+class ExampleBeans {
+
+ def beans = {
+ bean('greeter', Greeter) {
+ new Greeter('hello from GrailsBeans')
+ }
+
+ bean('fancyGreeter', FancyGreeter).conditionalOnMissingBean(FancyGreeter) {
+ new FancyGreeter()
+ }
+
+ bean('loudGreeter', LoudGreeter) { Greeter greeter ->
+ new LoudGreeter(greeter)
+ }
+ }
+
+}
diff --git a/grails-beans-dsl-example/src/main/groovy/beandsl/example/FancyGreeter.groovy b/grails-beans-dsl-example/src/main/groovy/beandsl/example/FancyGreeter.groovy
new file mode 100644
index 00000000000..61bd47116b9
--- /dev/null
+++ b/grails-beans-dsl-example/src/main/groovy/beandsl/example/FancyGreeter.groovy
@@ -0,0 +1,31 @@
+/*
+ * 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 beandsl.example
+
+/**
+ * Registered only when the application hasn't already defined its own {@code FancyGreeter} bean,
+ * demonstrating {@code .conditionalOnMissingBean(...)} in the {@code @GrailsBeans} DSL.
+ */
+class FancyGreeter {
+
+ String greet() {
+ '*** hello ***'
+ }
+
+}
diff --git a/grails-beans-dsl-example/src/main/groovy/beandsl/example/Greeter.groovy b/grails-beans-dsl-example/src/main/groovy/beandsl/example/Greeter.groovy
new file mode 100644
index 00000000000..23373381fc7
--- /dev/null
+++ b/grails-beans-dsl-example/src/main/groovy/beandsl/example/Greeter.groovy
@@ -0,0 +1,33 @@
+/*
+ * 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 beandsl.example
+
+class Greeter {
+
+ private final String message
+
+ Greeter(String message) {
+ this.message = message
+ }
+
+ String greet() {
+ message
+ }
+
+}
diff --git a/grails-beans-dsl-example/src/main/groovy/beandsl/example/LoudGreeter.groovy b/grails-beans-dsl-example/src/main/groovy/beandsl/example/LoudGreeter.groovy
new file mode 100644
index 00000000000..18521800b30
--- /dev/null
+++ b/grails-beans-dsl-example/src/main/groovy/beandsl/example/LoudGreeter.groovy
@@ -0,0 +1,37 @@
+/*
+ * 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 beandsl.example
+
+/**
+ * Wraps another {@link Greeter} bean, demonstrating that {@code @GrailsBeans} factory closures
+ * can declare typed parameters that become real constructor-style {@code @Bean} method injection.
+ */
+class LoudGreeter {
+
+ private final Greeter delegate
+
+ LoudGreeter(Greeter delegate) {
+ this.delegate = delegate
+ }
+
+ String greet() {
+ delegate.greet().toUpperCase()
+ }
+
+}
diff --git a/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoConfigurationSpec.groovy b/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoConfigurationSpec.groovy
new file mode 100644
index 00000000000..a157ae4e148
--- /dev/null
+++ b/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoConfigurationSpec.groovy
@@ -0,0 +1,74 @@
+/*
+ * 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 beandsl.example
+
+import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.test.context.assertj.AssertableApplicationContext
+import org.springframework.boot.test.context.runner.ApplicationContextRunner
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import spock.lang.Specification
+
+import static org.assertj.core.api.Assertions.assertThat
+
+/**
+ * Exercises the beans {@link ExampleBeans} contributes, including the
+ * {@code .conditionalOnMissingBean(...)} back-off behaviour compiled from the DSL.
+ */
+class ExampleBeansAutoConfigurationSpec extends Specification {
+
+ private final ApplicationContextRunner runner =
+ new ApplicationContextRunner().withConfiguration(AutoConfigurations.of(ExampleBeans))
+
+ def "registers greeter, fancyGreeter and loudGreeter with loudGreeter wired via constructor-style injection"() {
+ expect:
+ runner.run { AssertableApplicationContext context ->
+ assertThat(context).hasSingleBean(Greeter)
+ assertThat(context).hasSingleBean(FancyGreeter)
+ assertThat(context).hasSingleBean(LoudGreeter)
+ assertThat(context.getBean(Greeter).greet()).isEqualTo('hello from GrailsBeans')
+ assertThat(context.getBean(FancyGreeter).greet()).isEqualTo('*** hello ***')
+ assertThat(context.getBean(LoudGreeter).greet()).isEqualTo('HELLO FROM GRAILSBEANS')
+ }
+ }
+
+ def "backs off fancyGreeter when the application already defines one"() {
+ expect:
+ runner.withUserConfiguration(CustomFancyGreeterConfig).run { AssertableApplicationContext context ->
+ assertThat(context).hasSingleBean(FancyGreeter)
+ assertThat(context.getBean(FancyGreeter).greet()).isEqualTo('custom')
+ }
+ }
+
+ @Configuration(proxyBeanMethods = false)
+ static class CustomFancyGreeterConfig {
+
+ @Bean
+ FancyGreeter fancyGreeter() {
+ new FancyGreeter() {
+ @Override
+ String greet() {
+ 'custom'
+ }
+ }
+ }
+
+ }
+
+}
diff --git a/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoDiscoverySpec.groovy b/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoDiscoverySpec.groovy
new file mode 100644
index 00000000000..79ed1efa2db
--- /dev/null
+++ b/grails-beans-dsl-example/src/test/groovy/beandsl/example/ExampleBeansAutoDiscoverySpec.groovy
@@ -0,0 +1,62 @@
+/*
+ * 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 beandsl.example
+
+import org.springframework.boot.WebApplicationType
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration
+import org.springframework.boot.builder.SpringApplicationBuilder
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.annotation.Configuration
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+/**
+ * End-to-end proof that {@link ExampleBeans} is discovered purely by Spring Boot's own
+ * classpath-based auto-configuration import mechanism - nothing in this test references
+ * {@code ExampleBeans} by name. The only reason Boot finds it is the
+ * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports}
+ * file the build generates by scanning compiled classes for {@code @AutoConfiguration}.
+ *
+ * Deliberately {@code @EnableAutoConfiguration} rather than {@code @SpringBootApplication}:
+ * the latter's implicit {@code @ComponentScan} would also pick up unrelated {@code @Configuration}
+ * fixtures from sibling specs in this package, which is not what this test is proving.
+ */
+class ExampleBeansAutoDiscoverySpec extends Specification {
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration
+ static class TestApp {
+ }
+
+ @AutoCleanup
+ ConfigurableApplicationContext context
+
+ def "the compiled DSL beans are auto-discovered on the classpath with no explicit reference"() {
+ when:
+ context = new SpringApplicationBuilder(TestApp)
+ .web(WebApplicationType.NONE)
+ .run()
+
+ then:
+ context.getBean('greeter', Greeter).greet() == 'hello from GrailsBeans'
+ context.getBean('fancyGreeter', FancyGreeter).greet() == '*** hello ***'
+ context.getBean('loudGreeter', LoudGreeter).greet() == 'HELLO FROM GRAILSBEANS'
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/build.gradle b/grails-beans-dsl-plugin-example/build.gradle
new file mode 100644
index 00000000000..00bce1246bb
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/build.gradle
@@ -0,0 +1,64 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'groovy'
+ id 'java-library'
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+ id 'org.apache.grails.buildsrc.autoconfiguration-imports'
+ id 'org.apache.grails.gradle.grails-code-style'
+ id 'org.apache.grails.gradle.grails-jacoco'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+dependencies {
+
+ implementation platform(project(':grails-bom'))
+
+ implementation project(':grails-beans-dsl')
+
+ // Kept separate from grails-beans-dsl-example: pulling grails-core onto a module's
+ // classpath at all activates Grails' own plugin-bootstrap ApplicationContextInitializer
+ // (registered unconditionally via spring.factories) for any SpringApplication.run() in
+ // that module, which would have changed the other example's existing test behaviour.
+ implementation project(':grails-core')
+
+ api 'org.springframework.boot:spring-boot-autoconfigure'
+ api 'org.springframework:spring-context'
+
+ testImplementation 'org.springframework.boot:spring-boot-test'
+ testImplementation 'org.springframework:spring-test'
+ testImplementation 'org.assertj:assertj-core'
+
+ testImplementation 'org.apache.groovy:groovy-test-junit5'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api'
+ testImplementation 'org.junit.platform:junit-platform-suite'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
+ testImplementation 'org.spockframework:spock-core'
+ testRuntimeOnly 'org.slf4j:slf4j-simple'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+}
diff --git a/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Farewell.groovy b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Farewell.groovy
new file mode 100644
index 00000000000..5462f0f510e
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Farewell.groovy
@@ -0,0 +1,33 @@
+/*
+ * 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 beandsl.example.plugin
+
+class Farewell {
+
+ private final String message
+
+ Farewell(String message) {
+ this.message = message
+ }
+
+ String say() {
+ message
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/FarewellGrailsPlugin.groovy b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/FarewellGrailsPlugin.groovy
new file mode 100644
index 00000000000..f63c58e2625
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/FarewellGrailsPlugin.groovy
@@ -0,0 +1,47 @@
+/*
+ * 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 beandsl.example.plugin
+
+import org.springframework.boot.autoconfigure.AutoConfiguration
+
+import grails.compiler.beans.GrailsBeans
+import grails.plugins.Plugin
+
+/**
+ * Demonstrates {@code @GrailsBeans} applied directly to a {@code *GrailsPlugin.groovy}-style
+ * class. The {@code beans} block below compiles onto a generated sibling
+ * {@code FarewellAutoConfiguration} class rather than onto this one - a
+ * {@code Plugin} subclass is instantiated by {@code DefaultGrailsPlugin} via plain reflection,
+ * never as a Spring bean, so it cannot itself carry {@code @Bean} methods. Everything else about
+ * this class - the {@code Plugin} lifecycle hooks, {@code version}, etc. - works exactly as it
+ * would without {@code @GrailsBeans}.
+ */
+@GrailsBeans
+@AutoConfiguration
+class FarewellGrailsPlugin extends Plugin {
+
+ String version = '1.0'
+
+ def beans = {
+ bean('farewell', Farewell) {
+ new Farewell('goodbye from a Plugin')
+ }
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Greeting.groovy b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Greeting.groovy
new file mode 100644
index 00000000000..7bb8f224492
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/Greeting.groovy
@@ -0,0 +1,33 @@
+/*
+ * 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 beandsl.example.plugin
+
+class Greeting {
+
+ private final String text
+
+ Greeting(String text) {
+ this.text = text
+ }
+
+ String getText() {
+ text
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/GreetingGrailsPlugin.groovy b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/GreetingGrailsPlugin.groovy
new file mode 100644
index 00000000000..3ab2493169a
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/main/groovy/beandsl/example/plugin/GreetingGrailsPlugin.groovy
@@ -0,0 +1,52 @@
+/*
+ * 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 beandsl.example.plugin
+
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.boot.autoconfigure.AutoConfiguration
+
+import grails.compiler.beans.GrailsBeans
+import grails.plugins.Plugin
+
+/**
+ * Demonstrates {@code field(...)} and {@code method(...)} - the same shape real autoconfigurations
+ * like the built-in i18n plugin's need: injected configuration shared across beans, and a private
+ * helper factored out of a bean's construction logic. Both compile onto the generated
+ * {@code GreetingGrailsPluginAutoConfiguration} sibling as ordinary private members, exactly like
+ * a hand-written {@code @Configuration} class's own fields and methods.
+ */
+@GrailsBeans
+@AutoConfiguration
+class GreetingGrailsPlugin extends Plugin {
+
+ String version = '1.0'
+
+ def beans = {
+ field('greetingSuffix', String).annotate(Value, value: '${beandsl.example.greeting-suffix:!}')
+
+ method('buildGreeting', String) { String name ->
+ "Hello, ${name}${greetingSuffix}"
+ }
+
+ bean('greeting', Greeting) {
+ new Greeting(buildGreeting('World'))
+ }
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/FarewellGrailsPluginAutoDiscoverySpec.groovy b/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/FarewellGrailsPluginAutoDiscoverySpec.groovy
new file mode 100644
index 00000000000..e329bec5761
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/FarewellGrailsPluginAutoDiscoverySpec.groovy
@@ -0,0 +1,74 @@
+/*
+ * 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 beandsl.example.plugin
+
+import org.springframework.boot.WebApplicationType
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
+import org.springframework.boot.builder.SpringApplicationBuilder
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.annotation.Configuration
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+/**
+ * End-to-end proof that a {@code beans} block written directly inside a {@code Plugin} subclass
+ * is discovered purely via the generated {@code FarewellAutoConfiguration} sibling -
+ * nothing here references that generated class by name, only {@code Farewell} itself.
+ *
+ *
{@code AopAutoConfiguration} is excluded because grails-core being on the classpath at all
+ * activates Grails' own plugin-bootstrap {@code ApplicationContextInitializer} (registered via
+ * {@code spring.factories}, unconditionally, regardless of this example's needs), which loads
+ * {@code CoreGrailsPlugin} and registers its Groovy-aware AOP auto-proxy creator under Spring's
+ * standard internal bean name - a class Boot's own {@code AopAutoConfiguration} does not
+ * recognise when it tries to escalate that bean to class-proxying mode. Neither this test nor the
+ * feature it demonstrates has anything to do with AOP proxying, so excluding it sidesteps an
+ * unrelated clash rather than working around a bug in the DSL itself.
+ */
+class FarewellGrailsPluginAutoDiscoverySpec extends Specification {
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration(exclude = [AopAutoConfiguration])
+ static class TestApp {
+ }
+
+ @AutoCleanup
+ ConfigurableApplicationContext context
+
+ def "the beans block inside a Plugin subclass is auto-discovered from its generated sibling"() {
+ when:
+ context = new SpringApplicationBuilder(TestApp)
+ .web(WebApplicationType.NONE)
+ .run()
+
+ then:
+ context.getBean('farewell', Farewell).say() == 'goodbye from a Plugin'
+ }
+
+ def "the plugin class itself carries neither the beans DSL nor @AutoConfiguration"() {
+ expect:
+ FarewellGrailsPlugin.declaredFields*.name.every { it != 'beans' }
+ !FarewellGrailsPlugin.isAnnotationPresent(AutoConfiguration)
+
+ and: "the generated sibling exists as a genuinely separate class"
+ Class.forName('beandsl.example.plugin.FarewellAutoConfiguration').isAnnotationPresent(AutoConfiguration)
+ }
+
+}
diff --git a/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/GreetingGrailsPluginAutoDiscoverySpec.groovy b/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/GreetingGrailsPluginAutoDiscoverySpec.groovy
new file mode 100644
index 00000000000..6b1bfdeba7b
--- /dev/null
+++ b/grails-beans-dsl-plugin-example/src/test/groovy/beandsl/example/plugin/GreetingGrailsPluginAutoDiscoverySpec.groovy
@@ -0,0 +1,68 @@
+/*
+ * 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 beandsl.example.plugin
+
+import org.springframework.boot.WebApplicationType
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration
+import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration
+import org.springframework.boot.builder.SpringApplicationBuilder
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.annotation.Configuration
+import spock.lang.AutoCleanup
+import spock.lang.Specification
+
+/**
+ * End-to-end proof that {@code field(...)} and {@code method(...)} work as real class members on
+ * the generated sibling - not just structurally (as {@link org.grails.compiler.beans.GrailsBeansASTTransformationSpec}
+ * already proves at the AST level) but with a genuinely booted Spring context, so the
+ * {@code @Value} placeholder on the generated field is actually resolved from real application
+ * properties, not merely present as an annotation.
+ */
+class GreetingGrailsPluginAutoDiscoverySpec extends Specification {
+
+ @Configuration(proxyBeanMethods = false)
+ @EnableAutoConfiguration(exclude = [AopAutoConfiguration])
+ static class TestApp {
+ }
+
+ @AutoCleanup
+ ConfigurableApplicationContext context
+
+ def "a field(...)'s @Value is resolved from real properties, and method(...) is callable from bean(...)"() {
+ when:
+ context = new SpringApplicationBuilder(TestApp)
+ .web(WebApplicationType.NONE)
+ .properties('beandsl.example.greeting-suffix=!!!')
+ .run()
+
+ then:
+ context.getBean('greeting', Greeting).text == 'Hello, World!!!'
+ }
+
+ def "field(...)'s default value applies when the property is unset"() {
+ when:
+ context = new SpringApplicationBuilder(TestApp)
+ .web(WebApplicationType.NONE)
+ .run()
+
+ then:
+ context.getBean('greeting', Greeting).text == 'Hello, World!'
+ }
+
+}
diff --git a/grails-beans-dsl/build.gradle b/grails-beans-dsl/build.gradle
new file mode 100644
index 00000000000..849907c7443
--- /dev/null
+++ b/grails-beans-dsl/build.gradle
@@ -0,0 +1,71 @@
+/*
+ * 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.
+ */
+
+plugins {
+ id 'groovy'
+ id 'java-library'
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.dependency-validator'
+ id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.publish'
+ id 'org.apache.grails.buildsrc.sbom'
+ id 'org.apache.grails.buildsrc.vulnerability-scan'
+ id 'org.apache.grails.gradle.grails-code-style'
+ id 'org.apache.grails.gradle.grails-jacoco'
+}
+
+version = projectVersion
+group = 'org.apache.grails'
+
+ext {
+ pomTitle = 'Grails Bean DSL Compiler Support'
+ pomDescription =
+ 'A Groovy AST transformation (@GrailsBeans) that compiles a bean-definition DSL into ' +
+ 'real Spring @Bean methods on a plain Spring Boot @AutoConfiguration class at compile time.'
+}
+
+dependencies {
+
+ implementation platform(project(':grails-bom'))
+
+ api 'org.apache.groovy:groovy'
+
+ // The transformation resolves @Bean/@ConditionalOnMissingBean against these real
+ // classes (not by name) so Groovy recognises them as genuine annotation types when
+ // it validates AnnotationNodes added after semantic analysis has already run.
+ api 'org.springframework:spring-context'
+ api 'org.springframework.boot:spring-boot-autoconfigure'
+
+ // Only needed so fixture sources compiled by the tests can genuinely extend
+ // grails.plugins.Plugin; the transformation itself detects a Plugin superclass by
+ // fully-qualified name, not by type, so the main sourceSet has no such dependency.
+ testImplementation project(':grails-core')
+
+ testImplementation 'org.apache.groovy:groovy-test-junit5'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api'
+ testImplementation 'org.junit.platform:junit-platform-suite'
+ testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'
+ testImplementation 'org.spockframework:spock-core'
+ testRuntimeOnly 'org.slf4j:slf4j-simple'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
+}
diff --git a/grails-beans-dsl/src/main/java/grails/compiler/beans/GrailsBeans.java b/grails-beans-dsl/src/main/java/grails/compiler/beans/GrailsBeans.java
new file mode 100644
index 00000000000..f5196d28f42
--- /dev/null
+++ b/grails-beans-dsl/src/main/java/grails/compiler/beans/GrailsBeans.java
@@ -0,0 +1,126 @@
+/*
+ * 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.compiler.beans;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.codehaus.groovy.transform.GroovyASTTransformationClass;
+
+/**
+ * Marks a class whose {@code beans} closure property is a bean-definition DSL that should be
+ * compiled into real {@code @Bean} factory methods, so the class can serve as a plain
+ * {@code @AutoConfiguration} with no closure DSL surviving into the compiled bytecode.
+ *
+ *
The annotated class must declare a {@code beans} property initialised to a closure whose
+ * statements are one of:
+ *
+ * - {@code bean(["name", ] Type) { ... }} - or {@code bean(["name", ] Type)} with no body at all,
+ * which declares a bean that is simply {@code new Type()}, the most common shape. The bodyless form
+ * requires a concrete type with an accessible no-argument constructor; an interface or abstract type
+ * needs a body naming the implementation. It chains with the qualifiers below exactly as the
+ * closure form does ({@code bean(Foo).lazy().conditionalOnMissingBean()}). The closure form may be
+ * chained with any combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, the annotation's own named
+ * attributes such as {@code name:}/{@code search:}, or no arguments at all to let Spring infer
+ * the back-off type from the return type), {@code .conditionalOnMissingBeanName(...)} (backs off
+ * by this bean's own name - set automatically, stated once - accepting the annotation's other
+ * attributes but rejecting {@code name:} and types), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a {@code static} factory method - Spring's
+ * recommended shape for {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans, which
+ * must be creatable without instantiating their declaring configuration class), and (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])} - the last a generic escape hatch
+ * attaching any other single-valued annotation. The closure body becomes the generated method's
+ * body verbatim, and closure parameters become the generated method's parameters (for
+ * constructor-style bean injection). The generated method's name is an implementation detail:
+ * Spring resolves the bean by its {@code @Bean("name")} value, so a bean name that isn't a valid
+ * Java identifier (e.g. {@code "my-service"}) simply gets a synthesized {@code $N} method
+ * name behind the scenes. The same bean name may even be declared by more than one
+ * {@code bean(...)} statement - the standard Spring Boot pattern for mutually exclusive variants
+ * of one bean - provided every declaration with the name carries its own discriminating
+ * condition (e.g. {@code .annotate(ConditionalOnProperty, ...)}) so that at most one of them
+ * registers at runtime.
+ * - {@code field(["name", ] Type)}, optionally chained with {@code .value(...)} and/or
+ * (repeatably) {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private field on
+ * the generated class, for state shared across bean methods. The usual case is injected
+ * configuration: {@code field("encoding", String).value(Settings.GSP_VIEW_ENCODING, "UTF-8")}
+ * compiles to {@code @Value("${grails.views.gsp.encoding:UTF-8}")} - the two-argument form takes
+ * a config key (a literal or a bare constant reference) plus default. The one-argument form
+ * takes a bare config key with no default ({@code .value("app.encoding")} compiles to
+ * {@code @Value("${app.encoding}")}), while a string already containing a {@code ${...}}
+ * placeholder or {@code #{...}} SpEL expression passes through verbatim.
+ * - {@code method(["name", ] Type) { ... }}, chainable with {@code .annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared across bean methods,
+ * lifted from the closure the same way {@code bean(...)} is.
+ *
+ * When no name is given, one is derived from the type name following the JavaBeans convention
+ * ({@link java.beans.Introspector#decapitalize(String)}).
+ *
+ * The generated methods work on any class Spring processes as a configuration source: a
+ * registered {@code @AutoConfiguration} or {@code @Configuration} class, or the Spring Boot
+ * application class itself (e.g. a Grails {@code Application} class) - Spring Boot reads
+ * {@code @Bean} methods directly off the class it is launched with, so no further registration
+ * is needed there.
+ *
+ *
May also be applied to a {@code grails.plugins.Plugin} subclass, letting bean definitions
+ * live in the familiar {@code *GrailsPlugin.groovy} file. In that case the generated methods land
+ * on a new sibling class instead, named by the plugin-descriptor convention - a {@code *GrailsPlugin}
+ * name swaps that suffix for {@code AutoConfiguration} ({@code I18nGrailsPlugin} ->
+ * {@code I18nAutoConfiguration}), any other name appends it - or {@link #autoConfigurationName}
+ * if given. A {@code Plugin} subclass is never processed by
+ * Spring as a bean, so {@code @AutoConfiguration} together with every annotation that gates or
+ * configures it (the {@code @Conditional*} family, {@code @Import}/{@code @ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code @EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources},
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - including any composed annotation
+ * meta-annotated with one of these) found on the plugin class moves onto that sibling, since none
+ * of them has any effect where the author wrote them. Annotations outside that set can be moved
+ * explicitly via {@link #moveAnnotations}.
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@GroovyASTTransformationClass("org.grails.compiler.beans.GrailsBeansASTTransformation")
+public @interface GrailsBeans {
+
+ /**
+ * The simple name of the generated sibling class, for a {@code grails.plugins.Plugin}
+ * subclass. The default derives from the plugin's own name - a {@code *GrailsPlugin} class
+ * swaps that suffix for {@code AutoConfiguration}, any other name appends it. Set this when
+ * converting an existing public {@code @AutoConfiguration} class whose name doesn't follow
+ * that convention and whose class identity must be preserved (e.g. for {@code exclude =}
+ * references, {@code before=}/{@code after=} ordering from other modules, or tests that
+ * import it by name).
+ */
+ String autoConfigurationName() default "";
+
+ /**
+ * Additional annotation types to move from a {@code grails.plugins.Plugin} subclass onto its
+ * generated sibling, beyond the ones recognised automatically. The automatic set covers the
+ * common Spring configuration annotations (and anything meta-annotated with them), but it is
+ * a closed list - an annotation outside it that Spring reads off the configuration class
+ * (rather than one this DSL happens to know about) would otherwise silently stay on the
+ * plugin class, where Spring never sees it. E.g.
+ * {@code @GrailsBeans(moveAnnotations = [SomeVendorAnnotation])}.
+ */
+ Class>[] moveAnnotations() default {};
+
+}
diff --git a/grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java b/grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java
new file mode 100644
index 00000000000..0209530d6ef
--- /dev/null
+++ b/grails-beans-dsl/src/main/java/org/grails/compiler/beans/GrailsBeansASTTransformation.java
@@ -0,0 +1,1204 @@
+/*
+ * 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.compiler.beans;
+
+import java.beans.Introspector;
+import java.lang.reflect.Modifier;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import javax.lang.model.SourceVersion;
+
+import groovy.transform.CompilationUnitAware;
+import groovy.transform.CompileStatic;
+import org.apache.groovy.util.BeanUtils;
+import org.codehaus.groovy.ast.ASTNode;
+import org.codehaus.groovy.ast.AnnotatedNode;
+import org.codehaus.groovy.ast.AnnotationNode;
+import org.codehaus.groovy.ast.ClassHelper;
+import org.codehaus.groovy.ast.ClassNode;
+import org.codehaus.groovy.ast.FieldNode;
+import org.codehaus.groovy.ast.MethodNode;
+import org.codehaus.groovy.ast.Parameter;
+import org.codehaus.groovy.ast.PropertyNode;
+import org.codehaus.groovy.ast.expr.ArgumentListExpression;
+import org.codehaus.groovy.ast.expr.BinaryExpression;
+import org.codehaus.groovy.ast.expr.ClassExpression;
+import org.codehaus.groovy.ast.expr.ClosureExpression;
+import org.codehaus.groovy.ast.expr.ConstantExpression;
+import org.codehaus.groovy.ast.expr.ConstructorCallExpression;
+import org.codehaus.groovy.ast.expr.Expression;
+import org.codehaus.groovy.ast.expr.ListExpression;
+import org.codehaus.groovy.ast.expr.MapEntryExpression;
+import org.codehaus.groovy.ast.expr.MapExpression;
+import org.codehaus.groovy.ast.expr.MethodCallExpression;
+import org.codehaus.groovy.ast.expr.PropertyExpression;
+import org.codehaus.groovy.ast.stmt.BlockStatement;
+import org.codehaus.groovy.ast.stmt.ExpressionStatement;
+import org.codehaus.groovy.ast.stmt.ReturnStatement;
+import org.codehaus.groovy.ast.stmt.Statement;
+import org.codehaus.groovy.control.CompilationUnit;
+import org.codehaus.groovy.control.CompilePhase;
+import org.codehaus.groovy.control.SourceUnit;
+import org.codehaus.groovy.syntax.SyntaxException;
+import org.codehaus.groovy.syntax.Types;
+import org.codehaus.groovy.transform.ASTTransformation;
+import org.codehaus.groovy.transform.GroovyASTTransformation;
+import org.codehaus.groovy.transform.sc.StaticCompileTransformation;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.AutoConfiguration;
+import org.springframework.boot.autoconfigure.AutoConfigureAfter;
+import org.springframework.boot.autoconfigure.AutoConfigureBefore;
+import org.springframework.boot.autoconfigure.AutoConfigureOrder;
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.ComponentScan;
+import org.springframework.context.annotation.ComponentScans;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.context.annotation.Import;
+import org.springframework.context.annotation.ImportResource;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.PropertySource;
+import org.springframework.context.annotation.PropertySources;
+import org.springframework.context.annotation.Scope;
+
+/**
+ * Rewrites the {@code beans} closure DSL on a {@link grails.compiler.beans.GrailsBeans}-annotated
+ * class into real {@code @Bean} factory methods, at compile time.
+ *
+ *
Recognises three kinds of top-level statement inside the {@code beans} closure:
+ *
+ * - {@code bean(["name", ] Type) { ... }}, optionally chained with any combination of
+ * {@code .conditionalOnMissingBean(...)} (positional types, named annotation attributes, or bare),
+ * {@code .conditionalOnMissingBeanName(...)} (backs off by this bean's own name, set
+ * automatically), {@code .primary()}, {@code .lazy()},
+ * {@code .scope("name")}, {@code .staticMethod()} (a static factory method, for
+ * {@code BeanFactoryPostProcessor}/{@code BeanPostProcessor} beans), and (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Synthesises a public method, returning
+ * the declared type, annotated {@code @org.springframework.context.annotation.Bean("name")} plus
+ * whichever qualifiers were chained, whose body and parameters are lifted directly from the DSL
+ * closure. The generated method's name is an implementation detail: it matches the bean name when
+ * that is a usable Java identifier not already taken by an existing or generated member, and
+ * falls back to a synthesized {@code $N} name otherwise (a non-identifier name like
+ * {@code "my-service"}, a reserved keyword, or a collision - a bean named {@code toString} never
+ * overrides {@code Object.toString()}) - Spring resolves the bean by its {@code @Bean("name")}
+ * value either way, never by the method name. One bean name may be declared by several
+ * {@code bean(...)} statements when every declaration carries its own discriminating condition
+ * (see {@link #validateSharedBeanNames}).
+ * - {@code field(["name", ] Type)}, optionally chained with {@code .value(...)} (config
+ * injection: key + default, a bare key, or a verbatim placeholder/SpEL string) and/or (repeatably)
+ * {@code .annotate(AnnotationType[, attr: value, ...])}. Declares a private field on the
+ * generated class, for state shared across bean methods.
+ * - {@code method(["name", ] Type) { ... }}, chainable with {@code .annotate(...)} only
+ * ({@code .value(...)} is field-specific).
+ * Declares a private helper method on the generated class, for logic shared across bean methods,
+ * lifted from the DSL closure the same way {@code bean(...)} is.
+ *
+ *
+ * Fields and helper methods declared this way are ordinary private members of the generated
+ * class - {@code bean(...)} closures reference them the same way a hand-written {@code @Bean}
+ * method would reference a sibling field or method on its {@code @Configuration} class. The
+ * {@code beans} property itself is removed so no closure survives into the compiled class.
+ *
+ *
When the annotated class extends {@code grails.plugins.Plugin}, the generated members land
+ * on a new sibling class in the same package instead of on the plugin class itself - named by
+ * swapping a {@code *GrailsPlugin} suffix for {@code AutoConfiguration}, or appending
+ * {@code AutoConfiguration} otherwise. A {@code Plugin} subclass is instantiated by
+ * {@code DefaultGrailsPlugin} via plain reflection, never as a Spring bean, so it cannot carry
+ * {@code @Bean} methods or a meaningful {@code @AutoConfiguration} annotation of its own.
+ * {@code @AutoConfiguration} and every annotation that gates or configures it - the
+ * {@code @Conditional*} family, {@code @Import}/{@code @ImportAutoConfiguration}/
+ * {@code @ImportResource}, {@code @ComponentScan}, {@code @EnableConfigurationProperties},
+ * {@code @PropertySource}/{@code @PropertySources}, and
+ * {@code @AutoConfigureOrder}/{@code Before}/{@code After} - found on the plugin class are moved
+ * onto the generated sibling, since that is the only place any of them has any effect; annotations
+ * outside that set can be named explicitly via {@code @GrailsBeans(moveAnnotations = ...)}. This lets a
+ * plugin author keep bean definitions in the familiar {@code *GrailsPlugin.groovy} file while
+ * everything else about the plugin class - {@code doWithApplicationContext}, {@code onChange},
+ * {@code watchedResources}, etc. - continues to work exactly as it does today.
+ *
+ *
{@code @CompileStatic}/{@code @GrailsCompileStatic} on the plugin class is propagated to the
+ * generated sibling. Since the sibling is created after Groovy schedules local annotation
+ * transforms, this transformation invokes Groovy's static-compilation transform directly after
+ * generating the sibling's members. This is the same approach used by other Grails AST transforms
+ * that generate code after local transform discovery.
+ */
+@GroovyASTTransformation(phase = CompilePhase.CANONICALIZATION)
+public class GrailsBeansASTTransformation implements ASTTransformation, CompilationUnitAware {
+
+ private static final String BEANS_PROPERTY = "beans";
+ private static final String BEAN_CALL = "bean";
+ private static final String FIELD_CALL = "field";
+ private static final String METHOD_CALL = "method";
+ private static final Set ROOT_STATEMENT_CALL_NAMES = Set.of(BEAN_CALL, FIELD_CALL, METHOD_CALL);
+ private static final String CONDITIONAL_ON_MISSING_BEAN_CALL = "conditionalOnMissingBean";
+ private static final String CONDITIONAL_ON_MISSING_BEAN_NAME_CALL = "conditionalOnMissingBeanName";
+ private static final String PRIMARY_CALL = "primary";
+ private static final String LAZY_CALL = "lazy";
+ private static final String SCOPE_CALL = "scope";
+ private static final String STATIC_METHOD_CALL = "staticMethod";
+ private static final String ANNOTATE_CALL = "annotate";
+ private static final String VALUE_CALL = "value";
+ private static final Set BEAN_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL, CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, ANNOTATE_CALL);
+ // field(...) and method(...) declare plain class members, not beans - bean-specific
+ // qualifiers don't apply; .value(...) (@Value config injection) is field-only.
+ private static final Set FIELD_QUALIFIER_CALL_NAMES = Set.of(ANNOTATE_CALL, VALUE_CALL);
+ private static final Set METHOD_QUALIFIER_CALL_NAMES = Set.of(ANNOTATE_CALL);
+ private static final Set ALL_QUALIFIER_CALL_NAMES = Set.of(
+ CONDITIONAL_ON_MISSING_BEAN_CALL, CONDITIONAL_ON_MISSING_BEAN_NAME_CALL,
+ PRIMARY_CALL, LAZY_CALL, SCOPE_CALL, STATIC_METHOD_CALL, ANNOTATE_CALL, VALUE_CALL);
+ private static final String PLUGIN_SUPERCLASS_NAME = "grails.plugins.Plugin";
+ private static final String GRAILS_PLUGIN_SUFFIX = "GrailsPlugin";
+ private static final String AUTO_CONFIGURATION_SUFFIX = "AutoConfiguration";
+ private static final String AUTO_CONFIGURATION_NAME_MEMBER = "autoConfigurationName";
+ private static final String MOVE_ANNOTATIONS_MEMBER = "moveAnnotations";
+
+ private CompilationUnit compilationUnit;
+
+ @Override
+ public void setCompilationUnit(CompilationUnit compilationUnit) {
+ this.compilationUnit = compilationUnit;
+ }
+
+ @Override
+ public void visit(ASTNode[] nodes, SourceUnit source) {
+ AnnotationNode grailsBeansAnnotation = (AnnotationNode) nodes[0];
+ ClassNode classNode = (ClassNode) nodes[1];
+ PropertyNode beansProperty = classNode.getProperty(BEANS_PROPERTY);
+ if (beansProperty == null) {
+ addError(classNode, source, "@GrailsBeans requires a 'beans' property initialised to a closure");
+ return;
+ }
+
+ Expression initialExpression = beansProperty.getInitialExpression();
+ if (!(initialExpression instanceof ClosureExpression)) {
+ addError(beansProperty, source, "'beans' must be initialised to a closure, e.g. beans = { ... }");
+ return;
+ }
+
+ // An empty block is a no-op, not an error - an empty @Configuration class is legal in Spring
+ // and an empty resources.groovy is legal in Grails, so having nothing to declare should not
+ // fail the build. Returning before the sibling is created matters: generating it would leave
+ // a bean-less class holding the @AutoConfiguration and @Conditional* annotations moved off
+ // the plugin, which is worse than doing nothing. Only the DSL scaffolding is stripped.
+ List statements = beanStatements((ClosureExpression) initialExpression);
+ if (statements.isEmpty()) {
+ removeBeansProperty(classNode, beansProperty);
+ return;
+ }
+
+ boolean isPlugin = extendsGrailsPlugin(classNode);
+ if (!isPlugin) {
+ for (String pluginOnlyMember : new String[] { AUTO_CONFIGURATION_NAME_MEMBER, MOVE_ANNOTATIONS_MEMBER }) {
+ if (grailsBeansAnnotation.getMember(pluginOnlyMember) != null) {
+ addError(grailsBeansAnnotation, source, pluginOnlyMember + " has no effect here: it only applies " +
+ "when @GrailsBeans is applied to a grails.plugins.Plugin subclass, where the compiled beans " +
+ "land on a generated sibling class rather than on " + classNode.getNameWithoutPackage() + " itself");
+ }
+ }
+ }
+
+ ClassNode beanMethodHost = isPlugin ?
+ createAutoConfigurationSibling(classNode, grailsBeansAnnotation, source) : classNode;
+
+ Set usedNames = existingMemberNames(beanMethodHost);
+ validateSharedBeanNames(statements, source);
+ // Two passes: field(...)/method(...) declare explicit member names, so they are processed
+ // first (along with anything malformed, so every statement is still processed exactly
+ // once) and bean(...) statements second. A bean's derived method name then adapts to every
+ // explicitly-named member wherever it appears in the block - reordering equivalent DSL
+ // statements must never change validity.
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+ for (Statement statement : statements) {
+ if (isBeanRootedStatement(statement)) {
+ processStatement(beanMethodHost, statement, source, usedNames);
+ }
+ }
+
+ if (beanMethodHost != classNode) {
+ applyStaticCompilation(classNode, beanMethodHost, source);
+ }
+
+ removeBeansProperty(classNode, beansProperty);
+ }
+
+ private void removeBeansProperty(ClassNode classNode, PropertyNode beansProperty) {
+ classNode.getProperties().remove(beansProperty);
+ classNode.getFields().removeIf(field -> field.getName().equals(BEANS_PROPERTY));
+ }
+
+ private boolean extendsGrailsPlugin(ClassNode classNode) {
+ for (ClassNode current = classNode.getSuperClass(); current != null; current = current.getSuperClass()) {
+ if (PLUGIN_SUPERCLASS_NAME.equals(current.getName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // Annotations that only make sense on whatever class Spring Boot actually evaluates as an
+ // auto-configuration - meaningless on a Plugin subclass, which is instantiated by
+ // DefaultGrailsPlugin via plain reflection and never processed by Spring as a bean. Matching
+ // is transitive through meta-annotations (see belongsOnSibling), so this list only needs the
+ // "root" annotations - a composed annotation built on top of any of these (e.g. a custom
+ // @ConditionalOnFeature meta-annotated with Spring Boot's own @ConditionalOnProperty, or a
+ // custom @EnableSomething meta-annotated with @Import) is found automatically.
+ private static final Set SIBLING_ONLY_ANNOTATION_NAMES = Set.of(
+ AutoConfiguration.class.getName(), AutoConfigureOrder.class.getName(),
+ AutoConfigureBefore.class.getName(), AutoConfigureAfter.class.getName(),
+ Import.class.getName(), ImportAutoConfiguration.class.getName(), ImportResource.class.getName(),
+ ComponentScan.class.getName(), ComponentScans.class.getName(),
+ EnableConfigurationProperties.class.getName(),
+ PropertySource.class.getName(), PropertySources.class.getName(),
+ Conditional.class.getName());
+
+ private ClassNode createAutoConfigurationSibling(ClassNode pluginClass, AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+ List autoConfigurationAnnotations = pluginClass.getAnnotations(ClassHelper.make(AutoConfiguration.class));
+ if (autoConfigurationAnnotations.isEmpty()) {
+ addError(pluginClass, source, "A Plugin class using @GrailsBeans must also be annotated " +
+ "@AutoConfiguration (even with no before=/after=) - otherwise the generated " +
+ defaultSiblingSimpleName(pluginClass) +
+ " class would never be processed by Spring Boot");
+ }
+
+ String siblingSimpleName = siblingSimpleName(pluginClass, grailsBeansAnnotation, source);
+ String packageName = pluginClass.getPackageName();
+ String siblingName = (packageName == null || packageName.isEmpty()) ?
+ siblingSimpleName : packageName + "." + siblingSimpleName;
+ ClassNode sibling = new ClassNode(siblingName, Modifier.PUBLIC, ClassHelper.OBJECT_TYPE);
+ source.getAST().addClass(sibling);
+
+ // Matching annotations move entirely rather than being merely copied - they have no effect
+ // where the author wrote them (see SIBLING_ONLY_ANNOTATION_NAMES).
+ Set moveAnnotationNames = parseMoveAnnotations(grailsBeansAnnotation, source);
+ List siblingAnnotations = new ArrayList<>();
+ for (AnnotationNode annotation : pluginClass.getAnnotations()) {
+ if (belongsOnSibling(annotation.getClassNode(), moveAnnotationNames)) {
+ siblingAnnotations.add(annotation);
+ }
+ }
+ sibling.addAnnotations(siblingAnnotations);
+ pluginClass.getAnnotations().removeAll(siblingAnnotations);
+
+ return sibling;
+ }
+
+ private Set parseMoveAnnotations(AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+ Expression member = grailsBeansAnnotation.getMember(MOVE_ANNOTATIONS_MEMBER);
+ if (member == null) {
+ return Set.of();
+ }
+ List entries = member instanceof ListExpression ?
+ ((ListExpression) member).getExpressions() : List.of(member);
+ Set names = new HashSet<>();
+ for (Expression entry : entries) {
+ if (!(entry instanceof ClassExpression)) {
+ addError(entry, source, "moveAnnotations entries must be annotation class literals, " +
+ "e.g. @GrailsBeans(moveAnnotations = [ComponentScan])");
+ continue;
+ }
+ ClassNode annotationType = ((ClassExpression) entry).getType();
+ if (!annotationType.isAnnotationDefinition()) {
+ addError(entry, source, "\"" + annotationType.getName() + "\" is not an annotation type");
+ continue;
+ }
+ names.add(annotationType.getName());
+ }
+ return names;
+ }
+
+ // A *GrailsPlugin name swaps that suffix for AutoConfiguration (I18nGrailsPlugin ->
+ // I18nAutoConfiguration - the name the hand-written class it replaces would have had);
+ // anything else appends AutoConfiguration.
+ private String defaultSiblingSimpleName(ClassNode pluginClass) {
+ String simpleName = pluginClass.getNameWithoutPackage();
+ if (simpleName.endsWith(GRAILS_PLUGIN_SUFFIX) && simpleName.length() > GRAILS_PLUGIN_SUFFIX.length()) {
+ return simpleName.substring(0, simpleName.length() - GRAILS_PLUGIN_SUFFIX.length()) + AUTO_CONFIGURATION_SUFFIX;
+ }
+ return simpleName + AUTO_CONFIGURATION_SUFFIX;
+ }
+
+ private String siblingSimpleName(ClassNode pluginClass, AnnotationNode grailsBeansAnnotation, SourceUnit source) {
+ String defaultName = defaultSiblingSimpleName(pluginClass);
+ Expression nameArg = grailsBeansAnnotation.getMember(AUTO_CONFIGURATION_NAME_MEMBER);
+ if (nameArg == null) {
+ return defaultName;
+ }
+ Object nameValue = nameArg instanceof ConstantExpression ? ((ConstantExpression) nameArg).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName = ...) requires a String literal");
+ return defaultName;
+ }
+ String name = (String) nameValue;
+ if (name.isBlank()) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName = \"" + name + "\") must not be " +
+ "blank - omit the attribute entirely to use the default " + defaultName + " instead");
+ return defaultName;
+ }
+ if (!isValidJavaIdentifier(name)) {
+ addError(nameArg, source, "@GrailsBeans(autoConfigurationName = \"" + name + "\") is not a valid " +
+ "name: it becomes the generated sibling's simple class name, so it must be a valid Java identifier");
+ return defaultName;
+ }
+ return name;
+ }
+
+ private boolean belongsOnSibling(ClassNode annotationType, Set moveAnnotationNames) {
+ return belongsOnSibling(annotationType, moveAnnotationNames, new HashSet<>());
+ }
+
+ // Recurses through meta-annotations rather than checking only one level, since Spring's own
+ // composed-annotation convention is arbitrarily deep - e.g. a project-specific
+ // @ConditionalOnFeature is typically meta-annotated with an existing @ConditionalOnXxx (itself
+ // meta-annotated @Conditional), not with @Conditional directly. `visited` guards against cycles
+ // and, since every annotation type transitively reaches common JDK meta-annotations
+ // (@Retention, @Target, @Documented) from multiple paths, avoids redundant re-exploration.
+ private boolean belongsOnSibling(ClassNode annotationType, Set moveAnnotationNames, Set visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (SIBLING_ONLY_ANNOTATION_NAMES.contains(annotationType.getName()) ||
+ moveAnnotationNames.contains(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (belongsOnSibling(metaAnnotation.getClassNode(), moveAnnotationNames, visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void applyStaticCompilation(ClassNode pluginClass, ClassNode sibling, SourceUnit source) {
+ List annotations = pluginClass.getAnnotations(ClassHelper.make(CompileStatic.class));
+ if (annotations.isEmpty() || compilationUnit == null) {
+ return;
+ }
+
+ AnnotationNode sourceAnnotation = annotations.get(0);
+ AnnotationNode siblingAnnotation = new AnnotationNode(ClassHelper.make(CompileStatic.class));
+ sourceAnnotation.getMembers().forEach(siblingAnnotation::setMember);
+ sibling.addAnnotation(siblingAnnotation);
+
+ StaticCompileTransformation transformation = new StaticCompileTransformation();
+ transformation.setCompilationUnit(compilationUnit);
+ transformation.visit(new ASTNode[] { siblingAnnotation, sibling }, source);
+ }
+
+ private List beanStatements(ClosureExpression dsl) {
+ Statement code = dsl.getCode();
+ if (code instanceof BlockStatement) {
+ return ((BlockStatement) code).getStatements();
+ }
+ List single = new ArrayList<>();
+ single.add(code);
+ return single;
+ }
+
+ // Generated names must not collide with anything the host class already has: its own fields
+ // and methods (in the standalone form the host is a real user-written class), every method
+ // inherited through its full type graph - superclasses and interfaces alike, since a bean
+ // named 'toString' or after an interface's default method must synthesize, not override -
+ // and the GroovyObject methods Groovy itself adds at class generation.
+ private Set existingMemberNames(ClassNode host) {
+ Set names = new HashSet<>();
+ for (FieldNode field : host.getFields()) {
+ names.add(field.getName());
+ }
+ Set visited = new HashSet<>();
+ collectMethodNames(host, names, visited);
+ collectMethodNames(ClassHelper.GROOVY_OBJECT_TYPE, names, visited);
+ return names;
+ }
+
+ private void collectMethodNames(ClassNode type, Set names, Set visited) {
+ if (type == null || !visited.add(type.getName())) {
+ return;
+ }
+ for (MethodNode method : type.getMethods()) {
+ names.add(method.getName());
+ }
+ // A Groovy property's accessors are synthesized by the Verifier at class generation, AFTER
+ // this transform runs, so they are not in getMethods() yet - reserve the names they will
+ // occupy, or a same-named bean method would displace the real accessor.
+ for (PropertyNode property : type.getProperties()) {
+ String capitalized = BeanUtils.capitalize(property.getName());
+ names.add("get" + capitalized);
+ names.add("set" + capitalized);
+ if (ClassHelper.boolean_TYPE.equals(property.getType()) ||
+ ClassHelper.Boolean_TYPE.equals(property.getType())) {
+ names.add("is" + capitalized);
+ }
+ }
+ collectMethodNames(type.getSuperClass(), names, visited);
+ for (ClassNode implemented : type.getInterfaces()) {
+ collectMethodNames(implemented, names, visited);
+ }
+ }
+
+ // Silent classification counterpart of processStatement's qualifier-chain walk: descends to
+ // the root call without reporting anything, so malformed statements are classified (not
+ // validated) here and still produce their usual errors when actually processed.
+ private boolean isBeanRootedStatement(Statement statement) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof MethodCallExpression)) {
+ return false;
+ }
+ MethodCallExpression call = (MethodCallExpression) ((ExpressionStatement) statement).getExpression();
+ while (!ROOT_STATEMENT_CALL_NAMES.contains(call.getMethodAsString()) &&
+ call.getObjectExpression() instanceof MethodCallExpression) {
+ call = (MethodCallExpression) call.getObjectExpression();
+ }
+ return BEAN_CALL.equals(call.getMethodAsString());
+ }
+
+ // A Spring bean name may be declared by more than one bean(...) statement - the standard
+ // autoconfiguration pattern for mutually exclusive variants of one bean, e.g. Grails' two
+ // "grailsUrlConverter" beans selected by @ConditionalOnProperty - but only when every
+ // statement sharing the name carries a condition of its own that could discriminate between
+ // them at runtime. Without one, the duplicates can never all take effect (Spring keeps the
+ // first definition from a configuration class and silently skips the rest), so the likeliest
+ // explanation is a copy-paste accident - rejected at compile time instead. The shared-name
+ // back-off (.conditionalOnMissingBeanName(), or .conditionalOnMissingBean() with no
+ // arguments) does not count: it is identical on every duplicate by construction, so it can
+ // never tell them apart.
+ private void validateSharedBeanNames(List statements, SourceUnit source) {
+ Map> usesByName = new LinkedHashMap<>();
+ for (Statement statement : statements) {
+ if (!isBeanRootedStatement(statement)) {
+ continue;
+ }
+ BeanNameUse use = parseBeanNameUse(
+ (MethodCallExpression) ((ExpressionStatement) statement).getExpression());
+ if (use != null) {
+ usesByName.computeIfAbsent(use.beanName, key -> new ArrayList<>()).add(use);
+ }
+ }
+ for (List uses : usesByName.values()) {
+ if (uses.size() < 2) {
+ continue;
+ }
+ for (BeanNameUse use : uses) {
+ if (!use.conditioned) {
+ addError(use.baseCall, source, "\"" + use.beanName + "\" is already used as the Spring " +
+ "bean name of another bean(...) statement - declaring it more than once is only " +
+ "allowed when every declaration with the name carries its own discriminating " +
+ "condition (e.g. .annotate(ConditionalOnProperty, ...)), so that at most one of " +
+ "them registers at runtime");
+ }
+ }
+ }
+ }
+
+ private static final class BeanNameUse {
+ private final String beanName;
+ private final MethodCallExpression baseCall;
+ private final boolean conditioned;
+
+ BeanNameUse(String beanName, MethodCallExpression baseCall, boolean conditioned) {
+ this.beanName = beanName;
+ this.baseCall = baseCall;
+ this.conditioned = conditioned;
+ }
+ }
+
+ // Silent classification counterpart of processBeanStatement's parsing, in the same spirit as
+ // isBeanRootedStatement: extracts the bean name and whether the statement carries a
+ // discriminating condition, returning null for anything malformed - a malformed statement
+ // still produces its usual errors when actually processed.
+ private BeanNameUse parseBeanNameUse(MethodCallExpression outerCall) {
+ List qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while (!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if (!(baseCall.getObjectExpression() instanceof MethodCallExpression)) {
+ return null;
+ }
+ qualifierCalls.add(baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+ if (!BEAN_CALL.equals(baseCall.getMethodAsString())) {
+ return null;
+ }
+
+ List args = withoutTrailingClosure(flatten(baseCall.getArguments()), baseCall, outerCall);
+ if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) instanceof ClassExpression)) {
+ return null;
+ }
+ String name;
+ if (args.size() == 1) {
+ name = decapitalize(((ClassExpression) args.get(0)).getType().getNameWithoutPackage());
+ }
+ else {
+ Object nameValue = args.get(0) instanceof ConstantExpression ?
+ ((ConstantExpression) args.get(0)).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ return null;
+ }
+ name = (String) nameValue;
+ }
+ return new BeanNameUse(name, baseCall, hasDiscriminatingCondition(qualifierCalls, outerCall));
+ }
+
+ private boolean hasDiscriminatingCondition(List qualifierCalls, MethodCallExpression outerCall) {
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ List args = withoutTrailingClosure(flatten(qualifierCall.getArguments()), qualifierCall, outerCall);
+ if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(qualifierName) && !args.isEmpty()) {
+ return true;
+ }
+ if (ANNOTATE_CALL.equals(qualifierName)) {
+ for (Expression arg : args) {
+ if (arg instanceof ClassExpression && isConditionalAnnotation(((ClassExpression) arg).getType())) {
+ return true;
+ }
+ }
+ }
+ }
+ return false;
+ }
+
+ private List withoutTrailingClosure(List args, MethodCallExpression call,
+ MethodCallExpression outerCall) {
+ if (call == outerCall && !args.isEmpty() && args.get(args.size() - 1) instanceof ClosureExpression) {
+ return args.subList(0, args.size() - 1);
+ }
+ return args;
+ }
+
+ private boolean isConditionalAnnotation(ClassNode annotationType) {
+ return isConditionalAnnotation(annotationType, new HashSet<>());
+ }
+
+ // The same transitive meta-annotation walk belongsOnSibling does, against @Conditional alone:
+ // every @ConditionalOnXxx - Spring Boot's own and arbitrarily-deeply composed custom ones -
+ // eventually reaches @Conditional through its meta-annotations.
+ private boolean isConditionalAnnotation(ClassNode annotationType, Set visited) {
+ if (!visited.add(annotationType.getName())) {
+ return false;
+ }
+ if (Conditional.class.getName().equals(annotationType.getName())) {
+ return true;
+ }
+ for (AnnotationNode metaAnnotation : annotationType.getAnnotations()) {
+ if (isConditionalAnnotation(metaAnnotation.getClassNode(), visited)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private void processStatement(ClassNode classNode, Statement statement, SourceUnit source, Set usedNames) {
+ if (!(statement instanceof ExpressionStatement) ||
+ !(((ExpressionStatement) statement).getExpression() instanceof MethodCallExpression)) {
+ addError(statement, source, "Each 'beans' statement must be a bean(...), field(...), or method(...) call");
+ return;
+ }
+
+ MethodCallExpression outerCall = (MethodCallExpression) ((ExpressionStatement) statement).getExpression();
+
+ // Walk from the outermost (last-written) call back to the bean(...)/field(...)/method(...)
+ // call at the root, collecting any chained qualifiers along the way.
+ List qualifierCalls = new ArrayList<>();
+ MethodCallExpression baseCall = outerCall;
+ while (!ROOT_STATEMENT_CALL_NAMES.contains(baseCall.getMethodAsString())) {
+ if (!ALL_QUALIFIER_CALL_NAMES.contains(baseCall.getMethodAsString()) ||
+ !(baseCall.getObjectExpression() instanceof MethodCallExpression)) {
+ addError(statement, source, "Expected bean([\"name\", ] Type) { ... }, field([\"name\", ] Type), " +
+ "or method([\"name\", ] Type) { ... }, optionally chained with qualifiers");
+ return;
+ }
+ qualifierCalls.add(0, baseCall);
+ baseCall = (MethodCallExpression) baseCall.getObjectExpression();
+ }
+
+ String rootName = baseCall.getMethodAsString();
+ boolean isBean = BEAN_CALL.equals(rootName);
+ Set allowedQualifiers = isBean ? BEAN_QUALIFIER_CALL_NAMES :
+ FIELD_CALL.equals(rootName) ? FIELD_QUALIFIER_CALL_NAMES : METHOD_QUALIFIER_CALL_NAMES;
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ if (!allowedQualifiers.contains(qualifierCall.getMethodAsString())) {
+ addError(qualifierCall, source, "." + qualifierCall.getMethodAsString() + "(...) cannot be " +
+ "chained onto " + rootName + "(...)");
+ return;
+ }
+ }
+
+ // .annotate(...) is repeatable (once per distinct annotation type, enforced when the
+ // annotation is actually attached below); every other qualifier is single-use.
+ Set seenQualifiers = new HashSet<>();
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ String qualifierName = qualifierCall.getMethodAsString();
+ if (!ANNOTATE_CALL.equals(qualifierName) && !seenQualifiers.add(qualifierName)) {
+ addError(qualifierCall, source, "." + qualifierName + "(...) may only be chained once per " +
+ rootName + "(...)");
+ return;
+ }
+ }
+
+ if (isBean) {
+ processBeanStatement(classNode, outerCall, baseCall, qualifierCalls, source, usedNames);
+ }
+ else if (FIELD_CALL.equals(rootName)) {
+ processFieldStatement(classNode, baseCall, qualifierCalls, source, usedNames);
+ }
+ else {
+ processMethodStatement(classNode, outerCall, baseCall, qualifierCalls, source, usedNames);
+ }
+ }
+
+ private boolean registerName(String name, ASTNode location, SourceUnit source, Set usedNames, String errorSuffix) {
+ if (!usedNames.add(name)) {
+ addError(location, source, "\"" + name + "\" " + errorSuffix);
+ return false;
+ }
+ return true;
+ }
+
+ private void processBeanStatement(ClassNode classNode, MethodCallExpression outerCall, MethodCallExpression baseCall,
+ List qualifierCalls, SourceUnit source, Set usedNames) {
+ // The factory closure is optional: bean(Type) with no body declares a bean that is just its
+ // own no-argument construction, which is by far the most common shape and reads as noise
+ // when spelled out as bean(Type) { new Type() }.
+ List closureCallArgs = flatten(outerCall.getArguments());
+ ClosureExpression factory = !closureCallArgs.isEmpty() &&
+ closureCallArgs.get(closureCallArgs.size() - 1) instanceof ClosureExpression ?
+ (ClosureExpression) closureCallArgs.get(closureCallArgs.size() - 1) : null;
+
+ // When bean(...) is itself the outermost call (no qualifiers chained), it carries the
+ // trailing closure as its own last argument - exclude it before validating the [name, ] Type
+ // shape, since it was already validated above.
+ List baseArgs = flatten(baseCall.getArguments());
+ if (factory != null && baseCall == outerCall && !baseArgs.isEmpty()) {
+ baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+ }
+
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, BEAN_CALL, false);
+ if (typeAndName == null) {
+ return;
+ }
+
+ ClassNode beanType = typeAndName.type.getType();
+ if (factory == null && (beanType.isInterface() || Modifier.isAbstract(beanType.getModifiers()))) {
+ addError(baseCall, source, "bean(" + beanType.getNameWithoutPackage() + ") without a factory closure " +
+ "constructs the declared type, which cannot be done for an interface or abstract class - " +
+ "give it a body: bean(" + beanType.getNameWithoutPackage() + ") { new SomeImplementation() }");
+ return;
+ }
+
+ // The method name is an implementation detail - Spring resolves the bean by its
+ // @Bean("name") value, never by the factory method's name - so a bean name that isn't a
+ // usable Java identifier, or is already taken by an existing member, synthesizes instead
+ // of erroring.
+ String javaMethodName = isValidJavaIdentifier(typeAndName.name) && !usedNames.contains(typeAndName.name) ?
+ typeAndName.name :
+ syntheticBeanMethodName(typeAndName.type.getType(), usedNames);
+ usedNames.add(javaMethodName);
+
+ Parameter[] beanParameters = factory == null || factory.getParameters() == null ?
+ Parameter.EMPTY_ARRAY : factory.getParameters();
+ Statement beanBody = factory != null ? factory.getCode() :
+ new ReturnStatement(new ConstructorCallExpression(beanType, ArgumentListExpression.EMPTY_ARGUMENTS));
+
+ MethodNode beanMethod = new MethodNode(
+ javaMethodName,
+ Modifier.PUBLIC,
+ beanType,
+ beanParameters,
+ ClassNode.EMPTY_ARRAY,
+ beanBody);
+ beanMethod.addAnnotation(beanAnnotation(typeAndName.name));
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List qualifierArgs = flatten(qualifierCall.getArguments());
+ if (factory != null && qualifierCall == outerCall) {
+ // only the outermost call in the chain can carry the trailing factory closure
+ qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size() - 1);
+ }
+ if (!applyQualifier(beanMethod, typeAndName.name, qualifierCall, qualifierArgs, source)) {
+ return;
+ }
+ }
+
+ classNode.addMethod(beanMethod);
+ }
+
+ private String syntheticBeanMethodName(ClassNode beanType, Set usedNames) {
+ String base = decapitalize(beanType.getNameWithoutPackage());
+ String candidate;
+ int index = 0;
+ do {
+ candidate = base + "$" + index;
+ index++;
+ }
+ while (usedNames.contains(candidate));
+ return candidate;
+ }
+
+ private void processFieldStatement(ClassNode classNode, MethodCallExpression baseCall,
+ List qualifierCalls, SourceUnit source, Set usedNames) {
+ List baseArgs = flatten(baseCall.getArguments());
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, FIELD_CALL, true);
+ if (typeAndName == null) {
+ return;
+ }
+ if (!registerName(typeAndName.name, baseCall, source, usedNames,
+ "is already used by another member of the class (declared, inherited, or another field(...)/method(...) statement) - " +
+ "generated member names must be unique")) {
+ return;
+ }
+
+ FieldNode field = classNode.addField(typeAndName.name, Modifier.PRIVATE, typeAndName.type.getType(), null);
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List qualifierArgs = flatten(qualifierCall.getArguments());
+ if (VALUE_CALL.equals(qualifierCall.getMethodAsString())) {
+ AnnotationNode valueAnnotation = valueAnnotation(qualifierArgs, qualifierCall, source);
+ if (valueAnnotation == null || !addAnnotationIfAbsent(field, qualifierCall, valueAnnotation, source)) {
+ return;
+ }
+ }
+ else if (!applyGenericAnnotation(field, qualifierCall, qualifierArgs, source)) {
+ return;
+ }
+ }
+ }
+
+ // .value(key, default) builds the '${key:default}' placeholder itself, as a concatenation the
+ // compiler folds into a constant - which is what lets the key be a bare static-final constant
+ // reference, the one shape a directly-written annotation value rejects. .value(single) is a
+ // bare config key with no default, auto-wrapped into '${key}' - injecting the key's literal
+ // text is never what .value(...) is for - unless the string already contains a '${'
+ // placeholder or '#{' SpEL expression, which passes through verbatim (including mixed
+ // literals like 'http://${app.host}/'). A genuine literal stays expressible via
+ // .annotate(Value, value: ...).
+ private AnnotationNode valueAnnotation(List args, MethodCallExpression qualifierCall, SourceUnit source) {
+ if (args.isEmpty() || args.size() > 2) {
+ addError(qualifierCall, source, ".value(...) requires a config key and default - e.g. " +
+ ".value(Settings.GSP_VIEW_ENCODING, \"UTF-8\") - or a single config key/placeholder/SpEL string");
+ return null;
+ }
+ // The pieces are resolved and folded HERE, into a plain constant, rather than being left
+ // as a concatenation for Groovy's own annotation folding: under @CompileStatic the static
+ // compiler rewrites '+' into .plus() calls before that folding runs, which would reject
+ // the member as a non-constant.
+ String memberValue;
+ if (args.size() == 1) {
+ String placeholder = resolveStringConstant(args.get(0));
+ if (placeholder == null) {
+ addError(args.get(0), source, ".value(...) arguments must be compile-time String constants " +
+ "(a literal, a static final constant reference, or a concatenation of those)");
+ return null;
+ }
+ if (placeholder.isBlank()) {
+ addError(args.get(0), source, ".value(...) requires a non-blank config key - a blank one " +
+ "would compile to the unresolvable placeholder ${}");
+ return null;
+ }
+ memberValue = placeholder.contains("${") || placeholder.contains("#{") ?
+ placeholder : "${" + placeholder + "}";
+ }
+ else {
+ String key = resolveStringConstant(args.get(0));
+ String defaultValue = resolveStringConstant(args.get(1));
+ if (key == null || defaultValue == null) {
+ addError(key == null ? args.get(0) : args.get(1), source, ".value(...) arguments must be " +
+ "compile-time String constants (a literal, a static final constant reference, or a " +
+ "concatenation of those)");
+ return null;
+ }
+ // Only the KEY must be non-blank: a deliberately blank default ('${key:}') is legal
+ // and used (e.g. grails.i18n.default.locale falls back to the JVM default locale).
+ if (key.isBlank()) {
+ addError(args.get(0), source, ".value(key, default) requires a non-blank config key - " +
+ "only the default may be blank");
+ return null;
+ }
+ memberValue = "${" + key + ":" + defaultValue + "}";
+ }
+ AnnotationNode annotation = new AnnotationNode(ClassHelper.make(Value.class));
+ annotation.setMember("value", new ConstantExpression(memberValue));
+ return annotation;
+ }
+
+ // Resolves an expression to its compile-time String value: literals directly; a static final
+ // constant reference either from its AST initial expression (a constant declared in the same
+ // compilation unit) or reflectively from the already-compiled class on the classpath; and
+ // concatenations of resolvable pieces recursively.
+ private String resolveStringConstant(Expression expression) {
+ if (expression instanceof ConstantExpression) {
+ Object value = ((ConstantExpression) expression).getValue();
+ return value instanceof String ? (String) value : null;
+ }
+ if (expression instanceof BinaryExpression) {
+ BinaryExpression binary = (BinaryExpression) expression;
+ if (binary.getOperation().getType() != Types.PLUS) {
+ return null;
+ }
+ String left = resolveStringConstant(binary.getLeftExpression());
+ String right = resolveStringConstant(binary.getRightExpression());
+ return left != null && right != null ? left + right : null;
+ }
+ if (expression instanceof PropertyExpression) {
+ PropertyExpression property = (PropertyExpression) expression;
+ if (!(property.getObjectExpression() instanceof ClassExpression)) {
+ return null;
+ }
+ ClassNode owner = property.getObjectExpression().getType();
+ String fieldName = property.getPropertyAsString();
+ if (fieldName == null) {
+ return null;
+ }
+ FieldNode field = owner.getField(fieldName);
+ if (field != null && field.isStatic() && field.isFinal() &&
+ field.getInitialExpression() instanceof ConstantExpression) {
+ Object value = ((ConstantExpression) field.getInitialExpression()).getValue();
+ return value instanceof String ? (String) value : null;
+ }
+ try {
+ Object value = owner.getTypeClass().getField(fieldName).get(null);
+ return value instanceof String ? (String) value : null;
+ }
+ catch (ReflectiveOperationException | RuntimeException | LinkageError ignored) {
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private void processMethodStatement(ClassNode classNode, MethodCallExpression outerCall, MethodCallExpression baseCall,
+ List qualifierCalls, SourceUnit source, Set usedNames) {
+ List closureCallArgs = flatten(outerCall.getArguments());
+ if (closureCallArgs.isEmpty() || !(closureCallArgs.get(closureCallArgs.size() - 1) instanceof ClosureExpression)) {
+ addError(outerCall, source, "method(...) must end with a body closure: method(\"name\", Type) { ... }");
+ return;
+ }
+ ClosureExpression body = (ClosureExpression) closureCallArgs.get(closureCallArgs.size() - 1);
+
+ List baseArgs = flatten(baseCall.getArguments());
+ if (baseCall == outerCall && !baseArgs.isEmpty()) {
+ baseArgs = baseArgs.subList(0, baseArgs.size() - 1);
+ }
+
+ TypeAndName typeAndName = parseNameAndType(baseArgs, baseCall, source, METHOD_CALL, true);
+ if (typeAndName == null) {
+ return;
+ }
+ if (!registerName(typeAndName.name, baseCall, source, usedNames,
+ "is already used by another member of the class (declared, inherited, or another field(...)/method(...) statement) - " +
+ "generated member names must be unique")) {
+ return;
+ }
+
+ MethodNode helperMethod = new MethodNode(
+ typeAndName.name,
+ Modifier.PRIVATE,
+ typeAndName.type.getType(),
+ body.getParameters() == null ? Parameter.EMPTY_ARRAY : body.getParameters(),
+ ClassNode.EMPTY_ARRAY,
+ body.getCode());
+
+ for (MethodCallExpression qualifierCall : qualifierCalls) {
+ List qualifierArgs = flatten(qualifierCall.getArguments());
+ if (qualifierCall == outerCall) {
+ qualifierArgs = qualifierArgs.subList(0, qualifierArgs.size() - 1);
+ }
+ if (!applyGenericAnnotation(helperMethod, qualifierCall, qualifierArgs, source)) {
+ return;
+ }
+ }
+
+ classNode.addMethod(helperMethod);
+ }
+
+ private static final class TypeAndName {
+ private final ClassExpression type;
+ private final String name;
+
+ TypeAndName(ClassExpression type, String name) {
+ this.type = type;
+ this.name = name;
+ }
+ }
+
+ private TypeAndName parseNameAndType(List args, MethodCallExpression call, SourceUnit source,
+ String callName, boolean requireValidIdentifier) {
+ if (args.isEmpty() || args.size() > 2 || !(args.get(args.size() - 1) instanceof ClassExpression)) {
+ if (args.size() == 2 && args.get(0) instanceof ClassExpression) {
+ addError(call, source, callName + "(...) takes the name before the type: " +
+ callName + "(\"myGreeter\", Greeter), not " + callName + "(Greeter, \"myGreeter\")");
+ }
+ else {
+ addError(call, source, callName + "(...) requires a type, optionally preceded by a name, " +
+ "e.g. " + callName + "(Greeter) or " + callName + "(\"myGreeter\", Greeter)");
+ }
+ return null;
+ }
+ ClassExpression type = (ClassExpression) args.get(args.size() - 1);
+
+ String name;
+ if (args.size() == 1) {
+ name = decapitalize(type.getType().getNameWithoutPackage());
+ }
+ else {
+ Expression nameArg = args.get(0);
+ Object nameValue = nameArg instanceof ConstantExpression ? ((ConstantExpression) nameArg).getValue() : null;
+ if (!(nameValue instanceof String)) {
+ addError(nameArg, source, callName + "(name, Type) requires the name to be a String literal, " +
+ "e.g. " + callName + "(\"myGreeter\", Greeter)");
+ return null;
+ }
+ name = (String) nameValue;
+ if (requireValidIdentifier && !isValidJavaIdentifier(name)) {
+ addError(nameArg, source, "\"" + name + "\" is not a valid name: it becomes the generated " +
+ "member's name, so it must be a valid Java identifier");
+ return null;
+ }
+ // Even bean(...), which otherwise allows any Spring name, must reject a blank one:
+ // Spring treats a blank @Bean name as absent and falls back to the method name, so
+ // the name actually written would be silently discarded.
+ if (!requireValidIdentifier && name.isBlank()) {
+ addError(nameArg, source, callName + "(name, Type) requires a non-blank name");
+ return null;
+ }
+ }
+ return new TypeAndName(type, name);
+ }
+
+ private boolean applyQualifier(MethodNode beanMethod, String beanName, MethodCallExpression qualifierCall,
+ List args, SourceUnit source) {
+ String name = qualifierCall.getMethodAsString();
+ if (CONDITIONAL_ON_MISSING_BEAN_CALL.equals(name)) {
+ AnnotationNode annotation = conditionalOnMissingBeanAnnotation(args, qualifierCall, source);
+ return annotation != null && addAnnotationIfAbsent(beanMethod, qualifierCall, annotation, source);
+ }
+ if (CONDITIONAL_ON_MISSING_BEAN_NAME_CALL.equals(name)) {
+ AnnotationNode annotation = conditionalOnMissingBeanNameAnnotation(args, beanName, qualifierCall, source);
+ return annotation != null && addAnnotationIfAbsent(beanMethod, qualifierCall, annotation, source);
+ }
+ if (PRIMARY_CALL.equals(name) || LAZY_CALL.equals(name)) {
+ if (!args.isEmpty()) {
+ addError(qualifierCall, source, "." + name + "() takes no arguments");
+ return false;
+ }
+ Class> annotationType = PRIMARY_CALL.equals(name) ? Primary.class : Lazy.class;
+ return addAnnotationIfAbsent(beanMethod, qualifierCall,
+ new AnnotationNode(ClassHelper.make(annotationType)), source);
+ }
+ // Generates a static factory method - Spring's recommended shape for a
+ // BeanFactoryPostProcessor/BeanPostProcessor bean, which must be creatable without
+ // instantiating its declaring configuration class. The body consequently cannot touch
+ // field(...)/method(...) members, which are instance members of that class.
+ if (STATIC_METHOD_CALL.equals(name)) {
+ if (!args.isEmpty()) {
+ addError(qualifierCall, source, ".staticMethod() takes no arguments");
+ return false;
+ }
+ beanMethod.setModifiers(beanMethod.getModifiers() | Modifier.STATIC);
+ return true;
+ }
+ if (SCOPE_CALL.equals(name)) {
+ return applyScopeQualifier(beanMethod, qualifierCall, args, source);
+ }
+ return applyGenericAnnotation(beanMethod, qualifierCall, args, source);
+ }
+
+ private boolean applyScopeQualifier(MethodNode beanMethod, MethodCallExpression qualifierCall,
+ List args, SourceUnit source) {
+ Expression scopeArg = args.size() == 1 ? args.get(0) : null;
+ Object scopeValue = scopeArg instanceof ConstantExpression ? ((ConstantExpression) scopeArg).getValue() : null;
+ if (!(scopeValue instanceof String) || ((String) scopeValue).isEmpty()) {
+ addError(qualifierCall, source, ".scope(...) requires exactly one non-empty String argument, " +
+ "e.g. .scope(\"prototype\")");
+ return false;
+ }
+ AnnotationNode scopeAnnotation = new AnnotationNode(ClassHelper.make(Scope.class));
+ scopeAnnotation.setMember("value", scopeArg);
+ return addAnnotationIfAbsent(beanMethod, qualifierCall, scopeAnnotation, source);
+ }
+
+ private boolean applyGenericAnnotation(AnnotatedNode target, MethodCallExpression qualifierCall,
+ List args, SourceUnit source) {
+ MapExpression members = !args.isEmpty() && args.get(0) instanceof MapExpression ? (MapExpression) args.get(0) : null;
+ List remaining = members != null ? args.subList(1, args.size()) : args;
+ if (remaining.size() != 1 || !(remaining.get(0) instanceof ClassExpression)) {
+ addError(qualifierCall, source, ".annotate(...) requires an annotation type as its only " +
+ "positional argument, e.g. .annotate(Order, value: 1) or .annotate(ConditionalOnWebApplication)");
+ return false;
+ }
+
+ ClassNode annotationType = ((ClassExpression) remaining.get(0)).getType();
+ if (!annotationType.isAnnotationDefinition()) {
+ addError(qualifierCall, source, "\"" + annotationType.getName() + "\" is not an annotation type");
+ return false;
+ }
+
+ AnnotationNode annotation = new AnnotationNode(annotationType);
+ if (members != null && !addMembersFromMap(annotation, members, qualifierCall, source)) {
+ return false;
+ }
+ return addAnnotationIfAbsent(target, qualifierCall, annotation, source);
+ }
+
+ private boolean addMembersFromMap(AnnotationNode annotation, MapExpression members,
+ MethodCallExpression qualifierCall, SourceUnit source) {
+ for (MapEntryExpression entry : members.getMapEntryExpressions()) {
+ Object keyValue = entry.getKeyExpression() instanceof ConstantExpression ?
+ ((ConstantExpression) entry.getKeyExpression()).getValue() : null;
+ if (!(keyValue instanceof String)) {
+ addError(qualifierCall, source, "." + qualifierCall.getMethodAsString() +
+ "(...) attribute names must be simple identifiers, e.g. .annotate(Order, value: 1)");
+ return false;
+ }
+ annotation.setMember((String) keyValue, entry.getValueExpression());
+ }
+ return true;
+ }
+
+ private boolean addAnnotationIfAbsent(AnnotatedNode target, MethodCallExpression qualifierCall,
+ AnnotationNode annotation, SourceUnit source) {
+ ClassNode type = annotation.getClassNode();
+ if (!target.getAnnotations(type).isEmpty()) {
+ addError(qualifierCall, source, "@" + type.getNameWithoutPackage() + " is already attached " +
+ "here, via an earlier qualifier or .annotate(...)");
+ return false;
+ }
+ target.addAnnotation(annotation);
+ return true;
+ }
+
+ private List flatten(Expression arguments) {
+ List result = new ArrayList<>();
+ if (arguments instanceof org.codehaus.groovy.ast.expr.TupleExpression) {
+ result.addAll(((org.codehaus.groovy.ast.expr.TupleExpression) arguments).getExpressions());
+ }
+ else {
+ result.add(arguments);
+ }
+ return result;
+ }
+
+ private AnnotationNode beanAnnotation(String beanName) {
+ AnnotationNode annotation = new AnnotationNode(ClassHelper.make(Bean.class));
+ ListExpression names = new ListExpression();
+ names.addExpression(new ConstantExpression(beanName));
+ annotation.setMember("value", names);
+ return annotation;
+ }
+
+ // Zero args -> a bare annotation, letting Spring infer the back-off type from the method's
+ // return type. Positional types -> the value member. Named args -> the annotation's own
+ // attributes (name:, search:, ...), so the common name-based back-off doesn't need the
+ // generic .annotate(...) escape hatch.
+ private AnnotationNode conditionalOnMissingBeanAnnotation(List args,
+ MethodCallExpression qualifierCall, SourceUnit source) {
+ AnnotationNode annotation = new AnnotationNode(ClassHelper.make(ConditionalOnMissingBean.class));
+ MapExpression members = !args.isEmpty() && args.get(0) instanceof MapExpression ? (MapExpression) args.get(0) : null;
+ List types = members != null ? args.subList(1, args.size()) : args;
+ if (members != null && !types.isEmpty()) {
+ for (MapEntryExpression entry : members.getMapEntryExpressions()) {
+ Object keyValue = entry.getKeyExpression() instanceof ConstantExpression ?
+ ((ConstantExpression) entry.getKeyExpression()).getValue() : null;
+ if ("value".equals(keyValue)) {
+ addError(qualifierCall, source, "conditionalOnMissingBean(...) was given types both " +
+ "positionally and via value: - use one or the other");
+ return null;
+ }
+ }
+ }
+ if (members != null && !addMembersFromMap(annotation, members, qualifierCall, source)) {
+ return null;
+ }
+ if (!types.isEmpty()) {
+ ListExpression typeList = new ListExpression();
+ for (Expression type : types) {
+ if (!(type instanceof ClassExpression)) {
+ addError(qualifierCall, source, "conditionalOnMissingBean(...) arguments must be types " +
+ "and/or named attributes, e.g. conditionalOnMissingBean(Greeter) or " +
+ "conditionalOnMissingBean(name: \"greeter\", search: SearchStrategy.CURRENT)");
+ continue;
+ }
+ typeList.addExpression(type);
+ }
+ annotation.setMember("value", typeList);
+ }
+ return annotation;
+ }
+
+ // "Register this bean unless a bean with THIS bean's name already exists" - the name member is
+ // set from the bean's own (explicit or convention-derived) name, so the two strings the plain
+ // form would have to keep in sync cannot diverge. name:/value:/positional types are rejected:
+ // supplying them contradicts the qualifier's purpose, and the fully-explicit forms remain
+ // available on .conditionalOnMissingBean(...).
+ private AnnotationNode conditionalOnMissingBeanNameAnnotation(List args, String beanName,
+ MethodCallExpression qualifierCall, SourceUnit source) {
+ MapExpression members = !args.isEmpty() && args.get(0) instanceof MapExpression ? (MapExpression) args.get(0) : null;
+ if (args.size() > (members != null ? 1 : 0)) {
+ addError(qualifierCall, source, "conditionalOnMissingBeanName(...) takes only named attributes " +
+ "(e.g. search: SearchStrategy.CURRENT) - it always backs off by this bean's own name; " +
+ "use conditionalOnMissingBean(...) for type-based or fully explicit conditions");
+ return null;
+ }
+ if (members != null) {
+ for (MapEntryExpression entry : members.getMapEntryExpressions()) {
+ Object keyValue = entry.getKeyExpression() instanceof ConstantExpression ?
+ ((ConstantExpression) entry.getKeyExpression()).getValue() : null;
+ if ("name".equals(keyValue) || "value".equals(keyValue)) {
+ addError(qualifierCall, source, "conditionalOnMissingBeanName(...) sets name automatically " +
+ "from this bean's own name - use conditionalOnMissingBean(...) to spell out name: or types");
+ return null;
+ }
+ }
+ }
+ AnnotationNode annotation = new AnnotationNode(ClassHelper.make(ConditionalOnMissingBean.class));
+ if (members != null && !addMembersFromMap(annotation, members, qualifierCall, source)) {
+ return null;
+ }
+ annotation.setMember("name", new ConstantExpression(beanName));
+ return annotation;
+ }
+
+ private String decapitalize(String name) {
+ return Introspector.decapitalize(name);
+ }
+
+ private boolean isValidJavaIdentifier(String name) {
+ return SourceVersion.isIdentifier(name) && !SourceVersion.isKeyword(name);
+ }
+
+ private void addError(ASTNode node, SourceUnit source, String message) {
+ source.getErrorCollector().addErrorAndContinue(
+ new org.codehaus.groovy.control.messages.SyntaxErrorMessage(
+ new SyntaxException(message, node.getLineNumber(), node.getColumnNumber()), source));
+ }
+
+}
diff --git a/grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/GrailsBeansASTTransformationSpec.groovy b/grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/GrailsBeansASTTransformationSpec.groovy
new file mode 100644
index 00000000000..fc95b7b461f
--- /dev/null
+++ b/grails-beans-dsl/src/test/groovy/org/grails/compiler/beans/GrailsBeansASTTransformationSpec.groovy
@@ -0,0 +1,2651 @@
+/*
+ * 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.compiler.beans
+
+import java.lang.annotation.Annotation
+import java.lang.reflect.Modifier
+
+import org.codehaus.groovy.control.CompilationUnit
+import org.codehaus.groovy.control.CompilerConfiguration
+import org.codehaus.groovy.control.MultipleCompilationErrorsException
+import org.codehaus.groovy.control.Phases
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.AutoConfigureAfter
+import org.springframework.boot.autoconfigure.AutoConfigureBefore
+import org.springframework.boot.autoconfigure.AutoConfigureOrder
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+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.context.properties.EnableConfigurationProperties
+import org.springframework.context.annotation.AnnotationConfigApplicationContext
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.ComponentScan
+import org.springframework.context.annotation.ImportResource
+import org.springframework.context.annotation.Lazy
+import org.springframework.context.annotation.Primary
+import org.springframework.context.annotation.PropertySource
+import org.springframework.context.annotation.PropertySources
+import org.springframework.context.annotation.Scope
+import org.springframework.core.annotation.Order
+import org.springframework.core.env.MapPropertySource
+import spock.lang.Specification
+import spock.lang.TempDir
+import spock.lang.Unroll
+
+import grails.plugins.Plugin
+
+class GrailsBeansASTTransformationSpec extends Specification {
+
+ @TempDir
+ File tempDir
+
+ private static final String FIXTURE = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.beans.factory.annotation.Value
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+ import org.springframework.core.annotation.Order
+
+ @GrailsBeans
+ @AutoConfiguration
+ class FixtureBeans {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+
+ bean('answer', Integer).conditionalOnMissingBean(Integer) {
+ 42
+ }
+
+ bean('shout', String) { String input ->
+ input.toUpperCase()
+ }
+
+ bean('primaryGreeting', String).primary() {
+ 'primary hello'
+ }
+
+ bean('lazyGreeting', String).lazy() {
+ 'lazy hello'
+ }
+
+ bean('scopedGreeting', String).scope('prototype') {
+ 'scoped hello'
+ }
+
+ bean('combinedGreeting', String).primary().lazy().scope('prototype').conditionalOnMissingBean(String) {
+ 'combined hello'
+ }
+
+ bean('orderedGreeting', String).annotate(Order, value: 1) {
+ 'ordered hello'
+ }
+
+ bean('webOnlyGreeting', String).annotate(ConditionalOnWebApplication) {
+ 'web hello'
+ }
+
+ bean('multiAnnotatedGreeting', String).primary().annotate(Order, value: 2).annotate(ConditionalOnWebApplication) {
+ 'multi hello'
+ }
+
+ field('suffix', String).annotate(Value, value: '${greeting.suffix:!!!}')
+
+ method('yell', String) { String input ->
+ input.toUpperCase() + (suffix ?: '')
+ }
+
+ bean('yelledGreeting', String) {
+ yell('hello')
+ }
+ }
+ }
+ '''
+
+ private Class> compile() {
+ compile(FIXTURE)
+ }
+
+ private Class> compile(String source) {
+ new GroovyClassLoader(getClass().classLoader).parseClass(source)
+ }
+
+ def "compiles a plain bean() call into a public @Bean factory method"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('greeting')
+
+ then:
+ method.returnType == String
+ method.isAnnotationPresent(Bean)
+ method.getAnnotation(Bean).value() == ['greeting'] as String[]
+
+ and: "the closure body became the real method body"
+ fixtureBeans.getDeclaredConstructor().newInstance().greeting() == 'hello'
+ }
+
+ def "bean(Type) with no explicit name derives the JavaBeans-conventional property name, including for acronym-prefixed types"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class DecapitalizeFixtureBeans {
+ def beans = {
+ bean(String) {
+ 'unnamed'
+ }
+
+ bean(URLHelper) {
+ new URLHelper()
+ }
+ }
+ }
+
+ class URLHelper { }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "an ordinary type name is decapitalized the usual way"
+ fixtureBeans.getDeclaredMethod('string') != null
+
+ and: "a type whose name starts with two-or-more uppercase letters (an acronym) is left as-is, " +
+ "per java.beans.Introspector.decapitalize's convention - not naively lowercased to uRLHelper"
+ fixtureBeans.getDeclaredMethod('URLHelper') != null
+ }
+
+ def "bean(Type) with no factory closure compiles to a method that constructs the declared type"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BodylessFixtureBeans {
+ def beans = {
+ bean(Greeter)
+ bean('otherGreeter', Greeter)
+ }
+ }
+
+ class Greeter {
+ String greet() { 'hi' }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def derived = fixtureBeans.getDeclaredMethod('greeter')
+ def named = fixtureBeans.getDeclaredMethod('otherGreeter')
+
+ then: "the name is derived from the type, or taken from the explicit name"
+ derived.getAnnotation(Bean).value() == ['greeter'] as String[]
+ named.getAnnotation(Bean).value() == ['otherGreeter'] as String[]
+
+ and: "both take no parameters and return a new instance of the declared type"
+ derived.parameterCount == 0
+ named.parameterCount == 0
+
+ and:
+ def instance = fixtureBeans.getDeclaredConstructor().newInstance()
+ derived.invoke(instance).greet() == 'hi'
+ !derived.invoke(instance).is(derived.invoke(instance))
+ }
+
+ def "bean(Type) with no factory closure still accepts chained qualifiers"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BodylessQualifiedFixtureBeans {
+ def beans = {
+ bean(Widget).primary().lazy().conditionalOnMissingBean()
+ }
+ }
+
+ class Widget { }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def method = fixtureBeans.getDeclaredMethod('widget')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Primary)
+ method.isAnnotationPresent(Lazy)
+ method.isAnnotationPresent(ConditionalOnMissingBean)
+ method.parameterCount == 0
+ }
+
+ def "bean(Type) with no factory closure is rejected for a type that cannot be constructed"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class UninstantiableFixtureBeans {
+ def beans = {
+ bean(Runnable)
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ def e = thrown(Exception)
+ e.message.contains('cannot be done for an interface or abstract class')
+ }
+
+ def "compiles conditionalOnMissingBean(...) into a @ConditionalOnMissingBean annotation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('answer')
+
+ then:
+ method.returnType == Integer
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(ConditionalOnMissingBean)
+ method.getAnnotation(ConditionalOnMissingBean).value() as List == [Integer]
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().answer() == 42
+ }
+
+ def "conditionalOnMissingBean(...) accepts the annotation's named attributes"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.SearchStrategy
+
+ @GrailsBeans
+ @AutoConfiguration
+ class NamedAttributesFixture {
+ def beans = {
+ bean('greeting', String).conditionalOnMissingBean(name: 'greeting', search: SearchStrategy.CURRENT) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def annotation = fixtureBeans.getDeclaredMethod('greeting').getAnnotation(ConditionalOnMissingBean)
+
+ then:
+ annotation.name() == ['greeting'] as String[]
+ annotation.search() == SearchStrategy.CURRENT
+ annotation.value().length == 0
+ }
+
+ def "conditionalOnMissingBean(...) accepts positional types mixed with named attributes"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.SearchStrategy
+
+ @GrailsBeans
+ @AutoConfiguration
+ class MixedAttributesFixture {
+ def beans = {
+ bean('greeting', CharSequence).conditionalOnMissingBean(CharSequence, search: SearchStrategy.CURRENT) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def annotation = fixtureBeans.getDeclaredMethod('greeting').getAnnotation(ConditionalOnMissingBean)
+
+ then:
+ annotation.value() as List == [CharSequence]
+ annotation.search() == SearchStrategy.CURRENT
+ }
+
+ def "conditionalOnMissingBeanName(...) backs off by the bean's convention-derived name, stated once"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.SearchStrategy
+ import org.springframework.context.MessageSource
+ import org.springframework.context.support.StaticMessageSource
+
+ @GrailsBeans
+ @AutoConfiguration
+ class DerivedNameConditionFixture {
+ def beans = {
+ bean(MessageSource).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
+ new StaticMessageSource()
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def method = fixtureBeans.getDeclaredMethod('messageSource')
+ def annotation = method.getAnnotation(ConditionalOnMissingBean)
+
+ then: "the derived bean name feeds both @Bean and the condition - one statement of truth"
+ method.getAnnotation(Bean).value() == ['messageSource'] as String[]
+ annotation.name() == ['messageSource'] as String[]
+ annotation.search() == SearchStrategy.CURRENT
+ annotation.value().length == 0
+ }
+
+ def "conditionalOnMissingBeanName() uses an explicitly-supplied bean name, even a non-identifier one"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ExplicitNameConditionFixture {
+ def beans = {
+ bean('my-source', String).conditionalOnMissingBeanName() {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def annotation = fixtureBeans.getDeclaredMethod('string$0').getAnnotation(ConditionalOnMissingBean)
+
+ then:
+ annotation.name() == ['my-source'] as String[]
+ }
+
+ def "zero-argument conditionalOnMissingBean() compiles to a bare annotation, letting Spring infer the return type"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BareConditionFixture {
+ def beans = {
+ bean('greeting', String).conditionalOnMissingBean() {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def annotation = fixtureBeans.getDeclaredMethod('greeting').getAnnotation(ConditionalOnMissingBean)
+
+ then: "no members set - identical to writing bare @ConditionalOnMissingBean by hand"
+ annotation.value().length == 0
+ annotation.name().length == 0
+ annotation.type().length == 0
+ }
+
+ def "compiles primary() into a @Primary annotation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('primaryGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Primary)
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().primaryGreeting() == 'primary hello'
+ }
+
+ def "compiles lazy() into a @Lazy annotation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('lazyGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Lazy)
+ method.getAnnotation(Lazy).value()
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().lazyGreeting() == 'lazy hello'
+ }
+
+ def "compiles scope(...) into a @Scope annotation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('scopedGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Scope)
+ method.getAnnotation(Scope).value() == 'prototype'
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().scopedGreeting() == 'scoped hello'
+ }
+
+ def "chains primary(), lazy(), scope(...), and conditionalOnMissingBean(...) together on one bean"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('combinedGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Primary)
+ method.isAnnotationPresent(Lazy)
+ method.isAnnotationPresent(Scope)
+ method.getAnnotation(Scope).value() == 'prototype'
+ method.isAnnotationPresent(ConditionalOnMissingBean)
+ method.getAnnotation(ConditionalOnMissingBean).value() as List == [String]
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().combinedGreeting() == 'combined hello'
+ }
+
+ def "compiles annotate(Type, attr: value) into that annotation with its members set"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('orderedGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Order)
+ method.getAnnotation(Order).value() == 1
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().orderedGreeting() == 'ordered hello'
+ }
+
+ def "compiles annotate(Type) with no members into a bare annotation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('webOnlyGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(ConditionalOnWebApplication)
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().webOnlyGreeting() == 'web hello'
+ }
+
+ def "chains multiple different annotate(...) calls alongside a named qualifier"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('multiAnnotatedGreeting')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.isAnnotationPresent(Primary)
+ method.isAnnotationPresent(Order)
+ method.getAnnotation(Order).value() == 2
+ method.isAnnotationPresent(ConditionalOnWebApplication)
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().multiAnnotatedGreeting() == 'multi hello'
+ }
+
+ def "a non-identifier bean name combines with other qualifiers on the same bean"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.core.annotation.Order
+
+ @GrailsBeans
+ @AutoConfiguration
+ class CombinedNamedFixture {
+ def beans = {
+ bean('my-service', String).primary().annotate(Order, value: 1) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def method = fixtureBeans.getDeclaredMethod('string$0')
+
+ then:
+ method.isAnnotationPresent(Bean)
+ method.getAnnotation(Bean).value() == ['my-service'] as String[]
+ method.isAnnotationPresent(Primary)
+ method.isAnnotationPresent(Order)
+ method.getAnnotation(Order).value() == 1
+ }
+
+ def "a reserved-keyword bean name is a legal Spring bean name and gets a synthesized method name"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class KeywordBeanNameFixture {
+ def beans = {
+ bean('int', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "'int' can't be a Java method name, but it's a perfectly legal Spring bean name"
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['int'] as String[]
+ }
+
+ def "bean(...) with a non-identifier name falls back to a synthesized \$N method name"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class SynthesizedNameFixture {
+ def beans = {
+ bean('my-service', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def method = fixtureBeans.getDeclaredMethod('string$0')
+
+ then: "the @Bean annotation still carries the real (hyphenated) Spring bean name"
+ method.isAnnotationPresent(Bean)
+ method.getAnnotation(Bean).value() == ['my-service'] as String[]
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().'string$0'() == 'hello'
+ }
+
+ def "multiple beans of the same type with non-identifier names get distinct synthesized method names"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class MultipleSynthesizedNamesFixture {
+ def beans = {
+ bean('my-service', String) {
+ 'a'
+ }
+ bean('my-other-service', String) {
+ 'b'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then:
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['my-service'] as String[]
+ fixtureBeans.getDeclaredMethod('string$1').getAnnotation(Bean).value() == ['my-other-service'] as String[]
+ }
+
+ def "an arbitrary, not-even-identifier-like bean name also falls back to a synthesized method name"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class WeirdNameFixture {
+ def beans = {
+ bean('123 not valid!', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then:
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['123 not valid!'] as String[]
+ }
+
+ def "field(name, Type).annotate(...) declares a private annotated field"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def field = fixtureBeans.getDeclaredField('suffix')
+
+ then:
+ field.type == String
+ Modifier.isPrivate(field.modifiers)
+ field.isAnnotationPresent(Value)
+ field.getAnnotation(Value).value() == '${greeting.suffix:!!!}'
+ }
+
+ def "field(...).value(key, default) builds the @Value placeholder, accepting a bare constant key"() {
+ given: "keys given as a String literal, a BARE static-final constant reference (the shape a " +
+ "directly-written annotation value rejects), and an empty default"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ class ConfigKeys {
+ static final String ENCODING_KEY = 'app.encoding'
+ }
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ValueSugarFixture {
+ def beans = {
+ field('encoding', String).value(ConfigKeys.ENCODING_KEY, 'UTF-8')
+ field('cacheSeconds', int).value('app.cache.seconds', '5')
+ field('defaultLocale', String).value('app.default.locale', '')
+
+ bean('greeting', String) {
+ encoding
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> fixtureBeans = loader.loadClass('ValueSugarFixture')
+
+ then:
+ fixtureBeans.getDeclaredField('encoding').getAnnotation(Value).value() == '${app.encoding:UTF-8}'
+ fixtureBeans.getDeclaredField('cacheSeconds').getAnnotation(Value).value() == '${app.cache.seconds:5}'
+ fixtureBeans.getDeclaredField('defaultLocale').getAnnotation(Value).value() == '${app.default.locale:}'
+ }
+
+ def "field(...).value(...) with a bare constant key folds under @CompileStatic on a Plugin subclass"() {
+ given: "the exact shape that used to fail: the static compiler rewrites '+' into .plus() calls " +
+ "before Groovy's annotation folding runs, so the placeholder must be folded at transform time"
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ class StaticConfigKeys {
+ static final String ENCODING_KEY = 'app.encoding'
+ }
+
+ @CompileStatic
+ @GrailsBeans
+ @AutoConfiguration
+ class StaticValuePlugin extends Plugin {
+ def beans = {
+ field('encoding', String).value(StaticConfigKeys.ENCODING_KEY, 'UTF-8')
+
+ bean('greeting', String) {
+ encoding
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> autoConfigClass = loader.loadClass('StaticValuePluginAutoConfiguration')
+
+ then:
+ autoConfigClass.getDeclaredField('encoding').getAnnotation(Value).value() == '${app.encoding:UTF-8}'
+ }
+
+ def "field(...).value(placeholder) passes a string already carrying a placeholder or SpEL expression through verbatim"() {
+ given: "a complete placeholder, a SpEL expression, and a mixed literal with an embedded placeholder"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class VerbatimValueFixture {
+ def beans = {
+ field('encoding', String).value('${app.encoding:UTF-8}')
+ field('poolSize', int).value('#{T(java.lang.Runtime).getRuntime().availableProcessors()}')
+ field('endpoint', String).value('http://${app.host}/api')
+
+ bean('greeting', String) {
+ encoding
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then:
+ fixtureBeans.getDeclaredField('encoding').getAnnotation(Value).value() == '${app.encoding:UTF-8}'
+ fixtureBeans.getDeclaredField('poolSize').getAnnotation(Value).value() ==
+ '#{T(java.lang.Runtime).getRuntime().availableProcessors()}'
+ fixtureBeans.getDeclaredField('endpoint').getAnnotation(Value).value() == 'http://${app.host}/api'
+ }
+
+ def "field(...).value(key) auto-wraps a bare config key into a placeholder"() {
+ given: "a bare key - which can only ever mean 'inject this property', never its literal text"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BareKeyValueFixture {
+ def beans = {
+ field('cacheUrls', Boolean).value('grails.web.linkGenerator.useCache')
+
+ bean('greeting', String) {
+ String.valueOf(cacheUrls)
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then:
+ fixtureBeans.getDeclaredField('cacheUrls').getAnnotation(Value).value() ==
+ '${grails.web.linkGenerator.useCache}'
+ }
+
+ def "annotate(...) attribute values may reference a shared String constant, not just a literal"() {
+ given: "an @Value placeholder built the same way the real I18nGrailsPlugin conversion builds " +
+ "its property keys from grails.config.Settings, proving .annotate(...)'s member values " +
+ "aren't restricted to literals the way bean(name, Type)'s own name is"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.config.Settings
+ import org.springframework.beans.factory.annotation.Value
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class SharedConstantFixture {
+ def beans = {
+ field('localeResolverType', String).annotate(Value, value: '${' + Settings.I18N_LOCALE_RESOLVER + ':session}')
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+ def field = fixture.getDeclaredField('localeResolverType')
+
+ then:
+ field.getAnnotation(Value).value() == '${grails.i18n.localeResolver:session}'
+ }
+
+ def "method(name, Type) declares a private helper method usable from bean(...) and field(...)"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def helperMethod = fixtureBeans.getDeclaredMethod('yell', String)
+
+ then: "it is a plain private member, not itself a @Bean"
+ helperMethod.returnType == String
+ Modifier.isPrivate(helperMethod.modifiers)
+ !helperMethod.isAnnotationPresent(Bean)
+
+ when: "a bean() closure calls it, and it reads a sibling field(...)"
+ def instance = fixtureBeans.getDeclaredConstructor().newInstance()
+ def suffixField = fixtureBeans.getDeclaredField('suffix')
+ suffixField.accessible = true
+ suffixField.set(instance, '!!!')
+
+ then:
+ instance.yelledGreeting() == 'HELLO!!!'
+ }
+
+ def "closure parameters become method parameters for constructor-style injection"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ when:
+ def method = fixtureBeans.getDeclaredMethod('shout', String)
+
+ then:
+ method.returnType == String
+
+ and:
+ fixtureBeans.getDeclaredConstructor().newInstance().shout('hi') == 'HI'
+ }
+
+ def "a @Qualifier on a closure parameter carries through to the generated method's parameter"() {
+ given: "Groovy captures annotations on closure parameters at parse time, and the DSL " +
+ "reuses the closure's own Parameter AST nodes directly on the generated method, " +
+ "so no dedicated DSL syntax is needed for this - it already works"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.beans.factory.annotation.Qualifier
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class QualifiedParamFixture {
+ def beans = {
+ bean('special', String) {
+ 'special value'
+ }
+
+ bean('shout', String) { @Qualifier('special') String input ->
+ input.toUpperCase()
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+ def method = fixture.getDeclaredMethod('shout', String)
+ Annotation[] paramAnnotations = method.parameterAnnotations[0]
+
+ then:
+ paramAnnotations.any { it instanceof Qualifier && it.value() == 'special' }
+ }
+
+ def "the beans closure property does not survive compilation"() {
+ given:
+ Class> fixtureBeans = compile()
+
+ expect:
+ fixtureBeans.declaredFields*.name == fixtureBeans.declaredFields*.name.findAll { it != 'beans' }
+ fixtureBeans.declaredMethods*.name.every { !(it in ['getBeans', 'setBeans']) }
+ }
+
+ def "requires a beans property at all"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class NoBeansProperty {
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains("requires a 'beans' property initialised to a closure")
+ }
+
+ def "the beans property must be initialised to a closure"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BeansNotAClosure {
+ def beans = 'not a closure'
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains("'beans' must be initialised to a closure")
+ }
+
+ @Unroll
+ def "rejects a malformed beans statement: #description"() {
+ given:
+ String source = """
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.beans.factory.annotation.Value
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+ import org.springframework.context.annotation.Primary
+ import org.springframework.core.annotation.Order
+
+ @GrailsBeans
+ @AutoConfiguration
+ class MalformedBeans {
+ def beans = {
+ $statement
+ }
+ }
+ """
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains(expectedMessage)
+
+ where:
+ description | statement | expectedMessage
+ 'a statement that is not a method call' | 'def notACall = 1' | "Each 'beans' statement must be a bean(...), field(...), or method(...) call"
+ 'a method call that is not bean(...)' | "someOtherMethod(String) { 'x' }" | 'Expected bean(["name", ] Type) { ... }'
+ 'bean(...) with a name but no type' | "bean('NotAType') { 'x' }" | 'bean(...) requires a type'
+ 'bean(...) with the name and type in the old order' | "bean(String, 'x') { 'y' }" | 'takes the name before the type'
+ 'bean(...) with no factory closure on an uninstantiable type' | "bean('x', Runnable)" | 'cannot be done for an interface or abstract class'
+ 'conditionalOnMissingBean(...) with a non-type argument' |
+ "bean('x', String).conditionalOnMissingBean('not a type') { 'x' }" |
+ 'conditionalOnMissingBean(...) arguments must be types'
+ 'conditionalOnMissingBean(...) with types given both positionally and via value:' |
+ "bean('x', String).conditionalOnMissingBean(String, value: Integer) { 'y' }" |
+ 'use one or the other'
+ 'bean(...) with a non-constant name argument' | "bean(someVariable, String) { 'x' }" | 'requires the name to be a String literal'
+ 'bean(...) with a non-String constant name' | 'bean(42, String) { \'x\' }' | 'requires the name to be a String literal'
+ 'bean(...) with an unexpected third argument' | "bean('x', String, 'unexpected') { 'y' }" | 'requires a type, optionally preceded by a name'
+ 'bean(...) with an empty explicit name' | "bean('', String) { 'y' }" | 'requires a non-blank name'
+ 'bean(...) with a whitespace-only explicit name' | "bean(' ', String) { 'y' }" | 'requires a non-blank name'
+ 'field(...) with a reserved-keyword name' | "field('class', String)" | 'is not a valid name'
+ 'method(...) with a reserved-keyword name' | "method('return', String) { 'y' }" | 'is not a valid name'
+ 'an unrecognised qualifier chained after bean(...)' | "bean('x', String).unknownQualifier() { 'y' }" | 'Expected bean(["name", ] Type) { ... }'
+ 'the same qualifier chained twice' | "bean('x', String).primary().primary() { 'y' }" | 'may only be chained once'
+ 'primary() given an argument' | "bean('x', String).primary('oops') { 'y' }" | '.primary() takes no arguments'
+ 'lazy() given an argument' | "bean('x', String).lazy(true) { 'y' }" | '.lazy() takes no arguments'
+ 'staticMethod() given an argument' | "bean('x', String).staticMethod(true) { 'y' }" | '.staticMethod() takes no arguments'
+ 'staticMethod() chained twice' | "bean('x', String).staticMethod().staticMethod() { 'y' }" | 'may only be chained once'
+ 'staticMethod() chained onto field(...)' | "field('x', String).staticMethod()" | 'cannot be chained onto field(...)'
+ 'staticMethod() chained onto method(...)' | "method('x', String).staticMethod() { 'y' }" | 'cannot be chained onto method(...)'
+ 'scope(...) with no argument' | "bean('x', String).scope() { 'y' }" | '.scope(...) requires exactly one non-empty String argument'
+ 'scope(...) with a non-String argument' | "bean('x', String).scope(42) { 'y' }" | '.scope(...) requires exactly one non-empty String argument'
+ 'annotate(...) with no arguments' | "bean('x', String).annotate() { 'y' }" | 'requires an annotation type'
+ 'annotate(...) with a non-type argument' | "bean('x', String).annotate('NotAType') { 'y' }" | 'requires an annotation type'
+ 'annotate(...) with a non-annotation type' | "bean('x', String).annotate(String) { 'y' }" | 'is not an annotation type'
+ 'the same annotation attached twice via annotate(...)' |
+ "bean('x', String).annotate(Order, value: 1).annotate(Order, value: 2) { 'y' }" |
+ 'already attached'
+ 'annotate(...) colliding with a named qualifier' | "bean('x', String).primary().annotate(Primary) { 'y' }" | 'already attached'
+ 'field(...) with a name but no type' | "field('NotAType')" | 'field(...) requires a type'
+ 'field(...) chained with a bean-only qualifier' | "field('x', String).primary()" | 'cannot be chained onto field(...)'
+ '.value(...) chained onto bean(...)' | "bean('x', String).value('k', 'd') { 'y' }" | 'cannot be chained onto bean(...)'
+ '.value(...) chained onto method(...)' | "method('x', String).value('k', 'd') { 'y' }" | 'cannot be chained onto method(...)'
+ '.value(...) with no arguments' | "field('x', String).value()" | 'requires a config key'
+ '.value(...) with too many arguments' | "field('x', String).value('k', 'd', 'extra')" | 'requires a config key'
+ '.value(...) with an empty single-argument key' | "field('x', String).value('')" | 'requires a non-blank config key'
+ '.value(...) with a whitespace-only single-argument key' | "field('x', String).value(' ')" | 'requires a non-blank config key'
+ '.value(key, default) with a blank key' | "field('x', String).value('', 'fallback')" | 'requires a non-blank config key'
+ '.value(key, default) with a whitespace-only key' | "field('x', String).value(' ', 'fallback')" | 'requires a non-blank config key'
+ '.value(...) chained twice' | "field('x', String).value('a', 'b').value('c', 'd')" | 'may only be chained once'
+ '.value(...) combined with .annotate(Value, ...)' | "field('x', String).value('k', 'd').annotate(Value, value: 'v')" | 'already attached'
+ 'conditionalOnMissingBeanName(...) given a name: attribute' |
+ "bean('x', String).conditionalOnMissingBeanName(name: 'other') { 'y' }" | 'sets name automatically'
+ 'conditionalOnMissingBeanName(...) given a value: attribute' |
+ "bean('x', String).conditionalOnMissingBeanName(value: String) { 'y' }" | 'sets name automatically'
+ 'conditionalOnMissingBeanName(...) given a positional type' |
+ "bean('x', String).conditionalOnMissingBeanName(String) { 'y' }" | 'takes only named attributes'
+ 'conditionalOnMissingBeanName(...) chained onto field(...)' |
+ "field('x', String).conditionalOnMissingBeanName()" | 'cannot be chained onto field(...)'
+ 'conditionalOnMissingBeanName(...) combined with conditionalOnMissingBean(...)' |
+ "bean('x', String).conditionalOnMissingBean(String).conditionalOnMissingBeanName() { 'y' }" | 'already attached'
+ 'method(...) without a body closure' | "method('x', String)" | 'method(...) must end with a body closure'
+ 'method(...) chained with a bean-only qualifier' | "method('x', String).conditionalOnMissingBean(String) { 'y' }" | 'cannot be chained onto method(...)'
+ 'two bean(...) statements sharing the same explicit name' |
+ "bean('x', String) { 'a' }; bean('x', Integer) { 1 }" | 'is already used as the Spring bean name'
+ 'two bean(...) statements sharing the same non-identifier Spring bean name' |
+ "bean('my-service', String) { 'x' }; bean('my-service', Integer) { 1 }" |
+ 'is already used as the Spring bean name'
+ 'same-named bean(...) statements where only one carries a discriminating condition' |
+ "bean('x', String).annotate(ConditionalOnWebApplication) { 'a' }; bean('x', Integer) { 1 }" |
+ 'carries its own discriminating condition'
+ 'same-named bean(...) statements guarded only by the shared-name back-off' |
+ "bean('x', String).conditionalOnMissingBeanName() { 'a' }; bean('x', Integer).conditionalOnMissingBeanName() { 1 }" |
+ 'carries its own discriminating condition'
+ 'same-named bean(...) statements guarded only by the bare return-type back-off' |
+ "bean('x', String).conditionalOnMissingBean() { 'a' }; bean('x', Integer).conditionalOnMissingBean() { 1 }" |
+ 'carries its own discriminating condition'
+ 'a field(...) and a method(...) sharing the same name' |
+ "field('x', String); method('x', Integer) { 1 }" | 'is already used by another'
+ 'the removed .methodName(...) qualifier' | "bean('x', String).methodName('y') { 'z' }" | 'Expected bean(["name", ] Type) { ... }'
+ }
+
+ def "a bean(...).staticMethod() compiles to a static @Bean factory method"() {
+ given: "the shape Spring recommends for BeanFactoryPostProcessor/BeanPostProcessor beans, " +
+ "which must be creatable without instantiating their declaring configuration class"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class StaticBeanFixture {
+ def beans = {
+ bean('staticGreeting', String).staticMethod().conditionalOnMissingBean(String) {
+ 'created without an instance'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+ def method = fixture.getDeclaredMethod('staticGreeting')
+
+ then:
+ Modifier.isStatic(method.modifiers)
+ method.getAnnotation(Bean).value() == ['staticGreeting'] as String[]
+ method.isAnnotationPresent(ConditionalOnMissingBean)
+
+ and: "invocable with no instance, the way Spring invokes a static @Bean method"
+ method.invoke(null) == 'created without an instance'
+ }
+
+ def "a static bean body cannot reference instance field(...) state under @CompileStatic"() {
+ given: "field(...) members are instance members of the generated class, out of reach of a static method"
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @CompileStatic
+ @GrailsBeans
+ @AutoConfiguration
+ class StaticBeanFieldFixture {
+ def beans = {
+ field('suffix', String).value('app.suffix', '!')
+
+ bean('greeting', String).staticMethod() {
+ 'hello' + suffix
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('suffix')
+ }
+
+ private static final String SHARED_NAME_FIXTURE = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+
+ @GrailsBeans
+ @AutoConfiguration
+ class SharedNameFixtureBeans {
+ def beans = {
+ bean('converter', String).conditionalOnMissingBeanName().annotate(ConditionalOnProperty, name: 'app.converter', havingValue: 'camelCase', matchIfMissing: true) {
+ 'camel case converter'
+ }
+
+ bean('converter', String).conditionalOnMissingBeanName().annotate(ConditionalOnProperty, name: 'app.converter', havingValue: 'hyphenated') {
+ 'hyphenated converter'
+ }
+ }
+ }
+ '''
+
+ def "one bean name may be declared by several bean(...) statements when each carries its own discriminating condition"() {
+ given: "the standard autoconfiguration pattern for mutually exclusive variants of one bean - " +
+ "the exact shape of UrlMappingsAutoConfiguration's two grailsUrlConverter beans"
+ Class> fixture = compile(SHARED_NAME_FIXTURE)
+
+ when: "the first declaration claims the bean name as its method name and the second synthesizes"
+ def first = fixture.getDeclaredMethod('converter')
+ def second = fixture.getDeclaredMethod('string$0')
+
+ then: "both register under the same Spring bean name"
+ first.getAnnotation(Bean).value() == ['converter'] as String[]
+ second.getAnnotation(Bean).value() == ['converter'] as String[]
+
+ and: "each keeps its own guards"
+ first.getAnnotation(ConditionalOnProperty).havingValue() == 'camelCase'
+ first.getAnnotation(ConditionalOnProperty).matchIfMissing()
+ second.getAnnotation(ConditionalOnProperty).havingValue() == 'hyphenated'
+ !second.getAnnotation(ConditionalOnProperty).matchIfMissing()
+ first.getAnnotation(ConditionalOnMissingBean).name() == ['converter'] as String[]
+ second.getAnnotation(ConditionalOnMissingBean).name() == ['converter'] as String[]
+ }
+
+ @Unroll
+ def "at runtime the discriminating conditions select which of the same-named beans registers: #description"() {
+ given:
+ Class> fixture = compile(SHARED_NAME_FIXTURE)
+ def context = new AnnotationConfigApplicationContext()
+ if (configuredValue != null) {
+ context.environment.propertySources.addFirst(
+ new MapPropertySource('test', ['app.converter': configuredValue]))
+ }
+ context.register(fixture)
+
+ when:
+ context.refresh()
+
+ then:
+ context.getBean('converter') == expectedBean
+
+ cleanup:
+ context.close()
+
+ where:
+ description | configuredValue | expectedBean
+ 'the matchIfMissing default' | null | 'camel case converter'
+ 'an explicit property value' | 'hyphenated' | 'hyphenated converter'
+ }
+
+ def "the standalone form works on an application class: a GrailsAutoConfiguration subclass, with no @AutoConfiguration"() {
+ given: "the shape of a Grails app's Application class, which needs no @AutoConfiguration and no " +
+ "AutoConfiguration.imports registration - Spring Boot reads @Bean methods directly off " +
+ "the application class it is launched with"
+ String source = '''
+ import grails.boot.config.GrailsAutoConfiguration
+ import grails.compiler.beans.GrailsBeans
+
+ @GrailsBeans
+ class Application extends GrailsAutoConfiguration {
+ def beans = {
+ bean('applicationGreeting', String) {
+ 'hello from the application class'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> application = compile(source)
+ def method = application.getDeclaredMethod('applicationGreeting')
+
+ then: "the @Bean factory method landed on the application class itself"
+ method.getAnnotation(Bean).value() == ['applicationGreeting'] as String[]
+ method.invoke(application.getDeclaredConstructor().newInstance()) == 'hello from the application class'
+
+ and: "no beans closure survives into the compiled application class"
+ application.declaredFields.every { it.name != 'beans' }
+ }
+
+ def "@Bean methods compiled onto an unannotated class register when the class is a configuration source"() {
+ given: "no @AutoConfiguration and no @Configuration - just a class registered as a source, " +
+ "the same way Spring Boot processes the application class (booting a full Grails " +
+ "GrailsAutoConfiguration subclass needs the framework runtime, so the source-class " +
+ "mechanism itself is exercised on a plain class here)"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+
+ @GrailsBeans
+ class PlainSourceBeans {
+ def beans = {
+ bean('plainGreeting', String) {
+ 'hello from a plain source class'
+ }
+ }
+ }
+ '''
+ Class> plainSource = compile(source)
+
+ when:
+ def context = new AnnotationConfigApplicationContext()
+ context.register(plainSource)
+ context.refresh()
+
+ then:
+ context.getBean('plainGreeting') == 'hello from a plain source class'
+
+ cleanup:
+ context?.close()
+ }
+
+ def "a synthesized method name that would collide with a pre-existing field(...) name skips forward to the next free slot"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class SyntheticSkipForwardFixture {
+ def beans = {
+ field('string$0', String)
+
+ bean('my-service', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "string\$0 was already taken by the field, so the bean skips forward to string\$1"
+ fixtureBeans.getDeclaredField('string$0') != null
+ fixtureBeans.getDeclaredMethod('string$1').getAnnotation(Bean).value() == ['my-service'] as String[]
+ }
+
+ def "a bean whose valid-identifier name collides with a declared field gets a synthesized method name instead of an error"() {
+ given: "a private field and a Spring bean legitimately sharing the name 'config'"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class FieldBeanNameOverlapFixture {
+ def beans = {
+ field('config', String)
+
+ bean('config', Integer) {
+ 42
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "the Java member namespace resolves via synthesis - method names are an implementation detail"
+ fixtureBeans.getDeclaredField('config') != null
+ fixtureBeans.getDeclaredMethod('integer$0').getAnnotation(Bean).value() == ['config'] as String[]
+ }
+
+ def "declaration order does not matter: a field declared after a same-named bean still wins the member name"() {
+ given: "the same DSL as the field-first overlap test, with the statements reversed"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ReversedFieldBeanNameOverlapFixture {
+ def beans = {
+ bean('config', Integer) {
+ 42
+ }
+
+ field('config', String)
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "identical outcome to declaring the field first - reordering must never change validity"
+ fixtureBeans.getDeclaredField('config') != null
+ fixtureBeans.getDeclaredMethod('integer$0').getAnnotation(Bean).value() == ['config'] as String[]
+ }
+
+ def "declaration order does not matter: a helper method declared after a same-named bean still wins the member name"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ReversedMethodBeanNameOverlapFixture {
+ def beans = {
+ bean('helper', Integer) {
+ 42
+ }
+
+ method('helper', String) {
+ 'x'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then:
+ fixtureBeans.getDeclaredMethod('helper').returnType == String
+ fixtureBeans.getDeclaredMethod('integer$0').getAnnotation(Bean).value() == ['helper'] as String[]
+ }
+
+ def "a bean colliding with a method the standalone class already declares gets a synthesized method name"() {
+ given: "a hand-written greeting() method outside the DSL, and a bean named 'greeting'"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ExistingMemberFixture {
+ String greeting() { 'existing' }
+
+ def beans = {
+ bean('greeting', String) {
+ 'bean'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "the hand-written method is untouched"
+ fixtureBeans.getDeclaredConstructor().newInstance().greeting() == 'existing'
+ !fixtureBeans.getDeclaredMethod('greeting').isAnnotationPresent(Bean)
+
+ and: "the bean method synthesized around it, keeping the Spring name"
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['greeting'] as String[]
+ fixtureBeans.getDeclaredConstructor().newInstance().'string$0'() == 'bean'
+ }
+
+ def "a bean named after an inherited Object method does not override it"() {
+ given: "a bean whose Spring name is 'toString' - legal for Spring, lethal as a method override"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ToStringBeanFixture {
+ def beans = {
+ bean('toString', String) {
+ 'the bean'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+
+ then: "toString() still behaves as Object's, not as the bean factory"
+ fixtureBeans.getDeclaredConstructor().newInstance().toString() != 'the bean'
+
+ and: "the bean method synthesized instead"
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['toString'] as String[]
+ }
+
+ def "a bean named after an inherited Object method on the Plugin-subclass sibling does not override it either"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ToStringBeanPlugin extends Plugin {
+ def beans = {
+ bean('toString', String) {
+ 'the bean'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> autoConfigClass = loader.loadClass('ToStringBeanPluginAutoConfiguration')
+
+ then:
+ autoConfigClass.getDeclaredConstructor().newInstance().toString() != 'the bean'
+ autoConfigClass.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['toString'] as String[]
+ }
+
+ def "a bean named after a default method from a directly implemented interface does not override it"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ interface Described {
+ default String description() { 'interface behavior' }
+ }
+
+ @GrailsBeans
+ @AutoConfiguration
+ class InterfaceDefaultFixture implements Described {
+ def beans = {
+ bean('description', String) {
+ 'bean value'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> fixtureBeans = loader.loadClass('InterfaceDefaultFixture')
+
+ then: "the interface's default behavior is preserved"
+ fixtureBeans.getDeclaredConstructor().newInstance().description() == 'interface behavior'
+
+ and: "the bean method synthesized around it, keeping the Spring name"
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['description'] as String[]
+ }
+
+ def "a bean named after a default method from a parent interface further up the graph does not override it either"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ interface Labeled {
+ default String label() { 'parent interface behavior' }
+ }
+
+ interface ChildLabeled extends Labeled { }
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ParentInterfaceFixture implements ChildLabeled {
+ def beans = {
+ bean('label', String) {
+ 'bean value'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> fixtureBeans = loader.loadClass('ParentInterfaceFixture')
+
+ then:
+ fixtureBeans.getDeclaredConstructor().newInstance().label() == 'parent interface behavior'
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['label'] as String[]
+ }
+
+ def "beans named after a property's accessors do not become the accessors"() {
+ given: "a Groovy property whose getter/setter are synthesized only at class generation, after this transform"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class PropertyAccessorFixture {
+ String description
+
+ def beans = {
+ bean('getDescription', String) {
+ 'bean value'
+ }
+
+ bean('setDescription', String) { String value ->
+ value
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def instance = fixtureBeans.getDeclaredConstructor().newInstance()
+ instance.description = 'set value'
+
+ then: "the property reads and writes normally - neither accessor was displaced by a bean method"
+ instance.description == 'set value'
+
+ and: "both beans synthesized around the reserved accessor names, keeping their Spring names"
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['getDescription'] as String[]
+ fixtureBeans.getDeclaredMethod('string$1', String).getAnnotation(Bean).value() == ['setDescription'] as String[]
+ }
+
+ def "a bean named after a boolean property's is-accessor does not become the accessor"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class BooleanAccessorFixture {
+ boolean enabled
+
+ def beans = {
+ bean('isEnabled', String) {
+ 'bean value'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixtureBeans = compile(source)
+ def instance = fixtureBeans.getDeclaredConstructor().newInstance()
+ instance.enabled = true
+
+ then:
+ instance.isEnabled() == true
+ fixtureBeans.getDeclaredMethod('string$0').getAnnotation(Bean).value() == ['isEnabled'] as String[]
+ }
+
+ def "an explicit method(...) name colliding with a method the standalone class already declares is a compile error"() {
+ given: "method(...) declares a real private member, so unlike a bean it cannot silently adapt"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class ExistingMemberClashFixture {
+ String helper() { 'existing' }
+
+ def beans = {
+ method('helper', String) {
+ 'duplicate'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('is already used by another')
+ }
+
+ private static final String FIXTURE_PLUGIN = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+ import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+ import org.springframework.context.annotation.PropertySource
+
+ @GrailsBeans
+ @AutoConfiguration(before = [MessageSourceAutoConfiguration])
+ @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+ @PropertySource('classpath:fixture-plugin.properties')
+ class FixturePlugin extends Plugin {
+
+ String version = '1.0'
+
+ def beans = {
+ bean('greeting', String) {
+ 'hello from plugin'
+ }
+ }
+
+ String stillHere() {
+ 'plugin lifecycle members are untouched'
+ }
+ }
+ '''
+
+ def "applying @GrailsBeans to a Plugin subclass generates a sibling AutoConfiguration class"() {
+ given:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(FIXTURE_PLUGIN)
+
+ when:
+ Class> pluginClass = loader.loadClass('FixturePlugin')
+ Class> autoConfigClass = loader.loadClass('FixturePluginAutoConfiguration')
+
+ then: "the plugin class keeps its own identity and members, minus the DSL"
+ Plugin.isAssignableFrom(pluginClass)
+ pluginClass.getDeclaredMethod('stillHere').invoke(pluginClass.getDeclaredConstructor().newInstance()) ==
+ 'plugin lifecycle members are untouched'
+ pluginClass.declaredFields*.name.every { it != 'beans' }
+ !pluginClass.isAnnotationPresent(AutoConfiguration)
+
+ and: "@ConditionalOnWebApplication and @PropertySource are meaningless on a Plugin subclass " +
+ "(Spring never processes it as a bean), so they move too, not just @AutoConfiguration"
+ !pluginClass.isAnnotationPresent(ConditionalOnWebApplication)
+ !pluginClass.isAnnotationPresent(PropertySource)
+
+ and: "the generated sibling carries the compiled bean and every moved annotation"
+ autoConfigClass.isAnnotationPresent(AutoConfiguration)
+ autoConfigClass.getAnnotation(AutoConfiguration).before().toList() == [MessageSourceAutoConfiguration]
+ autoConfigClass.isAnnotationPresent(ConditionalOnWebApplication)
+ autoConfigClass.getAnnotation(ConditionalOnWebApplication).type() == ConditionalOnWebApplication.Type.SERVLET
+ autoConfigClass.isAnnotationPresent(PropertySource)
+ autoConfigClass.getAnnotation(PropertySource).value().toList() == ['classpath:fixture-plugin.properties']
+ autoConfigClass.getDeclaredMethod('greeting').isAnnotationPresent(Bean)
+ autoConfigClass.getDeclaredConstructor().newInstance().greeting() == 'hello from plugin'
+ }
+
+ def "AutoConfigureOrder, AutoConfigureBefore, AutoConfigureAfter, ImportAutoConfiguration, and EnableConfigurationProperties all move to the sibling"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.AutoConfigureAfter
+ import org.springframework.boot.autoconfigure.AutoConfigureBefore
+ import org.springframework.boot.autoconfigure.AutoConfigureOrder
+ import org.springframework.boot.autoconfigure.ImportAutoConfiguration
+ import org.springframework.boot.context.properties.EnableConfigurationProperties
+
+ class BeforeTarget { }
+ class AfterTarget { }
+ class ImportedAutoConfig { }
+ class SomeProperties { }
+
+ @GrailsBeans
+ @AutoConfiguration
+ @AutoConfigureOrder(1)
+ @AutoConfigureBefore(BeforeTarget)
+ @AutoConfigureAfter(AfterTarget)
+ @ImportAutoConfiguration(ImportedAutoConfig)
+ @EnableConfigurationProperties(SomeProperties)
+ class ManyAnnotationsPlugin extends Plugin {
+ def beans = {
+ bean("greeting", String) {
+ "hello"
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> pluginClass = loader.loadClass('ManyAnnotationsPlugin')
+ Class> autoConfigClass = loader.loadClass('ManyAnnotationsPluginAutoConfiguration')
+
+ then: "none of them are left behind on the Plugin class Spring never processes as a bean"
+ !pluginClass.isAnnotationPresent(AutoConfigureOrder)
+ !pluginClass.isAnnotationPresent(AutoConfigureBefore)
+ !pluginClass.isAnnotationPresent(AutoConfigureAfter)
+ !pluginClass.isAnnotationPresent(ImportAutoConfiguration)
+ !pluginClass.isAnnotationPresent(EnableConfigurationProperties)
+
+ and: "all of them moved to the sibling"
+ autoConfigClass.isAnnotationPresent(AutoConfigureOrder)
+ autoConfigClass.getAnnotation(AutoConfigureOrder).value() == 1
+ autoConfigClass.isAnnotationPresent(AutoConfigureBefore)
+ autoConfigClass.isAnnotationPresent(AutoConfigureAfter)
+ autoConfigClass.isAnnotationPresent(ImportAutoConfiguration)
+ autoConfigClass.isAnnotationPresent(EnableConfigurationProperties)
+ }
+
+ def "a composed condition/import annotation - meta-annotated with an existing @ConditionalOnXxx or @Import, not directly - also moves to the sibling"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+ import org.springframework.context.annotation.Import
+ import java.lang.annotation.ElementType
+ import java.lang.annotation.Retention
+ import java.lang.annotation.RetentionPolicy
+ import java.lang.annotation.Target
+
+ class ImportedConfig { }
+
+ @Target(ElementType.TYPE)
+ @Retention(RetentionPolicy.RUNTIME)
+ @ConditionalOnProperty(prefix = "feature", name = "enabled")
+ @interface ConditionalOnFeature { }
+
+ @Target(ElementType.TYPE)
+ @Retention(RetentionPolicy.RUNTIME)
+ @Import(ImportedConfig)
+ @interface EnableImportedConfig { }
+
+ @GrailsBeans
+ @AutoConfiguration
+ @ConditionalOnFeature
+ @EnableImportedConfig
+ class ComposedAnnotationPlugin extends Plugin {
+ def beans = {
+ bean("greeting", String) {
+ "hello"
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> pluginClass = loader.loadClass('ComposedAnnotationPlugin')
+ Class> autoConfigClass = loader.loadClass('ComposedAnnotationPluginAutoConfiguration')
+ Class> conditionalOnFeature = loader.loadClass('ConditionalOnFeature')
+ Class> enableImportedConfig = loader.loadClass('EnableImportedConfig')
+
+ then: "neither composed annotation is left behind on the Plugin class Spring never processes as a bean"
+ !pluginClass.isAnnotationPresent(conditionalOnFeature)
+ !pluginClass.isAnnotationPresent(enableImportedConfig)
+
+ and: "both moved to the sibling, found via their meta-annotations rather than by exact name"
+ autoConfigClass.isAnnotationPresent(conditionalOnFeature)
+ autoConfigClass.isAnnotationPresent(enableImportedConfig)
+ }
+
+ def "@ImportResource on a Plugin subclass moves to the sibling, matching @Import's treatment"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.context.annotation.ImportResource
+
+ @GrailsBeans
+ @AutoConfiguration
+ @ImportResource('classpath:foo.xml')
+ class ImportResourcePlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> pluginClass = loader.loadClass('ImportResourcePlugin')
+ Class> autoConfigClass = loader.loadClass('ImportResourcePluginAutoConfiguration')
+
+ then: "not left behind on the Plugin class Spring never processes as a bean"
+ !pluginClass.isAnnotationPresent(ImportResource)
+
+ and: "moved to the sibling"
+ autoConfigClass.isAnnotationPresent(ImportResource)
+ autoConfigClass.getAnnotation(ImportResource).value() == ['classpath:foo.xml'] as String[]
+ }
+
+ def "@ComponentScan and an explicit @PropertySources container also move to the sibling"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.context.annotation.ComponentScan
+ import org.springframework.context.annotation.PropertySource
+ import org.springframework.context.annotation.PropertySources
+
+ @GrailsBeans
+ @AutoConfiguration
+ @ComponentScan('com.example.scanned')
+ @PropertySources([
+ @PropertySource('classpath:one.properties'),
+ @PropertySource('classpath:two.properties')
+ ])
+ class ScanningPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+ Class> pluginClass = loader.loadClass('ScanningPlugin')
+ Class> autoConfigClass = loader.loadClass('ScanningPluginAutoConfiguration')
+
+ then: "neither is left behind on the Plugin class Spring never processes as a bean"
+ !pluginClass.isAnnotationPresent(ComponentScan)
+ !pluginClass.isAnnotationPresent(PropertySources)
+
+ and: "both moved to the sibling - @PropertySources matters because the repeatable-container " +
+ "form would otherwise be treated differently from writing @PropertySource twice"
+ autoConfigClass.isAnnotationPresent(ComponentScan)
+ autoConfigClass.getAnnotation(ComponentScan).value() == ['com.example.scanned'] as String[]
+ autoConfigClass.isAnnotationPresent(PropertySources)
+ autoConfigClass.getAnnotation(PropertySources).value()*.value()*.toList().flatten() ==
+ ['classpath:one.properties', 'classpath:two.properties']
+ }
+
+ def "moveAnnotations moves an annotation the transform has no automatic knowledge of; without it the annotation stays put"() {
+ given: "a marker annotation meta-annotated with nothing the transform recognises"
+ String sourceTemplate = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import java.lang.annotation.ElementType
+ import java.lang.annotation.Retention
+ import java.lang.annotation.RetentionPolicy
+ import java.lang.annotation.Target
+
+ @Target(ElementType.TYPE)
+ @Retention(RetentionPolicy.RUNTIME)
+ @interface VendorMarker { }
+
+ %s
+ @AutoConfiguration
+ @VendorMarker
+ class VendorPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when: "compiled WITHOUT moveAnnotations"
+ GroovyClassLoader strandedLoader = new GroovyClassLoader(getClass().classLoader)
+ strandedLoader.parseClass(String.format(sourceTemplate, '@GrailsBeans'))
+
+ then: "the marker stays on the plugin class - the automatic set can't know about it"
+ strandedLoader.loadClass('VendorPlugin').annotations*.annotationType()*.simpleName.contains('VendorMarker')
+ !strandedLoader.loadClass('VendorPluginAutoConfiguration').annotations*.annotationType()*.simpleName.contains('VendorMarker')
+
+ when: "compiled WITH moveAnnotations naming it"
+ GroovyClassLoader movedLoader = new GroovyClassLoader(getClass().classLoader)
+ movedLoader.parseClass(String.format(sourceTemplate, '@GrailsBeans(moveAnnotations = [VendorMarker])'))
+
+ then: "the marker moves to the sibling like the automatically-recognised annotations do"
+ !movedLoader.loadClass('VendorPlugin').annotations*.annotationType()*.simpleName.contains('VendorMarker')
+ movedLoader.loadClass('VendorPluginAutoConfiguration').annotations*.annotationType()*.simpleName.contains('VendorMarker')
+ }
+
+ def "moveAnnotations rejects a non-annotation class"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(moveAnnotations = [String])
+ @AutoConfiguration
+ class BadMoveAnnotationsPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('is not an annotation type')
+ }
+
+ def "moveAnnotations is rejected on a standalone (non-Plugin) class, where it can have no effect"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.context.annotation.ComponentScan
+
+ @GrailsBeans(moveAnnotations = [ComponentScan])
+ @AutoConfiguration
+ class StandaloneWithMoveAnnotations {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('moveAnnotations has no effect here')
+ }
+
+ def "a *GrailsPlugin class derives its sibling name by replacing the suffix with AutoConfiguration"() {
+ given: "the convention that made the real I18nGrailsPlugin regenerate I18nAutoConfiguration with no attribute"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class FooGrailsPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+
+ then: "FooGrailsPlugin -> FooAutoConfiguration, not FooGrailsPluginAutoConfiguration"
+ loader.loadClass('FooAutoConfiguration').getDeclaredMethod('greeting') != null
+
+ when:
+ loader.loadClass('FooGrailsPluginAutoConfiguration')
+
+ then:
+ thrown(ClassNotFoundException)
+ }
+
+ def "autoConfigurationName overrides the *GrailsPlugin suffix convention too"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = 'CustomName')
+ @AutoConfiguration
+ class BarGrailsPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+
+ then:
+ loader.loadClass('CustomName').getDeclaredMethod('greeting') != null
+ }
+
+ def "GrailsBeans(autoConfigurationName = ...) names the generated sibling instead of the default AutoConfiguration"() {
+ given:
+ String source = '''
+ package com.example.plugins
+
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = 'LegacyName')
+ @AutoConfiguration
+ class RenamedSiblingPlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ and:
+ GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader)
+ loader.parseClass(source)
+
+ expect: "the sibling takes the given simple name, in the plugin's own package, not the default suffix form"
+ loader.loadClass('com.example.plugins.LegacyName').getDeclaredMethod('greeting') != null
+
+ when: "the default-suffix name is looked up instead"
+ loader.loadClass('com.example.plugins.RenamedSiblingPluginAutoConfiguration')
+
+ then: "it was never generated"
+ thrown(ClassNotFoundException)
+ }
+
+ def "autoConfigurationName is rejected on a standalone (non-Plugin) class, where it can have no effect"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = 'Ignored')
+ @AutoConfiguration
+ class StandaloneWithAutoConfigurationName {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('autoConfigurationName has no effect here')
+ }
+
+ def "autoConfigurationName that is not a valid Java identifier falls back to the default sibling name with an error"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = 'not a valid name!')
+ @AutoConfiguration
+ class InvalidAutoConfigurationNamePlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('is not a valid name')
+ }
+
+ def "autoConfigurationName given explicitly as an empty string is rejected, not silently treated as unset"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = '')
+ @AutoConfiguration
+ class BlankAutoConfigurationNamePlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('must not be blank')
+ }
+
+ def "autoConfigurationName that is a reserved keyword is rejected"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans(autoConfigurationName = 'int')
+ @AutoConfiguration
+ class KeywordAutoConfigurationNamePlugin extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('is not a valid name')
+ }
+
+ @Unroll
+ def "autoConfigurationName colliding with #description is a plain Groovy duplicate-class compile error"() {
+ given:
+ String source = """
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ $extraClass
+
+ @GrailsBeans(autoConfigurationName = '$collidingName')
+ @AutoConfiguration
+ class $pluginName extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ """
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('duplicate class')
+
+ where:
+ description | extraClass | collidingName | pluginName
+ "the plugin's own simple name" | '' | 'SelfCollision' | 'SelfCollision'
+ 'another class in the same source' | 'class Existing { }' | 'Existing' | 'OtherCollisionPlugin'
+ }
+
+ def "a Plugin subclass using @GrailsBeans without @AutoConfiguration fails to compile"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+
+ @GrailsBeans
+ class PluginWithoutAutoConfiguration extends Plugin {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ compile(source)
+
+ then:
+ MultipleCompilationErrorsException e = thrown(MultipleCompilationErrorsException)
+ e.message.contains('must also be annotated @AutoConfiguration')
+ }
+
+ def "compiles under @CompileStatic"() {
+ given:
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @CompileStatic
+ @AutoConfiguration
+ class CompileStaticFixture {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+
+ then:
+ fixture.getDeclaredConstructor().newInstance().greeting() == 'hello'
+ }
+
+ def "annotate(...)'s named-argument syntax compiles under @CompileStatic"() {
+ given: "the beans property is stripped before static type-checking ever runs, so the " +
+ "DSL's Map-literal named-args never reach the type checker"
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.core.annotation.Order
+
+ @GrailsBeans
+ @CompileStatic
+ @AutoConfiguration
+ class AnnotateCompileStaticFixture {
+ def beans = {
+ bean('greeting', String).annotate(Order, value: 1) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+
+ then:
+ fixture.getDeclaredMethod('greeting').getAnnotation(Order).value() == 1
+ fixture.getDeclaredConstructor().newInstance().greeting() == 'hello'
+ }
+
+ def "compiles under @CompileStatic on a Plugin subclass"() {
+ given:
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @CompileStatic
+ @AutoConfiguration
+ class CompileStaticPluginFixture extends Plugin {
+ String version = '1.0'
+
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+
+ @Override
+ void doWithApplicationContext() {
+ String x = 'statically typed local'
+ }
+ }
+ '''
+
+ when:
+ Class> pluginClass = compile(source)
+ Class> autoConfigClass = new GroovyClassLoader(pluginClass.classLoader).loadClass('CompileStaticPluginFixtureAutoConfiguration')
+
+ then:
+ autoConfigClass.getDeclaredConstructor().newInstance().greeting() == 'hello'
+ }
+
+ def "field(...) and method(...) resolve correctly from a bean() closure under @CompileStatic"() {
+ given: "a field and a private helper method are relocated from the DSL closure into the " +
+ "generated class's own members, and this proves a sibling bean() referencing them " +
+ "by simple name still resolves once static type checking examines the final class"
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.beans.factory.annotation.Value
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @CompileStatic
+ @AutoConfiguration
+ class CompileStaticMembersFixture {
+ def beans = {
+ field('suffix', String).annotate(Value, value: '${greeting.suffix:!!!}')
+
+ method('yell', String) { String input ->
+ input.toUpperCase() + (suffix ?: '')
+ }
+
+ bean('yelledGreeting', String) {
+ yell('hello')
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> fixture = compile(source)
+ def instance = fixture.getDeclaredConstructor().newInstance()
+ def suffixField = fixture.getDeclaredField('suffix')
+ suffixField.accessible = true
+ suffixField.set(instance, '!!!')
+
+ then:
+ instance.yelledGreeting() == 'HELLO!!!'
+ }
+
+ def "field(...) and method(...) resolve correctly on the Plugin-subclass sibling under @CompileStatic"() {
+ given:
+ String source = '''
+ import groovy.transform.CompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.beans.factory.annotation.Value
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @CompileStatic
+ @AutoConfiguration
+ class CompileStaticMembersPluginFixture extends Plugin {
+ String version = '1.0'
+
+ def beans = {
+ field('suffix', String).annotate(Value, value: '${greeting.suffix:!!!}')
+
+ method('yell', String) { String input ->
+ input.toUpperCase() + (suffix ?: '')
+ }
+
+ bean('yelledGreeting', String) {
+ yell('hello')
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> pluginClass = compile(source)
+ Class> autoConfigClass = new GroovyClassLoader(pluginClass.classLoader)
+ .loadClass('CompileStaticMembersPluginFixtureAutoConfiguration')
+ def instance = autoConfigClass.getDeclaredConstructor().newInstance()
+ def suffixField = autoConfigClass.getDeclaredField('suffix')
+ suffixField.accessible = true
+ suffixField.set(instance, '!!!')
+
+ then:
+ instance.yelledGreeting() == 'HELLO!!!'
+ }
+
+ def "a realistic i18n-plugin-shaped conversion compiles and behaves correctly"() {
+ given: "field(...)/method(...)/bean(...) together, modelled on the real conversion now " +
+ "live in org.grails.plugins.i18n.I18nGrailsPlugin - injected config fields feeding a " +
+ "strategy-selecting helper method and a multi-field-dependent bean, each bean backing " +
+ "off an existing same-named bean the way the real plugin does. LocaleResolver stands " +
+ "in for org.springframework.web.servlet.LocaleResolver (spring-webmvc isn't a " +
+ "dependency of this module) but the DSL usage is identical either way"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
+ import org.springframework.boot.autoconfigure.condition.SearchStrategy
+ import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+ import org.springframework.context.support.ReloadableResourceBundleMessageSource
+
+ interface LocaleResolver {
+ String describe()
+ }
+
+ class CookieLocaleResolver implements LocaleResolver {
+ String describe() { 'cookie' }
+ }
+
+ class SessionLocaleResolver implements LocaleResolver {
+ String describe() { 'session' }
+ }
+
+ @GrailsBeans
+ @AutoConfiguration(before = MessageSourceAutoConfiguration)
+ class I18nStyleGrailsPlugin extends Plugin {
+
+ String version = "1.0"
+
+ def beans = {
+ field("encoding", String).value("grails.gsp.view.encoding", "UTF-8")
+ field("localeResolverType", String).value("grails.i18n.locale.resolver", "session")
+
+ method("buildLocaleResolver", LocaleResolver) {
+ localeResolverType?.toLowerCase() == "cookie" ? new CookieLocaleResolver() : new SessionLocaleResolver()
+ }
+
+ bean(LocaleResolver).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
+ buildLocaleResolver()
+ }
+
+ method("buildMessageSource", ReloadableResourceBundleMessageSource) {
+ def source = new ReloadableResourceBundleMessageSource(basename: "WEB-INF/grails-app/i18n/messages")
+ source.defaultEncoding = encoding
+ source
+ }
+
+ bean("messageSource", ReloadableResourceBundleMessageSource)
+ .conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
+ buildMessageSource()
+ }
+ }
+
+ @Override
+ void doWithApplicationContext() {
+ }
+ }
+ '''
+
+ when: "the *GrailsPlugin suffix derives the sibling name, exactly as the real conversion relies on"
+ Class> pluginClass = compile(source)
+ Class> autoConfigClass = new GroovyClassLoader(pluginClass.classLoader).loadClass('I18nStyleAutoConfiguration')
+ def instance = autoConfigClass.getDeclaredConstructor().newInstance()
+ def localeResolverTypeField = autoConfigClass.getDeclaredField('localeResolverType')
+ localeResolverTypeField.accessible = true
+ localeResolverTypeField.set(instance, 'cookie')
+ def encodingField = autoConfigClass.getDeclaredField('encoding')
+ encodingField.accessible = true
+ encodingField.set(instance, 'ISO-8859-1')
+
+ then: "the strategy-selecting helper, driven by an injected field, picks the right resolver"
+ instance.localeResolver().describe() == 'cookie'
+
+ and: "the second bean, built from a different helper reading a different field, also works"
+ instance.messageSource().defaultEncoding == 'ISO-8859-1'
+
+ and: "every bean backs off an existing same-named bean, matching the real plugin's semantics"
+ autoConfigClass.getDeclaredMethod('localeResolver')
+ .getAnnotation(ConditionalOnMissingBean).search() == SearchStrategy.CURRENT
+ }
+
+ def "compiles under @GrailsCompileStatic on a Plugin subclass"() {
+ given:
+ String source = '''
+ import grails.compiler.GrailsCompileStatic
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @GrailsCompileStatic
+ @AutoConfiguration
+ class GrailsCompileStaticPluginFixture extends Plugin {
+ String version = '1.0'
+
+ def beans = {
+ bean('greeting', String) {
+ 'hello'
+ }
+ }
+ }
+ '''
+
+ when:
+ Class> pluginClass = compile(source)
+ Class> autoConfigClass = new GroovyClassLoader(pluginClass.classLoader).loadClass('GrailsCompileStaticPluginFixtureAutoConfiguration')
+
+ then:
+ autoConfigClass.getDeclaredConstructor().newInstance().greeting() == 'hello'
+ }
+
+ private static final String PLUGIN_FIXTURE_TEMPLATE = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ %s
+
+ @GrailsBeans
+ %s
+ @AutoConfiguration
+ class DispatchFixturePlugin extends Plugin {
+ String version = '1.0'
+
+ def beans = {
+ bean('greeting', String) {
+ 'hello'.toUpperCase()
+ }
+ }
+ }
+ '''
+
+ private static final String STANDALONE_FIXTURE_TEMPLATE = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ %s
+
+ @GrailsBeans
+ %s
+ @AutoConfiguration
+ class DispatchFixtureStandalone {
+ def beans = {
+ bean('greeting', String) {
+ 'hello'.toUpperCase()
+ }
+ }
+ }
+ '''
+
+ private File compileToRealClassFiles(String template, String staticAnnotation, String fileName, String subDir) {
+ String annotationImport = staticAnnotation == 'GrailsCompileStatic' ?
+ 'import grails.compiler.GrailsCompileStatic' : 'import groovy.transform.CompileStatic'
+ String source = String.format(template,
+ staticAnnotation ? annotationImport : '',
+ staticAnnotation ? "@${staticAnnotation}" : '')
+ File destDir = new File(tempDir, subDir)
+ CompilerConfiguration config = new CompilerConfiguration()
+ config.targetDirectory = destDir
+ CompilationUnit unit = new CompilationUnit(config, null, new GroovyClassLoader(getClass().classLoader))
+ unit.addSource(fileName, source)
+ unit.compile(Phases.OUTPUT)
+ destDir
+ }
+
+ private boolean usesInvokeDynamic(File classFile) {
+ String javap = "${System.getProperty('java.home')}/bin/javap"
+ Process process = [javap, '-c', '-p', classFile.absolutePath].execute()
+ process.waitFor()
+ process.text.contains('invokedynamic')
+ }
+
+ def "the standalone form's @Bean methods are statically dispatched when @CompileStatic is present"() {
+ given: "the same DSL compiled with and without @CompileStatic on the annotated class"
+ File staticDir = compileToRealClassFiles(STANDALONE_FIXTURE_TEMPLATE, 'CompileStatic', 'Static.groovy', 'standalone-static')
+ File dynamicDir = compileToRealClassFiles(STANDALONE_FIXTURE_TEMPLATE, null, 'Dynamic.groovy', 'standalone-dynamic')
+
+ expect: "the generated greeting() method is compiled the same way as everything else on the class"
+ !usesInvokeDynamic(new File(staticDir, 'DispatchFixtureStandalone.class'))
+ usesInvokeDynamic(new File(dynamicDir, 'DispatchFixtureStandalone.class'))
+ }
+
+ def "the Plugin-subclass sibling's @Bean methods are statically dispatched when @CompileStatic is present"() {
+ given: "the same DSL compiled with and without @CompileStatic on the Plugin subclass"
+ File staticDir = compileToRealClassFiles(PLUGIN_FIXTURE_TEMPLATE, 'CompileStatic', 'StaticPlugin.groovy', 'plugin-static')
+ File dynamicDir = compileToRealClassFiles(PLUGIN_FIXTURE_TEMPLATE, null, 'DynamicPlugin.groovy', 'plugin-dynamic')
+
+ expect: "the transform explicitly applies static compilation after generating the sibling"
+ !usesInvokeDynamic(new File(staticDir, 'DispatchFixturePluginAutoConfiguration.class'))
+ usesInvokeDynamic(new File(dynamicDir, 'DispatchFixturePluginAutoConfiguration.class'))
+ }
+
+ def "the Plugin-subclass sibling's @Bean methods are statically dispatched under @GrailsCompileStatic"() {
+ given:
+ File staticDir = compileToRealClassFiles(
+ PLUGIN_FIXTURE_TEMPLATE, 'GrailsCompileStatic', 'GrailsStaticPlugin.groovy', 'plugin-grails-static')
+
+ expect:
+ !usesInvokeDynamic(new File(staticDir, 'DispatchFixturePluginAutoConfiguration.class'))
+ }
+
+
+ def "an empty beans block on a plugin generates no sibling and leaves the plugin's annotations alone"() {
+ given: "a plugin whose beans block declares nothing"
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import grails.plugins.Plugin
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+
+ @GrailsBeans
+ @AutoConfiguration
+ @ConditionalOnWebApplication
+ class EmptyBeansGrailsPlugin extends Plugin {
+ def beans = { }
+ }
+ '''
+
+ when: "it compiles without error"
+ Class> plugin = compile(source)
+
+ then: "the plugin keeps the annotations that would otherwise have been moved onto a sibling"
+ plugin.annotations*.annotationType()*.simpleName.containsAll(['AutoConfiguration', 'ConditionalOnWebApplication'])
+
+ and: "the DSL scaffolding is still stripped"
+ !plugin.declaredFields*.name.contains('beans')
+
+ when: "the sibling a non-empty block would have generated is looked up"
+ plugin.classLoader.loadClass('EmptyBeansAutoConfiguration')
+
+ then: "it was never generated, so nothing bean-less is registered as an auto-configuration"
+ thrown(ClassNotFoundException)
+ }
+
+ def "an empty beans block on a plain configuration class is a no-op"() {
+ given:
+ String source = '''
+ import grails.compiler.beans.GrailsBeans
+ import org.springframework.boot.autoconfigure.AutoConfiguration
+
+ @GrailsBeans
+ @AutoConfiguration
+ class EmptyBeansConfiguration {
+ def beans = { }
+ }
+ '''
+
+ when:
+ Class> configuration = compile(source)
+
+ then: "it compiles, contributes no @Bean methods, and keeps its own annotations"
+ configuration.declaredMethods.every { !it.isAnnotationPresent(Bean) }
+ configuration.annotations*.annotationType()*.simpleName.contains('AutoConfiguration')
+ !configuration.declaredFields*.name.contains('beans')
+ }
+}
diff --git a/grails-cache/build.gradle b/grails-cache/build.gradle
index 5534081f00d..b0cd620b09e 100644
--- a/grails-cache/build.gradle
+++ b/grails-cache/build.gradle
@@ -49,6 +49,7 @@ dependencies {
api "org.codehaus.gpars:gpars:$gparsVersion"
api "com.googlecode.concurrentlinkedhashmap:concurrentlinkedhashmap-lru:$concurrentlinkedhashmapLruVersion"
+ implementation project(':grails-beans-dsl')
compileOnly project(':grails-domain-class')
console project(':grails-console')
diff --git a/grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy b/grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy
index f42cb8c4e7a..e17a2859265 100644
--- a/grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy
+++ b/grails-cache/src/main/groovy/grails/plugin/cache/CacheGrailsPlugin.groovy
@@ -22,16 +22,25 @@ package grails.plugin.cache
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
-import org.springframework.beans.factory.BeanRegistrar
-import org.springframework.beans.factory.BeanRegistry
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
import org.springframework.cache.Cache
-import org.springframework.core.env.Environment
import grails.plugins.Plugin
import org.grails.plugin.cache.GrailsCacheManager
+/**
+ * Configures the cache plugin.
+ *
+ * Every bean is contributed as auto-configuration so that one supplied by the application or
+ * another plugin — for example a cache-provider plugin's {@code grailsCacheManager} — makes the
+ * default back off cleanly instead of triggering a bean-definition override. The whole set is gated
+ * on {@code grails.cache.enabled}.
+ */
@Slf4j
@CompileStatic
+@AutoConfiguration
+@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true)
class CacheGrailsPlugin extends Plugin {
def grailsVersion = '8.0.0-SNAPSHOT > *'
@@ -50,16 +59,21 @@ class CacheGrailsPlugin extends Plugin {
config.getProperty('grails.cache.enabled', Boolean, true)
}
- @Override
- BeanRegistrar beanRegistrar() {
- return { BeanRegistry registry, Environment environment ->
- if (!environment.getProperty('grails.cache.enabled', Boolean, true)) {
- log.warn('Cache plugin is disabled')
- return
- }
+ def beans = {
+ field('cacheManagerType', String).value('grails.cache.cacheManager', '')
+
+ bean(GrailsCacheAdminService)
- registry.registerBean('grailsCacheAdminService', GrailsCacheAdminService)
- registry.registerBean('grailsCacheConfiguration', CachePluginConfiguration)
+ // CachePluginConfiguration derives cachePluginConfiguration, so the contractual name is stated.
+ bean('grailsCacheConfiguration', CachePluginConfiguration)
+
+ bean(CustomCacheKeyGenerator).conditionalOnMissingBeanName()
+
+ bean(GrailsCacheManager).conditionalOnMissingBeanName() { CachePluginConfiguration grailsCacheConfiguration ->
+ if (cacheManagerType == 'GrailsConcurrentLinkedMapCacheManager') {
+ return new GrailsConcurrentLinkedMapCacheManager(configuration: grailsCacheConfiguration)
+ }
+ new GrailsConcurrentMapCacheManager(configuration: grailsCacheConfiguration)
}
}
diff --git a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsCacheAutoConfiguration.groovy b/grails-cache/src/main/groovy/grails/plugin/cache/GrailsCacheAutoConfiguration.groovy
deleted file mode 100644
index a668cbe64f0..00000000000
--- a/grails-cache/src/main/groovy/grails/plugin/cache/GrailsCacheAutoConfiguration.groovy
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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.plugin.cache
-
-import groovy.transform.CompileStatic
-
-import org.springframework.beans.factory.annotation.Value
-import org.springframework.boot.autoconfigure.AutoConfiguration
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
-import org.springframework.context.annotation.Bean
-
-import org.grails.plugin.cache.GrailsCacheManager
-
-/**
- * Auto-configures the cache plugin's default cache manager and key generator. Registered here
- * rather than by the plugin descriptor so that a bean contributed by the application or another
- * plugin — for example a cache-provider plugin's {@code grailsCacheManager} — makes the default
- * back off cleanly instead of triggering a bean-definition override.
- *
- * Gated on the {@code CachePluginConfiguration} definition contributed by the cache plugin
- * descriptor's registrar (which runs before auto-configuration conditions are evaluated), so the
- * auto-configuration backs off entirely when the plugin is not active — e.g. the jar is on the
- * classpath but the plugin is excluded — keeping it in lockstep with the descriptor.
- *
- * @since 8.0
- */
-@AutoConfiguration
-@ConditionalOnBooleanProperty(name = 'grails.cache.enabled', matchIfMissing = true)
-@ConditionalOnBean(CachePluginConfiguration)
-@CompileStatic
-class GrailsCacheAutoConfiguration {
-
- @Value('${grails.cache.cacheManager:}')
- String cacheManagerType
-
- @Bean
- @ConditionalOnMissingBean(name = 'customCacheKeyGenerator')
- CustomCacheKeyGenerator customCacheKeyGenerator() {
- new CustomCacheKeyGenerator()
- }
-
- @Bean
- @ConditionalOnMissingBean(name = 'grailsCacheManager')
- GrailsCacheManager grailsCacheManager(CachePluginConfiguration grailsCacheConfiguration) {
- if (cacheManagerType == 'GrailsConcurrentLinkedMapCacheManager') {
- return new GrailsConcurrentLinkedMapCacheManager(configuration: grailsCacheConfiguration)
- }
- new GrailsConcurrentMapCacheManager(configuration: grailsCacheConfiguration)
- }
-
-}
diff --git a/grails-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 030d10dc702..b4222e566db 100644
--- a/grails-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/grails-cache/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1 @@
-grails.plugin.cache.GrailsCacheAutoConfiguration
+grails.plugin.cache.CacheAutoConfiguration
\ No newline at end of file
diff --git a/grails-cache/src/test/groovy/grails/plugin/cache/GrailsCacheAutoConfigurationSpec.groovy b/grails-cache/src/test/groovy/grails/plugin/cache/CacheAutoConfigurationSpec.groovy
similarity index 82%
rename from grails-cache/src/test/groovy/grails/plugin/cache/GrailsCacheAutoConfigurationSpec.groovy
rename to grails-cache/src/test/groovy/grails/plugin/cache/CacheAutoConfigurationSpec.groovy
index dcf078a4aad..9562f7bfcf8 100644
--- a/grails-cache/src/test/groovy/grails/plugin/cache/GrailsCacheAutoConfigurationSpec.groovy
+++ b/grails-cache/src/test/groovy/grails/plugin/cache/CacheAutoConfigurationSpec.groovy
@@ -27,7 +27,7 @@ import org.grails.plugin.cache.GrailsCacheManager
import spock.lang.Specification
-class GrailsCacheAutoConfigurationSpec extends Specification {
+class CacheAutoConfigurationSpec extends Specification {
void 'the default cache manager and key generator are auto-configured'() {
given:
@@ -65,13 +65,25 @@ class GrailsCacheAutoConfigurationSpec extends Specification {
context.close()
}
- void 'the auto-configuration backs off entirely when the cache plugin is not active'() {
- given: 'a context without the plugin-contributed grailsCacheConfiguration definition'
- AnnotationConfigApplicationContext context = buildContext([:], null, false)
+ void 'the plugin infrastructure beans are auto-configured too'() {
+ given:
+ AnnotationConfigApplicationContext context = buildContext([:])
expect:
- !context.containsBean('grailsCacheManager')
- !context.containsBean('customCacheKeyGenerator')
+ context.getBean('grailsCacheConfiguration') instanceof CachePluginConfiguration
+ context.containsBean('grailsCacheAdminService')
+
+ cleanup:
+ context.close()
+ }
+
+ void 'no cache beans at all are auto-configured when caching is disabled'() {
+ given:
+ AnnotationConfigApplicationContext context = buildContext(['grails.cache.enabled': 'false'])
+
+ expect:
+ !context.containsBean('grailsCacheConfiguration')
+ !context.containsBean('grailsCacheAdminService')
cleanup:
context.close()
@@ -92,16 +104,13 @@ class GrailsCacheAutoConfigurationSpec extends Specification {
@CompileStatic
private static AnnotationConfigApplicationContext buildContext(
- Map properties, GrailsCacheManager userCacheManager = null, boolean pluginActive = true) {
+ Map properties, GrailsCacheManager userCacheManager = null) {
def context = new AnnotationConfigApplicationContext()
context.environment.propertySources.addFirst(new MapPropertySource('test', properties))
- if (pluginActive) {
- context.registerBean('grailsCacheConfiguration', CachePluginConfiguration, () -> new CachePluginConfiguration())
- }
if (userCacheManager != null) {
context.registerBean('grailsCacheManager', GrailsCacheManager, () -> userCacheManager)
}
- context.register(GrailsCacheAutoConfiguration)
+ context.register(CacheAutoConfiguration)
context.refresh()
return context
}
diff --git a/grails-cache/src/test/groovy/grails/plugin/cache/CacheGrailsPluginSpec.groovy b/grails-cache/src/test/groovy/grails/plugin/cache/CacheGrailsPluginSpec.groovy
deleted file mode 100644
index e514a83a965..00000000000
--- a/grails-cache/src/test/groovy/grails/plugin/cache/CacheGrailsPluginSpec.groovy
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * 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.plugin.cache
-
-import org.springframework.beans.factory.support.BeanRegistryAdapter
-import org.springframework.beans.factory.support.DefaultListableBeanFactory
-import org.springframework.core.env.MapPropertySource
-import org.springframework.core.env.StandardEnvironment
-
-import spock.lang.Specification
-
-class CacheGrailsPluginSpec extends Specification {
-
- void "beanRegistrar registers the cache infrastructure beans"() {
- given:
- def beanFactory = new DefaultListableBeanFactory()
-
- when:
- applyRegistrar(beanFactory, new StandardEnvironment())
-
- then:
- beanFactory.containsBeanDefinition('grailsCacheAdminService')
- beanFactory.containsBeanDefinition('grailsCacheConfiguration')
- }
-
- void "the cache manager and key generator defaults are left to GrailsCacheAutoConfiguration"() {
- // Auto-configured with @ConditionalOnMissingBean so an application- or plugin-defined
- // bean backs the default off instead of triggering a definition override
- given:
- def beanFactory = new DefaultListableBeanFactory()
-
- when:
- applyRegistrar(beanFactory, new StandardEnvironment())
-
- then:
- !beanFactory.containsBeanDefinition('grailsCacheManager')
- !beanFactory.containsBeanDefinition('customCacheKeyGenerator')
- }
-
- void "no cache beans are registered when caching is disabled"() {
- given:
- def beanFactory = new DefaultListableBeanFactory()
- def environment = new StandardEnvironment()
- environment.propertySources.addFirst(new MapPropertySource('test', ['grails.cache.enabled': 'false']))
-
- when:
- applyRegistrar(beanFactory, environment)
-
- then:
- beanFactory.beanDefinitionCount == 0
- }
-
- private static void applyRegistrar(DefaultListableBeanFactory beanFactory, StandardEnvironment environment) {
- def registrar = new CacheGrailsPlugin().beanRegistrar()
- new BeanRegistryAdapter(beanFactory, environment, registrar.getClass()).register(registrar)
- }
-}
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 1d7527b2d78..e9742d7471c 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
@@ -46,7 +46,7 @@
import grails.config.Settings;
import grails.core.GrailsApplication;
-import org.grails.plugins.domain.GrailsDomainClassAutoConfiguration;
+import org.grails.plugins.domain.DomainClassAutoConfiguration;
import org.grails.web.config.http.GrailsFilters;
import org.grails.web.errors.GrailsExceptionResolver;
import org.grails.web.filters.HiddenHttpMethodFilter;
@@ -55,7 +55,7 @@
@AutoConfiguration(
before = {DispatcherServletAutoConfiguration.class, HttpEncodingAutoConfiguration.class, WebMvcAutoConfiguration.class},
- after = {GrailsDomainClassAutoConfiguration.class}
+ after = {DomainClassAutoConfiguration.class}
)
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public class ControllersAutoConfiguration {
diff --git a/grails-core/build.gradle b/grails-core/build.gradle
index 96ba46f4e0e..980c636c720 100644
--- a/grails-core/build.gradle
+++ b/grails-core/build.gradle
@@ -51,6 +51,8 @@ dependencies {
api 'jakarta.persistence:jakarta.persistence-api'
api 'jakarta.annotation:jakarta.annotation-api'
+ api project(':grails-beans-dsl')
+
implementation 'com.github.ben-manes.caffeine:caffeine'
api 'org.apache.groovy:groovy'
implementation 'org.apache.groovy:groovy-json'
@@ -78,6 +80,13 @@ dependencies {
cliImplementation 'org.apache.groovy:groovy-templates'
testImplementation 'org.springframework:spring-jdbc'
+ testImplementation 'org.springframework.boot:spring-boot-test'
+ // ApplicationContextRunner's signatures reach AssertJ through ApplicationContextAssertProvider;
+ // without it on the classpath Groovy cannot introspect the runner and every call misses.
+ testRuntimeOnly 'org.assertj:assertj-core'
+ // AspectJ is compileOnly for the framework; on the test classpath so CoreGrailsPlugin's
+ // AspectJ-aware auto proxy creator branch is reachable
+ testRuntimeOnly 'org.aspectj:aspectjweaver'
testImplementation 'org.hamcrest:hamcrest'
diff --git a/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy b/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
index a8ac4f2d825..1bc6e300570 100644
--- a/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
+++ b/grails-core/src/main/groovy/org/grails/compiler/injection/GlobalGrailsClassInjectorTransformation.groovy
@@ -68,6 +68,10 @@ class GlobalGrailsClassInjectorTransformation implements ASTTransformation, Comp
public static final ClassNode ARTEFACT_HANDLER_CLASS = ClassHelper.make('grails.core.ArtefactHandler')
public static final ClassNode TRAIT_INJECTOR_CLASS = ClassHelper.make('grails.compiler.traits.TraitInjector')
+ private static final String GRAILS_AUTO_CONFIGURATION_CLASS_NAME = 'grails.boot.config.GrailsAutoConfiguration'
+ private static final String BEANS_PROPERTY = 'beans'
+ private static final ClassNode GRAILS_BEANS_ANNOTATION = ClassHelper.make('grails.compiler.beans.GrailsBeans')
+
@Override
int priority() {
return GroovyTransformOrder.GLOBAL_GRAILS_TRANSFORM_ORDER
@@ -115,9 +119,14 @@ class GlobalGrailsClassInjectorTransformation implements ASTTransformation, Comp
classNode.addProperty(new PropertyNode('version', Modifier.PUBLIC, ClassHelper.make(Object), classNode, new ConstantExpression(projectVersion.toString()), null, null))
}
+ compileBeansDsl(classNode, source)
continue
}
+ if (GrailsASTUtils.isSubclassOfOrImplementsInterface(classNode, GRAILS_AUTO_CONFIGURATION_CLASS_NAME)) {
+ compileBeansDsl(classNode, source)
+ }
+
if (updateGrailsFactoriesWithType(classNode, ARTEFACT_HANDLER_CLASS, compilationTargetDirectory)) {
continue
}
@@ -179,6 +188,44 @@ class GlobalGrailsClassInjectorTransformation implements ASTTransformation, Comp
/**
* @return {@code true} when the {@code grails.isolated.build} system property is {@code true}.
*/
+ /**
+ * Compiles a plugin descriptor's or application class's {@code beans} closure into {@code @Bean}
+ * factory methods, so {@code @GrailsBeans} does not have to be written out - the {@code beans}
+ * property is a convention here in the same way {@code doWithSpring} and {@code watchedResources}
+ * already are.
+ *
+ * The transformation is invoked directly rather than by adding the annotation: annotation-driven
+ * transformations are collected during semantic analysis, so an annotation added at
+ * {@code CANONICALIZATION} would never fire. A class that already declares {@code @GrailsBeans}
+ * is skipped, since its own transformation has run; a class without a {@code beans} property is
+ * skipped too, which is every plugin that does not use the DSL.
+ */
+ private void compileBeansDsl(ClassNode classNode, SourceUnit source) {
+ if (classNode.getProperty(BEANS_PROPERTY) == null) {
+ return
+ }
+ if (!classNode.getAnnotations(GRAILS_BEANS_ANNOTATION).isEmpty()) {
+ return
+ }
+
+ ASTTransformation transformation
+ try {
+ transformation = (ASTTransformation) getClass().classLoader
+ .loadClass('org.grails.compiler.beans.GrailsBeansASTTransformation')
+ .getDeclaredConstructor()
+ .newInstance()
+ }
+ catch (ClassNotFoundException ignored) {
+ // grails-beans-dsl is not on the compile classpath, so the beans property is left alone
+ return
+ }
+
+ if (transformation instanceof CompilationUnitAware) {
+ ((CompilationUnitAware) transformation).compilationUnit = compilationUnit
+ }
+ transformation.visit([new AnnotationNode(GRAILS_BEANS_ANNOTATION), classNode] as ASTNode[], source)
+ }
+
static boolean isIsolatedBuild() {
System.getProperty(ISOLATED_BUILD_PROPERTY, 'false').toBoolean()
}
diff --git a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
index 0ed776b0b80..c65796ecde2 100644
--- a/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
+++ b/grails-core/src/main/groovy/org/grails/plugins/CoreGrailsPlugin.groovy
@@ -20,19 +20,35 @@ package org.grails.plugins
import groovy.transform.CompileStatic
+import org.springframework.aop.config.AopConfigUtils
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.BeanRegistry
import org.springframework.beans.factory.config.CustomEditorConfigurer
import org.springframework.beans.factory.support.DefaultListableBeanFactory
import org.springframework.beans.factory.xml.XmlBeanDefinitionReader
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.AutoConfigureOrder
+import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration
import org.springframework.context.annotation.ConfigurationClassPostProcessor
import org.springframework.context.support.GenericApplicationContext
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+import org.springframework.core.Ordered
+import org.springframework.core.env.Environment
import org.springframework.core.io.Resource
import org.springframework.util.ClassUtils
+import java.beans.PropertyEditor
+
+import grails.compiler.beans.GrailsBeans
+import grails.config.Config
+import grails.config.ConfigProperties
import grails.config.Settings
+import grails.core.GrailsApplication
import grails.core.support.proxy.DefaultProxyHandler
+import grails.plugins.GrailsPluginManager
import grails.plugins.Plugin
import grails.util.BuildSettings
-import grails.util.Environment
+import grails.util.Environment as GrailsEnvironment
import grails.util.GrailsUtil
import org.grails.beans.support.PropertiesEditor
import org.grails.core.io.DefaultResourceLocator
@@ -43,8 +59,11 @@ import org.grails.spring.RuntimeSpringConfigUtilities
import org.grails.spring.RuntimeSpringConfiguration
import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator
import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator
+import org.grails.spring.beans.AbstractResourceLocatorPostProcessor
import org.grails.spring.beans.GrailsApplicationAwareBeanPostProcessor
import org.grails.spring.beans.PluginManagerAwareBeanPostProcessor
+import org.grails.spring.context.annotation.GrailsComponentScanPostProcessor
+import org.grails.spring.context.support.GrailsPlaceholderConfigurer
import org.grails.spring.context.support.MapBasedSmartPropertyOverrideConfigurer
/**
@@ -53,6 +72,10 @@ import org.grails.spring.context.support.MapBasedSmartPropertyOverrideConfigurer
* @author Graeme Rocher
* @since 0.4
*/
+@CompileStatic
+@GrailsBeans
+@AutoConfiguration(before = [PropertyPlaceholderAutoConfiguration])
+@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
class CoreGrailsPlugin extends Plugin {
def version = GrailsUtil.getGrailsVersion()
@@ -61,72 +84,132 @@ class CoreGrailsPlugin extends Plugin {
'file:./grails-app/conf/application.groovy',
'file:./grails-app/conf/application.yml']
- private static final SPRING_PROXY_TARGET_CLASS_CONFIG = 'spring.aop.proxy-target-class'
+ private static final String SPRING_PROXY_TARGET_CLASS_CONFIG = 'spring.aop.proxy-target-class'
- @Override
- Closure doWithSpring() {
- { ->
+ def beans = {
+ bean(ClassLoader).primary() { GrailsApplication grailsApplication ->
+ grailsApplication.classLoader
+ }
- def application = grailsApplication
+ bean('grailsConfigProperties', ConfigProperties).primary() { GrailsApplication grailsApplication ->
+ new ConfigProperties(grailsApplication.config)
+ }
- // Grails config as properties
- def config = application.config
+ // GroovyPagesGrailsPlugin registers its own caching, GSP-aware locator under this name
+ // from doWithSpring, which runs earlier, so this backs off when GSP is present.
+ bean('grailsResourceLocator', DefaultResourceLocator).conditionalOnMissingBeanName() {
+ new DefaultResourceLocator().tap {
+ searchLocations = [BuildSettings.BASE_DIR.absolutePath]
+ }
+ }
- // enable post-processing of @Configuration beans defined by plugins
- grailsConfigurationClassPostProcessor(ConfigurationClassPostProcessor)
- grailsBeanOverrideConfigurer(MapBasedSmartPropertyOverrideConfigurer) {
- delegate.grailsApplication = application
+ // A static factory method reading the Environment, rather than an instance method reading an
+ // injected @Value field as the hand-written class had it: this bean is a
+ // BeanFactoryPostProcessor, so it is created before @Value injection is active and a field
+ // would always still be null, leaving the configured prefix with no effect. Static is also
+ // Spring's documented shape for a BFPP bean, since it needs no enclosing instance.
+ bean(PropertySourcesPlaceholderConfigurer).primary().staticMethod() { Environment environment ->
+ def configurer = new GrailsPlaceholderConfigurer()
+ String prefix = environment.getProperty(Settings.SPRING_PLACEHOLDER_PREFIX)
+ if (prefix != null) {
+ configurer.placeholderPrefix = prefix
}
+ configurer
+ }
+ }
+
+ /**
+ * Scans the packages named by {@code grails.spring.bean.packages}. Contributed here rather
+ * than through {@code doWithSpring}'s {@code grailsContext:component-scan} element, which
+ * needs the XML namespace handler and therefore the bean builder DSL.
+ */
+ @Override
+ BeanRegistrar beanRegistrar() {
+ return { BeanRegistry registry, Environment springEnvironment ->
+ GrailsApplication application = grailsApplication
+ Config config = application.config
+
+ // enable post-processing of @Configuration beans defined by plugins
+ registry.registerBean('grailsConfigurationClassPostProcessor', ConfigurationClassPostProcessor)
- Class proxyCreatorClazz = null
- // replace AutoProxy advisor with Groovy aware one
- if (ClassUtils.isPresent('org.aspectj.lang.annotation.Around', application.classLoader) && !config.getProperty(Settings.SPRING_DISABLE_ASPECTJ, Boolean)) {
- proxyCreatorClazz = GroovyAwareAspectJAwareAdvisorAutoProxyCreator
- } else {
- proxyCreatorClazz = GroovyAwareInfrastructureAdvisorAutoProxyCreator
+ registry.registerBean('grailsBeanOverrideConfigurer', MapBasedSmartPropertyOverrideConfigurer) {
+ it.supplier {
+ MapBasedSmartPropertyOverrideConfigurer configurer = new MapBasedSmartPropertyOverrideConfigurer()
+ configurer.grailsApplication = application
+ configurer
+ }
}
+ // replace the AutoProxy advisor with a Groovy aware one; the two variants share
+ // Spring's own internalAutoProxyCreator name, so at most one is registered
Boolean isProxyTargetClass = config.getProperty(SPRING_PROXY_TARGET_CLASS_CONFIG, Boolean)
- 'org.springframework.aop.config.internalAutoProxyCreator'(proxyCreatorClazz) {
- if (isProxyTargetClass != null) {
- proxyTargetClass = isProxyTargetClass
+ if (ClassUtils.isPresent('org.aspectj.lang.annotation.Around', application.classLoader) &&
+ !config.getProperty(Settings.SPRING_DISABLE_ASPECTJ, Boolean)) {
+ registry.registerBean(AopConfigUtils.AUTO_PROXY_CREATOR_BEAN_NAME, GroovyAwareAspectJAwareAdvisorAutoProxyCreator) {
+ it.supplier {
+ GroovyAwareAspectJAwareAdvisorAutoProxyCreator creator = new GroovyAwareAspectJAwareAdvisorAutoProxyCreator()
+ if (isProxyTargetClass != null) {
+ creator.proxyTargetClass = isProxyTargetClass
+ }
+ creator
+ }
+ }
+ }
+ else {
+ registry.registerBean(AopConfigUtils.AUTO_PROXY_CREATOR_BEAN_NAME, GroovyAwareInfrastructureAdvisorAutoProxyCreator) {
+ it.supplier {
+ GroovyAwareInfrastructureAdvisorAutoProxyCreator creator = new GroovyAwareInfrastructureAdvisorAutoProxyCreator()
+ if (isProxyTargetClass != null) {
+ creator.proxyTargetClass = isProxyTargetClass
+ }
+ creator
+ }
}
}
- def packagesToScan = []
+ registry.registerBean('grailsApplicationAwarePostProcessor', GrailsApplicationAwareBeanPostProcessor) {
+ it.supplier { new GrailsApplicationAwareBeanPostProcessor(application) }
+ }
+ registry.registerBean('pluginManagerPostProcessor', PluginManagerAwareBeanPostProcessor)
- def beanPackages = config.getProperty(Settings.SPRING_BEAN_PACKAGES, List)
- if (beanPackages) {
- packagesToScan += beanPackages
+ // a shutdown hook only outside war deployment, in development, with jline present
+ if (!GrailsEnvironment.isWarDeployed() && environment == GrailsEnvironment.DEVELOPMENT &&
+ ClassUtils.isPresent('jline.Terminal', application.classLoader)) {
+ registry.registerBean('shutdownHook', DevelopmentShutdownHook)
}
- if (packagesToScan) {
- xmlns(grailsContext: 'http://grails.org/schema/context')
- grailsContext.'component-scan'('base-package': packagesToScan.join(','))
+ registry.registerBean('customEditors', CustomEditorConfigurer) {
+ it.supplier {
+ CustomEditorConfigurer configurer = new CustomEditorConfigurer()
+ Map, Class extends PropertyEditor>> editors = [:]
+ editors.put(Class, ClassEditor)
+ editors.put(Properties, PropertiesEditor)
+ configurer.customEditors = editors
+ configurer
+ }
}
- grailsApplicationAwarePostProcessor(GrailsApplicationAwareBeanPostProcessor, ref('grailsApplication'))
- pluginManagerPostProcessor(PluginManagerAwareBeanPostProcessor)
+ registry.registerBean('proxyHandler', DefaultProxyHandler)
- // add shutdown hook if not running in war deployed mode
- final warDeployed = Environment.isWarDeployed()
- final devMode = !warDeployed && environment == Environment.DEVELOPMENT
- if (devMode && ClassUtils.isPresent('jline.Terminal', application.classLoader)) {
- shutdownHook(DevelopmentShutdownHook)
- }
- abstractGrailsResourceLocator {
- searchLocations = [BuildSettings.BASE_DIR.absolutePath]
- }
- grailsResourceLocator(DefaultResourceLocator) { bean ->
- bean.parent = 'abstractGrailsResourceLocator'
+ // an abstract parent definition, which registerBean cannot express since it always
+ // takes a class; third-party plugins inherit their search locations from it
+ registry.registerBean('grailsAbstractResourceLocatorPostProcessor', AbstractResourceLocatorPostProcessor) {
+ it.infrastructure().supplier {
+ new AbstractResourceLocatorPostProcessor([BuildSettings.BASE_DIR.absolutePath])
+ }
}
- customEditors(CustomEditorConfigurer) {
- customEditors = [(Class): ClassEditor,
- (Properties): PropertiesEditor]
+ List packagesToScan = (List) grailsApplication.config
+ .getProperty(Settings.SPRING_BEAN_PACKAGES, List) ?: []
+ if (!packagesToScan) {
+ return
+ }
+ GrailsPluginManager pluginManager = manager
+ registry.registerBean('grailsComponentScanPostProcessor', GrailsComponentScanPostProcessor) {
+ it.infrastructure().supplier {
+ new GrailsComponentScanPostProcessor(packagesToScan, pluginManager)
+ }
}
-
- proxyHandler(DefaultProxyHandler)
}
}
diff --git a/grails-core/src/main/groovy/org/grails/plugins/core/CoreAutoConfiguration.java b/grails-core/src/main/groovy/org/grails/plugins/core/CoreAutoConfiguration.java
deleted file mode 100644
index 9bb20e7d2b2..00000000000
--- a/grails-core/src/main/groovy/org/grails/plugins/core/CoreAutoConfiguration.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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.core;
-
-import org.springframework.beans.factory.annotation.Value;
-import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.AutoConfigureOrder;
-import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Primary;
-import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
-import org.springframework.core.Ordered;
-
-import grails.config.ConfigProperties;
-import grails.config.Settings;
-import grails.core.GrailsApplication;
-import org.grails.spring.context.support.GrailsPlaceholderConfigurer;
-
-/**
- * Core beans.
- *
- * @author graemerocher
- * @since 4.0
- */
-@AutoConfiguration(before = { PropertyPlaceholderAutoConfiguration.class })
-@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE)
-public class CoreAutoConfiguration {
-
- @Value("${" + Settings.SPRING_PLACEHOLDER_PREFIX + ":#{null}}")
- private String placeholderPrefix;
-
- @Bean
- @Primary
- public ClassLoader classLoader(GrailsApplication grailsApplication) {
- return grailsApplication.getClassLoader();
- }
-
- @Bean
- @Primary
- public ConfigProperties grailsConfigProperties(GrailsApplication grailsApplication) {
- return new ConfigProperties(grailsApplication.getConfig());
- }
-
- @Bean
- @Primary
- PropertySourcesPlaceholderConfigurer propertySourcesPlaceholderConfigurer() {
- GrailsPlaceholderConfigurer grailsPlaceholderConfigurer = new GrailsPlaceholderConfigurer();
- if (placeholderPrefix != null) {
- grailsPlaceholderConfigurer.setPlaceholderPrefix(placeholderPrefix);
- }
- return grailsPlaceholderConfigurer;
- }
-}
diff --git a/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java
new file mode 100644
index 00000000000..7149a707ece
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/beans/AbstractResourceLocatorPostProcessor.java
@@ -0,0 +1,82 @@
+/*
+ * 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.spring.beans;
+
+import java.util.List;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
+import org.springframework.beans.factory.support.GenericBeanDefinition;
+import org.springframework.core.PriorityOrdered;
+
+/**
+ * Registers {@code abstractGrailsResourceLocator}, the abstract parent definition that
+ * resource-locator beans inherit their search locations from.
+ *
+ * The definition carries a single property and no bean class, so it exists purely to be
+ * inherited with {@code bean.parent = 'abstractGrailsResourceLocator'} — third-party plugins
+ * do so, asset-pipeline's {@code assetResourceLocator} among them, which makes it part of the
+ * core plugin's public surface.
+ *
+ * A classless, abstract definition has no equivalent in {@code BeanRegistry.Spec}, whose
+ * {@code registerBean} always takes a class, nor on a {@code @Bean} method. Contributing it
+ * through a {@link BeanDefinitionRegistryPostProcessor} reaches the underlying registry, where
+ * it can be expressed directly, so the core plugin needs no bean builder DSL for it.
+ *
+ * @since 8.0
+ */
+public class AbstractResourceLocatorPostProcessor implements BeanDefinitionRegistryPostProcessor, PriorityOrdered {
+
+ public static final String BEAN_NAME = "abstractGrailsResourceLocator";
+
+ private final List searchLocations;
+
+ public AbstractResourceLocatorPostProcessor(List searchLocations) {
+ this.searchLocations = searchLocations;
+ }
+
+ @Override
+ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
+ if (registry.containsBeanDefinition(BEAN_NAME)) {
+ return;
+ }
+ GenericBeanDefinition definition = new GenericBeanDefinition();
+ definition.setAbstract(true);
+ definition.getPropertyValues().add("searchLocations", this.searchLocations);
+ registry.registerBeanDefinition(BEAN_NAME, definition);
+ }
+
+ /**
+ * Ordered first, because a child definition naming this one as its parent cannot be merged
+ * until it exists, and merging happens as soon as anything resolves beans by type.
+ */
+ @Override
+ public int getOrder() {
+ return HIGHEST_PRECEDENCE;
+ }
+
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+ // the definition is contributed in postProcessBeanDefinitionRegistry
+ }
+
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/context/annotation/ClosureClassIgnoringComponentScanBeanDefinitionParser.java b/grails-core/src/main/groovy/org/grails/spring/context/annotation/ClosureClassIgnoringComponentScanBeanDefinitionParser.java
index 9803e766174..c78a03bf8f5 100644
--- a/grails-core/src/main/groovy/org/grails/spring/context/annotation/ClosureClassIgnoringComponentScanBeanDefinitionParser.java
+++ b/grails-core/src/main/groovy/org/grails/spring/context/annotation/ClosureClassIgnoringComponentScanBeanDefinitionParser.java
@@ -18,17 +18,7 @@
*/
package org.grails.spring.context.annotation;
-import java.io.IOException;
-import java.lang.reflect.Method;
-import java.net.URL;
-import java.util.Collection;
-import java.util.Collections;
-import java.util.Enumeration;
-import java.util.LinkedHashSet;
import java.util.List;
-import java.util.Set;
-
-import org.codehaus.groovy.runtime.DefaultGroovyMethods;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
@@ -41,16 +31,11 @@
import org.springframework.beans.factory.xml.XmlReaderContext;
import org.springframework.context.annotation.ClassPathBeanDefinitionScanner;
import org.springframework.context.annotation.ComponentScanBeanDefinitionParser;
-import org.springframework.core.io.Resource;
-import org.springframework.core.io.ResourceLoader;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.core.type.filter.TypeFilter;
import org.springframework.util.AntPathMatcher;
-import org.springframework.util.ReflectionUtils;
import grails.plugins.GrailsPluginManager;
-import grails.util.BuildSettings;
-import grails.util.Environment;
import grails.util.GrailsStringUtils;
/**
@@ -89,158 +74,20 @@ protected ClassPathBeanDefinitionScanner createScanner(XmlReaderContext readerCo
return scanner;
}
- /**
- * This ClassLoader is used to restrict getResources & getResource methods only to the
- * parent ClassLoader. getResources/getResource usually search all parent level classloaders.
- * (look at details in source code of java.lang.ClassLoader.getResources)
- *
- * @author Lari Hotari
- */
- private static final class ParentOnlyGetResourcesClassLoader extends ClassLoader {
- private final Method findResourcesMethod = ReflectionUtils.findMethod(ClassLoader.class, "findResources", String.class);
- private final Method findResourceMethod = ReflectionUtils.findMethod(ClassLoader.class, "findResource", String.class);
-
- private ClassLoader rootLoader;
-
- public ParentOnlyGetResourcesClassLoader(ClassLoader parent) {
- super(parent);
- rootLoader = DefaultGroovyMethods.getRootLoader(parent);
- ReflectionUtils.makeAccessible(findResourceMethod);
- ReflectionUtils.makeAccessible(findResourcesMethod);
- }
-
- @Override
- public Enumeration getResources(String name) throws IOException {
- if (Environment.isFork()) {
- return super.getResources(name);
- }
- else {
- if (rootLoader != null) {
- // search all parents up to rootLoader
- Collection urls = new LinkedHashSet<>();
- findResourcesRecursive(getParent(), name, urls);
- return Collections.enumeration(urls);
- }
-
- return invokeFindResources(getParent(), name);
- }
- }
-
- private void findResourcesRecursive(ClassLoader parent, String name, Collection urls) {
- Enumeration result = invokeFindResources(parent, name);
- while (result.hasMoreElements()) {
- urls.add(result.nextElement());
- }
- if (parent != rootLoader) {
- findResourcesRecursive(parent.getParent(), name, urls);
- }
- }
-
- @SuppressWarnings("unchecked")
- private Enumeration invokeFindResources(ClassLoader parent, String name) {
- return (Enumeration) ReflectionUtils.invokeMethod(findResourcesMethod, parent, name);
- }
-
- @Override
- public URL getResource(String name) {
- if (Environment.isFork()) {
- return super.getResource(name);
- }
- else {
- if (rootLoader != null) {
- return findResourceRecursive(getParent(), name);
- }
-
- return invokeFindResource(getParent(), name);
- }
- }
-
- private URL findResourceRecursive(ClassLoader parent, String name) {
- URL url = invokeFindResource(parent, name);
- if (url != null) {
- return url;
- }
-
- if (parent != rootLoader) {
- return findResourceRecursive(parent.getParent(), name);
- }
-
- return null;
- }
-
- private URL invokeFindResource(ClassLoader parent, String name) {
- return (URL) ReflectionUtils.invokeMethod(findResourceMethod, parent, name);
- }
- }
-
@Override
protected ClassPathBeanDefinitionScanner configureScanner(ParserContext parserContext, Element element) {
- final ClassPathBeanDefinitionScanner scanner = super.configureScanner(parserContext, element);
-
- final ResourceLoader originalResourceLoader = parserContext.getReaderContext().getResourceLoader();
- if (LOG.isDebugEnabled()) {
- LOG.debug("Scanning only this classloader:" + originalResourceLoader.getClassLoader());
- }
-
- ResourceLoader parentOnlyResourceLoader;
- try {
- parentOnlyResourceLoader = new ResourceLoader() {
- ClassLoader parentOnlyGetResourcesClassLoader = new ParentOnlyGetResourcesClassLoader(originalResourceLoader.getClassLoader());
+ ClassPathBeanDefinitionScanner scanner = super.configureScanner(parserContext, element);
- public Resource getResource(String location) {
- return originalResourceLoader.getResource(location);
- }
-
- public ClassLoader getClassLoader() {
- return parentOnlyGetResourcesClassLoader;
- }
- };
- }
- catch (Throwable t) {
- // restrictive classloading environment, use the original
- parentOnlyResourceLoader = originalResourceLoader;
- }
-
- final PathMatchingResourcePatternResolver resourceResolver = new PathMatchingResourcePatternResolver(parentOnlyResourceLoader) {
- @Override
- protected Resource[] findAllClassPathResources(String location) throws IOException {
- Set result = new LinkedHashSet<>(16);
-
- if (BuildSettings.CLASSES_DIR != null) {
- @SuppressWarnings("unused")
- URL classesDir = BuildSettings.CLASSES_DIR.toURI().toURL();
-
- // only scan classes from project classes directory
- String path = location;
- if (path.startsWith("/")) {
- path = path.substring(1);
- }
- Enumeration resourceUrls = getClassLoader().getResources(path);
- while (resourceUrls.hasMoreElements()) {
- URL url = resourceUrls.nextElement();
- if (LOG.isDebugEnabled()) {
- LOG.debug("Scanning URL " + url.toExternalForm() + " while searching for '" + location + "'");
- }
- /*
- if (!warDeployed && classesDir!= null && url.equals(classesDir)) {
- result.add(convertClassLoaderURL(url));
- }
- else if (warDeployed) {
- result.add(convertClassLoaderURL(url));
- }
- */
- result.add(convertClassLoaderURL(url));
- }
- }
- return result.toArray(new Resource[result.size()]);
- }
- };
+ // Standard classpath scanning, with one Grails-specific exclusion: a Groovy closure
+ // compiles to its own class file (Foo$_bar_closure1.class) that is never a component
+ // candidate, so any class whose file name contains $ is skipped before it is read.
+ PathMatchingResourcePatternResolver resourceResolver =
+ new PathMatchingResourcePatternResolver(parserContext.getReaderContext().getResourceLoader());
resourceResolver.setPathMatcher(new AntPathMatcher() {
@Override
public boolean match(String pattern, String path) {
- if (path.endsWith(".class")) {
- String filename = GrailsStringUtils.getFileBasename(path);
- if (filename.contains("$")) return false;
+ if (path.endsWith(".class") && GrailsStringUtils.getFileBasename(path).contains("$")) {
+ return false;
}
return super.match(pattern, path);
}
@@ -248,4 +95,5 @@ public boolean match(String pattern, String path) {
scanner.setResourceLoader(resourceResolver);
return scanner;
}
+
}
diff --git a/grails-core/src/main/groovy/org/grails/spring/context/annotation/GrailsComponentScanPostProcessor.java b/grails-core/src/main/groovy/org/grails/spring/context/annotation/GrailsComponentScanPostProcessor.java
new file mode 100644
index 00000000000..58bd2b4040c
--- /dev/null
+++ b/grails-core/src/main/groovy/org/grails/spring/context/annotation/GrailsComponentScanPostProcessor.java
@@ -0,0 +1,95 @@
+/*
+ * 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.spring.context.annotation;
+
+import java.util.List;
+
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
+import org.springframework.context.annotation.ClassPathBeanDefinitionScanner;
+import org.springframework.core.io.DefaultResourceLoader;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.core.type.filter.TypeFilter;
+import org.springframework.util.AntPathMatcher;
+
+import grails.plugins.GrailsPluginManager;
+import grails.util.GrailsStringUtils;
+
+/**
+ * Scans the packages named by {@code grails.spring.bean.packages} for annotated components.
+ *
+ * This is the programmatic equivalent of the {@code grailsContext:component-scan} element
+ * that {@link ClosureClassIgnoringComponentScanBeanDefinitionParser} serves, so the scan can
+ * be contributed without the XML namespace handler and therefore without the bean builder
+ * DSL. The two share their behaviour: class files whose name contains {@code $} are skipped,
+ * because a Groovy closure compiles to a class that is never a component candidate, and any
+ * {@link TypeFilter} the plugin manager contributes is applied as an include filter.
+ *
+ * @since 8.0
+ */
+public class GrailsComponentScanPostProcessor implements BeanDefinitionRegistryPostProcessor {
+
+ private final List packagesToScan;
+ private final GrailsPluginManager pluginManager;
+
+ public GrailsComponentScanPostProcessor(List packagesToScan, GrailsPluginManager pluginManager) {
+ this.packagesToScan = packagesToScan;
+ this.pluginManager = pluginManager;
+ }
+
+ @Override
+ public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
+ if (packagesToScan == null || packagesToScan.isEmpty()) {
+ return;
+ }
+
+ ClassPathBeanDefinitionScanner scanner = new ClassPathBeanDefinitionScanner(registry);
+ scanner.setResourceLoader(resourcePatternResolver());
+ if (pluginManager != null) {
+ for (TypeFilter typeFilter : pluginManager.getTypeFilters()) {
+ scanner.addIncludeFilter(typeFilter);
+ }
+ }
+ scanner.scan(packagesToScan.toArray(new String[0]));
+ }
+
+ private static PathMatchingResourcePatternResolver resourcePatternResolver() {
+ PathMatchingResourcePatternResolver resolver =
+ new PathMatchingResourcePatternResolver(new DefaultResourceLoader());
+ resolver.setPathMatcher(new AntPathMatcher() {
+ @Override
+ public boolean match(String pattern, String path) {
+ if (path.endsWith(".class") && GrailsStringUtils.getFileBasename(path).contains("$")) {
+ return false;
+ }
+ return super.match(pattern, path);
+ }
+ });
+ return resolver;
+ }
+
+ @Override
+ public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+ // bean definitions are contributed in postProcessBeanDefinitionRegistry
+ }
+
+}
diff --git a/grails-core/src/main/groovy/org/grails/spring/context/support/GrailsPlaceholderConfigurer.java b/grails-core/src/main/groovy/org/grails/spring/context/support/GrailsPlaceholderConfigurer.java
index c34aed786d2..ca5a0dda757 100644
--- a/grails-core/src/main/groovy/org/grails/spring/context/support/GrailsPlaceholderConfigurer.java
+++ b/grails-core/src/main/groovy/org/grails/spring/context/support/GrailsPlaceholderConfigurer.java
@@ -31,7 +31,6 @@
import org.springframework.util.StringValueResolver;
import grails.config.Config;
-import grails.core.support.GrailsConfigurationAware;
/**
* Uses Grails' ConfigObject for place holder values.
@@ -39,12 +38,11 @@
* @author Graeme Rocher
* @since 1.0
*/
-public class GrailsPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer implements GrailsConfigurationAware {
+public class GrailsPlaceholderConfigurer extends PropertySourcesPlaceholderConfigurer {
private Properties properties;
private String beanName;
private BeanFactory beanFactory;
- private Config config;
public GrailsPlaceholderConfigurer(String placeHolderPrefix, Properties properties) {
this.properties = properties;
@@ -58,10 +56,7 @@ public GrailsPlaceholderConfigurer() {
@Override
protected void loadProperties(Properties props) throws IOException {
- if (config != null) {
- props.putAll(config.toProperties());
- }
- else if (this.properties != null) {
+ if (this.properties != null) {
props.putAll(properties);
}
this.properties = props;
@@ -114,9 +109,4 @@ protected void visitMap(Map, ?> mapVal) {
// New in Spring 3.0: resolve placeholders in embedded values such as annotation attributes.
beanFactoryToProcess.addEmbeddedValueResolver(valueResolver);
}
-
- @Override
- public void setConfiguration(Config co) {
- this.config = co;
- }
}
diff --git a/grails-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 34c98463fd5..6e60d951a68 100644
--- a/grails-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/grails-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1 @@
-org.grails.plugins.core.CoreAutoConfiguration
\ No newline at end of file
+org.grails.plugins.CoreAutoConfiguration
\ No newline at end of file
diff --git a/grails-core/src/test/groovy/grails/spring/GrailsPlaceHolderConfigurerCorePluginRuntimeSpec.groovy b/grails-core/src/test/groovy/grails/spring/GrailsPlaceHolderConfigurerCorePluginRuntimeSpec.groovy
deleted file mode 100644
index 240d629466d..00000000000
--- a/grails-core/src/test/groovy/grails/spring/GrailsPlaceHolderConfigurerCorePluginRuntimeSpec.groovy
+++ /dev/null
@@ -1,73 +0,0 @@
-/*
- * 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.spring
-
-import grails.core.DefaultGrailsApplication
-import org.grails.plugins.CoreGrailsPlugin
-import org.grails.spring.context.support.GrailsPlaceholderConfigurer
-import spock.lang.Issue
-import spock.lang.Specification
-import spock.util.environment.RestoreSystemProperties
-
-/**
- * @author Graeme Rocher
- */
-@RestoreSystemProperties
-class GrailsPlaceHolderConfigurerCorePluginRuntimeSpec extends Specification{
-
- @Issue('GRAILS-10130')
- void "Test that system properties are used to replace values at runtime with GrailsPlaceHolderConfigurer"() {
- given:"A configured application context"
- def parent = new BeanBuilder()
- parent.beans {
- grailsApplication(DefaultGrailsApplication)
- }
- def bb = new BeanBuilder(parent.createApplicationContext())
-
- final beanBinding = new Binding()
-
- def app = new DefaultGrailsApplication()
- beanBinding.setVariable('application', app)
- bb.setBinding(beanBinding)
-
- def plugin = new CoreGrailsPlugin()
- plugin.grailsApplication = app
- bb.beans plugin.doWithSpring()
- bb.beans {
- propertySourcesPlaceholderConfigurer(GrailsPlaceholderConfigurer)
- testBean(ReplacePropertyBean) {
- foo = '${foo.bar}'
- }
- }
-
- when:"A system property is used in a bean property"
- System.setProperty('foo.bar', "test")
- final appCtx = bb.createApplicationContext()
- def bean = appCtx.getBean("testBean")
-
- then:"The system property is ready"
- appCtx != null
- bean.foo == 'test'
- }
-
-}
-class ReplacePropertyBean {
- String foo
-}
-
diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreAutoConfigurationSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreAutoConfigurationSpec.groovy
new file mode 100644
index 00000000000..9ddb34f6345
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/CoreAutoConfigurationSpec.groovy
@@ -0,0 +1,178 @@
+/*
+ * 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
+
+import org.springframework.beans.factory.support.BeanDefinitionRegistry
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration
+import org.springframework.boot.test.context.runner.ApplicationContextRunner
+import org.springframework.context.ApplicationContextInitializer
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.context.support.PropertySourcesPlaceholderConfigurer
+
+import grails.config.ConfigProperties
+import grails.config.Settings
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+
+import org.grails.spring.context.support.GrailsPlaceholderConfigurer
+
+import spock.lang.Issue
+import spock.lang.Specification
+import spock.util.environment.RestoreSystemProperties
+
+class CoreAutoConfigurationSpec extends Specification {
+
+ private GrailsApplication grailsApplication = new DefaultGrailsApplication()
+
+ private ApplicationContextRunner contextRunner() {
+ // Both classLoader and grailsConfigProperties are derived from the GrailsApplication,
+ // which the core plugin itself contributes at runtime rather than this auto-configuration.
+ new ApplicationContextRunner()
+ .withInitializer(registerSingleton('grailsApplication', grailsApplication))
+ .withConfiguration(AutoConfigurations.of(CoreAutoConfiguration, PropertyPlaceholderAutoConfiguration))
+ }
+
+ private static ApplicationContextInitializer registerSingleton(String name, Object instance) {
+ return { ConfigurableApplicationContext context ->
+ context.beanFactory.registerSingleton(name, instance)
+ } as ApplicationContextInitializer
+ }
+
+ // Placeholders are substituted by BeanDefinitionVisitor over registered bean definitions, so the
+ // probe has to be a real definition carrying a property value - a supplier-registered bean has
+ // no definition-level string for the configurer to visit.
+ private static ApplicationContextInitializer registerProbe(String placeholder) {
+ return { ConfigurableApplicationContext context ->
+ RootBeanDefinition definition = new RootBeanDefinition(PlaceholderProbe)
+ definition.propertyValues.add('name', placeholder)
+ ((BeanDefinitionRegistry) context.beanFactory).registerBeanDefinition('placeholderProbe', definition)
+ } as ApplicationContextInitializer
+ }
+
+ void 'the core beans register'() {
+ expect:
+ contextRunner().run { context ->
+ assert context.getBean('classLoader') instanceof ClassLoader
+ assert context.getBean('grailsConfigProperties') instanceof ConfigProperties
+ assert context.getBean('propertySourcesPlaceholderConfigurer') instanceof GrailsPlaceholderConfigurer
+ }
+ }
+
+ void 'the classLoader bean exposes the GrailsApplication class loader'() {
+ expect:
+ contextRunner().run { context ->
+ assert context.getBean('classLoader').is(grailsApplication.classLoader)
+ }
+ }
+
+ void 'the classLoader bean is primary, so it wins over another ClassLoader candidate'() {
+ given:
+ ClassLoader competing = new URLClassLoader(new URL[0])
+
+ expect:
+ contextRunner()
+ .withInitializer(registerSingleton('someOtherClassLoader', competing))
+ .run { context ->
+ assert context.getBeanNamesForType(ClassLoader).length == 2
+ assert context.getBean(ClassLoader).is(grailsApplication.classLoader)
+ }
+ }
+
+ void 'grailsConfigProperties reads through to the application config'() {
+ given:
+ grailsApplication.config.foo = [bar: 'test']
+
+ expect:
+ contextRunner().run { context ->
+ assert context.getBean('grailsConfigProperties', ConfigProperties).getProperty('foo.bar') == 'test'
+ }
+ }
+
+ void "the Grails placeholder configurer replaces Boot's, which orders after it and backs off"() {
+ expect:
+ contextRunner().run { context ->
+ def configurers = context.getBeansOfType(PropertySourcesPlaceholderConfigurer)
+ assert configurers.size() == 1
+ assert configurers.values().first() instanceof GrailsPlaceholderConfigurer
+ }
+ }
+
+ void 'bean definition placeholders are resolved with the default prefix'() {
+ expect:
+ contextRunner()
+ .withInitializer(registerProbe('${foo.bar}'))
+ .withPropertyValues('foo.bar=test')
+ .run { context ->
+ assert context.getBean(PlaceholderProbe).name == 'test'
+ }
+ }
+
+ void 'an unresolvable placeholder is left in place rather than failing the context'() {
+ expect:
+ contextRunner()
+ .withInitializer(registerProbe('${no.such.property}'))
+ .run { context ->
+ assert context.getBean(PlaceholderProbe).name == '${no.such.property}'
+ }
+ }
+
+ void 'a configured placeholder prefix is applied to the configurer'() {
+ expect:
+ contextRunner()
+ .withInitializer(registerProbe('@{foo.bar}'))
+ .withPropertyValues("${Settings.SPRING_PLACEHOLDER_PREFIX}=@{", 'foo.bar=test')
+ .run { context ->
+ assert context.getBean(PlaceholderProbe).name == 'test'
+ }
+ }
+
+ @Issue('GRAILS-10130')
+ @RestoreSystemProperties
+ void 'a system property is resolved in a bean definition placeholder'() {
+ given:
+ System.setProperty('foo.bar', 'test')
+
+ expect:
+ contextRunner()
+ .withInitializer(registerProbe('${foo.bar}'))
+ .run { context ->
+ assert context.getBean(PlaceholderProbe).name == 'test'
+ }
+ }
+
+ void 'a configured placeholder prefix displaces the default one'() {
+ expect:
+ contextRunner()
+ .withInitializer(registerProbe('${foo.bar}'))
+ .withPropertyValues("${Settings.SPRING_PLACEHOLDER_PREFIX}=@{", 'foo.bar=test')
+ .run { context ->
+ assert context.getBean(PlaceholderProbe).name == '${foo.bar}'
+ }
+ }
+
+ static class PlaceholderProbe {
+
+ String name
+
+ }
+
+}
diff --git a/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginRegistrarSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginRegistrarSpec.groovy
new file mode 100644
index 00000000000..e6f1e99c8dc
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/CoreGrailsPluginRegistrarSpec.groovy
@@ -0,0 +1,229 @@
+/*
+ * 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
+
+import java.beans.PropertyEditor
+
+import org.springframework.aop.config.AopConfigUtils
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.config.BeanDefinition
+import org.springframework.beans.factory.config.CustomEditorConfigurer
+import org.springframework.beans.factory.support.BeanRegistryAdapter
+import org.springframework.beans.factory.support.GenericBeanDefinition
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.support.GenericApplicationContext
+
+import grails.config.Settings
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+import grails.core.support.GrailsApplicationAware
+import grails.core.support.proxy.DefaultProxyHandler
+import grails.core.support.proxy.ProxyHandler
+import grails.util.BuildSettings
+
+import org.grails.beans.support.PropertiesEditor
+import org.grails.core.io.DefaultResourceLocator
+import org.grails.core.support.ClassEditor
+import org.grails.spring.aop.autoproxy.GroovyAwareAspectJAwareAdvisorAutoProxyCreator
+import org.grails.spring.aop.autoproxy.GroovyAwareInfrastructureAdvisorAutoProxyCreator
+import org.grails.spring.beans.AbstractResourceLocatorPostProcessor
+
+import spock.lang.Specification
+
+/**
+ * Covers the beans {@code CoreGrailsPlugin} contributes through {@code beanRegistrar()}. The beans
+ * it contributes through its {@code beans} DSL are covered by {@link CoreAutoConfigurationSpec},
+ * and the {@code grails.spring.bean.packages} scan by {@link SpringBeanPackagesSpec}.
+ */
+class CoreGrailsPluginRegistrarSpec extends Specification {
+
+ void 'the auto proxy creator is the AspectJ aware variant when AspectJ is available'() {
+ given:
+ GenericApplicationContext context = buildContext(new DefaultGrailsApplication())
+
+ expect:
+ context.getBean(AopConfigUtils.AUTO_PROXY_CREATOR_BEAN_NAME) instanceof GroovyAwareAspectJAwareAdvisorAutoProxyCreator
+
+ cleanup:
+ context.close()
+ }
+
+ void 'AspectJ auto-weaving can be turned off, leaving the infrastructure advisor'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.config.put(Settings.SPRING_DISABLE_ASPECTJ, true)
+
+ when:
+ GenericApplicationContext context = buildContext(application)
+
+ then:
+ context.getBean(AopConfigUtils.AUTO_PROXY_CREATOR_BEAN_NAME) instanceof GroovyAwareInfrastructureAdvisorAutoProxyCreator
+
+ cleanup:
+ context.close()
+ }
+
+ void 'the infrastructure advisor is used when AspectJ is off the application class path'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.classLoader = new AspectJHidingClassLoader(getClass().classLoader)
+
+ when:
+ GenericApplicationContext context = buildContext(application)
+
+ then:
+ context.getBean(AopConfigUtils.AUTO_PROXY_CREATOR_BEAN_NAME) instanceof GroovyAwareInfrastructureAdvisorAutoProxyCreator
+
+ cleanup:
+ context.close()
+ }
+
+ void 'the Class and Properties editors are registered'() {
+ given:
+ GenericApplicationContext context = buildContext(new DefaultGrailsApplication())
+
+ when:
+ Map, Class extends PropertyEditor>> editors =
+ context.getBean('customEditors', CustomEditorConfigurer).customEditors
+
+ then:
+ editors[Class] == ClassEditor
+ editors[Properties] == PropertiesEditor
+
+ cleanup:
+ context.close()
+ }
+
+ void 'a proxy handler is available'() {
+ given:
+ GenericApplicationContext context = buildContext(new DefaultGrailsApplication())
+
+ expect:
+ context.getBean(ProxyHandler) instanceof DefaultProxyHandler
+
+ cleanup:
+ context.close()
+ }
+
+ void 'the abstract resource locator parent is registered and can be inherited from'() {
+ given: 'a definition naming the parent the way a third-party plugin does'
+ GenericApplicationContext context = buildContext(new DefaultGrailsApplication()) {
+ GenericBeanDefinition child = new GenericBeanDefinition()
+ child.beanClass = DefaultResourceLocator
+ child.parentName = AbstractResourceLocatorPostProcessor.BEAN_NAME
+ it.registerBeanDefinition('inheritingLocator', child)
+ }
+
+ when:
+ BeanDefinition merged = context.beanFactory.getMergedBeanDefinition('inheritingLocator')
+
+ then: 'the parent exists, is abstract, and its property reaches the child'
+ context.getBeanDefinition(AbstractResourceLocatorPostProcessor.BEAN_NAME).abstract
+ merged.propertyValues.getPropertyValue('searchLocations').value == [BuildSettings.BASE_DIR.absolutePath]
+
+ and: 'so the child is instantiable'
+ context.getBean('inheritingLocator') instanceof DefaultResourceLocator
+
+ cleanup:
+ context.close()
+ }
+
+ void 'a GrailsApplicationAware bean is handed the application'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+
+ when:
+ GenericApplicationContext context = buildContext(application) {
+ it.registerBeanDefinition('aware', new RootBeanDefinition(AwareProbe))
+ }
+
+ then:
+ context.getBean(AwareProbe).grailsApplication.is(application)
+
+ cleanup:
+ context.close()
+ }
+
+ void 'bean properties are overridden from the beans config block'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.config.put('beans', [probe: [value: 'overridden']])
+
+ when:
+ GenericApplicationContext context = buildContext(application) {
+ it.registerBeanDefinition('probe', new RootBeanDefinition(ValueProbe))
+ }
+
+ then:
+ context.getBean('probe', ValueProbe).value == 'overridden'
+
+ cleanup:
+ context.close()
+ }
+
+ private static GenericApplicationContext buildContext(GrailsApplication application,
+ Closure> customizer = null) {
+ CoreGrailsPlugin plugin = new CoreGrailsPlugin()
+ plugin.grailsApplication = application
+
+ GenericApplicationContext context = new GenericApplicationContext()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+ customizer?.call(context)
+
+ BeanRegistrar registrar = plugin.beanRegistrar()
+ new BeanRegistryAdapter(context, context.beanFactory, context.environment, registrar.getClass())
+ .register(registrar)
+ context.refresh()
+ context
+ }
+
+ /**
+ * Stands in for a deployment without AspectJ, which the plugin detects by asking the
+ * application's class loader for {@code org.aspectj.lang.annotation.Around}.
+ */
+ static class AspectJHidingClassLoader extends ClassLoader {
+
+ AspectJHidingClassLoader(ClassLoader parent) {
+ super(parent)
+ }
+
+ @Override
+ Class> loadClass(String name) throws ClassNotFoundException {
+ if (name.startsWith('org.aspectj.')) {
+ throw new ClassNotFoundException(name)
+ }
+ super.loadClass(name)
+ }
+
+ }
+
+ static class AwareProbe implements GrailsApplicationAware {
+
+ GrailsApplication grailsApplication
+
+ }
+
+ static class ValueProbe {
+
+ String value
+
+ }
+
+}
diff --git a/grails-core/src/test/groovy/org/grails/plugins/SpringBeanPackagesSpec.groovy b/grails-core/src/test/groovy/org/grails/plugins/SpringBeanPackagesSpec.groovy
new file mode 100644
index 00000000000..ca4bf341638
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/SpringBeanPackagesSpec.groovy
@@ -0,0 +1,86 @@
+/*
+ * 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
+
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.support.BeanRegistryAdapter
+import org.springframework.context.support.GenericApplicationContext
+
+import grails.core.DefaultGrailsApplication
+import grails.core.GrailsApplication
+
+import org.grails.plugins.scan.ScannedComponent
+
+import spock.lang.Specification
+
+/**
+ * {@code grails.spring.bean.packages} is documented as "List of packages to scan for
+ * Spring beans". These assertions state that from an application's point of view: set
+ * the property, get the beans.
+ */
+class SpringBeanPackagesSpec extends Specification {
+
+ void 'a component in a configured package is registered as a bean'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+ application.config.put('grails.spring.bean.packages', ['org.grails.plugins.scan'])
+
+ when:
+ def context = buildContext(application)
+
+ then:
+ context.getBeanNamesForType(ScannedComponent).length == 1
+ context.getBean(ScannedComponent).greet() == 'scanned'
+
+ cleanup:
+ context.close()
+ }
+
+ void 'no scanning happens when the property is unset'() {
+ given:
+ GrailsApplication application = new DefaultGrailsApplication()
+
+ when:
+ def context = buildContext(application)
+
+ then:
+ context.getBeanNamesForType(ScannedComponent).length == 0
+
+ cleanup:
+ context.close()
+ }
+
+ private static GenericApplicationContext buildContext(GrailsApplication application) {
+ CoreGrailsPlugin plugin = new CoreGrailsPlugin()
+ plugin.grailsApplication = application
+
+ GenericApplicationContext context = new GenericApplicationContext()
+ context.beanFactory.registerSingleton(GrailsApplication.APPLICATION_ID, application)
+
+ // the scan is contributed through beanRegistrar(), applied before refresh the way the
+ // plugin manager applies it, so its BeanDefinitionRegistryPostProcessor actually runs
+ BeanRegistrar registrar = plugin.beanRegistrar()
+ new BeanRegistryAdapter(context, context.beanFactory, context.environment, registrar.getClass())
+ .register(registrar)
+ context.refresh()
+ context
+ }
+
+}
diff --git a/grails-core/src/test/groovy/org/grails/plugins/scan/ScannedComponent.groovy b/grails-core/src/test/groovy/org/grails/plugins/scan/ScannedComponent.groovy
new file mode 100644
index 00000000000..3b22d8d0308
--- /dev/null
+++ b/grails-core/src/test/groovy/org/grails/plugins/scan/ScannedComponent.groovy
@@ -0,0 +1,35 @@
+/*
+ * 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.scan
+
+import org.springframework.stereotype.Component
+
+/**
+ * Target for the {@code grails.spring.bean.packages} component scan, in a package
+ * containing nothing else so the scan's reach is unambiguous.
+ */
+@Component
+class ScannedComponent {
+
+ String greet() {
+ 'scanned'
+ }
+
+}
diff --git a/grails-databinding/build.gradle b/grails-databinding/build.gradle
index c80aaa7ecc4..0f66d8ac082 100644
--- a/grails-databinding/build.gradle
+++ b/grails-databinding/build.gradle
@@ -38,6 +38,7 @@ dependencies {
implementation platform(project(':grails-bom'))
+ implementation project(':grails-beans-dsl')
api project(':grails-core')
api project(':grails-web-core')
api 'org.apache.groovy:groovy'
diff --git a/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingConfiguration.java b/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingConfiguration.java
deleted file mode 100644
index 8bc31ca4c9a..00000000000
--- a/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingConfiguration.java
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * 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.databinding;
-
-import org.springframework.boot.autoconfigure.AutoConfiguration;
-import org.springframework.boot.autoconfigure.AutoConfigureOrder;
-import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
-import org.springframework.boot.context.properties.EnableConfigurationProperties;
-import org.springframework.context.ApplicationContext;
-import org.springframework.context.MessageSource;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.annotation.Lazy;
-import org.springframework.core.annotation.AnnotationAwareOrderComparator;
-
-import grails.core.GrailsApplication;
-import grails.databinding.TypedStructuredBindingEditor;
-import grails.databinding.converters.FormattedValueConverter;
-import grails.databinding.converters.ValueConverter;
-import grails.databinding.events.DataBindingListener;
-import grails.util.GrailsArrayUtils;
-import grails.web.databinding.GrailsWebDataBinder;
-import org.grails.databinding.bindingsource.DataBindingSourceCreator;
-import org.grails.databinding.converters.DefaultConvertersConfiguration;
-import org.grails.web.databinding.bindingsource.DataBindingSourceRegistry;
-import org.grails.web.databinding.bindingsource.DefaultDataBindingSourceRegistry;
-import org.grails.web.databinding.bindingsource.HalJsonDataBindingSourceCreator;
-import org.grails.web.databinding.bindingsource.HalXmlDataBindingSourceCreator;
-import org.grails.web.databinding.bindingsource.JsonApiDataBindingSourceCreator;
-import org.grails.web.databinding.bindingsource.JsonDataBindingSourceCreator;
-import org.grails.web.databinding.bindingsource.XmlDataBindingSourceCreator;
-
-@AutoConfiguration
-@AutoConfigureOrder
-@EnableConfigurationProperties(DataBindingConfigurationProperties.class)
-@ImportAutoConfiguration(DefaultConvertersConfiguration.class)
-public class DataBindingConfiguration {
-
- private final DataBindingConfigurationProperties configurationProperties;
-
- public DataBindingConfiguration(DataBindingConfigurationProperties configurationProperties) {
- this.configurationProperties = configurationProperties;
- }
-
- /*
- Must be lazy initialized because plugins ValueConverters & StructuredBindingEditors may be initialized by the Grails
- Bean DSL instead of an AutoConfiguration. For example, DataBindingConfigurationSpec defines beans as part of the test
- startup and without this being Lazy it, those beans will never be wired to the GrailsWebDataBinder bean.
- */
- @Lazy
- @Bean("grailsWebDataBinder")
- protected GrailsWebDataBinder grailsWebDataBinder(
- GrailsApplication grailsApplication,
- ValueConverter[] valueConverters,
- FormattedValueConverter[] formattedValueConverters,
- TypedStructuredBindingEditor[] structuredBindingEditors,
- DataBindingListener[] dataBindingListeners) {
-
- GrailsWebDataBinder dataBinder = new GrailsWebDataBinder(grailsApplication);
- dataBinder.setConvertEmptyStringsToNull(configurationProperties.isConvertEmptyStringsToNull());
- dataBinder.setTrimStrings(configurationProperties.isTrimStrings());
- dataBinder.setAutoGrowCollectionLimit(configurationProperties.getAutoGrowCollectionLimit());
- final ApplicationContext mainContext = grailsApplication.getMainContext();
- final ValueConverter[] mainContextConverters = mainContext
- .getBeansOfType(ValueConverter.class).values().toArray(new ValueConverter[0]);
- final ValueConverter[] allValueConverters = GrailsArrayUtils.concat(valueConverters, mainContextConverters);
- AnnotationAwareOrderComparator.sort(allValueConverters);
- dataBinder.setValueConverters(allValueConverters);
-
- final FormattedValueConverter[] mainContextFormattedValueConverters = mainContext
- .getBeansOfType(FormattedValueConverter.class).values().toArray(new FormattedValueConverter[0]);
- dataBinder.setFormattedValueConverters(GrailsArrayUtils.concat(formattedValueConverters, mainContextFormattedValueConverters));
- final TypedStructuredBindingEditor[] mainContextStructuredBindingEditors = mainContext
- .getBeansOfType(TypedStructuredBindingEditor.class).values().toArray(new TypedStructuredBindingEditor[0]);
- dataBinder.setStructuredBindingEditors(GrailsArrayUtils.concat(structuredBindingEditors, mainContextStructuredBindingEditors));
- final DataBindingListener[] mainContextDataBindingListeners = mainContext
- .getBeansOfType(DataBindingListener.class).values().toArray(new DataBindingListener[0]);
- dataBinder.setDataBindingListeners(GrailsArrayUtils.concat(dataBindingListeners, mainContextDataBindingListeners));
- dataBinder.setMessageSource(mainContext.getBean("messageSource", MessageSource.class));
- return dataBinder;
- }
-
- @Bean("xmlDataBindingSourceCreator")
- protected XmlDataBindingSourceCreator xmlDataBindingSourceCreator() {
- return new XmlDataBindingSourceCreator();
- }
-
- @Bean("jsonDataBindingSourceCreator")
- protected JsonDataBindingSourceCreator jsonDataBindingSourceCreator() {
- return new JsonDataBindingSourceCreator();
- }
-
- @Bean("halJsonDataBindingSourceCreator")
- protected HalJsonDataBindingSourceCreator halJsonDataBindingSourceCreator() {
- return new HalJsonDataBindingSourceCreator();
- }
-
- @Bean("halXmlDataBindingSourceCreator")
- protected HalXmlDataBindingSourceCreator halXmlDataBindingSourceCreator() {
- return new HalXmlDataBindingSourceCreator();
- }
-
- @Bean("jsonApiDataBindingSourceCreator")
- protected JsonApiDataBindingSourceCreator jsonApiDataBindingSourceCreator() {
- return new JsonApiDataBindingSourceCreator();
- }
-
- @Bean("dataBindingSourceRegistry")
- protected DataBindingSourceRegistry dataBindingSourceRegistry(DataBindingSourceCreator... creators) {
- final DefaultDataBindingSourceRegistry registry = new DefaultDataBindingSourceRegistry();
- registry.setDataBindingSourceCreators(creators);
- registry.initialize();
- return registry;
- }
-
-}
diff --git a/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingGrailsPlugin.groovy b/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingGrailsPlugin.groovy
index e295df27840..f9981a5b503 100644
--- a/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingGrailsPlugin.groovy
+++ b/grails-databinding/src/main/groovy/org/grails/plugins/databinding/DataBindingGrailsPlugin.groovy
@@ -16,10 +16,36 @@
* specific language governing permissions and limitations
* under the License.
*/
+
package org.grails.plugins.databinding
+import groovy.transform.CompileStatic
+
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.AutoConfigureOrder
+import org.springframework.boot.autoconfigure.ImportAutoConfiguration
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.context.ApplicationContext
+import org.springframework.context.MessageSource
+import org.springframework.core.annotation.AnnotationAwareOrderComparator
+
+import grails.core.GrailsApplication
+import grails.databinding.TypedStructuredBindingEditor
+import grails.databinding.converters.FormattedValueConverter
+import grails.databinding.converters.ValueConverter
+import grails.databinding.events.DataBindingListener
import grails.plugins.Plugin
import grails.util.GrailsUtil
+import grails.web.databinding.GrailsWebDataBinder
+import org.grails.databinding.bindingsource.DataBindingSourceCreator
+import org.grails.databinding.converters.DefaultConvertersConfiguration
+import org.grails.web.databinding.bindingsource.DataBindingSourceRegistry
+import org.grails.web.databinding.bindingsource.DefaultDataBindingSourceRegistry
+import org.grails.web.databinding.bindingsource.HalJsonDataBindingSourceCreator
+import org.grails.web.databinding.bindingsource.HalXmlDataBindingSourceCreator
+import org.grails.web.databinding.bindingsource.JsonApiDataBindingSourceCreator
+import org.grails.web.databinding.bindingsource.JsonDataBindingSourceCreator
+import org.grails.web.databinding.bindingsource.XmlDataBindingSourceCreator
/**
* Plugin for configuring the data binding features of Grails
@@ -29,7 +55,68 @@ import grails.util.GrailsUtil
*
* @since 2.3
*/
+@CompileStatic
+@AutoConfiguration
+@AutoConfigureOrder
+@EnableConfigurationProperties(DataBindingConfigurationProperties)
+@ImportAutoConfiguration(DefaultConvertersConfiguration)
class DataBindingGrailsPlugin extends Plugin {
def version = GrailsUtil.getGrailsVersion()
+
+ def beans = {
+ // Must be lazily initialized because plugins' ValueConverters and StructuredBindingEditors
+ // may be registered through the Grails bean DSL rather than an auto-configuration. For
+ // example DataBindingConfigurationSpec defines beans as part of test startup, and without
+ // this they would never be wired into the GrailsWebDataBinder bean.
+ //
+ // configurationProperties was a constructor-injected field on the hand-written class; the
+ // generated sibling always has a no-arg constructor, so the one bean that reads it takes it
+ // as a parameter instead.
+ bean(GrailsWebDataBinder).lazy() { GrailsApplication grailsApplication,
+ DataBindingConfigurationProperties configurationProperties,
+ ValueConverter[] valueConverters,
+ FormattedValueConverter[] formattedValueConverters,
+ TypedStructuredBindingEditor[] structuredBindingEditors,
+ DataBindingListener[] dataBindingListeners ->
+
+ GrailsWebDataBinder dataBinder = new GrailsWebDataBinder(grailsApplication)
+ dataBinder.convertEmptyStringsToNull = configurationProperties.convertEmptyStringsToNull
+ dataBinder.trimStrings = configurationProperties.trimStrings
+ dataBinder.autoGrowCollectionLimit = configurationProperties.autoGrowCollectionLimit
+
+ ApplicationContext mainContext = grailsApplication.mainContext
+ ValueConverter[] allValueConverters = (valueConverters + mainContext.getBeansOfType(ValueConverter).values()) as ValueConverter[]
+ AnnotationAwareOrderComparator.sort(allValueConverters)
+ dataBinder.valueConverters = allValueConverters
+
+ dataBinder.formattedValueConverters =
+ (formattedValueConverters + mainContext.getBeansOfType(FormattedValueConverter).values()) as FormattedValueConverter[]
+ dataBinder.structuredBindingEditors =
+ (structuredBindingEditors + mainContext.getBeansOfType(TypedStructuredBindingEditor).values()) as TypedStructuredBindingEditor[]
+ dataBinder.dataBindingListeners =
+ (dataBindingListeners + mainContext.getBeansOfType(DataBindingListener).values()) as DataBindingListener[]
+
+ dataBinder.messageSource = mainContext.getBean('messageSource', MessageSource)
+ dataBinder
+ }
+
+ // Each of these is nothing but its own no-argument construction, and each type's
+ // JavaBeans-derived name is exactly the bean name the hand-written class declared.
+ bean(XmlDataBindingSourceCreator)
+ bean(JsonDataBindingSourceCreator)
+ bean(HalJsonDataBindingSourceCreator)
+ bean(HalXmlDataBindingSourceCreator)
+ bean(JsonApiDataBindingSourceCreator)
+
+ // Declared as an array rather than the original varargs parameter; Spring resolves both the
+ // same way, injecting every DataBindingSourceCreator bean.
+ bean(DataBindingSourceRegistry) { DataBindingSourceCreator[] creators ->
+ new DefaultDataBindingSourceRegistry().tap {
+ dataBindingSourceCreators = creators
+ initialize()
+ }
+ }
+ }
+
}
diff --git a/grails-databinding/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-databinding/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index a07693962b3..ebcd2e6890f 100644
--- a/grails-databinding/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/grails-databinding/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -1 +1 @@
-org.grails.plugins.databinding.DataBindingConfiguration
\ No newline at end of file
+org.grails.plugins.databinding.DataBindingAutoConfiguration
\ No newline at end of file
diff --git a/grails-doc/src/en/guide/conf/applicationClass/applicationLifeCycle.adoc b/grails-doc/src/en/guide/conf/applicationClass/applicationLifeCycle.adoc
index 86b18ffb761..0ffc979d5b0 100644
--- a/grails-doc/src/en/guide/conf/applicationClass/applicationLifeCycle.adoc
+++ b/grails-doc/src/en/guide/conf/applicationClass/applicationLifeCycle.adoc
@@ -34,3 +34,5 @@ class Application extends GrailsAutoConfiguration {
...
}
----
+
+The `Application` class can also define its beans at compile time instead, by annotating it with `@GrailsBeans` — see link:spring.html#springdslAdditional[Configuring Additional Beans].
diff --git a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc
index 4413ca03168..b9bffa7616e 100644
--- a/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc
+++ b/grails-doc/src/en/guide/plugins/hookingIntoRuntimeConfiguration.adoc
@@ -119,6 +119,146 @@ class I18nGrailsPlugin extends Plugin {
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.
+==== Compiling Bean Definitions into a Spring Boot AutoConfiguration
+
+
+`beanRegistrar()` and `doWithSpring` register beans at a single, fixed point in startup: before Spring Boot processes any `{springbootapi}org/springframework/boot/autoconfigure/AutoConfiguration.html[AutoConfiguration]`. That is enough for a bean to win against a Boot default guarded by `@ConditionalOnMissingBean`, but it cannot express ordering *relative to a specific* auto-configuration — running before `WebMvcAutoConfiguration` but after `MessageSourceAutoConfiguration`, say, regardless of where those two land in Boot's overall sort. Only a real `AutoConfiguration` class can declare that ordering, via `@AutoConfiguration(before = ..., after = ...)`.
+
+The link:{api}grails/compiler/beans/GrailsBeans.html[GrailsBeans] annotation lets you author bean definitions with DSL syntax similar to the classic Beans DSL, while compiling them at build time into genuine `@Bean` factory methods on a plain `AutoConfiguration` class — no closures, and nothing DSL-specific, survive into the compiled bytecode. Declare a class whose `beans` property is a closure of `bean(["name", ] Type) { ... }` statements:
+
+NOTE: On a `*GrailsPlugin.groovy` plugin descriptor or an application's `Application` class the annotation is implicit — a `beans` property is compiled automatically, the same way `doWithSpring` and `watchedResources` are conventions rather than annotated members. `@GrailsBeans` is written out only on a standalone class, as in the first example below. A class with no `beans` property is untouched.
+
+[source,groovy]
+----
+import grails.compiler.beans.GrailsBeans
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration
+import org.springframework.context.MessageSource
+import org.springframework.context.support.ReloadableResourceBundleMessageSource
+import org.springframework.web.servlet.i18n.CookieLocaleResolver
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor
+
+@GrailsBeans
+@AutoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration])
+class I18nBeans {
+
+ def beans = {
+ bean('messageSource', ReloadableResourceBundleMessageSource) {
+ new ReloadableResourceBundleMessageSource(basename: 'WEB-INF/grails-app/i18n/messages')
+ }
+
+ bean('localeResolver', CookieLocaleResolver).conditionalOnMissingBean(CookieLocaleResolver) {
+ new CookieLocaleResolver('locale')
+ }
+
+ bean('localeChangeInterceptor', LocaleChangeInterceptor) { MessageSource messageSource ->
+ new LocaleChangeInterceptor(paramName: 'lang')
+ }
+ }
+}
+----
+
+Each `bean(["name", ] Type)` statement compiles to a public method returning the declared type and annotated `@Bean("name")`. The generated method's name is an implementation detail — Spring resolves the bean by its `@Bean("name")` value, never by the method name. It matches the bean name when that is a valid Java identifier not already taken by an existing member (declared, inherited, or defined elsewhere in the DSL — a bean named `toString` will not override `Object.toString()`); otherwise a synthesized `$N` name (`service$0`, `service$1`, ...) is used instead, so any legal, non-blank Spring bean name compiles:
+
+* The bean name defaults to the decapitalized simple class name when omitted.
+* The factory closure itself may be omitted when a bean is nothing but its own no-argument construction: `bean(XmlDataBindingSourceCreator)` declares exactly what `bean(XmlDataBindingSourceCreator) { new XmlDataBindingSourceCreator() }` does, without restating the type twice. It takes an explicit name (`bean('xmlCreator', XmlDataBindingSourceCreator)`) and chains the qualifiers below just as the closure form does (`bean(Widget).primary().lazy()`). The declared type is the one constructed, so it must be concrete and have an accessible no-argument constructor; for an interface or abstract type, give the bean a body naming the implementation.
+* The same bean name may be declared by more than one `bean(...)` statement — the standard Spring Boot pattern for mutually exclusive variants of one bean, such as the framework's own `grailsUrlConverter` selected by `@ConditionalOnProperty` — provided every declaration with the name carries its own discriminating condition (such as `.annotate(ConditionalOnProperty, ...)`), so that at most one of them registers at runtime. Duplicating a bean name without that is a compile-time error, since Spring would keep the first definition and silently skip the rest.
+* Chaining `.conditionalOnMissingBean(...)` adds a `@ConditionalOnMissingBean` annotation to the generated method, matching Spring Boot's usual back-off semantics. It takes positional types (`.conditionalOnMissingBean(Greeter)`), the annotation's own named attributes (`.conditionalOnMissingBean(name: 'localeResolver', search: SearchStrategy.CURRENT)`), or both. With no arguments at all it compiles to the bare annotation, letting Spring Boot infer the back-off type from the method's return type — the recommended form when a bean simply backs off its own type, since `bean(AvailableLocaleResolver).conditionalOnMissingBean()` already says everything `.conditionalOnMissingBean(AvailableLocaleResolver)` would repeat.
+* Chaining `.conditionalOnMissingBeanName(...)` is the name-based counterpart: "register this bean unless a bean with *this bean's name* already exists". The condition's `name` member is set from the bean's own (explicit or convention-derived) name, so `bean(MessageSource).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) { ... }` states `messageSource` once and it feeds both `@Bean` and the condition — the two strings the explicit form has to keep in sync cannot diverge. It accepts the annotation's other attributes (`search:`, `ignored:`, ...) but rejects `name:` and types, which would contradict its purpose; use `.conditionalOnMissingBean(...)` for those.
+* Chaining `.primary()`, `.lazy()`, and/or `.scope("name")` adds `@Primary`, `@Lazy`, and `@Scope("name")` respectively.
+* Chaining `.staticMethod()` makes the generated factory method `static` — Spring's recommended shape for `BeanFactoryPostProcessor` and `BeanPostProcessor` beans, which must be creatable without instantiating their declaring configuration class. A static bean's closure cannot reference `field(...)` or `method(...)` members, since those are instance members; under `@CompileStatic` that mistake is a compile error.
+* Chaining `.annotate(AnnotationType[, attr: value, ...])` attaches any other single-valued annotation directly — any `@Conditional*` (built-in or a custom `Condition`), `@Order`, `@DependsOn`, or anything else a hand-written `@Bean` method could carry. It's repeatable, so several different annotations can be chained onto the same bean: `bean('special', Special).annotate(Order, value: 1).annotate(ConditionalOnProperty, prefix: 'myapp', name: 'enabled', havingValue: 'true') { ... }`.
+* Any combination of the above can be chained together, in any order — for example `bean('slowGreeter', SlowGreeter).lazy().scope('prototype') { ... }`.
+* Typed closure parameters (`{ MessageSource messageSource -> ... }`) become the generated method's parameters, so other beans are injected the same way a hand-written `@Bean` method would declare them — and a parameter-level annotation such as `{ @Qualifier('special') Greeter g -> ... }` carries straight through too, since the closure's own parameters become the generated method's parameters directly.
+
+Two more statement kinds can appear inside `beans { }` alongside `bean(...)`, for state and logic shared across bean methods — the same role a hand-written `@Configuration` class's own fields and private methods play:
+
+* `field(["name", ] Type)`, optionally chained with `.value(...)` and/or (repeatably) `.annotate(AnnotationType[, attr: value, ...])`, declares a private field on the generated class. The usual case is injected configuration, and `.value(...)` covers it directly: `field('encoding', String).value('grails.gsp.view.encoding', 'UTF-8')` compiles to `@Value("${grails.gsp.view.encoding:UTF-8}")`. The two-argument form takes a config key and default — and because the DSL builds the placeholder itself, the key may be a bare constant reference (`.value(Settings.GSP_VIEW_ENCODING, 'UTF-8')`), the one shape a directly-written annotation value can't accept. The one-argument form takes a bare config key with no default — `.value('grails.web.linkGenerator.useCache')` compiles to `@Value("${grails.web.linkGenerator.useCache}")` — while a string already containing a `${...}` placeholder or a `#{...}` SpEL expression passes through verbatim: `.value('#{T(java.lang.Runtime).getRuntime().availableProcessors()}')`.
+* `method(["name", ] Type) { ... }`, chainable with `.annotate(...)` only (`.value(...)` is field-specific), declares a private helper method the same way `bean(...)` declares a public one — typed closure parameters become method parameters, and the closure body becomes the method body.
+
+Both are ordinary private members of the generated class, so a `bean(...)` closure reads a `field(...)` or calls a `method(...)` exactly like a hand-written `@Bean` method would reference a sibling field or method on its own class:
+
+[source,groovy]
+----
+field('suffix', String).value('myapp.greeting-suffix', '!')
+
+method('buildGreeting', String) { String name ->
+ "Hello, ${name}${suffix}"
+}
+
+bean('greeting', Greeting) {
+ new Greeting(buildGreeting('World'))
+}
+----
+
+Because the result is an ordinary `AutoConfiguration` class, it must be registered the same way any Spring Boot auto-configuration is: listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`. Modules built as part of this project can apply the `org.apache.grails.buildsrc.autoconfiguration-imports` Gradle convention plugin to generate that file automatically, by scanning the module's own compiled classes for `@AutoConfiguration` at build time. Projects outside this build register the class the same way any hand-written `AutoConfiguration` is registered — by listing it in that file directly.
+
+NOTE: `@GrailsBeans` does not require extending `Plugin` at all — it produces a plain Spring Boot `AutoConfiguration`, so it can be used by any Spring Boot module, not just a Grails plugin descriptor. Applications can use it too, including directly on the `Application` class — where no imports-file registration is needed, since Spring Boot processes the application class itself — see link:spring.html#springdslAdditional[Configuring Additional Beans]. Within a plugin, reach for it specifically when a bean's registration needs to be ordered against a particular other auto-configuration; otherwise `beanRegistrar()` remains the recommended way to register beans from a `Plugin`.
+
+The DSL still covers a narrower surface than `doWithSpring(BeanBuilder)`:
+
+* Each top-level statement inside `beans { }` must be exactly `bean(...)`, `field(...)`, or `method(...)`, with their respective chaining — an `if`, a `for` loop, or any other imperative Groovy directly inside `beans { }` is a compile-time error, not a runtime one. Note that this is about the DSL's own top-level statements, not the bodies of `bean(...)`/`method(...)` closures themselves, which accept arbitrary Groovy — and `field(...)`/`method(...)` already cover the common reasons to want shared state or logic in the first place.
+* `.annotate(...)` attribute values must be simple compile-time constants (strings, numbers, booleans, class literals, enum constants, or arrays of these) — an annotation attribute that itself takes a nested annotation as a value isn't supported.
+* Dependencies on other beans are expressed only through typed closure parameters (autowired by type), not by name — there is no `ref('beanName')` lookup.
+
+===== Using @GrailsBeans directly on a *GrailsPlugin.groovy class
+
+`@GrailsBeans` may also be applied directly to a `Plugin` subclass, so bean definitions can live in the familiar `*GrailsPlugin.groovy` file instead of a separate class:
+
+[source,groovy]
+----
+import grails.compiler.beans.GrailsBeans
+import grails.plugins.Plugin
+import grails.util.GrailsUtil
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.context.MessageSourceAutoConfiguration
+import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration
+import org.springframework.context.MessageSource
+import org.springframework.context.support.ReloadableResourceBundleMessageSource
+import org.springframework.web.servlet.i18n.CookieLocaleResolver
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor
+
+@GrailsBeans
+@AutoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration])
+class I18nGrailsPlugin extends Plugin {
+
+ def version = GrailsUtil.getGrailsVersion()
+
+ def beans = {
+ bean('messageSource', ReloadableResourceBundleMessageSource) {
+ new ReloadableResourceBundleMessageSource(basename: 'WEB-INF/grails-app/i18n/messages')
+ }
+
+ bean('localeResolver', CookieLocaleResolver).conditionalOnMissingBean(CookieLocaleResolver) {
+ new CookieLocaleResolver('locale')
+ }
+
+ bean('localeChangeInterceptor', LocaleChangeInterceptor) { MessageSource messageSource ->
+ new LocaleChangeInterceptor(paramName: 'lang')
+ }
+ }
+
+ @Override
+ void doWithApplicationContext() {
+ // other Plugin lifecycle hooks work exactly as they do without @GrailsBeans
+ }
+}
+----
+
+A `Plugin` subclass is instantiated by `DefaultGrailsPlugin` via plain reflection, never as a Spring bean, so it cannot itself carry `@Bean` methods or a meaningful `@AutoConfiguration`/`@Conditional*` annotation. When `@GrailsBeans` is applied to a `Plugin` subclass, the compiled `@Bean` methods land on a generated sibling class in the same package instead. Its name follows the Grails plugin-descriptor convention: a class named `*GrailsPlugin` swaps that suffix for `AutoConfiguration` — the example above produces both `I18nGrailsPlugin` (with `beans` stripped, `doWithApplicationContext` untouched) and a separate `I18nAutoConfiguration` class carrying the three `@Bean` methods, exactly the name the hand-written class it replaces would have had. Any other class name appends `AutoConfiguration` (`FooPlugin` → `FooPluginAutoConfiguration`). Everything else about the plugin class — `version`, `watchedResources`, `doWithApplicationContext`, `onChange`, and so on — works exactly as it does without `@GrailsBeans`.
+
+Every annotation on the plugin class that only makes sense on whatever Spring Boot actually evaluates moves onto that sibling too, since none of them has any effect on a class Spring never processes as a bean: `@AutoConfiguration`, the whole `@Conditional*` family, `@Import`/`@ImportAutoConfiguration`/`@ImportResource`, `@ComponentScan`, `@EnableConfigurationProperties`, `@PropertySource`/`@PropertySources`, and `@AutoConfigureOrder`/`Before`/`After`. This also recognises a *composed* annotation built on top of any of these — for example a project-specific `@ConditionalOnFeature` that is itself meta-annotated with Spring Boot's `@ConditionalOnProperty` (rather than `@Conditional` directly), or a custom `@EnableSomething` meta-annotated with `@Import` — so it isn't limited to Spring's own built-in annotation types. An annotation *outside* that set (one neither listed nor meta-annotated with anything listed) stays on the plugin class, where Spring never sees it — for those, name them explicitly with `@GrailsBeans(moveAnnotations = [SomeVendorAnnotation])` and they move like the recognised ones do.
+
+The `@AutoConfiguration` annotation is required whenever `@GrailsBeans` is applied to a `Plugin` subclass, even with no `before=`/`after=`: without it, the generated sibling would carry no annotation telling Spring Boot to process it, and its beans would silently never be registered. Omitting it is a compile-time error rather than a silent runtime gap.
+
+Set `@GrailsBeans(autoConfigurationName = "...")` to override the derived name (still generated in the plugin's own package) — useful when converting an existing, already-public `@AutoConfiguration` class whose name doesn't follow the convention, since other modules may reference it by name (`exclude =`, `before=`/`after=` ordering, `AutoConfiguration.imports`) or import it directly in tests.
+
+`@GrailsBeans` compiles cleanly alongside `@CompileStatic`/`@GrailsCompileStatic` on the same class. The `beans` closure is rewritten into real `@Bean` methods during `CompilePhase.CANONICALIZATION`, which runs before static type checking (`CompilePhase.INSTRUCTION_SELECTION`) ever examines the class, so the type checker only ever sees the generated, concretely-typed methods — never the `bean(...)` DSL calls themselves.
+
+On both the standalone-class and `Plugin`-subclass forms, the generated `@Bean` methods are genuinely statically compiled whenever `@CompileStatic` or `@GrailsCompileStatic` is present — verified directly by disassembling the bytecode (no `invokedynamic` call sites). The Plugin form requires one additional step internally: because its sibling class is generated after Groovy discovers local annotation transforms, `@GrailsBeans` invokes Groovy's static-compilation transform directly after generating the sibling's methods.
+
+
==== Customizing the Servlet Environment
diff --git a/grails-doc/src/en/guide/spring/springdslAdditional.adoc b/grails-doc/src/en/guide/spring/springdslAdditional.adoc
index 7b38630f87d..28f8d9efa5d 100644
--- a/grails-doc/src/en/guide/spring/springdslAdditional.adoc
+++ b/grails-doc/src/en/guide/spring/springdslAdditional.adoc
@@ -205,3 +205,41 @@ class MyBeanImpl {
Using `ref` (in XML or the DSL) is very powerful since it configures a runtime reference, so the referenced bean doesn't have to exist yet. As long as it's in place when the final application context configuration occurs, everything will be resolved correctly.
For a full reference of the available beans see the plugin reference in the reference guide.
+
+
+==== Defining Beans at Compile Time with @GrailsBeans
+
+
+`resources.groovy` and `doWithSpring` wire their beans at runtime, every time the application starts. The link:{api}grails/compiler/beans/GrailsBeans.html[GrailsBeans] annotation is a compile-time alternative: a similar closure style of bean definition, compiled at build time into real `@Bean` factory methods — no closures, and nothing DSL-specific, survive into the bytecode.
+
+`@GrailsBeans` is not plugin-specific. It works on any class Spring Boot processes as a configuration source — including the application's own `Application` class, since Spring Boot reads `@Bean` methods directly off the class it is launched with. Declare a `beans` closure whose statements construct and return each bean:
+
+[source,groovy]
+----
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+import my.company.MyBeanImpl
+
+class Application extends GrailsAutoConfiguration {
+
+ static void main(String[] args) {
+ GrailsApp.run(Application, args)
+ }
+
+ def beans = {
+ bean(MyService)
+
+ bean('myBean', MyBeanImpl) {
+ new MyBeanImpl(someProperty: 42, otherProperty: 'blue')
+ }
+ }
+}
+----
+
+Both the name and the body are optional. `bean(MyService)` declares a bean named `myService` — the type's decapitalized simple name — that is simply `new MyService()`; give it a name only when the type does not produce the one you want, and a body only when there is something to say beyond constructing it.
+
+No annotation is required on the `Application` class, or on a `*GrailsPlugin.groovy` plugin descriptor: a `beans` property on either is compiled automatically, in the same way `doWithSpring` and `watchedResources` are conventions rather than annotated members. `@GrailsBeans` is only needed on a class that is neither — a standalone `@AutoConfiguration` class, which must also be listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` like any other hand-written auto-configuration. Declaring the annotation explicitly on a plugin or application class is harmless, and a class with no `beans` property is left entirely alone.
+
+No further registration is needed on the `Application` class — Spring Boot already processes it. Beans declared here are also ordinary user beans as far as Spring Boot is concerned, so any auto-configuration guarded by `@ConditionalOnMissingBean` backs off in their favour, exactly as it would for a bean defined in `resources.groovy`. A standalone class annotated with both `@GrailsBeans` and `@AutoConfiguration` must instead be listed in `META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports`, like any other hand-written auto-configuration.
+
+The DSL's full surface — the `bean(...)` qualifiers such as `.conditionalOnMissingBean(...)`, `.primary()` and `.annotate(...)`, plus `field(...)` and `method(...)` for state and logic shared across bean methods — is documented in the link:plugins.html#hookingIntoRuntimeConfiguration[plugin development chapter], together with applying `@GrailsBeans` directly to a plugin descriptor.
diff --git a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
index 2bb87483681..dddbc5f58a0 100644
--- a/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
+++ b/grails-doc/src/en/guide/upgrading/upgrading80x.adoc
@@ -1579,7 +1579,7 @@ This was introduced during the Grails 7.1 plugin-loading changes and remains req
| `grails-domain-class`
| `ConstraintEvaluatorAdapter` and `GrailsDomainClassAutoConfiguration.constraintsEvaluator(...)`
-| Code that imported the auto-configuration method should use the current `validateableConstraintsEvaluator(...)` bean path and the datastore constraint evaluator APIs.
+| Code that imported the auto-configuration method should use the current `validateableConstraintsEvaluator(...)` bean path and the datastore constraint evaluator APIs. The auto-configuration itself is now named `org.grails.plugins.domain.DomainClassAutoConfiguration`, following the `*GrailsPlugin` -> `*AutoConfiguration` convention; update any `@EnableAutoConfiguration(exclude = ...)` or `spring.autoconfigure.exclude` entry that referenced the old name.
| `grails-fields`
| `BeanPropertyAccessor.getBeanClass()`, its setter, `PropertyPathAccessor.getBeanClass()`, `FormFieldsTagLib.input(...)`, and direct `render*` helper methods
diff --git a/grails-domain-class/build.gradle b/grails-domain-class/build.gradle
index 8bb2f88b7a0..6a01c808d19 100644
--- a/grails-domain-class/build.gradle
+++ b/grails-domain-class/build.gradle
@@ -38,6 +38,7 @@ dependencies {
implementation platform(project(':grails-bom'))
+ implementation project(':grails-beans-dsl')
api project(':grails-core')
api project(':grails-spring')
api project(':grails-validation')
diff --git a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassEnvironmentPostProcessor.groovy b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassEnvironmentPostProcessor.groovy
new file mode 100644
index 00000000000..47fe346c1a8
--- /dev/null
+++ b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassEnvironmentPostProcessor.groovy
@@ -0,0 +1,70 @@
+/*
+ * 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.domain
+
+import groovy.transform.CompileStatic
+
+import org.springframework.boot.EnvironmentPostProcessor
+import org.springframework.boot.SpringApplication
+import org.springframework.core.Ordered
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.MapPropertySource
+
+import grails.util.Environment as GrailsEnvironment
+import org.grails.datastore.mapping.config.Settings as DatastoreSettings
+
+/**
+ * Defaults auto-timestamp annotation caching to off in development mode, so that
+ * reloading a domain class picks up changed annotations.
+ *
+ * The default has to reach the {@link ConfigurableEnvironment}: both consumers —
+ * {@code AutoTimestampEventListener} and {@code DomainModelServiceImpl} — read it
+ * through a {@code @Value("${...}")} placeholder, and placeholders resolve against
+ * the environment. Writing it into the Grails config with {@code Config.put} reached
+ * neither, since that mutates only the config's own map and
+ * {@code GrailsPlaceholderConfigurer} never receives the configuration to fold in.
+ */
+@CompileStatic
+class DomainClassEnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
+
+ private static final String PROPERTY_SOURCE_NAME = 'defaultDomainClassProperties'
+
+ @Override
+ void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
+ if (environment.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS) != null) {
+ return
+ }
+ Map defaults = [:]
+ defaults.put(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS, !GrailsEnvironment.isDevelopmentMode())
+ environment.propertySources.addLast(new MapPropertySource(PROPERTY_SOURCE_NAME, defaults))
+ }
+
+ /**
+ * Ordered last so the configuration data post-processor has already loaded the
+ * application's own configuration, making the check above meaningful. The source
+ * is appended with lowest precedence regardless, so application configuration
+ * wins either way.
+ */
+ @Override
+ int getOrder() {
+ Ordered.LOWEST_PRECEDENCE
+ }
+
+}
diff --git a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy
index 9578ddd8846..aa422b43737 100644
--- a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy
+++ b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/DomainClassGrailsPlugin.groovy
@@ -20,14 +20,19 @@ package org.grails.plugins.domain
import groovy.transform.CompileStatic
-import org.springframework.beans.factory.BeanRegistrar
-import org.springframework.beans.factory.BeanRegistry
-import org.springframework.core.env.Environment
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+import org.springframework.context.MessageSource
-import grails.config.Config
+import grails.core.GrailsApplication
import grails.plugins.Plugin
import grails.util.GrailsUtil
-import org.grails.datastore.mapping.config.Settings as DatastoreSettings
+import org.grails.datastore.gorm.validation.constraints.factory.ConstraintFactory
+import org.grails.datastore.mapping.model.MappingContext
+import org.grails.plugins.domain.support.DefaultConstraintEvaluatorFactoryBean
+import org.grails.plugins.domain.support.DefaultMappingContextFactoryBean
+import org.grails.plugins.domain.support.ValidatorRegistryFactoryBean
/**
* Configures the domain classes in the spring context.
@@ -36,6 +41,11 @@ import org.grails.datastore.mapping.config.Settings as DatastoreSettings
* @since 0.4
*/
@CompileStatic
+// TODO: datasource plugin is supposed to always load after this (currently will because this is a configuration)
+// Ordered by name, not by class literal: I18nGrailsPlugin's @GrailsBeans-generated
+// I18nAutoConfiguration doesn't exist as a compilable class for this class to reference.
+@AutoConfiguration(afterName = ['org.grails.plugins.i18n.I18nAutoConfiguration'])
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
class DomainClassGrailsPlugin extends Plugin {
def watchedResources = ['file:./grails-app/domain/**/*.groovy',
@@ -45,15 +55,24 @@ class DomainClassGrailsPlugin extends Plugin {
def dependsOn = [i18n: version]
def loadAfter = ['controllers', 'dataSource']
- @Override
- BeanRegistrar beanRegistrar() {
- return { BeanRegistry registry, Environment environment ->
- // Set default for auto-timestamp annotation caching based on environment if not explicitly configured
- Config config = grailsApplication.config
- if (!config.containsProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS)) {
- // Not configured - disable caching in development mode to support class reloading
- config.put(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS,
- !grails.util.Environment.isDevelopmentMode())
+ // The deleted class held grailsApplication and messageSources as fields populated by an
+ // @Autowired constructor. The generated sibling always has a no-arg constructor, so both are
+ // taken as bean method parameters instead - resolved identically by Spring, and only when the
+ // bean that needs them is created rather than when the configuration class is instantiated.
+ def beans = {
+ bean('grailsDomainClassMappingContext', DefaultMappingContextFactoryBean).lazy() { GrailsApplication grailsApplication, List messageSources, List factories ->
+ new DefaultMappingContextFactoryBean(grailsApplication, messageSources).tap {
+ constraintFactories = factories ?: []
+ }
+ }
+
+ bean('validateableConstraintsEvaluator', DefaultConstraintEvaluatorFactoryBean).lazy() { GrailsApplication grailsApplication, List messageSources, @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext ->
+ new DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, grailsApplication)
+ }
+
+ bean('gormValidatorRegistry', ValidatorRegistryFactoryBean).lazy() { @Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext ->
+ new ValidatorRegistryFactoryBean().tap {
+ it.mappingContext = mappingContext
}
}
}
diff --git a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/GrailsDomainClassAutoConfiguration.groovy b/grails-domain-class/src/main/groovy/org/grails/plugins/domain/GrailsDomainClassAutoConfiguration.groovy
deleted file mode 100644
index 217d2ae6811..00000000000
--- a/grails-domain-class/src/main/groovy/org/grails/plugins/domain/GrailsDomainClassAutoConfiguration.groovy
+++ /dev/null
@@ -1,78 +0,0 @@
-/*
- * 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.domain
-
-import groovy.transform.CompileStatic
-
-import org.springframework.beans.factory.annotation.Autowired
-import org.springframework.beans.factory.annotation.Qualifier
-import org.springframework.boot.autoconfigure.AutoConfiguration
-import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
-import org.springframework.context.MessageSource
-import org.springframework.context.annotation.Bean
-import org.springframework.context.annotation.Lazy
-
-import grails.core.GrailsApplication
-import org.grails.datastore.gorm.validation.constraints.factory.ConstraintFactory
-import org.grails.datastore.mapping.model.MappingContext
-
-import org.grails.plugins.domain.support.DefaultConstraintEvaluatorFactoryBean
-import org.grails.plugins.domain.support.DefaultMappingContextFactoryBean
-import org.grails.plugins.domain.support.ValidatorRegistryFactoryBean
-import org.grails.plugins.i18n.I18nAutoConfiguration
-
-@CompileStatic
-// TODO: datasource plugin is supposed to always load after this (currently will because this is a configuration)
-@AutoConfiguration(after = [I18nAutoConfiguration])
-@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
-class GrailsDomainClassAutoConfiguration {
-
- GrailsApplication grailsApplication
-
- List messageSources
-
- @Autowired
- GrailsDomainClassAutoConfiguration(GrailsApplication grailsApplication, List messageSources) {
- this.grailsApplication = grailsApplication
- this.messageSources = messageSources
- }
-
- @Lazy
- @Bean(name = 'grailsDomainClassMappingContext')
- DefaultMappingContextFactoryBean grailsDomainClassMappingContext(List factories) {
- new DefaultMappingContextFactoryBean(grailsApplication, messageSources).tap {
- constraintFactories = factories ?: []
- }
- }
-
- @Lazy
- @Bean
- DefaultConstraintEvaluatorFactoryBean validateableConstraintsEvaluator(@Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext) {
- new DefaultConstraintEvaluatorFactoryBean(messageSources, mappingContext, grailsApplication)
- }
-
- @Lazy
- @Bean
- ValidatorRegistryFactoryBean gormValidatorRegistry(@Qualifier('grailsDomainClassMappingContext') MappingContext mappingContext) {
- new ValidatorRegistryFactoryBean().tap {
- it.mappingContext = mappingContext
- }
- }
-}
diff --git a/grails-domain-class/src/main/resources/META-INF/spring.factories b/grails-domain-class/src/main/resources/META-INF/spring.factories
new file mode 100644
index 00000000000..d48055df0da
--- /dev/null
+++ b/grails-domain-class/src/main/resources/META-INF/spring.factories
@@ -0,0 +1,20 @@
+# 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.
+
+
+org.springframework.boot.EnvironmentPostProcessor=\
+org.grails.plugins.domain.DomainClassEnvironmentPostProcessor
diff --git a/grails-domain-class/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/grails-domain-class/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 380d493973e..6edb134123a 100644
--- a/grails-domain-class/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/grails-domain-class/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -17,4 +17,4 @@
# under the License.
#
-org.grails.plugins.domain.GrailsDomainClassAutoConfiguration
\ No newline at end of file
+org.grails.plugins.domain.DomainClassAutoConfiguration
\ No newline at end of file
diff --git a/grails-domain-class/src/test/groovy/org/grails/plugins/domain/AutoTimestampCacheDefaultSpec.groovy b/grails-domain-class/src/test/groovy/org/grails/plugins/domain/AutoTimestampCacheDefaultSpec.groovy
new file mode 100644
index 00000000000..b95243caa2e
--- /dev/null
+++ b/grails-domain-class/src/test/groovy/org/grails/plugins/domain/AutoTimestampCacheDefaultSpec.groovy
@@ -0,0 +1,97 @@
+/*
+ * 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.domain
+
+import org.springframework.beans.factory.support.RootBeanDefinition
+import org.springframework.context.support.GenericApplicationContext
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.StandardEnvironment
+
+import grails.util.Environment as GrailsEnvironment
+
+import org.grails.datastore.mapping.config.Settings as DatastoreSettings
+
+import spock.lang.Specification
+
+/**
+ * The auto-timestamp annotation-caching default is read by
+ * {@code AutoTimestampEventListener} and {@code DomainModelServiceImpl} through a
+ * {@code @Value("${...}")} placeholder, so it has to reach the environment.
+ */
+class AutoTimestampCacheDefaultSpec extends Specification {
+
+ void 'the default is contributed to the environment, keyed on development mode'() {
+ given:
+ StandardEnvironment environment = new StandardEnvironment()
+
+ expect: 'nothing is configured to begin with'
+ environment.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS) == null
+
+ when:
+ new DomainClassEnvironmentPostProcessor().postProcessEnvironment(environment, null)
+
+ then:
+ environment.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS, Boolean) ==
+ !GrailsEnvironment.isDevelopmentMode()
+ }
+
+ void 'an explicitly configured value is left alone'() {
+ given:
+ StandardEnvironment environment = new StandardEnvironment()
+ environment.propertySources.addFirst(new MapPropertySource('test',
+ [(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS): 'false']))
+
+ when:
+ new DomainClassEnvironmentPostProcessor().postProcessEnvironment(environment, null)
+
+ then:
+ !environment.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS, Boolean)
+ }
+
+ void 'the contributed default resolves through a property placeholder'() {
+ given: 'the environment the post-processor has run against'
+ GenericApplicationContext context = new GenericApplicationContext()
+ new DomainClassEnvironmentPostProcessor().postProcessEnvironment(context.environment, null)
+
+ and: 'a bean reading the setting the way the real consumers do'
+ RootBeanDefinition probe = new RootBeanDefinition(Probe)
+ probe.propertyValues.add('value',
+ '${' + DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS + ':unset}')
+ context.registerBeanDefinition('probe', probe)
+ context.registerBeanDefinition('placeholderConfigurer',
+ new RootBeanDefinition(org.springframework.context.support.PropertySourcesPlaceholderConfigurer))
+
+ when:
+ context.refresh()
+
+ then: 'it sees the contributed default rather than falling back'
+ context.getBean(Probe).value == String.valueOf(!GrailsEnvironment.isDevelopmentMode())
+
+ cleanup:
+ context.close()
+ }
+
+ static class Probe {
+
+ String value
+
+ }
+
+}
diff --git a/grails-domain-class/src/test/groovy/org/grails/plugins/domain/DomainClassGrailsPluginSpec.groovy b/grails-domain-class/src/test/groovy/org/grails/plugins/domain/DomainClassGrailsPluginSpec.groovy
deleted file mode 100644
index 443de53f734..00000000000
--- a/grails-domain-class/src/test/groovy/org/grails/plugins/domain/DomainClassGrailsPluginSpec.groovy
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * 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.domain
-
-import org.springframework.beans.factory.support.BeanRegistryAdapter
-import org.springframework.beans.factory.support.DefaultListableBeanFactory
-import org.springframework.core.env.StandardEnvironment
-
-import grails.core.DefaultGrailsApplication
-import org.grails.config.PropertySourcesConfig
-import org.grails.datastore.mapping.config.Settings as DatastoreSettings
-
-import spock.lang.Specification
-
-class DomainClassGrailsPluginSpec extends Specification {
-
- void "beanRegistrar defaults the auto-timestamp annotation cache setting when not configured"() {
- given:
- def application = new DefaultGrailsApplication()
- application.config = new PropertySourcesConfig()
- def plugin = new DomainClassGrailsPlugin(grailsApplication: application)
-
- when:
- applyRegistrar(plugin)
-
- then: 'outside development mode annotation caching stays enabled'
- application.config.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS, Boolean) == true
- }
-
- void "beanRegistrar leaves an explicitly configured auto-timestamp cache setting alone"() {
- given:
- def application = new DefaultGrailsApplication()
- application.config = new PropertySourcesConfig(
- (DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS): false)
- def plugin = new DomainClassGrailsPlugin(grailsApplication: application)
-
- when:
- applyRegistrar(plugin)
-
- then:
- application.config.getProperty(DatastoreSettings.SETTING_AUTO_TIMESTAMP_CACHE_ANNOTATIONS, Boolean) == false
- }
-
- private static void applyRegistrar(DomainClassGrailsPlugin plugin) {
- def registrar = plugin.beanRegistrar()
- def beanFactory = new DefaultListableBeanFactory()
- new BeanRegistryAdapter(beanFactory, new StandardEnvironment(), registrar.getClass()).register(registrar)
- }
-}
diff --git a/grails-gsp/grails-sitemesh3/build.gradle b/grails-gsp/grails-sitemesh3/build.gradle
index 4f701bef388..b1c20a8e6ae 100644
--- a/grails-gsp/grails-sitemesh3/build.gradle
+++ b/grails-gsp/grails-sitemesh3/build.gradle
@@ -52,6 +52,7 @@ dependencies {
api project(':grails-core')
implementation 'org.apache.groovy:groovy'
+ implementation project(':grails-beans-dsl')
compileOnly 'jakarta.servlet:jakarta.servlet-api'
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.groovy
deleted file mode 100644
index 06b31052e88..00000000000
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3AutoConfiguration.groovy
+++ /dev/null
@@ -1,131 +0,0 @@
-/*
- * 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.sitemesh3
-
-import groovy.transform.CompileStatic
-
-import org.sitemesh.webmvc.SiteMeshViewResolverBeanPostProcessor
-import org.sitemesh.webmvc.SiteMeshViewResolverPostProcessor
-
-import org.springframework.beans.factory.ObjectProvider
-import org.springframework.boot.autoconfigure.AutoConfiguration
-import org.springframework.boot.autoconfigure.AutoConfigureAfter
-import org.springframework.boot.autoconfigure.AutoConfigureBefore
-import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
-import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
-import org.springframework.context.annotation.Bean
-import org.springframework.web.servlet.DispatcherServlet
-
-import grails.config.Config
-import grails.core.GrailsApplication
-import grails.util.Environment
-import grails.util.Metadata
-import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator
-
-/**
- * Registers the Grails SiteMesh 3 integration beans ahead of the upstream
- * auto-configuration: the {@link Sitemesh3ViewResolverDefinitionPostProcessor}
- * (which rewrites the {@code jspViewResolver} definition into the decorating
- * {@link GrailsSiteMeshViewResolver}), the
- * {@link GrailsSiteMeshViewResolverBeanPostProcessor}, the
- * {@link CaptureAwareContentProcessor} ({@code contentProcessor}) and the
- * {@link Sitemesh3LayoutFinder} ({@code decoratorSelector}).
- *
- * The definition-level rewrite is what applies decoration: because it acts
- * on the bean definition, the decorating resolver is what gets instantiated no
- * matter how early a consumer forces the lazy {@code jspViewResolver} into
- * existence (see {@link Sitemesh3ViewResolverDefinitionPostProcessor}, the
- * Grails implementation of upstream's {@code bean-definition} wrap mode). The
- * bean post-processor is the fallback tier: it decorates a
- * {@code jspViewResolver} registered as an instance rather than a definition.
- * Upstream's post-processor never re-wraps a resolver that is already a
- * {@code SiteMeshViewResolver}, so the two tiers cannot double-decorate.
- *
- * Upstream's {@code SiteMeshViewResolverAutoConfiguration} declares its
- * beans with {@code @ConditionalOnMissingBean} guards. By scheduling this
- * configuration first (via {@link AutoConfigureBefore}) the Grails
- * implementations are registered before those guards are evaluated, so the
- * upstream defaults back off cleanly rather than being registered and then
- * overridden after the fact. The two post-processors here cover all of
- * upstream's registrations by type: the definition post-processor preempts the
- * {@code bean-definition} mode bean, and the bean post-processor preempts both
- * the wrap-all and {@code bean-instance} mode beans.
- *
- * The {@code contentProcessor} and {@code decoratorSelector} beans drive view
- * decoration, which is only meaningful when Spring MVC is resolving views, so
- * they are gated on a {@link DispatcherServlet} being present. This keeps them
- * out of the lightweight unit-test contexts built by grails-testing-support,
- * which have no dispatcher servlet — and because the definition post-processor
- * only rewrites {@code jspViewResolver} when both of those beans are registered,
- * it keeps decoration out of such contexts too.
- */
-@CompileStatic
-@AutoConfiguration
-@AutoConfigureAfter(name = 'org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration')
-@AutoConfigureBefore(name = 'org.sitemesh.autoconfigure.SiteMeshViewResolverAutoConfiguration')
-@ConditionalOnClass(SiteMeshViewResolverBeanPostProcessor)
-@ConditionalOnProperty(name = 'sitemesh.integration', havingValue = 'view-resolver', matchIfMissing = true)
-class Sitemesh3AutoConfiguration {
-
- @Bean
- @ConditionalOnMissingBean(SiteMeshViewResolverPostProcessor)
- static Sitemesh3ViewResolverDefinitionPostProcessor siteMeshViewResolverPostProcessor() {
- new Sitemesh3ViewResolverDefinitionPostProcessor()
- }
-
- @Bean
- @ConditionalOnMissingBean(SiteMeshViewResolverBeanPostProcessor)
- static GrailsSiteMeshViewResolverBeanPostProcessor siteMeshViewResolverBeanPostProcessor() {
- new GrailsSiteMeshViewResolverBeanPostProcessor()
- }
-
- @Bean
- @ConditionalOnBean(DispatcherServlet)
- @ConditionalOnMissingBean(name = 'contentProcessor')
- CaptureAwareContentProcessor contentProcessor() {
- new CaptureAwareContentProcessor()
- }
-
- @Bean
- @ConditionalOnBean(DispatcherServlet)
- @ConditionalOnMissingBean(name = 'decoratorSelector')
- Sitemesh3LayoutFinder decoratorSelector(ObjectProvider groovyPageLocator,
- GrailsApplication grailsApplication) {
- Config config = grailsApplication.config
- Environment env = Environment.current
- boolean developmentMode = Metadata.current.isDevelopmentEnvironmentAvailable()
- boolean reloadEnabled = env.isReloadEnabled() ||
- config.getProperty('grails.gsp.enable.reload', Boolean, false) ||
- (developmentMode && env == Environment.DEVELOPMENT)
-
- // The SiteMesh 3 specific key wins; fall back to the legacy
- // grails.views.layout.default key so existing apps keep their
- // configured default layout when switching.
- String defaultLayout = config.getProperty('grails.sitemesh.default.layout') ?:
- config.getProperty('grails.views.layout.default')
-
- Sitemesh3LayoutFinder finder = new Sitemesh3LayoutFinder(groovyPageLocator.getIfAvailable())
- finder.gspReloadEnabled = reloadEnabled
- finder.defaultDecoratorName = defaultLayout ?: null
- finder.layoutCacheExpirationMillis = config.getProperty('grails.sitemesh.layout.cache.interval', Long, 5000L)
- return finder
- }
-}
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy
index 94fd21a7294..7947fab0c0e 100644
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy
+++ b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3EnvironmentPostProcessor.groovy
@@ -19,10 +19,13 @@
package org.grails.plugins.sitemesh3
import groovy.transform.CompileStatic
-import groovy.util.logging.Slf4j
import org.springframework.boot.SpringApplication
-import org.springframework.boot.env.EnvironmentPostProcessor
+import org.apache.commons.logging.Log
+import org.apache.commons.logging.LogFactory
+
+import org.springframework.boot.EnvironmentPostProcessor
+import org.springframework.boot.logging.DeferredLogFactory
import org.springframework.core.Ordered
import org.springframework.core.env.ConfigurableEnvironment
import org.springframework.core.env.MapPropertySource
@@ -47,9 +50,25 @@ import org.grails.web.util.WebUtils
* configuration always wins.
*/
@CompileStatic
-@Slf4j
class Sitemesh3EnvironmentPostProcessor implements EnvironmentPostProcessor, Ordered {
+ /**
+ * An EnvironmentPostProcessor runs before Spring Boot initialises logging
+ * (its listener orders ahead of LoggingApplicationListener), so a plain
+ * static logger writes into a logging system that does not exist yet and
+ * the message is lost. Spring Boot passes a DeferredLogFactory to whichever
+ * constructor accepts one; those records are replayed once logging is up.
+ */
+ private final Log log
+
+ Sitemesh3EnvironmentPostProcessor() {
+ this.log = LogFactory.getLog(Sitemesh3EnvironmentPostProcessor)
+ }
+
+ Sitemesh3EnvironmentPostProcessor(DeferredLogFactory logFactory) {
+ this.log = logFactory.getLog(Sitemesh3EnvironmentPostProcessor)
+ }
+
static final String PROPERTY_SOURCE_NAME = 'defaultSitemesh3Properties'
/**
@@ -79,12 +98,13 @@ class Sitemesh3EnvironmentPostProcessor implements EnvironmentPostProcessor, Ord
@Override
void postProcessEnvironment(ConfigurableEnvironment environment, SpringApplication application) {
if (isSiteMesh2Present()) {
- log.warn('Both grails-sitemesh3 and grails-layout (SiteMesh 2) are on the classpath. ' +
- 'grails-sitemesh3 is a drop-in replacement and the two are mutually exclusive; ' +
- 'the SiteMesh 2 integration stays active and SiteMesh 3 view-resolver decoration ' +
- 'is disabled. Remove the grails-layout dependency, or exclude grails-sitemesh3 ' +
- '(it arrives via grails-dependencies-starter-web) to silence this warning - ' +
- 'tolerance for the combined classpath may be removed in a future release.')
+ log.error('Both grails-sitemesh3 and grails-layout (SiteMesh 2) are on the classpath. ' +
+ 'They are mutually exclusive - grails-sitemesh3 is a drop-in replacement for ' +
+ 'grails-layout - and an application should never depend on both. SiteMesh 2 ' +
+ 'keeps decorating and SiteMesh 3 view-resolver decoration is disabled, but this ' +
+ 'is a broken build rather than a supported configuration: remove the ' +
+ 'grails-layout dependency, or exclude grails-sitemesh3, which arrives ' +
+ 'transitively through grails-dependencies-starter-web.')
}
MapPropertySource defaults = getDefaultPropertySource(environment)
if (!defaults.source.isEmpty()) {
diff --git a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
index 9282c5b8626..096273b0957 100644
--- a/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
+++ b/grails-gsp/grails-sitemesh3/src/main/groovy/org/grails/plugins/sitemesh3/Sitemesh3GrailsPlugin.groovy
@@ -20,12 +20,26 @@ package org.grails.plugins.sitemesh3
import groovy.transform.CompileStatic
-import org.springframework.beans.factory.BeanRegistrar
-import org.springframework.beans.factory.BeanRegistry
-import org.springframework.core.env.Environment
+import org.sitemesh.webmvc.SiteMeshViewResolverBeanPostProcessor
+import org.sitemesh.webmvc.SiteMeshViewResolverPostProcessor
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.AutoConfigureAfter
+import org.springframework.boot.autoconfigure.AutoConfigureBefore
+import org.springframework.boot.autoconfigure.condition.ConditionalOnBean
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingClass
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.web.servlet.DispatcherServlet
+
+import grails.config.Config
+import grails.core.GrailsApplication
import grails.plugins.Plugin
+import grails.util.Environment as GrailsEnvironment
+import grails.util.Metadata
import org.grails.plugins.web.taglib.RenderSitemeshTagLib
+import org.grails.web.gsp.io.GrailsConventionGroovyPageLocator
/**
* Provides GSP layout decoration through SiteMesh 3's filter-less,
@@ -37,18 +51,56 @@ import org.grails.plugins.web.taglib.RenderSitemeshTagLib
* keeps decorating and this module stands down) but warned about by
* {@link Sitemesh3EnvironmentPostProcessor}, and support for it may be removed.
*
- * The heavy lifting lives outside this class: default configuration
- * properties are contributed by {@link Sitemesh3EnvironmentPostProcessor}
- * (registered in {@code META-INF/spring.factories}) and the view-resolver
- * decoration is applied by {@link Sitemesh3AutoConfiguration}. The only bean
- * this plugin itself contributes is registered through {@link #beanRegistrar()},
- * the modern replacement for the deprecated {@code doWithSpring()} bean DSL.
+ * Default configuration properties are contributed by
+ * {@link Sitemesh3EnvironmentPostProcessor} (registered in
+ * {@code META-INF/spring.factories}). The view-resolver decoration beans are
+ * declared in this class's {@code beans} block, which {@code @GrailsBeans}
+ * compiles into the generated {@code Sitemesh3AutoConfiguration} class — the
+ * Spring annotations above this class gate and order that auto-configuration,
+ * not the plugin itself, and move onto it at compile time.
+ *
+ * The decoration beans register ahead of the upstream auto-configuration:
+ * the {@link Sitemesh3ViewResolverDefinitionPostProcessor} (which rewrites the
+ * {@code jspViewResolver} definition into the decorating
+ * {@link GrailsSiteMeshViewResolver}), the
+ * {@link GrailsSiteMeshViewResolverBeanPostProcessor}, the
+ * {@link CaptureAwareContentProcessor} ({@code contentProcessor}) and the
+ * {@link Sitemesh3LayoutFinder} ({@code decoratorSelector}).
+ *
+ * The definition-level rewrite is what applies decoration: because it acts
+ * on the bean definition, the decorating resolver is what gets instantiated no
+ * matter how early a consumer forces the lazy {@code jspViewResolver} into
+ * existence (see {@link Sitemesh3ViewResolverDefinitionPostProcessor}, the
+ * Grails implementation of upstream's {@code bean-definition} wrap mode). The
+ * bean post-processor is the fallback tier: it decorates a
+ * {@code jspViewResolver} registered as an instance rather than a definition.
+ * Upstream's post-processor never re-wraps a resolver that is already a
+ * {@code SiteMeshViewResolver}, so the two tiers cannot double-decorate.
*
- * With no {@code doWithSpring()} bean-builder closure — whose dynamic
- * dispatch against the bean builder prevents static compilation of descriptor
- * classes — this plugin compiles statically as a whole.
+ * Upstream's {@code SiteMeshViewResolverAutoConfiguration} declares its
+ * beans with {@code @ConditionalOnMissingBean} guards. By scheduling the
+ * generated configuration first (via {@link AutoConfigureBefore}) the Grails
+ * implementations are registered before those guards are evaluated, so the
+ * upstream defaults back off cleanly rather than being registered and then
+ * overridden after the fact. The two post-processors here cover all of
+ * upstream's registrations by type: the definition post-processor preempts the
+ * {@code bean-definition} mode bean, and the bean post-processor preempts both
+ * the wrap-all and {@code bean-instance} mode beans.
+ *
+ * The {@code contentProcessor} and {@code decoratorSelector} beans drive view
+ * decoration, which is only meaningful when Spring MVC is resolving views, so
+ * they are gated on a {@link DispatcherServlet} being present. This keeps them
+ * out of the lightweight unit-test contexts built by grails-testing-support,
+ * which have no dispatcher servlet — and because the definition post-processor
+ * only rewrites {@code jspViewResolver} when both of those beans are registered,
+ * it keeps decoration out of such contexts too.
*/
@CompileStatic
+@AutoConfiguration
+@AutoConfigureAfter(name = 'org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration')
+@AutoConfigureBefore(name = 'org.sitemesh.autoconfigure.SiteMeshViewResolverAutoConfiguration')
+@ConditionalOnClass(SiteMeshViewResolverBeanPostProcessor)
+@ConditionalOnProperty(name = 'sitemesh.integration', havingValue = 'view-resolver', matchIfMissing = true)
class Sitemesh3GrailsPlugin extends Plugin {
def grailsVersion = '7.0.0-SNAPSHOT > *'
@@ -70,14 +122,41 @@ class Sitemesh3GrailsPlugin extends Plugin {
Sitemesh3LayoutTagLib,
]
- @Override
- BeanRegistrar beanRegistrar() {
- return { BeanRegistry registry, Environment environment ->
- // Registrar beans win name conflicts over doWithSpring() beans, so
- // registering unconditionally would displace the SiteMesh 2
- // module's GrailsLayoutRenderViewMutator on a combined classpath.
- if (!Sitemesh3EnvironmentPostProcessor.isSiteMesh2Present()) {
- registry.registerBean('grailsRenderViewMutator', Sitemesh3RenderViewMutator)
+ def beans = {
+ // The SiteMesh 2 module (grails-layout) owns view-resolver decoration when it shares the
+ // classpath, so this stands down. @ConditionalOnMissingClass names the same marker class
+ // Sitemesh3EnvironmentPostProcessor.SITEMESH2_MARKER_CLASS holds - an .annotate(...)
+ // attribute must be an inline constant.
+ bean('grailsRenderViewMutator', Sitemesh3RenderViewMutator)
+ .annotate(ConditionalOnMissingClass, value: 'org.apache.grails.web.layout.GrailsLayoutViewResolverPostProcessor')
+
+ bean('siteMeshViewResolverPostProcessor', Sitemesh3ViewResolverDefinitionPostProcessor).staticMethod().conditionalOnMissingBean(SiteMeshViewResolverPostProcessor) {
+ new Sitemesh3ViewResolverDefinitionPostProcessor()
+ }
+
+ bean('siteMeshViewResolverBeanPostProcessor', GrailsSiteMeshViewResolverBeanPostProcessor).staticMethod().conditionalOnMissingBean(SiteMeshViewResolverBeanPostProcessor)
+
+ bean('contentProcessor', CaptureAwareContentProcessor).annotate(ConditionalOnBean, value: DispatcherServlet).conditionalOnMissingBeanName()
+
+ bean('decoratorSelector', Sitemesh3LayoutFinder).annotate(ConditionalOnBean, value: DispatcherServlet).conditionalOnMissingBeanName() { ObjectProvider groovyPageLocator,
+ GrailsApplication grailsApplication ->
+ Config config = grailsApplication.config
+ GrailsEnvironment env = GrailsEnvironment.current
+ boolean developmentMode = Metadata.current.isDevelopmentEnvironmentAvailable()
+ boolean reloadEnabled = env.reloadEnabled ||
+ config.getProperty('grails.gsp.enable.reload', Boolean, false) ||
+ (developmentMode && env == GrailsEnvironment.DEVELOPMENT)
+
+ // The SiteMesh 3 specific key wins; fall back to the legacy
+ // grails.views.layout.default key so existing apps keep their
+ // configured default layout when switching.
+ String defaultLayout = config.getProperty('grails.sitemesh.default.layout') ?:
+ config.getProperty('grails.views.layout.default')
+
+ new Sitemesh3LayoutFinder(groovyPageLocator.ifAvailable).tap {
+ gspReloadEnabled = reloadEnabled
+ defaultDecoratorName = defaultLayout ?: null
+ layoutCacheExpirationMillis = config.getProperty('grails.sitemesh.layout.cache.interval', Long, 5000L)
}
}
}
diff --git a/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories
index 0c3287c90e1..12f2f807f83 100644
--- a/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories
+++ b/grails-gsp/grails-sitemesh3/src/main/resources/META-INF/spring.factories
@@ -15,5 +15,5 @@
# specific language governing permissions and limitations
# under the License.
-org.springframework.boot.env.EnvironmentPostProcessor=\
+org.springframework.boot.EnvironmentPostProcessor=\
org.grails.plugins.sitemesh3.Sitemesh3EnvironmentPostProcessor
diff --git a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy
index af90a0a1232..3e25c1a3961 100644
--- a/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy
+++ b/grails-gsp/plugin/src/test/groovy/org/grails/web/taglib/AbstractGrailsTagTests.groovy
@@ -33,9 +33,12 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.w3c.dom.Document
+import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.config.AutowireCapableBeanFactory
+import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.beans.factory.support.RootBeanDefinition
import org.springframework.context.ApplicationContext
+import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.MessageSource
import org.springframework.context.support.StaticMessageSource
import org.springframework.core.convert.support.DefaultConversionService
@@ -333,7 +336,10 @@ abstract class AbstractGrailsTagTests {
dependentPlugins*.doWithRuntimeConfiguration(springConfig)
- grailsApplication.mainContext = springConfig.getUnrefreshedApplicationContext()
+ GenericApplicationContext unrefreshedContext = springConfig.getUnrefreshedApplicationContext() as GenericApplicationContext
+ applyPluginBeanRegistrars(dependentPlugins, unrefreshedContext)
+
+ grailsApplication.mainContext = unrefreshedContext
appCtx = springConfig.getApplicationContext()
ctx.servletContext.setAttribute(GrailsApplicationAttributes.APPLICATION_CONTEXT, appCtx)
@@ -350,6 +356,22 @@ abstract class AbstractGrailsTagTests {
}
}
+ /**
+ * Applies each plugin's {@link BeanRegistrar} to the unrefreshed context, the second half of
+ * what the runtime does for a plugin: {@code doWithRuntimeConfiguration} drains the deprecated
+ * {@code doWithSpring} DSL, then the registrars run. A harness that only does the former sees
+ * none of the beans a plugin contributes through {@code beanRegistrar()}.
+ */
+ private static void applyPluginBeanRegistrars(List plugins, GenericApplicationContext context) {
+ for (DefaultGrailsPlugin plugin in plugins) {
+ BeanRegistrar registrar = plugin.beanRegistrar
+ if (registrar != null) {
+ new BeanRegistryAdapter(context, context.beanFactory, context.environment, registrar.getClass())
+ .register(registrar)
+ }
+ }
+ }
+
private initRequestAndResponse() {
request = webRequest.currentRequest
request.characterEncoding = 'utf-8'
diff --git a/grails-i18n/build.gradle b/grails-i18n/build.gradle
index 7f41699e342..fce6d87924b 100644
--- a/grails-i18n/build.gradle
+++ b/grails-i18n/build.gradle
@@ -41,6 +41,8 @@ dependencies {
api project(':grails-web-core')
api 'org.apache.groovy:groovy'
+ implementation project(':grails-beans-dsl')
+
testCompileOnly 'org.apache.groovy:groovy-ant'
compileOnly 'jakarta.servlet:jakarta.servlet-api'
diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfiguration.java b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfiguration.java
index df7ccf2dede..cdb143987ee 100644
--- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfiguration.java
+++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfiguration.java
@@ -36,20 +36,22 @@
/**
* When an application declares {@code @EnableWebMvc}, Spring's {@link WebMvcConfigurationSupport}
* contributes its own {@code localeResolver} bean (an {@code AcceptHeaderLocaleResolver}). That bean
- * exists before {@link I18nAutoConfiguration} is evaluated, so its
- * {@code @ConditionalOnMissingBean(name = "localeResolver")} backs off and Grails' configured
- * resolver (a {@code SessionLocaleResolver} by default) never registers — silently disabling
- * {@code ?lang=} switching. This was not the case before {@code WebMvcConfigurationSupport} gained a
- * {@code localeResolver} bean: Grails' resolver used to take precedence, matching Grails 7 behavior.
+ * exists before {@link I18nGrailsPlugin}'s generated {@code I18nAutoConfiguration} is
+ * evaluated, so its {@code @ConditionalOnMissingBean(name = "localeResolver")} backs off and Grails'
+ * configured resolver (a {@code SessionLocaleResolver} by default) never registers — silently
+ * disabling {@code ?lang=} switching. This was not the case before {@code WebMvcConfigurationSupport}
+ * gained a {@code localeResolver} bean: Grails' resolver used to take precedence, matching Grails 7
+ * behavior.
*
* This removes the {@code WebMvcConfigurationSupport}-contributed {@code localeResolver} so that
- * {@link I18nAutoConfiguration} registers the Grails-configured resolver instead. A
- * {@code localeResolver} contributed by the application itself (for example via
+ * {@link I18nGrailsPlugin}'s generated auto-configuration registers the Grails-configured resolver
+ * instead. A {@code localeResolver} contributed by the application itself (for example via
* {@code resources.groovy} or a user {@code @Configuration}) is left untouched, so an explicit
- * application resolver still wins. Ordered before {@link I18nAutoConfiguration} so the removal
- * happens before that configuration's condition is evaluated.
+ * application resolver still wins. Ordered before that generated auto-configuration (by name, since
+ * it does not exist as a compilable class for this class to reference directly) so the removal
+ * happens before its condition is evaluated.
*/
-@AutoConfiguration(before = I18nAutoConfiguration.class)
+@AutoConfiguration(beforeName = "org.grails.plugins.i18n.I18nAutoConfiguration")
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@Import(GrailsLocaleResolverAutoConfiguration.RemoveWebMvcSupportLocaleResolverRegistrar.class)
public class GrailsLocaleResolverAutoConfiguration {
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
deleted file mode 100644
index 7882c51d9bd..00000000000
--- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nAutoConfiguration.java
+++ /dev/null
@@ -1,159 +0,0 @@
-/*
- * 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.Locale;
-
-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;
-import org.springframework.context.annotation.Bean;
-import org.springframework.context.support.AbstractApplicationContext;
-import org.springframework.util.StringUtils;
-import org.springframework.web.servlet.DispatcherServlet;
-import org.springframework.web.servlet.LocaleResolver;
-import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver;
-import org.springframework.web.servlet.i18n.CookieLocaleResolver;
-import org.springframework.web.servlet.i18n.FixedLocaleResolver;
-import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
-import org.springframework.web.servlet.i18n.SessionLocaleResolver;
-
-import grails.config.Settings;
-import grails.core.GrailsApplication;
-import grails.plugins.GrailsPluginManager;
-import grails.util.Environment;
-import org.grails.spring.context.support.PluginAwareResourceBundleMessageSource;
-import org.grails.web.i18n.ParamsAwareLocaleChangeInterceptor;
-
-@AutoConfiguration(before = { MessageSourceAutoConfiguration.class, WebMvcAutoConfiguration.class })
-@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
-public class I18nAutoConfiguration {
-
- @Value("${" + Settings.GSP_VIEW_ENCODING + ":UTF-8}")
- private String encoding;
-
- @Value("${" + Settings.GSP_ENABLE_RELOAD + ":false}")
- private boolean gspEnableReload;
-
- @Value("${" + Settings.I18N_CACHE_SECONDS + ":5}")
- private int cacheSeconds;
-
- @Value("${" + Settings.I18N_FILE_CACHE_SECONDS + ":5}")
- private int fileCacheSeconds;
-
- @Value("${" + Settings.I18N_LOCALE_RESOLVER + ":session}")
- private String localeResolverType;
-
- // Default locale for the read-only 'fixed' resolver; empty falls back to the JVM default.
- @Value("${grails.i18n.default.locale:}")
- private String defaultLocale;
-
- enum LocaleResolverStrategy { SESSION, COOKIE, ACCEPT_HEADER, FIXED }
-
- // Normalizes the configured strategy, ignoring case and separators (e.g. 'accept-header').
- static LocaleResolverStrategy resolveStrategy(String value) {
- String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT).replaceAll("[^a-z]", "");
- return switch (normalized) {
- case "cookie" -> LocaleResolverStrategy.COOKIE;
- case "acceptheader", "header", "accept" -> LocaleResolverStrategy.ACCEPT_HEADER;
- case "fixed" -> LocaleResolverStrategy.FIXED;
- default -> LocaleResolverStrategy.SESSION;
- };
- }
-
- // 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 switch (resolveStrategy(localeResolverType)) {
- case COOKIE -> new CookieLocaleResolver("locale");
- case ACCEPT_HEADER -> new AcceptHeaderLocaleResolver();
- case FIXED -> new FixedLocaleResolver(fixedLocale());
- case SESSION -> new SessionLocaleResolver();
- };
- }
-
- private Locale fixedLocale() {
- if (StringUtils.hasText(defaultLocale)) {
- Locale parsed = StringUtils.parseLocale(defaultLocale);
- if (parsed != null) {
- return parsed;
- }
- }
- return Locale.getDefault();
- }
-
- // The ?lang= interceptor is always registered. When the configured LocaleResolver is read-only
- // (ACCEPT_HEADER or FIXED), ParamsAwareLocaleChangeInterceptor detects that the resolver cannot
- // change and ignores the parameter, so ?lang= simply has no effect.
- @Bean
- @ConditionalOnMissingBean(name = "localeChangeInterceptor", search = SearchStrategy.CURRENT)
- public LocaleChangeInterceptor localeChangeInterceptor() {
- ParamsAwareLocaleChangeInterceptor localeChangeInterceptor = new ParamsAwareLocaleChangeInterceptor();
- localeChangeInterceptor.setParamName("lang");
- return 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);
- messageSource.setFallbackToSystemLocale(false);
- if (Environment.getCurrent().isReloadEnabled() || gspEnableReload) {
- messageSource.setCacheSeconds(cacheSeconds);
- messageSource.setFileCacheSeconds(fileCacheSeconds);
- }
- return messageSource;
- }
-
- /**
- * Discovers the locales the application is translated into (from {@code _.properties}
- * bundles on the classpath) so that a language selector can list only real translations rather
- * than every JVM locale. Published to the servlet context by {@link I18nGrailsPlugin} and
- * consumed by the {@code g:localeSelect available="true"} tag.
- *
- * By default every {@code *.properties} bundle on the classpath is considered, so locales
- * contributed by plugins — whose bundles are namespaced (e.g.
- * {@code spring-security-core_*.properties}) — are included alongside the application's own.
- * Set {@code grails.i18n.availableLocales.includePlugins=false} to restrict discovery to the
- * application's own {@code messages_*.properties} bundles.
- *
- *
The base {@code messages.properties} locale is always included, resolved from
- * {@code grails.i18n.default.locale} exactly like the rest of this class (falling back to the
- * JVM default locale when the property is not set).
- *
- * @param includePlugins whether to also scan plugin-contributed message bundles
- * ({@code grails.i18n.availableLocales.includePlugins}, defaults to {@code true})
- */
- @Bean
- @ConditionalOnMissingBean(AvailableLocaleResolver.class)
- public AvailableLocaleResolver availableLocaleResolver(GrailsApplication grailsApplication,
- @Value("${grails.i18n.availableLocales.includePlugins:true}") boolean includePlugins) {
- return new AvailableLocaleResolver(grailsApplication.getClassLoader(), fixedLocale(), includePlugins);
- }
-}
diff --git a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy
index dc3a8a08da8..a0926c58b81 100644
--- a/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy
+++ b/grails-i18n/src/main/groovy/org/grails/plugins/i18n/I18nGrailsPlugin.groovy
@@ -20,15 +20,36 @@ package org.grails.plugins.i18n
import java.nio.file.Files
+import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.boot.autoconfigure.AutoConfiguration
+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
import org.springframework.context.support.ReloadableResourceBundleMessageSource
import org.springframework.core.io.FileSystemResource
+import org.springframework.util.StringUtils
import org.springframework.web.context.WebApplicationContext
-
+import org.springframework.web.servlet.LocaleResolver
+import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver
+import org.springframework.web.servlet.i18n.CookieLocaleResolver
+import org.springframework.web.servlet.i18n.FixedLocaleResolver
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor
+import org.springframework.web.servlet.i18n.SessionLocaleResolver
+
+import grails.config.Settings
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
import grails.plugins.Plugin
import grails.util.BuildSettings
+import grails.util.Environment
import grails.util.GrailsUtil
+import org.grails.spring.context.support.PluginAwareResourceBundleMessageSource
+import org.grails.web.i18n.ParamsAwareLocaleChangeInterceptor
/**
* Configures Grails' internationalisation support.
@@ -37,6 +58,9 @@ import grails.util.GrailsUtil
* @since 0.4
*/
@Slf4j
+@CompileStatic
+@AutoConfiguration(before = [MessageSourceAutoConfiguration, WebMvcAutoConfiguration])
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
class I18nGrailsPlugin extends Plugin {
String baseDir = 'grails-app/i18n'
@@ -50,6 +74,84 @@ class I18nGrailsPlugin extends Plugin {
*/
static final String AVAILABLE_LOCALES_ATTRIBUTE = 'availableLocales'
+ def beans = {
+ field('encoding', String).value(Settings.GSP_VIEW_ENCODING, 'UTF-8')
+ field('gspEnableReload', boolean).value(Settings.GSP_ENABLE_RELOAD, 'false')
+ field('cacheSeconds', int).value(Settings.I18N_CACHE_SECONDS, '5')
+ field('fileCacheSeconds', int).value(Settings.I18N_FILE_CACHE_SECONDS, '5')
+ field('localeResolverType', String).value(Settings.I18N_LOCALE_RESOLVER, 'session')
+ // Default locale for the read-only 'fixed' resolver; empty falls back to the JVM default.
+ // No Settings constant exists for this key - the original file didn't use one either.
+ field('defaultLocale', String).value('grails.i18n.default.locale', '')
+
+ // Shared by localeResolver (the 'fixed' case) and availableLocaleResolver below - the only
+ // helper the original file needed too, everything else lives directly in its bean method.
+ method('fixedLocale', Locale) {
+ if (StringUtils.hasText(defaultLocale)) {
+ Locale parsed = StringUtils.parseLocale(defaultLocale)
+ if (parsed != null) {
+ return parsed
+ }
+ }
+ Locale.getDefault()
+ }
+
+ // SearchStrategy.CURRENT on all three guards below: 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.
+
+ // Normalizes the configured strategy, ignoring case and separators (e.g. 'accept-header').
+ bean(LocaleResolver).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
+ String normalized = localeResolverType == null ? '' :
+ localeResolverType.toLowerCase(Locale.ROOT).replaceAll('[^a-z]', '')
+ switch (normalized) {
+ case 'cookie':
+ return new CookieLocaleResolver('locale')
+ case 'acceptheader':
+ case 'header':
+ case 'accept':
+ return new AcceptHeaderLocaleResolver()
+ case 'fixed':
+ return new FixedLocaleResolver(fixedLocale())
+ default:
+ return new SessionLocaleResolver()
+ }
+ }
+
+ // The ?lang= interceptor is always registered. When the configured LocaleResolver is read-only
+ // (accept-header or fixed), ParamsAwareLocaleChangeInterceptor detects that the resolver cannot
+ // change and ignores the parameter, so ?lang= simply has no effect.
+ //
+ // The derived bean names on all three guarded beans are contractual: external code (e.g.
+ // grails-test-suite-uber's PluginTests) references them by literal string, so renaming a
+ // bean's type changes its derived name and breaks those references.
+ bean(LocaleChangeInterceptor).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) {
+ new ParamsAwareLocaleChangeInterceptor(paramName: 'lang')
+ }
+
+ bean(MessageSource).conditionalOnMissingBeanName(search: SearchStrategy.CURRENT) { GrailsApplication grailsApplication, GrailsPluginManager pluginManager ->
+ // Captured before tap: the message source has cacheSeconds/fileCacheSeconds
+ // properties of its own, which inside tap would shadow these injected fields.
+ int configuredCacheSeconds = cacheSeconds
+ int configuredFileCacheSeconds = fileCacheSeconds
+ new PluginAwareResourceBundleMessageSource(grailsApplication, pluginManager).tap {
+ defaultEncoding = encoding
+ fallbackToSystemLocale = false
+ if (Environment.current.reloadEnabled || gspEnableReload) {
+ cacheSeconds = configuredCacheSeconds
+ fileCacheSeconds = configuredFileCacheSeconds
+ }
+ }
+ }
+
+ // Discovers the locales the application is translated into so a language selector can list
+ // only real translations; see AvailableLocaleResolver's own class docs for the full contract.
+ bean(AvailableLocaleResolver).conditionalOnMissingBean() { GrailsApplication grailsApplication,
+ @Value('${grails.i18n.availableLocales.includePlugins:true}') boolean includePlugins ->
+ new AvailableLocaleResolver(grailsApplication.classLoader, fixedLocale(), includePlugins)
+ }
+ }
+
@Override
void doWithApplicationContext() {
publishAvailableLocales()
diff --git a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfigurationSpec.groovy b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfigurationSpec.groovy
index 2185e925531..b93a3eaa481 100644
--- a/grails-i18n/src/test/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfigurationSpec.groovy
+++ b/grails-i18n/src/test/groovy/org/grails/plugins/i18n/GrailsLocaleResolverAutoConfigurationSpec.groovy
@@ -95,6 +95,30 @@ class GrailsLocaleResolverAutoConfigurationSpec extends Specification {
}
}
+ void 'beforeName genuinely forces GrailsLocaleResolverAutoConfiguration to run before I18nAutoConfiguration, not merely declaration order'() {
+ given: 'contextRunner()\'s own AutoConfigurations.of(...) call already lists them in the correct ' +
+ 'final order, so declaring them backwards here is the only way to prove @AutoConfiguration' +
+ '(beforeName = ...) - not declaration order - is what puts GrailsLocaleResolverAutoConfiguration first'
+ GrailsApplication grailsApplication = new DefaultGrailsApplication()
+ GrailsPluginManager pluginManager = Mock(GrailsPluginManager) {
+ getAllPlugins() >> ([] as GrailsPlugin[])
+ }
+
+ expect:
+ new WebApplicationContextRunner()
+ .withBean(GrailsApplication, () -> grailsApplication)
+ .withBean(GrailsPluginManager, () -> pluginManager)
+ .withConfiguration(AutoConfigurations.of(
+ PropertyPlaceholderAutoConfiguration,
+ I18nAutoConfiguration,
+ GrailsLocaleResolverAutoConfiguration))
+ .withUserConfiguration(EnableWebMvcConfig)
+ .run { context ->
+ assert context.getBean('localeResolver') instanceof SessionLocaleResolver
+ assert context.getBeanNamesForType(AcceptHeaderLocaleResolver).length == 0
+ }
+ }
+
@Configuration(proxyBeanMethods = false)
@EnableWebMvc
static class EnableWebMvcConfig {
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 7f0bffb95fc..7d3a01a0f28 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
@@ -29,6 +29,7 @@ import grails.plugins.GrailsPluginManager
import org.springframework.boot.autoconfigure.AutoConfigurations
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration
+import org.springframework.boot.test.context.runner.ApplicationContextRunner
import org.springframework.boot.test.context.runner.WebApplicationContextRunner
import org.springframework.context.MessageSource
import org.springframework.context.support.GenericApplicationContext
@@ -71,6 +72,31 @@ class I18nAutoConfigurationSpec extends Specification {
}
}
+ void 'the Grails i18n beans do not register outside a servlet web application context'() {
+ given: "the same setup as contextRunner(), but a plain (non-web) ApplicationContextRunner"
+ GrailsApplication grailsApplication = new DefaultGrailsApplication()
+ GrailsPluginManager pluginManager = Mock(GrailsPluginManager) {
+ getAllPlugins() >> ([] as GrailsPlugin[])
+ }
+
+ expect: "@ConditionalOnWebApplication(SERVLET) on the generated I18nAutoConfiguration - the class Spring " +
+ "Boot actually evaluates, not I18nGrailsPlugin itself - backs the whole auto-configuration off, " +
+ "matching the hand-written I18nAutoConfiguration.java's behaviour before it moved into " +
+ "I18nGrailsPlugin.groovy's @GrailsBeans block"
+ new ApplicationContextRunner()
+ .withBean(GrailsApplication, () -> grailsApplication)
+ .withBean(GrailsPluginManager, () -> pluginManager)
+ .withConfiguration(AutoConfigurations.of(PropertyPlaceholderAutoConfiguration, I18nAutoConfiguration))
+ .run { context ->
+ assert !context.containsBean('localeResolver')
+ assert !context.containsBean('localeChangeInterceptor')
+ assert !context.containsBean('availableLocaleResolver')
+ // every ApplicationContext registers a fallback DelegatingMessageSource under this
+ // name if nothing else defines one, so check the bean's type rather than presence
+ assert !(context.getBean('messageSource') instanceof PluginAwareResourceBundleMessageSource)
+ }
+ }
+
void 'grails.i18n.localeResolver=cookie uses a CookieLocaleResolver and keeps the ?lang= interceptor'() {
expect:
contextRunner()
@@ -158,6 +184,11 @@ class I18nAutoConfigurationSpec extends Specification {
void 'the availableLocaleResolver bean registers by default and includes plugin bundles'() {
expect:
contextRunner().run { context ->
+ // bean(AvailableLocaleResolver) has no explicit name in the DSL - pins down that
+ // Introspector.decapitalize really does derive 'availableLocaleResolver', not just that
+ // some bean of the right type exists under an unrelated name
+ assert context.containsBean('availableLocaleResolver')
+
def resolver = context.getBean(AvailableLocaleResolver)
// without grails.i18n.default.locale the JVM default is included (same fallback the
// fixed localeResolver uses), and includePlugins defaults to true so the
diff --git a/grails-mail/build.gradle b/grails-mail/build.gradle
index a7fd7b900d6..e3b5c9e7331 100644
--- a/grails-mail/build.gradle
+++ b/grails-mail/build.gradle
@@ -76,10 +76,14 @@ dependencies {
compileOnly 'org.springframework.boot:spring-boot-autoconfigure' // @AutoConfiguration, @ConditionalOnProperty, @ConfigurationProperties
compileOnly 'org.apache.groovy:groovy' // Provided as this is a Grails plugin
compileOnly 'org.slf4j:slf4j-api' // @Slf4j
+ compileOnly project(':grails-beans-dsl') // comp: @GrailsBeans
testImplementation project(':grails-testing-support-web')
testImplementation 'jakarta.mail:jakarta.mail-api'
testImplementation 'org.spockframework:spock-core'
+ testImplementation 'org.springframework.boot:spring-boot-autoconfigure'
+ testImplementation 'org.springframework.boot:spring-boot-test'
+ testRuntimeOnly 'org.assertj:assertj-core'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
diff --git a/grails-mail/src/main/groovy/grails/plugins/mail/MailAutoConfiguration.groovy b/grails-mail/src/main/groovy/grails/plugins/mail/MailAutoConfiguration.groovy
deleted file mode 100644
index 146a23afc71..00000000000
--- a/grails-mail/src/main/groovy/grails/plugins/mail/MailAutoConfiguration.groovy
+++ /dev/null
@@ -1,118 +0,0 @@
-/*
- * Copyright 2022-2025 the original author or authors.
- *
- * Licensed 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.mail
-
-import grails.core.GrailsApplication
-import grails.plugins.GrailsPluginManager
-import grails.web.pages.GroovyPagesUriService
-import groovy.transform.CompileStatic
-import jakarta.mail.Session
-import org.grails.gsp.GroovyPagesTemplateEngine
-import org.springframework.beans.factory.annotation.Autowired
-import org.springframework.beans.factory.annotation.Qualifier
-import org.springframework.boot.autoconfigure.AutoConfiguration
-import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
-import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
-import org.springframework.boot.context.properties.EnableConfigurationProperties
-import org.springframework.context.annotation.Bean
-import org.springframework.jndi.JndiObjectFactoryBean
-import org.springframework.mail.MailSender
-import org.springframework.mail.javamail.JavaMailSender
-import org.springframework.mail.javamail.JavaMailSenderImpl
-
-@CompileStatic
-@AutoConfiguration
-@EnableConfigurationProperties(MailConfigurationProperties)
-class MailAutoConfiguration {
-
- @Bean
- @ConditionalOnMissingBean
- @ConditionalOnProperty(prefix = 'grails.mail', name = 'jndiName')
- JndiObjectFactoryBean mailSession(MailConfigurationProperties mailProperties) {
- def factory = new JndiObjectFactoryBean()
- factory.jndiName = mailProperties.jndiName
- return factory
- }
-
- @Bean
- @ConditionalOnMissingBean
- JavaMailSender mailSender(
- @Autowired(required = false)
- @Qualifier('mailSession') Session mailSession,
- MailConfigurationProperties mailProperties) {
-
- def mailSender = new JavaMailSenderImpl()
- if (mailProperties.host) {
- mailSender.host = mailProperties.host
- } else if (!mailProperties.jndiName) {
- def envHost = System.getenv()['SMTP_HOST']
- if (envHost) {
- mailSender.host = envHost
- } else {
- mailSender.host = 'localhost'
- }
- }
- if (mailProperties.encoding) {
- mailSender.defaultEncoding = mailProperties.encoding
- } else if (!mailProperties.jndiName) {
- mailSender.defaultEncoding = 'utf-8'
- }
- if (mailSession != null) {
- mailSender.session = mailSession
- }
- if (mailProperties.port) {
- mailSender.port = mailProperties.port
- }
- if (mailProperties.username) {
- mailSender.username = mailProperties.username
- }
- if (mailProperties.password) {
- mailSender.password = mailProperties.password
- }
- if (mailProperties.protocol) {
- mailSender.protocol = mailProperties.protocol
- }
- if (mailProperties.props) {
- mailSender.javaMailProperties = mailProperties.props
- }
- return mailSender
- }
-
- @Bean
- @ConditionalOnMissingBean
- MailMessageBuilderFactory mailMessageBuilderFactory(
- MailSender mailSender,
- MailMessageContentRenderer mailMessageContentRenderer) {
- new MailMessageBuilderFactory(mailSender, mailMessageContentRenderer)
- }
-
- @Bean
- @ConditionalOnMissingBean
- MailMessageContentRenderer mailMessageContentRenderer(
- GroovyPagesTemplateEngine groovyPagesTemplateEngine,
- GroovyPagesUriService groovyPagesUriService,
- GrailsApplication grailsApplication,
- GrailsPluginManager pluginManager) {
- new MailMessageContentRenderer(groovyPagesTemplateEngine, groovyPagesUriService, grailsApplication, pluginManager)
- }
-
- @Bean
- @ConditionalOnMissingBean
- MailService mailService(MailConfigurationProperties mailConfigurationProperties,
- MailMessageBuilderFactory mailMessageBuilderFactory) {
- new MailService(mailConfigurationProperties, mailMessageBuilderFactory)
- }
-}
diff --git a/grails-mail/src/main/groovy/grails/plugins/mail/MailGrailsPlugin.groovy b/grails-mail/src/main/groovy/grails/plugins/mail/MailGrailsPlugin.groovy
index 362675b1a16..06cf4c54e2f 100644
--- a/grails-mail/src/main/groovy/grails/plugins/mail/MailGrailsPlugin.groovy
+++ b/grails-mail/src/main/groovy/grails/plugins/mail/MailGrailsPlugin.groovy
@@ -15,10 +15,28 @@
*/
package grails.plugins.mail
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
import grails.plugins.Plugin
+import grails.web.pages.GroovyPagesUriService
+import groovy.transform.CompileStatic
+import jakarta.mail.Session
+import org.grails.gsp.GroovyPagesTemplateEngine
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.beans.factory.annotation.Qualifier
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+import org.springframework.jndi.JndiObjectFactoryBean
+import org.springframework.mail.MailSender
+import org.springframework.mail.javamail.JavaMailSender
+import org.springframework.mail.javamail.JavaMailSenderImpl
+@CompileStatic
+@AutoConfiguration
+@EnableConfigurationProperties(MailConfigurationProperties)
class MailGrailsPlugin extends Plugin {
-
+
def grailsVersion = '7.0.0 > *'
def author = 'The Grails team'
def authorEmail = 'info@grails.org'
@@ -26,9 +44,9 @@ class MailGrailsPlugin extends Plugin {
def description = '''\
This plugin provides a MailService class as well as configuring the necessary beans within
the Spring ApplicationContext.
-
+
It also adds a "sendMail" method to all controller classes. A typical example usage is:
-
+
sendMail {
to 'fred@g2one.com','ginger@g2one.com'
from 'john@g2one.com'
@@ -59,4 +77,54 @@ class MailGrailsPlugin extends Plugin {
url: 'https://github.com/grails-plugins/grails-mail'
]
def providedArtefacts = [PlainTextMailTagLib]
+
+ def beans = {
+ bean('mailSession', JndiObjectFactoryBean).conditionalOnMissingBean().annotate(ConditionalOnProperty, prefix: 'grails.mail', name: 'jndiName') { MailConfigurationProperties mailProperties ->
+ new JndiObjectFactoryBean().tap {
+ jndiName = mailProperties.jndiName
+ }
+ }
+
+ bean('mailSender', JavaMailSender).conditionalOnMissingBean() { @Autowired(required = false) @Qualifier('mailSession') Session mailSession,
+ MailConfigurationProperties mailProperties ->
+ new JavaMailSenderImpl().tap {
+ if (mailProperties.host || !mailProperties.jndiName) {
+ host = mailProperties.host ?: System.getenv('SMTP_HOST') ?: 'localhost'
+ }
+ if (mailProperties.encoding || !mailProperties.jndiName) {
+ defaultEncoding = mailProperties.encoding ?: 'utf-8'
+ }
+ if (mailSession) {
+ session = mailSession
+ }
+ if (mailProperties.port) {
+ port = mailProperties.port
+ }
+ if (mailProperties.username) {
+ username = mailProperties.username
+ }
+ if (mailProperties.password) {
+ password = mailProperties.password
+ }
+ if (mailProperties.protocol) {
+ protocol = mailProperties.protocol
+ }
+ if (mailProperties.props) {
+ javaMailProperties = mailProperties.props
+ }
+ }
+ }
+
+ bean(MailMessageBuilderFactory).conditionalOnMissingBean() { MailSender mailSender, MailMessageContentRenderer mailMessageContentRenderer ->
+ new MailMessageBuilderFactory(mailSender, mailMessageContentRenderer)
+ }
+
+ bean(MailMessageContentRenderer).conditionalOnMissingBean() { GroovyPagesTemplateEngine groovyPagesTemplateEngine, GroovyPagesUriService groovyPagesUriService, GrailsApplication grailsApplication, GrailsPluginManager pluginManager ->
+ new MailMessageContentRenderer(groovyPagesTemplateEngine, groovyPagesUriService, grailsApplication, pluginManager)
+ }
+
+ bean(MailService).conditionalOnMissingBean() { MailConfigurationProperties mailConfigurationProperties, MailMessageBuilderFactory mailMessageBuilderFactory ->
+ new MailService(mailConfigurationProperties, mailMessageBuilderFactory)
+ }
+ }
}
diff --git a/grails-mail/src/test/groovy/grails/plugins/mail/MailAutoConfigurationSpec.groovy b/grails-mail/src/test/groovy/grails/plugins/mail/MailAutoConfigurationSpec.groovy
new file mode 100644
index 00000000000..9a2f93dc756
--- /dev/null
+++ b/grails-mail/src/test/groovy/grails/plugins/mail/MailAutoConfigurationSpec.groovy
@@ -0,0 +1,150 @@
+/*
+ * Copyright 2025 the original author or authors.
+ *
+ * Licensed 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.mail
+
+import java.util.function.Supplier
+
+import javax.naming.Context
+import javax.naming.spi.InitialContextFactory
+
+import jakarta.mail.Session
+
+import grails.core.GrailsApplication
+import grails.plugins.GrailsPluginManager
+import grails.web.pages.GroovyPagesUriService
+import org.grails.gsp.GroovyPagesTemplateEngine
+import org.grails.web.pages.DefaultGroovyPagesUriService
+import org.springframework.boot.autoconfigure.AutoConfigurations
+import org.springframework.boot.test.context.runner.ApplicationContextRunner
+import org.springframework.mail.javamail.JavaMailSender
+import org.springframework.mail.javamail.JavaMailSenderImpl
+import spock.lang.Specification
+
+class MailAutoConfigurationSpec extends Specification {
+
+ GrailsApplication grailsApplication = Stub()
+ GrailsPluginManager pluginManager = Stub()
+
+ // mailMessageContentRenderer needs the GSP collaborators a real application gets from the
+ // GSP plugin; every context here supplies them so the eager mail bean chain can start.
+ private ApplicationContextRunner contextRunner() {
+ new ApplicationContextRunner()
+ .withConfiguration(AutoConfigurations.of(MailAutoConfiguration))
+ .withBean(GroovyPagesTemplateEngine, () -> new GroovyPagesTemplateEngine())
+ .withBean(GroovyPagesUriService, () -> new DefaultGroovyPagesUriService())
+ .withBean(GrailsApplication, () -> grailsApplication)
+ .withBean(GrailsPluginManager, () -> pluginManager)
+ }
+
+ void 'the mail sender registers with defaults when no mailSession bean or configuration is present'() {
+ expect: 'the optional @Autowired(required = false) @Qualifier session parameter carried through: a required parameter would fail refresh, since no mailSession bean exists'
+ contextRunner().run { context ->
+ JavaMailSenderImpl mailSender = context.getBean('mailSender', JavaMailSenderImpl)
+ assert mailSender.host == (System.getenv('SMTP_HOST') ?: 'localhost')
+ assert mailSender.defaultEncoding == 'utf-8'
+ }
+ }
+
+ void 'the mail sender is configured from grails.mail properties'() {
+ expect:
+ contextRunner().withPropertyValues(
+ 'grails.mail.host=smtp.example.org',
+ 'grails.mail.port=2525',
+ 'grails.mail.username=mailer',
+ 'grails.mail.encoding=ISO-8859-1')
+ .run { context ->
+ JavaMailSenderImpl mailSender = context.getBean('mailSender', JavaMailSenderImpl)
+ assert mailSender.host == 'smtp.example.org'
+ assert mailSender.port == 2525
+ assert mailSender.username == 'mailer'
+ assert mailSender.defaultEncoding == 'ISO-8859-1'
+ }
+ }
+
+ void 'the JNDI mailSession bean is only declared when grails.mail.jndiName is set'() {
+ expect:
+ contextRunner().run { context ->
+ assert !context.containsBean('mailSession')
+ }
+ }
+
+ void 'the JNDI mail session registers when grails.mail.jndiName is set and is injected into the mail sender'() {
+ given: 'a Session bound in JNDI under the configured name'
+ Session boundSession = Session.getInstance(new Properties())
+ TestMailSessionContextFactory.boundSession = boundSession
+
+ expect:
+ contextRunner()
+ .withSystemProperties("java.naming.factory.initial=${TestMailSessionContextFactory.name}")
+ .withPropertyValues('grails.mail.jndiName=mail/testSession')
+ .run { context ->
+ assert context.containsBean('mailSession')
+ assert context.getBean('mailSession').is(boundSession)
+ JavaMailSenderImpl mailSender = context.getBean('mailSender', JavaMailSenderImpl)
+ assert mailSender.session.is(boundSession)
+ assert mailSender.host == null
+ assert mailSender.defaultEncoding == null
+ }
+
+ cleanup:
+ TestMailSessionContextFactory.boundSession = null
+ }
+
+ void 'a user-defined mailSession bean is injected into the mail sender through the qualified optional parameter'() {
+ given:
+ Session userSession = Session.getInstance(new Properties())
+ Supplier userSessionSupplier = () -> userSession
+
+ expect:
+ contextRunner().withBean('mailSession', Session, userSessionSupplier)
+ .run { context ->
+ assert context.getBean('mailSender', JavaMailSenderImpl).session.is(userSession)
+ }
+ }
+
+ void 'a user-defined mail sender makes the auto-configured one back off'() {
+ given:
+ JavaMailSender userMailSender = new JavaMailSenderImpl()
+ Supplier userMailSenderSupplier = () -> userMailSender
+
+ expect:
+ contextRunner().withBean('customMailSender', JavaMailSender, userMailSenderSupplier)
+ .run { context ->
+ assert !context.containsBean('mailSender')
+ assert context.getBean(JavaMailSender).is(userMailSender)
+ }
+ }
+
+ void 'the full mail service chain registers'() {
+ expect:
+ contextRunner().run { context ->
+ assert context.containsBean('mailMessageContentRenderer')
+ assert context.containsBean('mailMessageBuilderFactory')
+ assert context.containsBean('mailService')
+ assert context.getBean(MailService) != null
+ }
+ }
+
+ static class TestMailSessionContextFactory implements InitialContextFactory {
+
+ static Session boundSession
+
+ @Override
+ Context getInitialContext(Hashtable, ?> environment) {
+ [lookup: { String name -> boundSession }, close: { }] as Context
+ }
+ }
+}
diff --git a/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy b/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy
index b34467716e0..0f3bd9c00a5 100644
--- a/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy
+++ b/grails-test-examples/gsp-layout/src/test/groovy/org/apache/grails/views/gsp/layout/AbstractGrailsTagTests.groovy
@@ -35,10 +35,13 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.w3c.dom.Document
+import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.config.AutowireCapableBeanFactory
+import org.springframework.beans.factory.support.BeanRegistryAdapter
import org.springframework.beans.factory.support.RootBeanDefinition
import org.springframework.context.ApplicationContext
import org.springframework.context.MessageSource
+import org.springframework.context.support.GenericApplicationContext
import org.springframework.context.support.StaticMessageSource
import org.springframework.core.convert.support.DefaultConversionService
import org.springframework.core.io.Resource
@@ -338,7 +341,10 @@ abstract class AbstractGrailsTagTests {
dependentPlugins*.doWithRuntimeConfiguration(springConfig)
- grailsApplication.mainContext = springConfig.getUnrefreshedApplicationContext()
+ GenericApplicationContext unrefreshedContext = springConfig.getUnrefreshedApplicationContext() as GenericApplicationContext
+ applyPluginBeanRegistrars(dependentPlugins, unrefreshedContext)
+
+ grailsApplication.mainContext = unrefreshedContext
appCtx = springConfig.getApplicationContext()
ctx.servletContext.setAttribute(GrailsApplicationAttributes.APPLICATION_CONTEXT, appCtx)
@@ -355,6 +361,22 @@ abstract class AbstractGrailsTagTests {
}
}
+ /**
+ * Applies each plugin's {@link BeanRegistrar} to the unrefreshed context, the second half of
+ * what the runtime does for a plugin: {@code doWithRuntimeConfiguration} drains the deprecated
+ * {@code doWithSpring} DSL, then the registrars run. A harness that only does the former sees
+ * none of the beans a plugin contributes through {@code beanRegistrar()}.
+ */
+ private static void applyPluginBeanRegistrars(List plugins, GenericApplicationContext context) {
+ for (DefaultGrailsPlugin plugin in plugins) {
+ BeanRegistrar registrar = plugin.beanRegistrar
+ if (registrar != null) {
+ new BeanRegistryAdapter(context, context.beanFactory, context.environment, registrar.getClass())
+ .register(registrar)
+ }
+ }
+ }
+
private initRequestAndResponse() {
request = webRequest.currentRequest
request.characterEncoding = 'utf-8'
diff --git a/grails-test-suite-uber/src/test/groovy/org/grails/plugins/CoreGrailsPluginTests.groovy b/grails-test-suite-uber/src/test/groovy/org/grails/plugins/CoreGrailsPluginTests.groovy
index 7a7ae70baf0..2f70212690a 100644
--- a/grails-test-suite-uber/src/test/groovy/org/grails/plugins/CoreGrailsPluginTests.groovy
+++ b/grails-test-suite-uber/src/test/groovy/org/grails/plugins/CoreGrailsPluginTests.groovy
@@ -19,8 +19,12 @@
package org.grails.plugins
+import org.springframework.beans.factory.BeanRegistrar
+import org.springframework.beans.factory.support.BeanRegistryAdapter
+import org.springframework.context.support.GenericApplicationContext
import org.springframework.core.env.StandardEnvironment
+import grails.core.support.proxy.DefaultProxyHandler
import grails.plugins.GrailsPlugin
import grails.plugins.GrailsPluginManager
import grails.web.servlet.plugins.GrailsWebPluginManager
@@ -33,58 +37,61 @@ import org.grails.commons.test.AbstractGrailsMockTests
import org.springframework.jdbc.datasource.DataSourceTransactionManager
import org.springframework.beans.factory.config.RuntimeBeanReference
+/**
+ * Covers what {@code CoreGrailsPlugin} contributes through {@code beanRegistrar()}. The beans it
+ * contributes through its {@code beans} DSL are covered by {@code CoreAutoConfigurationSpec}, and
+ * the {@code grails.spring.bean.packages} scan by {@code SpringBeanPackagesSpec}.
+ */
class CoreGrailsPluginTests extends AbstractGrailsMockTests {
- void testComponentScan() {
- def pluginClass = gcl.loadClass("org.grails.plugins.CoreGrailsPlugin")
-
- def plugin = new DefaultGrailsPlugin(pluginClass, ga)
- def pluginManager = new MockGrailsPluginManager(ga)
- ctx.registerMockBean(GrailsPluginManager.BEAN_NAME, pluginManager)
- ga.config.grails.spring.bean.packages = ['org.grails.plugins.test']
-
- def springConfig = new WebRuntimeSpringConfiguration(ctx)
- springConfig.servletContext = createMockServletContext()
-
- plugin.doWithRuntimeConfiguration(springConfig)
-
- def appCtx = springConfig.getApplicationContext()
-
- }
void testCorePlugin() {
- def pluginClass = gcl.loadClass("org.grails.plugins.CoreGrailsPlugin")
-
- def plugin = new DefaultGrailsPlugin(pluginClass, ga)
+ def plugin = corePlugin()
def springConfig = new WebRuntimeSpringConfiguration(ctx)
springConfig.servletContext = createMockServletContext()
- plugin.doWithRuntimeConfiguration(springConfig)
+ def appCtx = configure(plugin, springConfig)
- def appCtx = springConfig.getApplicationContext()
-
- assert appCtx.containsBean("classLoader")
assert appCtx.containsBean("customEditors")
+ assert appCtx.getBean("proxyHandler") instanceof DefaultProxyHandler
assert appCtx.getBean("org.springframework.aop.config.internalAutoProxyCreator") instanceof GroovyAwareAspectJAwareAdvisorAutoProxyCreator
}
void testDisableAspectj() {
- def pluginClass = gcl.loadClass("org.grails.plugins.CoreGrailsPlugin")
-
- def plugin = new DefaultGrailsPlugin(pluginClass, ga)
+ def plugin = corePlugin()
def springConfig = new WebRuntimeSpringConfiguration(ctx)
springConfig.servletContext = createMockServletContext()
ga.config.grails.spring.disable.aspectj.autoweaving=true
ga.configChanged()
- plugin.doWithRuntimeConfiguration(springConfig)
- def appCtx = springConfig.getApplicationContext()
+ def appCtx = configure(plugin, springConfig)
- assert appCtx.containsBean("classLoader")
assert appCtx.containsBean("customEditors")
assert appCtx.getBean("org.springframework.aop.config.internalAutoProxyCreator") instanceof GroovyAwareInfrastructureAdvisorAutoProxyCreator
+ }
+ private DefaultGrailsPlugin corePlugin() {
+ new DefaultGrailsPlugin(gcl.loadClass("org.grails.plugins.CoreGrailsPlugin"), ga)
+ }
+
+ /**
+ * Runs both halves of a plugin's Spring contribution in the order the runtime does: the
+ * deprecated {@code doWithSpring} DSL drains first, then the registrar, so registrar beans
+ * win any name conflict.
+ */
+ private static configure(DefaultGrailsPlugin plugin, WebRuntimeSpringConfiguration springConfig) {
+ plugin.doWithRuntimeConfiguration(springConfig)
+ applyBeanRegistrar(plugin, springConfig.getUnrefreshedApplicationContext() as GenericApplicationContext)
+ springConfig.getApplicationContext()
+ }
+
+ private static void applyBeanRegistrar(DefaultGrailsPlugin plugin, GenericApplicationContext context) {
+ BeanRegistrar registrar = plugin.beanRegistrar
+ if (registrar != null) {
+ new BeanRegistryAdapter(context, context.beanFactory, context.environment, registrar.getClass())
+ .register(registrar)
+ }
}
protected void onSetUp() {
@@ -147,6 +154,9 @@ class CoreGrailsPluginTests extends AbstractGrailsMockTests {
def plugin = new DefaultGrailsPlugin(pluginClass, ga)
plugin.doWithRuntimeConfiguration(springConfig)
+ // the configurer that applies the beans {} config block comes from the core registrar
+ applyBeanRegistrar(corePlugin, springConfig.getUnrefreshedApplicationContext() as GenericApplicationContext)
+
def appCtx = springConfig.getApplicationContext()
assertEquals(1, appCtx.getBean('someTransactionalService').i)
diff --git a/grails-url-mappings/build.gradle b/grails-url-mappings/build.gradle
index c7e81c943fc..d289719e5b1 100644
--- a/grails-url-mappings/build.gradle
+++ b/grails-url-mappings/build.gradle
@@ -43,6 +43,8 @@ dependencies {
api 'org.apache.groovy:groovy'
api 'org.springframework.boot:spring-boot-servlet'
+ implementation project(':grails-beans-dsl')
+
testCompileOnly 'org.junit.jupiter:junit-jupiter-api'
compileOnly 'jakarta.servlet:jakarta.servlet-api'
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
deleted file mode 100644
index 0022ea9d398..00000000000
--- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsAutoConfiguration.java
+++ /dev/null
@@ -1,102 +0,0 @@
-/*
- * 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 org.springframework.beans.factory.ObjectProvider;
-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.ConditionalOnProperty;
-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;
-import grails.web.CamelCaseUrlConverter;
-import grails.web.HyphenatedUrlConverter;
-import grails.web.UrlConverter;
-import grails.web.mapping.LinkGenerator;
-import grails.web.mapping.UrlMappings;
-import grails.web.mapping.cors.GrailsCorsConfiguration;
-import grails.web.mapping.cors.GrailsCorsFilter;
-import org.grails.web.mapping.CachingLinkGenerator;
-import org.grails.web.mapping.DefaultLinkGenerator;
-import org.grails.web.mapping.mvc.UrlMappingsInfoHandlerAdapter;
-import org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer;
-
-@AutoConfiguration
-@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
-@EnableConfigurationProperties({ GrailsCorsConfiguration.class })
-public class UrlMappingsAutoConfiguration {
- @Value("${" + Settings.WEB_LINK_GENERATOR_USE_CACHE + ":#{null}}")
- private Boolean cacheUrls;
-
- @Value("${" + Settings.SERVER_URL + ":#{null}}")
- private String serverURL;
-
- @Bean(UrlConverter.BEAN_NAME)
- @ConditionalOnMissingBean(name = UrlConverter.BEAN_NAME)
- @ConditionalOnProperty(name = Settings.WEB_URL_CONVERTER, havingValue = "camelCase", matchIfMissing = true)
- public UrlConverter camelCaseUrlConverter() {
- return new CamelCaseUrlConverter();
- }
-
- @Bean(UrlConverter.BEAN_NAME)
- @ConditionalOnMissingBean(name = UrlConverter.BEAN_NAME)
- @ConditionalOnProperty(name = Settings.WEB_URL_CONVERTER, havingValue = "hyphenated")
- public UrlConverter hyphenatedUrlConverter() {
- return new HyphenatedUrlConverter();
- }
-
- @Bean
- @ConditionalOnMissingBean(name = LinkGenerator.BEAN_NAME)
- public LinkGenerator grailsLinkGenerator() {
- if (cacheUrls == null) {
- cacheUrls = !Environment.isDevelopmentMode() && !Environment.getCurrent().isReloadEnabled();
- }
- 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());
- return errorPageCustomizer;
- }
-
- @Bean
- @ConditionalOnMissingBean(UrlMappingsInfoHandlerAdapter.class)
- public UrlMappingsInfoHandlerAdapter urlMappingsInfoHandlerAdapter() {
- return new UrlMappingsInfoHandlerAdapter();
- }
-}
diff --git a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
index a95bae0cab6..164d35bc13e 100644
--- a/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
+++ b/grails-url-mappings/src/main/groovy/org/grails/plugins/web/mapping/UrlMappingsGrailsPlugin.groovy
@@ -24,17 +24,33 @@ import groovy.transform.CompileStatic
import org.springframework.aop.target.HotSwappableTargetSource
import org.springframework.beans.factory.BeanRegistrar
import org.springframework.beans.factory.BeanRegistry
+import org.springframework.beans.factory.ObjectProvider
+import org.springframework.boot.autoconfigure.AutoConfiguration
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
+import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication
+import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.ApplicationContext
import org.springframework.core.env.Environment
+import org.springframework.web.filter.CorsFilter
import grails.config.Settings
import grails.plugins.Plugin
+import grails.util.Environment as GrailsEnvironment
import grails.util.GrailsUtil
+import grails.web.CamelCaseUrlConverter
+import grails.web.HyphenatedUrlConverter
+import grails.web.UrlConverter
import grails.web.mapping.LinkGenerator
+import grails.web.mapping.UrlMappings
import grails.web.mapping.UrlMappingsHolder
+import grails.web.mapping.cors.GrailsCorsConfiguration
+import grails.web.mapping.cors.GrailsCorsFilter
import org.grails.core.artefact.UrlMappingsArtefactHandler
import org.grails.web.mapping.CachingLinkGenerator
+import org.grails.web.mapping.DefaultLinkGenerator
import org.grails.web.mapping.UrlMappingsHolderFactoryBean
+import org.grails.web.mapping.mvc.UrlMappingsInfoHandlerAdapter
+import org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer
/**
* Handles the configuration of URL mappings.
@@ -43,6 +59,9 @@ import org.grails.web.mapping.UrlMappingsHolderFactoryBean
* @since 0.4
*/
@CompileStatic
+@AutoConfiguration
+@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
+@EnableConfigurationProperties([GrailsCorsConfiguration])
class UrlMappingsGrailsPlugin extends Plugin {
def watchedResources = ['file:./grails-app/controllers/*UrlMappings.groovy']
@@ -51,6 +70,47 @@ class UrlMappingsGrailsPlugin extends Plugin {
def dependsOn = [core: version]
def loadAfter = ['controllers']
+ def beans = {
+ field('cacheUrls', Boolean).value(Settings.WEB_LINK_GENERATOR_USE_CACHE, '#{null}')
+ field('serverURL', String).value(Settings.SERVER_URL, '#{null}')
+
+ // The two mutually exclusive grailsUrlConverter variants share one bean name; the
+ // @ConditionalOnProperty selects which registers. Its name: attribute must be an inline
+ // constant, so the property names below are literals mirroring Settings.WEB_URL_CONVERTER
+ // and Settings.SETTING_CORS_FILTER.
+ bean('grailsUrlConverter', UrlConverter).conditionalOnMissingBeanName()
+ .annotate(ConditionalOnProperty, name: 'grails.web.url.converter', havingValue: 'camelCase', matchIfMissing: true) {
+ new CamelCaseUrlConverter()
+ }
+
+ bean('grailsUrlConverter', UrlConverter).conditionalOnMissingBeanName()
+ .annotate(ConditionalOnProperty, name: 'grails.web.url.converter', havingValue: 'hyphenated') {
+ new HyphenatedUrlConverter()
+ }
+
+ bean('grailsLinkGenerator', LinkGenerator).conditionalOnMissingBeanName() {
+ boolean useCache = cacheUrls != null ? cacheUrls :
+ !GrailsEnvironment.developmentMode && !GrailsEnvironment.current.reloadEnabled
+ useCache ? 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(GrailsCorsFilter).conditionalOnMissingBean(CorsFilter)
+ .annotate(ConditionalOnProperty, name: 'grails.cors.filter', havingValue: 'true', matchIfMissing: true) { GrailsCorsConfiguration grailsCorsConfiguration ->
+ new GrailsCorsFilter(grailsCorsConfiguration)
+ }
+
+ bean(UrlMappingsErrorPageCustomizer).conditionalOnMissingBean() { ObjectProvider urlMappingsProvider ->
+ new UrlMappingsErrorPageCustomizer().tap {
+ urlMappings = urlMappingsProvider.ifAvailable
+ }
+ }
+
+ bean(UrlMappingsInfoHandlerAdapter).conditionalOnMissingBean()
+ }
+
@Override
BeanRegistrar beanRegistrar() {
return { BeanRegistry registry, Environment environment ->
@@ -58,8 +118,8 @@ class UrlMappingsGrailsPlugin extends Plugin {
grailsApplication.addArtefact(UrlMappingsArtefactHandler.TYPE, DefaultUrlMappings)
}
- boolean reloadEnabled = grails.util.Environment.isDevelopmentMode() ||
- grails.util.Environment.current.isReloadEnabled()
+ boolean reloadEnabled = GrailsEnvironment.developmentMode ||
+ GrailsEnvironment.current.reloadEnabled
boolean corsFilterEnabled = environment.getProperty(Settings.SETTING_CORS_FILTER, Boolean, true)
// The url-mapping holder is a ProxyFactoryBean (reload mode) whose produced UrlMappings
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 b13e145351f..c24bbc26a9f 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
@@ -22,6 +22,10 @@ package org.grails.plugins.web.mapping
import java.util.function.Supplier
import grails.config.Settings
+import grails.web.CamelCaseUrlConverter
+import grails.web.HyphenatedUrlConverter
+import grails.web.UrlConverter
+import grails.web.mapping.LinkGenerator
import grails.web.mapping.UrlMappings
import grails.web.mapping.cors.GrailsCorsConfiguration
import grails.web.mapping.cors.GrailsCorsFilter
@@ -32,6 +36,8 @@ 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.CachingLinkGenerator
+import org.grails.web.mapping.DefaultLinkGenerator
import org.grails.web.mapping.mvc.UrlMappingsInfoHandlerAdapter
import org.grails.web.mapping.servlet.UrlMappingsErrorPageCustomizer
@@ -58,6 +64,84 @@ class UrlMappingsAutoConfigurationSpec extends Specification {
}
}
+ void 'the camelCase url converter registers by default'() {
+ expect:
+ contextRunner().run { context ->
+ assert context.getBean(UrlConverter.BEAN_NAME) instanceof CamelCaseUrlConverter
+ }
+ }
+
+ void 'the hyphenated url converter registers when selected by property'() {
+ expect:
+ contextRunner()
+ .withPropertyValues("${Settings.WEB_URL_CONVERTER}=hyphenated")
+ .run { context ->
+ assert context.getBean(UrlConverter.BEAN_NAME) instanceof HyphenatedUrlConverter
+ }
+ }
+
+ void 'the link generator caches when enabled by property'() {
+ expect:
+ contextRunner()
+ .withPropertyValues("${Settings.WEB_LINK_GENERATOR_USE_CACHE}=true")
+ .run { context ->
+ assert context.getBean(LinkGenerator.BEAN_NAME) instanceof CachingLinkGenerator
+ }
+ }
+
+ void 'the link generator does not cache when disabled by property, and receives the configured server URL'() {
+ expect:
+ contextRunner()
+ .withPropertyValues(
+ "${Settings.WEB_LINK_GENERATOR_USE_CACHE}=false",
+ "${Settings.SERVER_URL}=https://example.org")
+ .run { context ->
+ def linkGenerator = context.getBean(LinkGenerator.BEAN_NAME)
+ assert linkGenerator instanceof DefaultLinkGenerator
+ assert !(linkGenerator instanceof CachingLinkGenerator)
+ assert linkGenerator.configuredServerBaseURL == 'https://example.org'
+ }
+ }
+
+ void 'the configured server URL also propagates to the caching link generator'() {
+ expect:
+ contextRunner()
+ .withPropertyValues(
+ "${Settings.WEB_LINK_GENERATOR_USE_CACHE}=true",
+ "${Settings.SERVER_URL}=https://example.org")
+ .run { context ->
+ assert context.getBean(LinkGenerator.BEAN_NAME).configuredServerBaseURL == 'https://example.org'
+ }
+ }
+
+ void 'a user-defined grailsLinkGenerator bean makes the auto-configured one back off'() {
+ given:
+ LinkGenerator userLinkGenerator = new DefaultLinkGenerator('https://user.example.org')
+ Supplier userLinkGeneratorSupplier = () -> userLinkGenerator
+
+ expect:
+ contextRunner()
+ .withBean(LinkGenerator.BEAN_NAME, LinkGenerator, userLinkGeneratorSupplier)
+ .run { context ->
+ assert context.getBeanNamesForType(LinkGenerator).length == 1
+ assert context.getBean(LinkGenerator.BEAN_NAME).is(userLinkGenerator)
+ }
+ }
+
+ void 'a user-defined grailsUrlConverter bean makes both auto-configured variants back off'() {
+ given:
+ UrlConverter userConverter = new HyphenatedUrlConverter()
+ Supplier userConverterSupplier = () -> userConverter
+
+ expect:
+ contextRunner()
+ .withBean(UrlConverter.BEAN_NAME, UrlConverter, userConverterSupplier)
+ .run { context ->
+ assert context.getBeanNamesForType(UrlConverter).length == 1
+ assert context.getBean(UrlConverter.BEAN_NAME).is(userConverter)
+ }
+ }
+
void 'the CORS filter is not registered when disabled by property'() {
expect:
contextRunner()
diff --git a/settings.gradle b/settings.gradle
index 011380687f1..2a1504b6cc3 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -107,6 +107,9 @@ def skipMicronautProjects = explicitlySkipMicronaut || (!buildJdkSupportsMicrona
include(
'grails-bootstrap',
+ 'grails-beans-dsl',
+ 'grails-beans-dsl-example',
+ 'grails-beans-dsl-plugin-example',
'grails-cache',
'grails-codecs-core',
'grails-codecs',