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: + *

+ * 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: + *

+ * + *

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> 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> 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',