Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
a8a12d5
Add @GrailsBeans: compile a bean-wiring DSL into real @AutoConfigurat…
codeconsole Jul 19, 2026
e361649
Allow @GrailsBeans directly on a *GrailsPlugin.groovy Plugin subclass
codeconsole Jul 20, 2026
0ed1645
Verify and document @GrailsBeans compatibility with @CompileStatic
codeconsole Jul 20, 2026
79b2f3f
Address review findings: publishing, static compilation, arg validati…
codeconsole Jul 20, 2026
48ed0fa
Statically compile generated GrailsBeans plugin siblings
codeconsole Jul 20, 2026
360bc43
Document @GrailsBeans DSL limitations
codeconsole Jul 20, 2026
ff22419
Add primary(), lazy(), and scope(...) qualifiers to the beans DSL
codeconsole Jul 20, 2026
d5c7d2d
Correct inaccurate claim about per-bean Spring conditionals in docs
codeconsole Jul 20, 2026
4cf0a7a
Add a generic annotate(...) qualifier to the beans DSL
codeconsole Jul 21, 2026
4dea95c
Add field(...) and method(...) to the beans DSL
codeconsole Jul 21, 2026
6385b33
Convert the real grails-i18n autoconfiguration to @GrailsBeans
codeconsole Jul 22, 2026
e4f92b1
Use the real Settings constants for @Value keys in the i18n conversion
codeconsole Jul 22, 2026
dc07794
Remove unnecessary method() indirection from the i18n conversion
codeconsole Jul 22, 2026
8c35ae1
Split a merged comment back into its two original topics
codeconsole Jul 22, 2026
c038c98
Address review: propagate Conditional*/Import* annotations to the gen…
codeconsole Jul 22, 2026
12fa9df
Document field()/method()/.primary()/.lazy()/.scope()/.annotate() and…
codeconsole Jul 22, 2026
ec5f8e5
Address a second review: recurse through composed condition/import an…
codeconsole Jul 22, 2026
959fa3d
Reject autoConfigurationName on a standalone class, where it silently…
codeconsole Jul 22, 2026
b36edf3
Document the full sibling-annotation move, composed annotations, auto…
codeconsole Jul 22, 2026
6806043
Fix the bean-name-limitation workaround: it doesn't apply to the Plug…
codeconsole Jul 22, 2026
32b0e06
Use autoConfigurationName to preserve I18nAutoConfiguration's identit…
codeconsole Jul 22, 2026
48d4af2
Complete the I18nAutoConfiguration identity restoration (previous com…
codeconsole Jul 22, 2026
471008b
Add bean(...).named(...) so a bean name that isn't a valid Java ident…
codeconsole Jul 22, 2026
98089e5
Make .named(...) optional: fall back to a synthesized <type>$N method…
codeconsole Jul 22, 2026
bdb6b63
Drop the redundant explicit bean names that just repeated the convent…
codeconsole Jul 22, 2026
faf14de
Reject blank/reserved-keyword names, add ImportResource to the siblin…
codeconsole Jul 22, 2026
dde9f33
Keep localeChangeInterceptor's explicit bean name; verify beforeName …
codeconsole Jul 22, 2026
b9063a7
Rename .methodNamed to .methodName; auto-move @ComponentScan/@Propert…
codeconsole Jul 22, 2026
a019de0
Remove .methodName(...) from the DSL: generated method names are an i…
codeconsole Jul 22, 2026
31feae1
Make bean/member name-collision handling independent of declaration o…
codeconsole Jul 22, 2026
c41c33b
Reserve existing host and hierarchy member names before choosing gene…
codeconsole Jul 22, 2026
21f69f9
Traverse the full interface graph when reserving existing member names
codeconsole Jul 22, 2026
2ff9ebc
Reserve the accessor names Groovy will synthesize for properties
codeconsole Jul 22, 2026
e0f8d13
Consolidate comments that restated each other or nearby documentation
codeconsole Jul 22, 2026
07e89bb
Groovy-fy the beans DSL: suffix-derived sibling names, richer conditi…
codeconsole Jul 22, 2026
b936793
Apply the new DSL conveniences to the real i18n conversion
codeconsole Jul 22, 2026
6148123
Add .conditionalOnMissingBeanName(...): name-based self-back-off stat…
codeconsole Jul 22, 2026
2306135
Reject types given both positionally and via value: on conditionalOnM…
codeconsole Jul 22, 2026
b821683
Fold .value(...) placeholders at transform time; compile I18nGrailsPl…
codeconsole Jul 22, 2026
98b046b
Merge branch '8.0.x' into spike/grails-beans-dsl
codeconsole Jul 22, 2026
19a502c
Flip the DSL argument order to name-first: bean('name', Type), field(…
codeconsole Jul 24, 2026
48d352f
Allow one bean name across several bean(...) statements when each car…
codeconsole Jul 24, 2026
855a283
Document @GrailsBeans for applications, including the Application class
codeconsole Jul 24, 2026
32ead2b
Auto-wrap a bare config key given to single-argument .value(...)
codeconsole Jul 24, 2026
5e9e162
Convert UrlMappingsAutoConfiguration to a @GrailsBeans block on UrlMa…
codeconsole Jul 24, 2026
776985d
Reject a blank config key in .value(...)
codeconsole Jul 24, 2026
03209d1
Cover the migrated grailsLinkGenerator behavior in UrlMappingsAutoCon…
codeconsole Jul 24, 2026
5450dff
Add bean(...).staticMethod(): a static @Bean factory method
codeconsole Jul 24, 2026
53dcb87
Convert Sitemesh3AutoConfiguration to a @GrailsBeans block on Sitemes…
codeconsole Jul 24, 2026
6e4a520
Convert MailAutoConfiguration to a @GrailsBeans block on MailGrailsPl…
codeconsole Jul 24, 2026
8ce8482
Cover the enabled JNDI path and qualified session injection in MailAu…
codeconsole Jul 24, 2026
483d9a5
Simplify the mail sender's nested host/encoding defaults
codeconsole Jul 24, 2026
203a8ae
Use tap for the mail sender's conditional configuration
codeconsole Jul 24, 2026
7d17c3a
Polish the converted bean bodies with Groovy idioms
codeconsole Jul 24, 2026
5c71d9e
Merge branch '8.0.x' into spike/grails-beans-dsl
codeconsole Jul 26, 2026
6986f0c
Convert CoreAutoConfiguration to a @GrailsBeans block on CoreGrailsPl…
codeconsole Jul 27, 2026
100b9db
Apply the configured Spring placeholder prefix
codeconsole Jul 27, 2026
311fb9a
Convert GrailsCacheAutoConfiguration to a @GrailsBeans block on Cache…
codeconsole Jul 27, 2026
700cf9c
Convert GrailsDomainClassAutoConfiguration to a @GrailsBeans block on…
codeconsole Jul 27, 2026
623ce97
Convert DataBindingConfiguration to a @GrailsBeans block on DataBindi…
codeconsole Jul 27, 2026
4dab384
Allow bean(Type) with no factory closure
codeconsole Jul 27, 2026
6fe91a2
Collapse the databinding source creators onto bodyless bean(Type)
codeconsole Jul 27, 2026
bab7b05
Drop redundant explicit bean names from the converted plugins
codeconsole Jul 27, 2026
cd1639f
Use idiomatic array concatenation in the data binder bean
codeconsole Jul 27, 2026
350bb98
Collapse the remaining bare-construction beans onto bodyless bean(Type)
codeconsole Jul 27, 2026
f4410fd
Use tap for the source registry and wrap the long url-mapping chains
codeconsole Jul 27, 2026
285168d
Show the bodyless bean form in the application-class documentation
codeconsole Jul 27, 2026
80e526a
Treat an empty beans block as a no-op
codeconsole Jul 27, 2026
6a71fa5
Merge branch '8.0.x' into spike/grails-beans-dsl
codeconsole Jul 27, 2026
5a4e2b8
Compile a beans property without requiring @GrailsBeans
codeconsole Jul 27, 2026
1af34ee
Drop @GrailsBeans from the plugin descriptors that no longer need it
codeconsole Jul 27, 2026
1346b27
Move the cache plugin fully onto auto-configuration
codeconsole Jul 27, 2026
da28b4f
Move the SiteMesh 3 mutator into the DSL and fix its environment post…
codeconsole Jul 27, 2026
1544bd7
Contribute the auto-timestamp cache default to the environment
codeconsole Jul 27, 2026
2930502
Drop the dead GrailsConfigurationAware contract from GrailsPlaceholde…
codeconsole Jul 28, 2026
0161f9d
Fix component scanning outside the project directory
codeconsole Jul 28, 2026
48b2bfb
Contribute the bean-packages scan through beanRegistrar
codeconsole Jul 28, 2026
637b7af
Move CoreGrailsPlugin's bean definitions out of doWithSpring
codeconsole Jul 28, 2026
a70ff87
Register the abstract resource locator without doWithSpring
codeconsole Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions build-logic/plugins/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down Expand Up @@ -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'
}
}
}
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>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<Project> {

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<Directory> generatedDir = project.layout.buildDirectory.dir(GENERATED_RESOURCES_PATH)

TaskProvider<GenerateAutoConfigurationImportsTask> 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.<String, Object> singletonMap('builtBy', task), generatedDir)
}

}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String> 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<String> scan(Set<File> classesDirs, Set<File> 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<String> 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<String> 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)
}
}
}

}
Original file line number Diff line number Diff line change
@@ -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<File> 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<String> 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<String> 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<String> 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<String> reportedFailures = []
SortedSet<String> 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']
}

}
Loading
Loading