You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@grails.apache.org by ja...@apache.org on 2026/07/24 00:31:45 UTC
(grails-core) 01/01: Generate metadata for Groovy configuration DSLs
This is an automated email from the ASF dual-hosted git repository.
jamesfredley pushed a commit to branch feature/groovy-dsl-configuration-metadata
in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit d12545ae42611bf5c7e222a1581b0dc36a684ccb
Author: James Fredley <ja...@users.noreply.github.com>
AuthorDate: Thu Jul 23 20:30:30 2026 -0400
Generate metadata for Groovy configuration DSLs
Parse registered Groovy DSL sources without executing them and merge all remaining curated metadata through canonical generated resources.
Assisted-by: opencode:gpt-5.6-sol codegraph
---
.../buildsrc/ConfigurationMetadataPlugin.groovy | 54 ++-
.../GroovyDslConfigurationMetadataParser.groovy | 207 +++++++++
.../ConfigurationMetadataPluginSpec.groovy | 233 +++++++++-
grails-core/build.gradle | 1 +
.../additional-spring-configuration-metadata.json | 484 ++++++++++++++++++---
.../META-INF/spring-configuration-metadata.json | 346 ---------------
grails-data-hibernate5/core/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-data-hibernate5/dbmigration/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-data-hibernate7/core/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-data-hibernate7/dbmigration/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-data-mongodb/grails-plugin/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-doc/src/en/guide/conf/config.adoc | 10 +-
grails-gsp/plugin/build.gradle | 1 +
... additional-spring-configuration-metadata.json} | 0
grails-spring-security/plugin/build.gradle | 6 +
... additional-spring-configuration-metadata.json} | 0
.../ConfigurationMetadataSpec.groovy | 62 +++
grails-web-core/build.gradle | 3 +-
... additional-spring-configuration-metadata.json} | 0
24 files changed, 982 insertions(+), 430 deletions(-)
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
index 1dc5d9d460..eb42e611c8 100644
--- a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy
@@ -28,7 +28,9 @@ import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.FileSystemOperations
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.plugins.JavaPluginExtension
+import org.gradle.api.provider.MapProperty
import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Optional
@@ -106,6 +108,13 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
@PathSensitive(PathSensitivity.RELATIVE)
abstract ConfigurableFileCollection getClassesDirs()
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ abstract ConfigurableFileCollection getDslConfigurationFiles()
+
+ @Input
+ abstract MapProperty<String, String> getDslRootPrefixes()
+
@InputFile
@Optional
@PathSensitive(PathSensitivity.RELATIVE)
@@ -117,9 +126,15 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
@Inject
abstract FileSystemOperations getFileSystemOperations()
+ GenerateConfigurationMetadataTask() {
+ dslRootPrefixes.convention([:])
+ }
+
@TaskAction
void generate() {
Map<String, ClassModel> models = readModels()
+ List<Map<String, Object>> dslProperties = GroovyDslConfigurationMetadataParser.parse(
+ dslConfigurationFiles.files, dslRootPrefixes.get())
List<Map<String, Object>> groups = []
List<Map<String, Object>> properties = []
models.values().findAll { ClassModel model -> model.prefix != null }.sort { ClassModel model -> model.name }.each {
@@ -144,7 +159,7 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
}
}
- Map<String, Object> metadata = merge(groups, properties, readOverlay())
+ Map<String, Object> metadata = merge([], dslProperties, groups, properties, readOverlay())
File output = outputDirectory.get().asFile
fileSystemOperations.delete { it.delete(output) }
File target = new File(output, 'META-INF/spring-configuration-metadata.json')
@@ -348,13 +363,17 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
file?.isFile() ? new JsonSlurper().parse(file, StandardCharsets.UTF_8.name()) as Map : [:]
}
- private static Map<String, Object> merge(List<Map<String, Object>> groups,
- List<Map<String, Object>> properties, Map overlay) {
+ private static Map<String, Object> merge(List<Map<String, Object>> dslGroups,
+ List<Map<String, Object>> dslProperties,
+ List<Map<String, Object>> typedGroups,
+ List<Map<String, Object>> typedProperties,
+ Map overlay) {
Map<String, Object> result = [:]
- result['groups'] = mergeNamed(groups, (overlay.get('groups') ?: []) as List, 'groups')
- result['properties'] = mergeNamed(properties, (overlay.get('properties') ?: []) as List, 'properties')
+ result['groups'] = mergeNamed(dslGroups, typedGroups, (overlay.get('groups') ?: []) as List, 'groups')
+ result['properties'] = mergeNamed(dslProperties, typedProperties,
+ (overlay.get('properties') ?: []) as List, 'properties')
if (overlay.containsKey('hints')) {
- result['hints'] = mergeNamed([], overlay.get('hints') as List, 'hints')
+ result['hints'] = mergeNamed([], [], overlay.get('hints') as List, 'hints')
}
overlay.each { Object keyValue, Object value ->
String key = keyValue.toString()
@@ -365,27 +384,32 @@ abstract class GenerateConfigurationMetadataTask extends DefaultTask {
if (overlay.containsKey('ignored')) {
Map ignored = new LinkedHashMap((overlay.get('ignored') ?: [:]) as Map)
if (ignored.containsKey('properties')) {
- ignored['properties'] = mergeNamed([], ignored.get('properties') as List, 'ignored.properties')
+ ignored['properties'] = mergeNamed([], [], ignored.get('properties') as List, 'ignored.properties')
}
result['ignored'] = ignored
}
result
}
- private static List<Object> mergeNamed(List generated, List overlay, String category) {
- Map<String, Object> generatedByName = indexByName(generated, category, 'generated')
+ private static List<Object> mergeNamed(List dsl, List typed, List overlay, String category) {
+ Map<String, Object> dslByName = indexByName(dsl, category, 'DSL')
+ Map<String, Object> typedByName = indexByName(typed, category, 'generated')
Map<String, Object> overlayByName = indexByName(overlay, category, 'overlay')
- Map<String, Object> merged = new LinkedHashMap<>(generatedByName)
+ Map<String, Object> merged = new LinkedHashMap<>(dslByName)
+ typedByName.each { String name, Object value ->
+ merged[name] = mergeFields(merged[name], value)
+ }
overlayByName.each { String name, Object value ->
- if (merged[name] instanceof Map && value instanceof Map) {
- merged[name] = new LinkedHashMap((Map) merged[name]) + (Map) value
- } else {
- merged[name] = value
- }
+ merged[name] = mergeFields(merged[name], value)
}
merged.keySet().sort().collect { String name -> merged[name] }
}
+ private static Object mergeFields(Object lowerPrecedence, Object higherPrecedence) {
+ lowerPrecedence instanceof Map && higherPrecedence instanceof Map ?
+ new LinkedHashMap((Map) lowerPrecedence) + (Map) higherPrecedence : higherPrecedence
+ }
+
private static Map<String, Object> indexByName(List source, String category, String sourceName) {
Map<String, Object> indexed = [:]
source.each { Object entry ->
diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy
new file mode 100644
index 0000000000..76b160574d
--- /dev/null
+++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/GroovyDslConfigurationMetadataParser.groovy
@@ -0,0 +1,207 @@
+/*
+ * 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.codehaus.groovy.ast.ModuleNode
+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.Expression
+import org.codehaus.groovy.ast.expr.ListExpression
+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.expr.TernaryExpression
+import org.codehaus.groovy.ast.expr.VariableExpression
+import org.codehaus.groovy.ast.stmt.BlockStatement
+import org.codehaus.groovy.ast.stmt.ExpressionStatement
+import org.codehaus.groovy.ast.stmt.IfStatement
+import org.codehaus.groovy.ast.stmt.Statement
+import org.codehaus.groovy.control.CompilationFailedException
+import org.codehaus.groovy.control.SourceUnit
+import org.codehaus.groovy.syntax.Types
+
+import java.nio.charset.StandardCharsets
+
+/** Extracts configuration metadata from Groovy DSL source without evaluating source code. */
+final class GroovyDslConfigurationMetadataParser {
+
+ private GroovyDslConfigurationMetadataParser() {
+ }
+
+ static List<Map<String, Object>> parse(Collection<File> files, Map<String, String> rootPrefixes) {
+ List<Map<String, Object>> properties = []
+ files.findAll { File file -> file.isFile() }.sort { File file -> file.absolutePath }.each { File file ->
+ parseFile(file, rootPrefixes, properties)
+ }
+ properties.sort { Map<String, Object> property -> property.name as String }
+ }
+
+ private static void parseFile(File file, Map<String, String> rootPrefixes,
+ List<Map<String, Object>> properties) {
+ ModuleNode module = parseSource(file)
+ module.statementBlock.statements.each { Statement statement ->
+ MethodCallExpression call = methodCall(statement)
+ String root = call?.methodAsString
+ ClosureExpression closure = call == null ? null : closureArgument(call)
+ String prefix = root == null ? null : rootPrefixes[root]
+ if (prefix != null && closure != null) {
+ parseStatements(closure.code, prefix, false, properties)
+ }
+ }
+ }
+
+ private static ModuleNode parseSource(File file) {
+ try {
+ SourceUnit source = SourceUnit.create(file.absolutePath, file.getText(StandardCharsets.UTF_8.name()))
+ source.parse()
+ source.completePhase()
+ source.nextPhase()
+ source.convert()
+ source.errorCollector.failIfErrors()
+ source.AST
+ } catch (CompilationFailedException exception) {
+ throw new IllegalArgumentException("Failed to parse Groovy DSL source '${file.absolutePath}'", exception)
+ }
+ }
+
+ private static void parseStatements(Statement statement, String prefix, boolean conditional,
+ List<Map<String, Object>> properties) {
+ if (statement instanceof BlockStatement) {
+ statement.statements.each { Statement child -> parseStatements(child, prefix, conditional, properties) }
+ } else if (statement instanceof IfStatement) {
+ parseStatements(statement.ifBlock, prefix, true, properties)
+ parseStatements(statement.elseBlock, prefix, true, properties)
+ } else if (statement instanceof ExpressionStatement) {
+ Expression expression = statement.expression
+ if (expression instanceof BinaryExpression && expression.operation.type == Types.ASSIGN) {
+ addAssignment(expression, prefix, conditional, properties)
+ } else if (expression instanceof MethodCallExpression) {
+ ClosureExpression closure = closureArgument(expression)
+ String nestedName = expression.methodAsString
+ if (closure != null && nestedName != null) {
+ parseStatements(closure.code, "${prefix}.${nestedName}", conditional, properties)
+ }
+ }
+ }
+ }
+
+ private static void addAssignment(BinaryExpression assignment, String prefix, boolean conditional,
+ List<Map<String, Object>> properties) {
+ List<String> segments = leftHandPath(assignment.leftExpression)
+ if (segments == null) {
+ return
+ }
+ String name = "${prefix}.${segments.join('.')}"
+ Inference inference = infer(assignment.rightExpression)
+ Map<String, Object> property = [name: name]
+ if (inference.type != null) {
+ property.type = inference.type
+ }
+ if (!conditional && inference.literal) {
+ property.defaultValue = inference.value
+ }
+ properties << property
+ }
+
+ private static List<String> leftHandPath(Expression expression) {
+ if (expression instanceof VariableExpression) {
+ return [expression.name]
+ }
+ if (expression instanceof PropertyExpression && expression.property instanceof ConstantExpression &&
+ expression.property.value instanceof String) {
+ List<String> owner = leftHandPath(expression.objectExpression)
+ return owner == null ? null : owner + expression.property.value
+ }
+ null
+ }
+
+ private static MethodCallExpression methodCall(Statement statement) {
+ statement instanceof ExpressionStatement && statement.expression instanceof MethodCallExpression ?
+ statement.expression as MethodCallExpression : null
+ }
+
+ private static ClosureExpression closureArgument(MethodCallExpression call) {
+ call.arguments instanceof ArgumentListExpression ?
+ (call.arguments.expressions.find { Expression expression -> expression instanceof ClosureExpression } as ClosureExpression) : null
+ }
+
+ private static Inference infer(Expression expression) {
+ if (expression instanceof ConstantExpression) {
+ return new Inference(type: expression.value?.class?.name, literal: true, value: expression.value)
+ }
+ if (expression instanceof ListExpression) {
+ return listInference(expression)
+ }
+ if (expression instanceof MapExpression) {
+ return mapInference(expression)
+ }
+ if (expression instanceof TernaryExpression) {
+ return sharedType(infer(expression.trueExpression), infer(expression.falseExpression))
+ }
+ if (expression instanceof MethodCallExpression && isSystemCall(expression) &&
+ expression.methodAsString in ['getProperty', 'getenv']) {
+ return new Inference(type: 'java.lang.String')
+ }
+ new Inference()
+ }
+
+ private static boolean isSystemCall(MethodCallExpression expression) {
+ Expression receiver = expression.objectExpression
+ receiver instanceof ClassExpression && receiver.type.name == 'java.lang.System' ||
+ receiver instanceof VariableExpression && receiver.name == 'System'
+ }
+
+ private static Inference sharedType(Inference left, Inference right) {
+ left.type != null && left.type == right.type ? new Inference(type: left.type) : new Inference()
+ }
+
+ private static Inference listInference(ListExpression expression) {
+ List<Object> values = []
+ for (Expression element : expression.expressions) {
+ Inference inference = infer(element)
+ if (!inference.literal) {
+ return new Inference(type: 'java.util.List')
+ }
+ values << inference.value
+ }
+ new Inference(type: 'java.util.List', literal: true, value: values)
+ }
+
+ private static Inference mapInference(MapExpression expression) {
+ Map<String, Object> values = new LinkedHashMap<>()
+ for (def entry : expression.mapEntryExpressions) {
+ Inference key = infer(entry.keyExpression)
+ Inference value = infer(entry.valueExpression)
+ if (!key.literal || !(key.value instanceof String) || !value.literal) {
+ return new Inference(type: 'java.util.Map')
+ }
+ values[key.value] = value.value
+ }
+ new Inference(type: 'java.util.Map', literal: true, value: values)
+ }
+
+ private static final class Inference {
+ String type
+ boolean literal
+ Object value
+ }
+}
diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
index f56816bd30..dc4fbed166 100644
--- a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
+++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy
@@ -207,6 +207,181 @@ class ConfigurationMetadataPluginSpec extends Specification {
metadataEntryCount() == 1
}
+ def "generates static Groovy DSL metadata without executing the DSL source"() {
+ given: 'a mapped DSL source and curated metadata for one of its properties'
+ writeGroovyDslConfiguration(false)
+ configureDslMetadata('src/dsl/fixture/SecurityConfig.groovy')
+ write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
+ {
+ "properties": [
+ {
+ "name": "grails.plugin.springsecurity.userLookup.userDomainClassName",
+ "type": "java.lang.String",
+ "defaultValue": "overlay.User",
+ "description": "Curated user domain class"
+ }
+ ]
+ }
+ '''.stripIndent())
+
+ when: 'the jar is built without executing the DSL script'
+ BuildResult clean = run('clean', 'jar')
+ Map metadata = readMetadata()
+
+ then: 'nested closures use the mapped root prefix and literal defaults retain their types'
+ clean.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(metadata, 'grails.plugin.springsecurity.userLookup.enabled').type == 'java.lang.Boolean'
+ property(metadata, 'grails.plugin.springsecurity.userLookup.enabled').defaultValue == true
+ property(metadata, 'grails.plugin.springsecurity.oauth.client.clientId').type == 'java.lang.String'
+ property(metadata, 'grails.plugin.springsecurity.oauth.client.clientId').defaultValue == 'client'
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').type == 'java.lang.Integer'
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').defaultValue == 3
+ property(metadata, 'grails.plugin.springsecurity.authentication.roles').type == 'java.util.List'
+ property(metadata, 'grails.plugin.springsecurity.authentication.roles').defaultValue == ['ROLE_USER', 'ROLE_ADMIN']
+ property(metadata, 'grails.plugin.springsecurity.authentication.options').type == 'java.util.Map'
+ property(metadata, 'grails.plugin.springsecurity.authentication.options').defaultValue == [maxSessions: 1, rememberMe: true]
+ property(metadata, 'grails.plugin.springsecurity.authentication.session.timeout').type == 'java.lang.Integer'
+ property(metadata, 'grails.plugin.springsecurity.authentication.session.timeout').defaultValue == 30
+
+ and: 'dynamic and environment-conditional expressions have no static default'
+ property(metadata, 'grails.plugin.springsecurity.authentication.dynamicDefault').type == 'java.lang.String'
+ !property(metadata, 'grails.plugin.springsecurity.authentication.dynamicDefault').containsKey('defaultValue')
+ property(metadata, 'grails.plugin.springsecurity.authentication.environmentDefault').type == 'java.lang.String'
+ !property(metadata, 'grails.plugin.springsecurity.authentication.environmentDefault').containsKey('defaultValue')
+
+ and: 'curated metadata remains the final overlay'
+ property(metadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').defaultValue == 'overlay.User'
+ property(metadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').description == 'Curated user domain class'
+
+ and: 'the generated jar has one canonical metadata entry'
+ metadataEntryCount() == 1
+
+ when: 'nothing changes'
+ BuildResult unchanged = run('jar')
+
+ then: 'the task is up to date'
+ unchanged.task(':generateConfigurationMetadata').outcome == TaskOutcome.UP_TO_DATE
+
+ when: 'the DSL source is edited without cleaning'
+ writeGroovyDslConfiguration(true)
+ BuildResult edited = run('jar')
+ Map editedMetadata = readMetadata()
+
+ then: 'metadata is regenerated from the edited DSL source'
+ edited.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(editedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout').type == 'java.lang.Integer'
+ property(editedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout').defaultValue == 30
+ metadataEntryCount() == 1
+
+ when: 'the DSL source is deleted without cleaning'
+ projectDir.resolve('src/dsl/fixture/SecurityConfig.groovy').toFile().delete()
+ BuildResult deleted = run('jar')
+ Map deletedMetadata = readMetadata()
+
+ then: 'DSL-derived metadata is removed while the curated overlay remains'
+ deleted.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(deletedMetadata, 'grails.plugin.springsecurity.oauth.client.clientId') == null
+ property(deletedMetadata, 'grails.plugin.springsecurity.authentication.sessionTimeout') == null
+ property(deletedMetadata, 'grails.plugin.springsecurity.userLookup.userDomainClassName').defaultValue == 'overlay.User'
+ metadataEntryCount() == 1
+ }
+
+ def "merges same-name DSL typed and curated metadata fields by precedence"() {
+ given: 'a DSL property also declared by a typed configuration class and curated overlay'
+ writeGroovyDslConfiguration(false)
+ writeTypedSecurityConfiguration()
+ configureDslMetadata('src/dsl/fixture/SecurityConfig.groovy')
+ write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
+ {
+ "properties": [
+ {
+ "name": "grails.plugin.springsecurity.authentication.maxAttempts",
+ "description": "Curated maximum attempts"
+ }
+ ]
+ }
+ '''.stripIndent())
+
+ when:
+ BuildResult result = run('generateConfigurationMetadata')
+ Map metadata = readMetadata()
+
+ then: 'typed fields override the DSL while the DSL default and curated fields remain'
+ result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').type == 'java.lang.Long'
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').sourceType == 'fixture.SecurityConfiguration'
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').defaultValue == 3
+ property(metadata, 'grails.plugin.springsecurity.authentication.maxAttempts').description == 'Curated maximum attempts'
+ }
+
+ def "parses DSL sources without executing transforms or dynamic nested calls"() {
+ given: 'a DSL source outside the compiled source set with an impossible dependency transform'
+ write('src/dsl/fixture/TransformConfig.groovy', '''
+ @GrabResolver(name = 'missing', root = 'file:///not-a-repository')
+ @Grab('missing.group:missing-artifact:1.0')
+ import missing.Dependency
+
+ security {
+ enabled = true
+ this."${System.getProperty('fixture.dynamic.section')}" {
+ ignored = true
+ }
+ }
+ '''.stripIndent())
+ configureDslMetadata('src/dsl/fixture/TransformConfig.groovy')
+
+ when:
+ BuildResult result = run('generateConfigurationMetadata')
+ Map metadata = readMetadata()
+
+ then: 'the transform is not discovered or executed and dynamic nested names are ignored'
+ result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(metadata, 'grails.plugin.springsecurity.enabled').defaultValue == true
+ property(metadata, 'grails.plugin.springsecurity.null.ignored') == null
+ !metadata.properties*.name.any { String name -> name.contains('.null.') }
+ }
+
+ def "preserves null literals and omits map defaults with non-string keys"() {
+ given:
+ write('src/dsl/fixture/LiteralConfig.groovy', '''
+ security {
+ literals {
+ nullList = [null, 'present']
+ nullMap = [first: null, second: 'present']
+ unsupportedMap = [(1): 'one']
+ }
+ }
+ '''.stripIndent())
+ configureDslMetadata('src/dsl/fixture/LiteralConfig.groovy')
+
+ when:
+ BuildResult result = run('generateConfigurationMetadata')
+ Map metadata = readMetadata()
+
+ then:
+ result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS
+ property(metadata, 'grails.plugin.springsecurity.literals.nullList').defaultValue == [null, 'present']
+ property(metadata, 'grails.plugin.springsecurity.literals.nullMap').defaultValue == [first: null, second: 'present']
+ property(metadata, 'grails.plugin.springsecurity.literals.unsupportedMap').type == 'java.util.Map'
+ !property(metadata, 'grails.plugin.springsecurity.literals.unsupportedMap').containsKey('defaultValue')
+ }
+
+ def "reports the actual source file for malformed DSL"() {
+ given:
+ File malformedDsl = write('src/dsl/fixture/BrokenConfig.groovy', '''
+ security {
+ enabled =
+ }
+ '''.stripIndent())
+ configureDslMetadata('src/dsl/fixture/BrokenConfig.groovy')
+
+ when:
+ BuildResult result = runner('generateConfigurationMetadata').buildAndFail()
+
+ then:
+ result.output.contains(malformedDsl.absolutePath)
+ }
+
def "fails on conflicting duplicate identities in an overlay source"() {
given:
write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', '''
@@ -293,6 +468,52 @@ class ConfigurationMetadataPluginSpec extends Specification {
""".stripIndent())
}
+ private void writeGroovyDslConfiguration(boolean includeSessionTimeout) {
+ String sessionTimeout = includeSessionTimeout ? 'sessionTimeout = 30' : ''
+ write('src/dsl/fixture/SecurityConfig.groovy', """
+ package fixture
+
+ throw new AssertionError('The configuration metadata task must not execute DSL sources')
+
+ security {
+ userLookup {
+ userDomainClassName = 'fixture.User'
+ enabled = true
+ }
+ oauth {
+ client {
+ clientId = 'client'
+ }
+ }
+ authentication {
+ maxAttempts = 3
+ session.timeout = 30
+ roles = ['ROLE_USER', 'ROLE_ADMIN']
+ options = [maxSessions: 1, rememberMe: true]
+ dynamicDefault = System.getProperty('fixture.security.dynamic')
+ environmentDefault = System.getenv('FIXTURE_SECURITY_ENABLED') ? 'enabled' : 'disabled'
+ ${sessionTimeout}
+ }
+ }
+ """.stripIndent())
+ }
+
+ private void writeTypedSecurityConfiguration() {
+ write('src/main/java/fixture/SecurityConfiguration.java', '''
+ package fixture;
+
+ import org.springframework.boot.context.properties.ConfigurationProperties;
+
+ @ConfigurationProperties("grails.plugin.springsecurity.authentication")
+ public class SecurityConfiguration {
+ private Long maxAttempts;
+
+ public Long getMaxAttempts() { return maxAttempts; }
+ public void setMaxAttempts(Long maxAttempts) { this.maxAttempts = maxAttempts; }
+ }
+ '''.stripIndent())
+ }
+
private BuildResult run(String... arguments) {
runner(arguments).build()
}
@@ -304,10 +525,20 @@ class ConfigurationMetadataPluginSpec extends Specification {
.withPluginClasspath()
}
- private void write(String relativePath, String contents) {
+ private void configureDslMetadata(String relativePath) {
+ projectDir.resolve('build.gradle').toFile() << """
+ tasks.named('generateConfigurationMetadata') {
+ dslConfigurationFiles.from(file('${relativePath}'))
+ dslRootPrefixes.put('security', 'grails.plugin.springsecurity')
+ }
+ """.stripIndent()
+ }
+
+ private File write(String relativePath, String contents) {
File file = projectDir.resolve(relativePath).toFile()
file.parentFile.mkdirs()
file.text = contents
+ file
}
private File metadataFile() {
diff --git a/grails-core/build.gradle b/grails-core/build.gradle
index 96ba46f4e0..2bef623b5f 100644
--- a/grails-core/build.gradle
+++ b/grails-core/build.gradle
@@ -25,6 +25,7 @@ plugins {
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.gradle.grails-plugin-cli'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
index ec339dce9a..054640fdbf 100644
--- a/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
+++ b/grails-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
@@ -1,66 +1,420 @@
{
- "properties": [
- {
- "name": "grails.banner.art.display",
- "description": "Whether to display the Grails banner art.",
- "type": "java.lang.Boolean",
- "defaultValue": true
- },
- {
- "name": "grails.banner.art.file",
- "description": "The file path on the classpath to the Grails banner art to display.",
- "type": "java.lang.String",
- "defaultValue": "grails-banner.txt"
- },
- {
- "name": "grails.banner.versions.display",
- "description": "Whether to display version information in the Grails banner.",
- "type": "java.lang.Boolean",
- "defaultValue": true
- },
- {
- "name": "grails.banner.versions.include",
- "description": "A list of optional versions to include in the banner.",
- "type": "java.util.List<grails.boot.GrailsBanner.OptionalVersionOption>"
- },
- {
- "name": "grails.banner.versions.exclude",
- "description": "A list of versions to exclude from the banner.",
- "type": "java.util.List<grails.boot.GrailsBanner.DefaultVersionOption>"
- },
- {
- "name": "grails.banner.versions.order",
- "description": "A sorted list of versions to put first in the banner versions.",
- "type": "java.util.List<grails.boot.GrailsBanner.VersionOption>"
- },
- {
- "name": "grails.web.removeDefaultViewResolverBean",
- "description": "Whether to remove Spring Boot's defaultViewResolver bean so Grails' own view resolution is used.",
- "type": "java.lang.Boolean",
- "defaultValue": true
- },
- {
- "name": "grails.web.removeWelcomePageMapping",
- "description": "Whether to remove Spring Boot's welcome-page handler mapping so Grails' URL mappings own the root path ('/') rather than serving a static index.html for it.",
- "type": "java.lang.Boolean",
- "defaultValue": true
- },
- {
- "name": "grails.i18n.localeResolver",
- "description": "The locale resolution strategy. 'session' and 'cookie' are mutable (the ?lang= switch works); 'acceptHeader' and 'fixed' are read-only (the ?lang= switch is ignored).",
- "type": "java.lang.String",
- "defaultValue": "session"
- }
- ],
- "hints": [
- {
- "name": "grails.i18n.localeResolver",
- "values": [
- { "value": "session", "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default." },
- { "value": "cookie", "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)." },
- { "value": "acceptHeader", "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)." },
- { "value": "fixed", "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)." }
- ]
- }
- ]
-}
\ No newline at end of file
+ "groups": [
+ {
+ "name": "grails",
+ "description": "Core Properties"
+ },
+ {
+ "name": "grails.codegen",
+ "description": "Core Properties"
+ },
+ {
+ "name": "grails.bootstrap",
+ "description": "Core Properties"
+ },
+ {
+ "name": "grails.spring",
+ "description": "Core Properties"
+ },
+ {
+ "name": "grails.plugin",
+ "description": "Core Properties"
+ },
+ {
+ "name": "grails.reload",
+ "description": "Development & Reloading"
+ },
+ {
+ "name": "grails.events",
+ "description": "Events"
+ },
+ {
+ "name": "grails.json",
+ "description": "JSON & Converters"
+ },
+ {
+ "name": "grails.converters.json",
+ "description": "JSON & Converters"
+ },
+ {
+ "name": "grails.converters.xml",
+ "description": "JSON & Converters"
+ },
+ {
+ "name": "grails.i18n",
+ "description": "Internationalization"
+ },
+ {
+ "name": "grails.gorm",
+ "description": "GORM"
+ },
+ {
+ "name": "grails.gorm.reactor",
+ "description": "GORM"
+ },
+ {
+ "name": "grails.gorm.events",
+ "description": "GORM"
+ },
+ {
+ "name": "grails.gorm.multiTenancy",
+ "description": "GORM"
+ },
+ {
+ "name": "dataSource",
+ "description": "DataSource"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.profile",
+ "type": "java.lang.String",
+ "description": "The active Grails application profile (e.g., web, rest-api, plugin).",
+ "defaultValue": "Set by project template"
+ },
+ {
+ "name": "grails.codegen.defaultPackage",
+ "type": "java.lang.String",
+ "description": "The default package used when generating artefacts with grails create-* commands.",
+ "defaultValue": "Set by project template"
+ },
+ {
+ "name": "grails.serverURL",
+ "type": "java.lang.String",
+ "description": "The server URL used to generate absolute links (e.g., https://my.app.com) and used by redirects.",
+ "defaultValue": "derived from request"
+ },
+ {
+ "name": "grails.enable.native2ascii",
+ "type": "java.lang.Boolean",
+ "description": "Whether to perform native2ascii conversion of i18n properties files.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.bootstrap.skip",
+ "type": "java.lang.Boolean",
+ "description": "Whether to skip execution of BootStrap.groovy classes on startup.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.spring.bean.packages",
+ "type": "java.util.List<java.lang.String>",
+ "description": "List of packages to scan for Spring beans.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.spring.disable.aspectj.autoweaving",
+ "type": "java.lang.Boolean",
+ "description": "Whether to disable AspectJ auto-weaving.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.spring.placeholder.prefix",
+ "type": "java.lang.String",
+ "description": "The prefix for property placeholder resolution.",
+ "defaultValue": "${"
+ },
+ {
+ "name": "grails.spring.transactionManagement.proxies",
+ "type": "java.lang.Boolean",
+ "description": "Whether to enable Spring proxy-based transaction management since @Transactional uses an AST transform and proxies are typically redundant.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.plugin.includes",
+ "type": "java.util.List<java.lang.String>",
+ "description": "List of plugin names to include in the plugin manager (all others excluded).",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.plugin.excludes",
+ "type": "java.util.List<java.lang.String>",
+ "description": "List of plugin names to exclude from the plugin manager.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.reload.includes",
+ "type": "java.util.List<java.lang.String>",
+ "description": "List of fully qualified class names to include in development reloading, when set only these classes are reloaded.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.reload.excludes",
+ "type": "java.util.List<java.lang.String>",
+ "description": "List of fully qualified class names to exclude from development reloading.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.events.spring",
+ "type": "java.lang.Boolean",
+ "description": "Whether to bridge GORM/Grails events to the Spring ApplicationEventPublisher, allowing EventListener methods to receive domain events.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.json.legacy.builder",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use the legacy JSON builder.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.converters.json.domain.include.class",
+ "type": "java.lang.Boolean",
+ "description": "Whether to include the class property when marshalling domain objects to JSON.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.converters.xml.domain.include.class",
+ "type": "java.lang.Boolean",
+ "description": "Whether to include the class attribute when marshalling domain objects to XML.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.i18n.cache.seconds",
+ "type": "java.lang.Integer",
+ "description": "How long (in seconds) to cache resolved message bundles with -1 to cache indefinitely and 0 to disable caching.",
+ "defaultValue": -1
+ },
+ {
+ "name": "grails.i18n.filecache.seconds",
+ "type": "java.lang.Integer",
+ "description": "How long (in seconds) to cache the message bundle file lookup with -1 to cache indefinitely.",
+ "defaultValue": -1
+ },
+ {
+ "name": "grails.gorm.failOnError",
+ "type": "java.lang.Boolean",
+ "description": "When true, save() throws ValidationException on validation failure instead of returning null and can also be a list of package names to apply selectively.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.gorm.autoFlush",
+ "type": "java.lang.Boolean",
+ "description": "Whether to automatically flush the Hibernate session between queries.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.gorm.flushMode",
+ "type": "java.lang.String",
+ "description": "The default Hibernate flush mode (AUTO, COMMIT, MANUAL).",
+ "defaultValue": "AUTO"
+ },
+ {
+ "name": "grails.gorm.markDirty",
+ "type": "java.lang.Boolean",
+ "description": "Whether to mark a domain instance as dirty on an explicit save() call.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.gorm.autowire",
+ "type": "java.lang.Boolean",
+ "description": "Whether to autowire Spring beans into domain class instances.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.gorm.default.mapping",
+ "type": "java.util.Map<java.lang.String,java.lang.Object>",
+ "description": "A closure applied as the default mapping block for all domain classes.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gorm.default.constraints",
+ "type": "java.util.Map<java.lang.String,java.lang.Object>",
+ "description": "A closure applied as the default constraints for all domain classes.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gorm.default.nullable",
+ "type": "java.lang.Boolean",
+ "description": "Whether an unconstrained persistent property is nullable by default (Grails 8). Set to false to restore the legacy required-by-default behaviour.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.gorm.custom.types",
+ "type": "java.util.Map<java.lang.String,java.lang.Object>",
+ "description": "Map of custom GORM types.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gorm.reactor.events",
+ "type": "java.lang.Boolean",
+ "description": "Whether to translate GORM events into Reactor events, which is disabled by default for performance.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.gorm.events.autoTimestampInsertOverwrite",
+ "type": "java.lang.Boolean",
+ "description": "Whether auto-timestamp (dateCreated) overwrites a user-provided value on insert.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.gorm.multiTenancy.mode",
+ "type": "java.lang.String",
+ "description": "The multi-tenancy mode: DISCRIMINATOR, DATABASE, SCHEMA, or NONE.",
+ "defaultValue": "NONE"
+ },
+ {
+ "name": "grails.gorm.multiTenancy.tenantResolverClass",
+ "type": "java.lang.String",
+ "description": "Fully qualified class name of the TenantResolver implementation.",
+ "defaultValue": "required when mode is not NONE"
+ },
+ {
+ "name": "dataSource.driverClassName",
+ "type": "java.lang.String",
+ "description": "The JDBC driver class name.",
+ "defaultValue": "org.h2.Driver"
+ },
+ {
+ "name": "dataSource.username",
+ "type": "java.lang.String",
+ "description": "The database username.",
+ "defaultValue": "sa"
+ },
+ {
+ "name": "dataSource.password",
+ "type": "java.lang.String",
+ "description": "The database password.",
+ "defaultValue": ""
+ },
+ {
+ "name": "dataSource.url",
+ "type": "java.lang.String",
+ "description": "The JDBC connection URL.",
+ "defaultValue": "jdbc:h2:mem:devDb"
+ },
+ {
+ "name": "dataSource.dbCreate",
+ "type": "java.lang.String",
+ "description": "The schema generation strategy: create-drop, create, update, validate, or none, use none in production with a migration tool.",
+ "defaultValue": "create-drop (dev), none (prod)"
+ },
+ {
+ "name": "dataSource.pooled",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use a connection pool.",
+ "defaultValue": true
+ },
+ {
+ "name": "dataSource.logSql",
+ "type": "java.lang.Boolean",
+ "description": "Whether to log SQL statements to stdout.",
+ "defaultValue": false
+ },
+ {
+ "name": "dataSource.formatSql",
+ "type": "java.lang.Boolean",
+ "description": "Whether to format logged SQL for readability.",
+ "defaultValue": false
+ },
+ {
+ "name": "dataSource.dialect",
+ "type": "java.lang.String",
+ "description": "The Hibernate dialect class name or class.",
+ "defaultValue": "auto-detected from driver"
+ },
+ {
+ "name": "dataSource.readOnly",
+ "type": "java.lang.Boolean",
+ "description": "Whether the DataSource is read-only (calls setReadOnly(true) on connections).",
+ "defaultValue": false
+ },
+ {
+ "name": "dataSource.transactional",
+ "type": "java.lang.Boolean",
+ "description": "For additional datasources, whether to include in the chained transaction manager.",
+ "defaultValue": true
+ },
+ {
+ "name": "dataSource.persistenceInterceptor",
+ "type": "java.lang.Boolean",
+ "description": "For additional datasources, whether to wire up the persistence interceptor (the default datasource is always wired).",
+ "defaultValue": false
+ },
+ {
+ "name": "dataSource.jmxExport",
+ "type": "java.lang.Boolean",
+ "description": "Whether to register JMX MBeans for the DataSource.",
+ "defaultValue": true
+ },
+ {
+ "name": "dataSource.type",
+ "type": "java.lang.String",
+ "description": "The connection pool implementation class when multiple are on the classpath.",
+ "defaultValue": "com.zaxxer.hikari.HikariDataSource"
+ },
+ {
+ "name": "grails.banner.art.display",
+ "description": "Whether to display the Grails banner art.",
+ "type": "java.lang.Boolean",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.banner.art.file",
+ "description": "The file path on the classpath to the Grails banner art to display.",
+ "type": "java.lang.String",
+ "defaultValue": "grails-banner.txt"
+ },
+ {
+ "name": "grails.banner.versions.display",
+ "description": "Whether to display version information in the Grails banner.",
+ "type": "java.lang.Boolean",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.banner.versions.include",
+ "description": "A list of optional versions to include in the banner.",
+ "type": "java.util.List<grails.boot.GrailsBanner.OptionalVersionOption>"
+ },
+ {
+ "name": "grails.banner.versions.exclude",
+ "description": "A list of versions to exclude from the banner.",
+ "type": "java.util.List<grails.boot.GrailsBanner.DefaultVersionOption>"
+ },
+ {
+ "name": "grails.banner.versions.order",
+ "description": "A sorted list of versions to put first in the banner versions.",
+ "type": "java.util.List<grails.boot.GrailsBanner.VersionOption>"
+ },
+ {
+ "name": "grails.web.removeDefaultViewResolverBean",
+ "description": "Whether to remove Spring Boot's defaultViewResolver bean so Grails' own view resolution is used.",
+ "type": "java.lang.Boolean",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.web.removeWelcomePageMapping",
+ "description": "Whether to remove Spring Boot's welcome-page handler mapping so Grails' URL mappings own the root path ('/') rather than serving a static index.html for it.",
+ "type": "java.lang.Boolean",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.i18n.localeResolver",
+ "description": "The locale resolution strategy. 'session' and 'cookie' are mutable (the ?lang= switch works); 'acceptHeader' and 'fixed' are read-only (the ?lang= switch is ignored).",
+ "type": "java.lang.String",
+ "defaultValue": "session"
+ }
+ ],
+ "hints": [
+ {
+ "name": "grails.i18n.localeResolver",
+ "values": [
+ {
+ "value": "session",
+ "description": "Resolve the locale from the HTTP session (mutable; ?lang= switching works). The default."
+ },
+ {
+ "value": "cookie",
+ "description": "Resolve the locale from a cookie (mutable; ?lang= switching works)."
+ },
+ {
+ "value": "acceptHeader",
+ "description": "Resolve the locale from the Accept-Language header (read-only; ?lang= is ignored)."
+ },
+ {
+ "value": "fixed",
+ "description": "Use a fixed locale from grails.i18n.default.locale, falling back to the JVM default (read-only; ?lang= is ignored)."
+ }
+ ]
+ }
+ ]
+}
diff --git a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json
deleted file mode 100644
index 50ba004db1..0000000000
--- a/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json
+++ /dev/null
@@ -1,346 +0,0 @@
-{
- "groups": [
- {
- "name": "grails",
- "description": "Core Properties"
- },
- {
- "name": "grails.codegen",
- "description": "Core Properties"
- },
- {
- "name": "grails.bootstrap",
- "description": "Core Properties"
- },
- {
- "name": "grails.spring",
- "description": "Core Properties"
- },
- {
- "name": "grails.plugin",
- "description": "Core Properties"
- },
- {
- "name": "grails.reload",
- "description": "Development & Reloading"
- },
- {
- "name": "grails.events",
- "description": "Events"
- },
- {
- "name": "grails.json",
- "description": "JSON & Converters"
- },
- {
- "name": "grails.converters.json",
- "description": "JSON & Converters"
- },
- {
- "name": "grails.converters.xml",
- "description": "JSON & Converters"
- },
- {
- "name": "grails.i18n",
- "description": "Internationalization"
- },
- {
- "name": "grails.gorm",
- "description": "GORM"
- },
- {
- "name": "grails.gorm.reactor",
- "description": "GORM"
- },
- {
- "name": "grails.gorm.events",
- "description": "GORM"
- },
- {
- "name": "grails.gorm.multiTenancy",
- "description": "GORM"
- },
- {
- "name": "dataSource",
- "description": "DataSource"
- }
- ],
- "properties": [
- {
- "name": "grails.profile",
- "type": "java.lang.String",
- "description": "The active Grails application profile (e.g., web, rest-api, plugin).",
- "defaultValue": "Set by project template"
- },
- {
- "name": "grails.codegen.defaultPackage",
- "type": "java.lang.String",
- "description": "The default package used when generating artefacts with grails create-* commands.",
- "defaultValue": "Set by project template"
- },
- {
- "name": "grails.serverURL",
- "type": "java.lang.String",
- "description": "The server URL used to generate absolute links (e.g., https://my.app.com) and used by redirects.",
- "defaultValue": "derived from request"
- },
- {
- "name": "grails.enable.native2ascii",
- "type": "java.lang.Boolean",
- "description": "Whether to perform native2ascii conversion of i18n properties files.",
- "defaultValue": true
- },
- {
- "name": "grails.bootstrap.skip",
- "type": "java.lang.Boolean",
- "description": "Whether to skip execution of BootStrap.groovy classes on startup.",
- "defaultValue": false
- },
- {
- "name": "grails.spring.bean.packages",
- "type": "java.util.List<java.lang.String>",
- "description": "List of packages to scan for Spring beans.",
- "defaultValue": []
- },
- {
- "name": "grails.spring.disable.aspectj.autoweaving",
- "type": "java.lang.Boolean",
- "description": "Whether to disable AspectJ auto-weaving.",
- "defaultValue": false
- },
- {
- "name": "grails.spring.placeholder.prefix",
- "type": "java.lang.String",
- "description": "The prefix for property placeholder resolution.",
- "defaultValue": "${"
- },
- {
- "name": "grails.spring.transactionManagement.proxies",
- "type": "java.lang.Boolean",
- "description": "Whether to enable Spring proxy-based transaction management since @Transactional uses an AST transform and proxies are typically redundant.",
- "defaultValue": false
- },
- {
- "name": "grails.plugin.includes",
- "type": "java.util.List<java.lang.String>",
- "description": "List of plugin names to include in the plugin manager (all others excluded).",
- "defaultValue": []
- },
- {
- "name": "grails.plugin.excludes",
- "type": "java.util.List<java.lang.String>",
- "description": "List of plugin names to exclude from the plugin manager.",
- "defaultValue": []
- },
- {
- "name": "grails.reload.includes",
- "type": "java.util.List<java.lang.String>",
- "description": "List of fully qualified class names to include in development reloading, when set only these classes are reloaded.",
- "defaultValue": []
- },
- {
- "name": "grails.reload.excludes",
- "type": "java.util.List<java.lang.String>",
- "description": "List of fully qualified class names to exclude from development reloading.",
- "defaultValue": []
- },
- {
- "name": "grails.events.spring",
- "type": "java.lang.Boolean",
- "description": "Whether to bridge GORM/Grails events to the Spring ApplicationEventPublisher, allowing EventListener methods to receive domain events.",
- "defaultValue": true
- },
- {
- "name": "grails.json.legacy.builder",
- "type": "java.lang.Boolean",
- "description": "Whether to use the legacy JSON builder.",
- "defaultValue": false
- },
- {
- "name": "grails.converters.json.domain.include.class",
- "type": "java.lang.Boolean",
- "description": "Whether to include the class property when marshalling domain objects to JSON.",
- "defaultValue": false
- },
- {
- "name": "grails.converters.xml.domain.include.class",
- "type": "java.lang.Boolean",
- "description": "Whether to include the class attribute when marshalling domain objects to XML.",
- "defaultValue": false
- },
- {
- "name": "grails.i18n.cache.seconds",
- "type": "java.lang.Integer",
- "description": "How long (in seconds) to cache resolved message bundles with -1 to cache indefinitely and 0 to disable caching.",
- "defaultValue": -1
- },
- {
- "name": "grails.i18n.filecache.seconds",
- "type": "java.lang.Integer",
- "description": "How long (in seconds) to cache the message bundle file lookup with -1 to cache indefinitely.",
- "defaultValue": -1
- },
- {
- "name": "grails.gorm.failOnError",
- "type": "java.lang.Boolean",
- "description": "When true, save() throws ValidationException on validation failure instead of returning null and can also be a list of package names to apply selectively.",
- "defaultValue": false
- },
- {
- "name": "grails.gorm.autoFlush",
- "type": "java.lang.Boolean",
- "description": "Whether to automatically flush the Hibernate session between queries.",
- "defaultValue": false
- },
- {
- "name": "grails.gorm.flushMode",
- "type": "java.lang.String",
- "description": "The default Hibernate flush mode (AUTO, COMMIT, MANUAL).",
- "defaultValue": "AUTO"
- },
- {
- "name": "grails.gorm.markDirty",
- "type": "java.lang.Boolean",
- "description": "Whether to mark a domain instance as dirty on an explicit save() call.",
- "defaultValue": true
- },
- {
- "name": "grails.gorm.autowire",
- "type": "java.lang.Boolean",
- "description": "Whether to autowire Spring beans into domain class instances.",
- "defaultValue": true
- },
- {
- "name": "grails.gorm.default.mapping",
- "type": "java.util.Map<java.lang.String,java.lang.Object>",
- "description": "A closure applied as the default mapping block for all domain classes.",
- "defaultValue": {}
- },
- {
- "name": "grails.gorm.default.constraints",
- "type": "java.util.Map<java.lang.String,java.lang.Object>",
- "description": "A closure applied as the default constraints for all domain classes.",
- "defaultValue": {}
- },
- {
- "name": "grails.gorm.default.nullable",
- "type": "java.lang.Boolean",
- "description": "Whether an unconstrained persistent property is nullable by default (Grails 8). Set to false to restore the legacy required-by-default behaviour.",
- "defaultValue": true
- },
- {
- "name": "grails.gorm.custom.types",
- "type": "java.util.Map<java.lang.String,java.lang.Object>",
- "description": "Map of custom GORM types.",
- "defaultValue": {}
- },
- {
- "name": "grails.gorm.reactor.events",
- "type": "java.lang.Boolean",
- "description": "Whether to translate GORM events into Reactor events, which is disabled by default for performance.",
- "defaultValue": false
- },
- {
- "name": "grails.gorm.events.autoTimestampInsertOverwrite",
- "type": "java.lang.Boolean",
- "description": "Whether auto-timestamp (dateCreated) overwrites a user-provided value on insert.",
- "defaultValue": true
- },
- {
- "name": "grails.gorm.multiTenancy.mode",
- "type": "java.lang.String",
- "description": "The multi-tenancy mode: DISCRIMINATOR, DATABASE, SCHEMA, or NONE.",
- "defaultValue": "NONE"
- },
- {
- "name": "grails.gorm.multiTenancy.tenantResolverClass",
- "type": "java.lang.String",
- "description": "Fully qualified class name of the TenantResolver implementation.",
- "defaultValue": "required when mode is not NONE"
- },
- {
- "name": "dataSource.driverClassName",
- "type": "java.lang.String",
- "description": "The JDBC driver class name.",
- "defaultValue": "org.h2.Driver"
- },
- {
- "name": "dataSource.username",
- "type": "java.lang.String",
- "description": "The database username.",
- "defaultValue": "sa"
- },
- {
- "name": "dataSource.password",
- "type": "java.lang.String",
- "description": "The database password.",
- "defaultValue": ""
- },
- {
- "name": "dataSource.url",
- "type": "java.lang.String",
- "description": "The JDBC connection URL.",
- "defaultValue": "jdbc:h2:mem:devDb"
- },
- {
- "name": "dataSource.dbCreate",
- "type": "java.lang.String",
- "description": "The schema generation strategy: create-drop, create, update, validate, or none, use none in production with a migration tool.",
- "defaultValue": "create-drop (dev), none (prod)"
- },
- {
- "name": "dataSource.pooled",
- "type": "java.lang.Boolean",
- "description": "Whether to use a connection pool.",
- "defaultValue": true
- },
- {
- "name": "dataSource.logSql",
- "type": "java.lang.Boolean",
- "description": "Whether to log SQL statements to stdout.",
- "defaultValue": false
- },
- {
- "name": "dataSource.formatSql",
- "type": "java.lang.Boolean",
- "description": "Whether to format logged SQL for readability.",
- "defaultValue": false
- },
- {
- "name": "dataSource.dialect",
- "type": "java.lang.String",
- "description": "The Hibernate dialect class name or class.",
- "defaultValue": "auto-detected from driver"
- },
- {
- "name": "dataSource.readOnly",
- "type": "java.lang.Boolean",
- "description": "Whether the DataSource is read-only (calls setReadOnly(true) on connections).",
- "defaultValue": false
- },
- {
- "name": "dataSource.transactional",
- "type": "java.lang.Boolean",
- "description": "For additional datasources, whether to include in the chained transaction manager.",
- "defaultValue": true
- },
- {
- "name": "dataSource.persistenceInterceptor",
- "type": "java.lang.Boolean",
- "description": "For additional datasources, whether to wire up the persistence interceptor (the default datasource is always wired).",
- "defaultValue": false
- },
- {
- "name": "dataSource.jmxExport",
- "type": "java.lang.Boolean",
- "description": "Whether to register JMX MBeans for the DataSource.",
- "defaultValue": true
- },
- {
- "name": "dataSource.type",
- "type": "java.lang.String",
- "description": "The connection pool implementation class when multiple are on the classpath.",
- "defaultValue": "com.zaxxer.hikari.HikariDataSource"
- }
- ]
-}
diff --git a/grails-data-hibernate5/core/build.gradle b/grails-data-hibernate5/core/build.gradle
index 28dab355f5..c8823ed745 100644
--- a/grails-data-hibernate5/core/build.gradle
+++ b/grails-data-hibernate5/core/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate5/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate5/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate5/dbmigration/build.gradle b/grails-data-hibernate5/dbmigration/build.gradle
index 952ed3ffd6..777c29e091 100644
--- a/grails-data-hibernate5/dbmigration/build.gradle
+++ b/grails-data-hibernate5/dbmigration/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.gradle.grails-plugin'
id 'org.apache.grails.gradle.grails-plugin-cli'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-hibernate5/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate5/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate5/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate5/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate7/core/build.gradle b/grails-data-hibernate7/core/build.gradle
index a9d7a481ac..258b13cb1e 100644
--- a/grails-data-hibernate7/core/build.gradle
+++ b/grails-data-hibernate7/core/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.gradle.grails-code-style'
diff --git a/grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate7/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate7/core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate7/core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-hibernate7/dbmigration/build.gradle b/grails-data-hibernate7/dbmigration/build.gradle
index b5f31beada..147475fce0 100644
--- a/grails-data-hibernate7/dbmigration/build.gradle
+++ b/grails-data-hibernate7/dbmigration/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.gradle.grails-plugin'
id 'org.apache.grails.gradle.grails-plugin-cli'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.gradle.grails-code-style'
diff --git a/grails-data-hibernate7/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-hibernate7/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-hibernate7/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-hibernate7/dbmigration/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-data-mongodb/grails-plugin/build.gradle b/grails-data-mongodb/grails-plugin/build.gradle
index 047d52bacf..230c73bc3c 100644
--- a/grails-data-mongodb/grails-plugin/build.gradle
+++ b/grails-data-mongodb/grails-plugin/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.gradle.grails-plugin'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-data-mongodb/grails-plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-data-mongodb/grails-plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-doc/src/en/guide/conf/config.adoc b/grails-doc/src/en/guide/conf/config.adoc
index d5da23da10..1d4bee7742 100644
--- a/grails-doc/src/en/guide/conf/config.adoc
+++ b/grails-doc/src/en/guide/conf/config.adoc
@@ -125,10 +125,16 @@ transformation stores metadata independently in each compiled `@ConfigurationPro
those class payloads after compilation. Java configuration classes are inspected from their compiled bytecode. This
avoids Java annotation processing on Groovy sources, which would interfere with incremental Groovy compilation.
+Framework modules can also register legacy Groovy configuration DSL sources for static metadata discovery. Grails
+inspects the source syntax without executing the configuration script. Literal values can provide generated defaults,
+while values from method calls, environment branches, and other dynamic expressions are discovered without treating a
+build-specific result as the default.
+
The generated metadata describes bindable fields and bean properties. A module can also provide
`META-INF/additional-spring-configuration-metadata.json` for descriptions, hints, legacy or indirect properties, and
-other details that cannot be derived safely from bytecode. Curated values override generated values for the same
-property.
+other details that cannot be derived safely from bytecode or static DSL syntax. Curated values override generated values
+for the same property. The module build merges all generated and curated entries into the single standard
+`META-INF/spring-configuration-metadata.json` resource published in the module JAR.
Only compile-time constant Groovy initializers are emitted as generated defaults. Defaults calculated by constructors,
method calls, environment checks, system properties, or other dynamic expressions are intentionally omitted unless a
diff --git a/grails-gsp/plugin/build.gradle b/grails-gsp/plugin/build.gradle
index bdcd3205c2..99bb2f35f6 100644
--- a/grails-gsp/plugin/build.gradle
+++ b/grails-gsp/plugin/build.gradle
@@ -23,6 +23,7 @@ plugins {
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.gradle.grails-plugin'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
diff --git a/grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-gsp/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-gsp/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-spring-security/plugin/build.gradle b/grails-spring-security/plugin/build.gradle
index 14453792e6..1ba8e085f7 100644
--- a/grails-spring-security/plugin/build.gradle
+++ b/grails-spring-security/plugin/build.gradle
@@ -25,6 +25,7 @@ plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.gradle.grails-code-style'
@@ -119,3 +120,8 @@ apply {
from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
from rootProject.layout.projectDirectory.file('gradle/spring-security-test-config.gradle')
}
+
+tasks.named('generateConfigurationMetadata') {
+ dslConfigurationFiles.from(file('grails-app/conf/DefaultSecurityConfig.groovy'))
+ dslRootPrefixes.put('security', 'grails.plugin.springsecurity')
+}
diff --git a/grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-spring-security/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-spring-security/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-spring-security/plugin/src/main/resources/META-INF/additional-spring-configuration-metadata.json
diff --git a/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.groovy b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.groovy
new file mode 100644
index 0000000000..17323588c0
--- /dev/null
+++ b/grails-spring-security/plugin/src/test/groovy/grails/plugin/springsecurity/ConfigurationMetadataSpec.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 grails.plugin.springsecurity
+
+import groovy.json.JsonSlurper
+import spock.lang.Specification
+
+class ConfigurationMetadataSpec extends Specification {
+
+ void 'generates merged configuration metadata from DefaultSecurityConfig'() {
+ given:
+ Map metadata = configurationMetadata()
+ Map<String, Map> properties = metadata.get('properties').collectEntries { Map property -> [(property.get('name')): property] }
+
+ expect: 'the complete curated metadata contract is retained'
+ metadata.get('groups').size() == 14
+ properties.size() == 145
+
+ and: 'literal DSL defaults are present'
+ properties['grails.plugin.springsecurity.active'].type == 'java.lang.Boolean'
+ properties['grails.plugin.springsecurity.active'].defaultValue == true
+
+ and: 'conditional DSL properties retain curated defaults'
+ properties['grails.plugin.springsecurity.password.bcrypt.logrounds'].type == 'java.lang.Integer'
+ properties['grails.plugin.springsecurity.password.bcrypt.logrounds'].defaultValue == 10
+
+ and: 'nested DSL paths are mapped beneath the Spring Security prefix'
+ properties['grails.plugin.springsecurity.userLookup.usernamePropertyName'].type == 'java.lang.String'
+ properties['grails.plugin.springsecurity.userLookup.usernamePropertyName'].defaultValue == 'username'
+
+ and: 'curated metadata augments statically discovered values'
+ properties['grails.plugin.springsecurity.beanTypeResolverClass'].type == 'java.lang.String'
+ properties['grails.plugin.springsecurity.beanTypeResolverClass'].description == 'Bean type resolver class name.'
+ }
+
+ private static Map configurationMetadata() {
+ List<URL> resources = ConfigurationMetadataSpec.classLoader
+ .getResources('META-INF/spring-configuration-metadata.json')
+ .findAll { URL candidate ->
+ Map metadata = new JsonSlurper().parse(candidate) as Map
+ (metadata.get('groups') ?: []).any { Map group -> group.get('name') == 'grails.plugin.springsecurity' }
+ }
+ assert resources.size() == 1
+ new JsonSlurper().parse(resources.first()) as Map
+ }
+}
diff --git a/grails-web-core/build.gradle b/grails-web-core/build.gradle
index cc3619fbf9..77fbb473ef 100644
--- a/grails-web-core/build.gradle
+++ b/grails-web-core/build.gradle
@@ -24,6 +24,7 @@ plugins {
id 'org.apache.grails.buildsrc.properties'
id 'org.apache.grails.buildsrc.dependency-validator'
id 'org.apache.grails.buildsrc.compile'
+ id 'org.apache.grails.buildsrc.configuration-metadata'
id 'org.apache.grails.buildsrc.publish'
id 'org.apache.grails.buildsrc.sbom'
id 'org.apache.grails.buildsrc.vulnerability-scan'
@@ -78,4 +79,4 @@ dependencies {
apply {
from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle')
from rootProject.layout.projectDirectory.file('gradle/test-config.gradle')
-}
\ No newline at end of file
+}
diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json
similarity index 100%
rename from grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json
rename to grails-web-core/src/main/resources/META-INF/additional-spring-configuration-metadata.json