diff --git a/grails-cache/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-cache/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000000..1e7a631d854
--- /dev/null
+++ b/grails-cache/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,46 @@
+{
+ "groups": [
+ {
+ "name": "grails.cache",
+ "description": "Cache Plugin"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.cache.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Whether the cache plugin is enabled.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.cache.clearAtStartup",
+ "type": "java.lang.Boolean",
+ "description": "Whether to clear all caches when the application starts.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.cache.cacheManager",
+ "type": "java.lang.String",
+ "description": "The cache manager implementation class name. Use `GrailsConcurrentLinkedMapCacheManager` for bounded caches with maxCapacity support.",
+ "defaultValue": "GrailsConcurrentMapCacheManager"
+ },
+ {
+ "name": "grails.cache.caches",
+ "type": "java.util.Map",
+ "description": "Map of cache-specific configurations keyed by cache name, each supporting a `maxCapacity` setting (used by GrailsConcurrentLinkedMapCacheManager).",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.cache.ehcache.ehcacheXmlLocation",
+ "type": "java.lang.String",
+ "description": "Location of the Ehcache XML configuration file on the classpath.",
+ "defaultValue": "classpath:ehcache.xml"
+ },
+ {
+ "name": "grails.cache.ehcache.lockTimeout",
+ "type": "java.lang.Integer",
+ "description": "The timeout in milliseconds for acquiring a lock on a cache element.",
+ "defaultValue": 200
+ }
+ ]
+}
diff --git a/grails-core/build.gradle b/grails-core/build.gradle
index 54b420bc31b..101475ebf05 100644
--- a/grails-core/build.gradle
+++ b/grails-core/build.gradle
@@ -43,6 +43,7 @@ dependencies {
implementation 'com.github.ben-manes.caffeine:caffeine'
api 'org.apache.groovy:groovy'
+ implementation 'org.apache.groovy:groovy-json'
api 'org.springframework.boot:spring-boot'
api 'org.springframework:spring-core'
api 'org.springframework:spring-tx'
@@ -97,4 +98,4 @@ tasks.named('processResources', ProcessResources).configure { ProcessResources i
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-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy
new file mode 100644
index 00000000000..83e9373a201
--- /dev/null
+++ b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy
@@ -0,0 +1,371 @@
+/*
+ * 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.dev.commands
+
+import groovy.json.JsonSlurper
+import groovy.transform.CompileStatic
+import groovy.util.logging.Slf4j
+
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.EnumerablePropertySource
+import org.springframework.core.env.PropertySource
+
+/**
+ * An {@link ApplicationCommand} that generates an AsciiDoc report
+ * of the application's resolved configuration properties.
+ *
+ *
Properties are collected directly from the Spring {@link ConfigurableEnvironment},
+ * iterating all {@link EnumerablePropertySource} instances to capture every
+ * resolvable property regardless of how it was defined (YAML, Groovy config,
+ * system properties, environment variables, etc.).
+ *
+ *
Usage:
+ *
+ * grails config-report
+ * ./gradlew configReport
+ *
+ *
+ * The report is written to {@code config-report.adoc} in the project's base directory.
+ *
+ * @since 7.0
+ */
+@Slf4j
+@CompileStatic
+class ConfigReportCommand implements ApplicationCommand {
+
+ static final String DEFAULT_REPORT_FILE = 'config-report.adoc'
+
+ final String description = 'Generates an AsciiDoc report of the application configuration'
+
+ @Override
+ boolean handle(ExecutionContext executionContext) {
+ try {
+ ConfigurableEnvironment environment = (ConfigurableEnvironment) applicationContext.getEnvironment()
+ Map sorted = collectProperties(environment)
+
+ File reportFile = new File(executionContext.baseDir, DEFAULT_REPORT_FILE)
+ writeReport(sorted, reportFile)
+
+ log.info('Configuration report written to {}', reportFile.absolutePath)
+ return true
+ }
+ catch (Throwable e) {
+ log.error("Failed to generate configuration report: ${e.message}", e)
+ return false
+ }
+ }
+
+ /**
+ * Collects all configuration properties from the Spring {@link ConfigurableEnvironment}
+ * by iterating its {@link EnumerablePropertySource} instances. Property values are
+ * resolved through the environment to ensure placeholders are expanded and
+ * the correct precedence order is applied.
+ *
+ * @param environment the Spring environment
+ * @return a sorted map of property names to their resolved values
+ */
+ Map collectProperties(ConfigurableEnvironment environment) {
+ Map sorted = new TreeMap()
+ for (PropertySource> propertySource : environment.getPropertySources()) {
+ if (propertySource instanceof EnumerablePropertySource) {
+ EnumerablePropertySource> enumerable = (EnumerablePropertySource>) propertySource
+ for (String propertyName : enumerable.getPropertyNames()) {
+ if (!sorted.containsKey(propertyName)) {
+ try {
+ String value = environment.getProperty(propertyName)
+ if (value != null) {
+ sorted.put(propertyName, value)
+ }
+ }
+ catch (Exception e) {
+ log.debug('Could not resolve property {}: {}', propertyName, e.message)
+ }
+ }
+ }
+ }
+ }
+ sorted
+ }
+
+ /**
+ * Writes the configuration properties as an AsciiDoc file grouped by top-level namespace.
+ *
+ * @param sorted the sorted configuration properties
+ * @param reportFile the file to write the report to
+ */
+ void writeReport(Map runtimeProperties, File reportFile) {
+ MetadataResult metadataResult = loadPropertyMetadata()
+ List metadata = metadataResult.properties
+ Map groupDescriptions = metadataResult.groupDescriptions
+ Map> categories = new LinkedHashMap>()
+ for (ConfigPropertyMetadata property : metadata) {
+ String category = groupDescriptions.get(property.group) ?: property.group
+ if (!categories.containsKey(category)) {
+ categories.put(category, new ArrayList())
+ }
+ categories.get(category).add(property)
+ }
+
+ Set knownKeys = metadata.collect { ConfigPropertyMetadata property -> property.name }.toSet()
+ Map environmentProperties = collectEnvironmentProperties(runtimeProperties)
+ Map otherProperties = new TreeMap()
+ runtimeProperties.each { String key, String value ->
+ if (!knownKeys.contains(key) && !environmentProperties.containsKey(key)) {
+ otherProperties.put(key, value)
+ }
+ }
+
+ reportFile.withWriter('UTF-8') { BufferedWriter writer ->
+ writer.writeLine('= Grails Application Configuration Report')
+ writer.writeLine(':toc: left')
+ writer.writeLine(':toclevels: 2')
+ writer.writeLine(':source-highlighter: coderay')
+ writer.writeLine('')
+
+ categories.each { String categoryName, List categoryProperties ->
+ writer.writeLine("== ${categoryName}")
+ writer.writeLine('')
+ writer.writeLine('[cols="2,5,2", options="header"]')
+ writer.writeLine('|===')
+ writer.writeLine('| Property | Description | Default')
+ writer.writeLine('')
+
+ categoryProperties.each { ConfigPropertyMetadata property ->
+ String key = property.name
+ String description = property.description
+ String defaultValue = formatDefaultValue(property.defaultValue)
+ String resolvedValue
+ if (runtimeProperties.containsKey(key)) {
+ resolvedValue = "`${escapeAsciidoc(runtimeProperties.get(key))}`"
+ }
+ else {
+ resolvedValue = escapeAsciidoc(defaultValue)
+ }
+ writer.writeLine("| `${key}`")
+ writer.writeLine("| ${escapeAsciidoc(description)}")
+ writer.writeLine("| ${resolvedValue}")
+ writer.writeLine('')
+ }
+ writer.writeLine('|===')
+ writer.writeLine('')
+ }
+
+ if (!otherProperties.isEmpty()) {
+ writer.writeLine('== Other Properties')
+ writer.writeLine('')
+ writer.writeLine('[cols="2,3", options="header"]')
+ writer.writeLine('|===')
+ writer.writeLine('| Property | Default')
+ writer.writeLine('')
+ otherProperties.each { String key, String value ->
+ writer.writeLine("| `${key}`")
+ writer.writeLine("| `${escapeAsciidoc(value)}`")
+ writer.writeLine('')
+ }
+ writer.writeLine('|===')
+ }
+
+ if (!environmentProperties.isEmpty()) {
+ if (!otherProperties.isEmpty()) {
+ writer.writeLine('')
+ }
+ writer.writeLine('== Environment Properties')
+ writer.writeLine('')
+ writer.writeLine('[cols="2,3", options="header"]')
+ writer.writeLine('|===')
+ writer.writeLine('| Property | Default')
+ writer.writeLine('')
+ environmentProperties.each { String key, String value ->
+ writer.writeLine("| `${key}`")
+ writer.writeLine("| `${escapeAsciidoc(value)}`")
+ writer.writeLine('')
+ }
+ writer.writeLine('|===')
+ }
+ }
+ }
+
+ MetadataResult loadPropertyMetadata() {
+ Enumeration resources = ConfigReportCommand.classLoader.getResources('META-INF/spring-configuration-metadata.json')
+ List metadata = new ArrayList()
+ Map groupDescriptions = new LinkedHashMap()
+ JsonSlurper slurper = new JsonSlurper()
+ while (resources.hasMoreElements()) {
+ URL resource = resources.nextElement()
+ InputStream stream = resource.openStream()
+ Map jsonData
+ try {
+ jsonData = (Map) slurper.parse(stream)
+ }
+ finally {
+ stream.close()
+ }
+ if (!(jsonData instanceof Map)) {
+ continue
+ }
+ groupDescriptions.putAll(loadGroupDescriptions(jsonData.get('groups')))
+ Object propertiesObject = jsonData.get('properties')
+ if (!(propertiesObject instanceof List)) {
+ continue
+ }
+ for (Object propertyObject : (List) propertiesObject) {
+ if (!(propertyObject instanceof Map)) {
+ continue
+ }
+ Map propertyMap = (Map) propertyObject
+ Object nameObject = propertyMap.get('name')
+ if (!(nameObject instanceof String)) {
+ continue
+ }
+ String name = (String) nameObject
+ if (!isGrailsProperty(name)) {
+ continue
+ }
+ String description = propertyMap.get('description') instanceof String ? (String) propertyMap.get('description') : ''
+ String type = propertyMap.get('type') instanceof String ? (String) propertyMap.get('type') : 'java.lang.String'
+ Object defaultValue = propertyMap.get('defaultValue')
+ String group = propertyMap.get('group') instanceof String ? (String) propertyMap.get('group') : resolveGroup(name, groupDescriptions.keySet())
+ metadata.add(new ConfigPropertyMetadata(name, type, description, defaultValue, group))
+ }
+ }
+ metadata.sort { ConfigPropertyMetadata left, ConfigPropertyMetadata right -> left.name <=> right.name }
+ new MetadataResult(metadata, groupDescriptions)
+ }
+
+ Map loadGroupDescriptions(Object groupsObject) {
+ if (!(groupsObject instanceof List)) {
+ return new LinkedHashMap()
+ }
+ Map descriptions = new LinkedHashMap()
+ for (Object groupObject : (List) groupsObject) {
+ if (!(groupObject instanceof Map)) {
+ continue
+ }
+ Map groupMap = (Map) groupObject
+ Object nameObject = groupMap.get('name')
+ if (!(nameObject instanceof String)) {
+ continue
+ }
+ String name = (String) nameObject
+ Object descriptionObject = groupMap.get('description')
+ if (descriptionObject instanceof String) {
+ descriptions.put(name, (String) descriptionObject)
+ }
+ }
+ descriptions
+ }
+
+ Map collectEnvironmentProperties(Map runtimeProperties) {
+ Map environmentProperties = new TreeMap()
+ Set normalizedKeys = new LinkedHashSet()
+ for (String envKey : System.getenv().keySet()) {
+ String lowerKey = envKey.toLowerCase(Locale.ENGLISH)
+ normalizedKeys.add(lowerKey.replace('_', '.'))
+ normalizedKeys.add(lowerKey.replace('_', '-'))
+ }
+ runtimeProperties.each { String key, String value ->
+ if (normalizedKeys.contains(key.toLowerCase(Locale.ENGLISH))) {
+ environmentProperties.put(key, value)
+ }
+ }
+ environmentProperties
+ }
+
+ String resolveGroup(String name, Set groupNames) {
+ if (groupNames == null || groupNames.isEmpty()) {
+ return fallbackGroup(name)
+ }
+ String match = groupNames.findAll { String groupName ->
+ name == groupName || name.startsWith("${groupName}.")
+ }.sort { String left, String right -> right.length() <=> left.length() }
+ .find { String groupName -> groupName }
+ match ?: fallbackGroup(name)
+ }
+
+ String fallbackGroup(String name) {
+ int delimiter = name.lastIndexOf('.')
+ if (delimiter <= 0) {
+ return name
+ }
+ name.substring(0, delimiter)
+ }
+
+ boolean isGrailsProperty(String name) {
+ name.startsWith('grails.') || name.startsWith('dataSource.') || name.startsWith('hibernate.')
+ }
+
+ String formatDefaultValue(Object defaultValue) {
+ if (defaultValue == null) {
+ return ''
+ }
+ if (defaultValue instanceof List) {
+ List values = (List) defaultValue
+ String joined = values.collect { Object item -> "\"${item?.toString()}\"" }.join(', ')
+ return "[${joined}]"
+ }
+ if (defaultValue instanceof Map) {
+ Map mapValue = (Map) defaultValue
+ if (mapValue.isEmpty()) {
+ return '{}'
+ }
+ return mapValue.toString()
+ }
+ defaultValue.toString()
+ }
+
+ /**
+ * Escapes special AsciiDoc characters in a value string.
+ *
+ * @param value the raw value
+ * @return the escaped value safe for AsciiDoc table cells
+ */
+ static String escapeAsciidoc(String value) {
+ if (!value) {
+ return value
+ }
+ value.replace('|', '\\|')
+ }
+
+ static class ConfigPropertyMetadata {
+ final String name
+ final String type
+ final String description
+ final Object defaultValue
+ final String group
+
+ ConfigPropertyMetadata(String name, String type, String description, Object defaultValue, String group) {
+ this.name = name
+ this.type = type
+ this.description = description
+ this.defaultValue = defaultValue
+ this.group = group
+ }
+ }
+
+ static class MetadataResult {
+ final List properties
+ final Map groupDescriptions
+
+ MetadataResult(List properties, Map groupDescriptions) {
+ this.properties = properties
+ this.groupDescriptions = groupDescriptions
+ }
+ }
+
+}
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
new file mode 100644
index 00000000000..2db82bc455d
--- /dev/null
+++ b/grails-core/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,340 @@
+{
+ "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",
+ "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",
+ "description": "List of plugin names to include in the plugin manager (all others excluded).",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.plugin.excludes",
+ "type": "java.util.List",
+ "description": "List of plugin names to exclude from the plugin manager.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.reload.includes",
+ "type": "java.util.List",
+ "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",
+ "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",
+ "description": "A closure applied as the default mapping block for all domain classes.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gorm.default.constraints",
+ "type": "java.util.Map",
+ "description": "A closure applied as the default constraints for all domain classes.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gorm.custom.types",
+ "type": "java.util.Map",
+ "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-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
new file mode 100644
index 00000000000..c6cb06a98fc
--- /dev/null
+++ b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy
@@ -0,0 +1,425 @@
+/*
+ * 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.dev.commands
+
+import org.springframework.context.ConfigurableApplicationContext
+import org.springframework.core.env.ConfigurableEnvironment
+import org.springframework.core.env.MapPropertySource
+import org.springframework.core.env.MutablePropertySources
+
+import org.grails.build.parsing.CommandLine
+import spock.lang.Specification
+import spock.lang.TempDir
+
+class ConfigReportCommandSpec extends Specification {
+
+ @TempDir
+ File tempDir
+
+ ConfigReportCommand command
+
+ ConfigurableApplicationContext applicationContext
+
+ ConfigurableEnvironment environment
+
+ MutablePropertySources propertySources
+
+ def setup() {
+ propertySources = new MutablePropertySources()
+ environment = Mock(ConfigurableEnvironment)
+ environment.getPropertySources() >> propertySources
+ applicationContext = Mock(ConfigurableApplicationContext)
+ applicationContext.getEnvironment() >> environment
+
+ command = new ConfigReportCommand()
+ command.applicationContext = applicationContext
+ }
+
+ def "command name is derived from class name"() {
+ expect:
+ command.name == 'config-report'
+ }
+
+ def "command has a description"() {
+ expect:
+ command.description == 'Generates an AsciiDoc report of the application configuration'
+ }
+
+ def "handle generates AsciiDoc report file"() {
+ given:
+ Map props = [
+ 'grails.profile': 'web',
+ 'grails.codegen.defaultPackage': 'myapp',
+ 'server.port': '8080',
+ 'spring.main.banner-mode': 'off',
+ 'my.custom.prop': 'value'
+ ]
+ propertySources.addFirst(new MapPropertySource('test', props))
+ props.each { String key, Object value ->
+ environment.getProperty(key) >> value.toString()
+ }
+
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+
+ when:
+ boolean result = command.handle(executionContext)
+
+ then:
+ result
+
+ and: "report file is written to the base directory"
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+ reportFile.exists()
+
+ and: "report has correct AsciiDoc structure"
+ String content = reportFile.text
+ content.contains('= Grails Application Configuration Report')
+
+ and: "known Grails properties appear in their metadata category sections"
+ content.contains('`grails.profile`')
+ content.contains('`grails.codegen.defaultPackage`')
+
+ and: "unknown runtime properties appear in the Other Properties section"
+ content.contains('== Other Properties')
+ content.contains('`server.port`')
+ content.contains('`8080`')
+ content.contains('`spring.main.banner-mode`')
+ content.contains('`off`')
+ content.contains('`my.custom.prop`')
+ content.contains('`value`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "handle returns false when an error occurs"() {
+ given:
+ ConfigurableApplicationContext failingContext = Mock(ConfigurableApplicationContext)
+ failingContext.getEnvironment() >> { throw new RuntimeException('test error') }
+ ConfigReportCommand failingCommand = new ConfigReportCommand()
+ failingCommand.applicationContext = failingContext
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+
+ when:
+ boolean result = failingCommand.handle(executionContext)
+
+ then:
+ !result
+ }
+
+ def "collectProperties gathers from all enumerable property sources"() {
+ given:
+ Map yamlProps = ['myapp.yaml.greeting': 'Hello']
+ Map groovyProps = ['myapp.groovy.name': 'TestApp']
+ propertySources.addLast(new MapPropertySource('yaml', yamlProps))
+ propertySources.addLast(new MapPropertySource('groovy', groovyProps))
+ environment.getProperty('myapp.yaml.greeting') >> 'Hello'
+ environment.getProperty('myapp.groovy.name') >> 'TestApp'
+
+ when:
+ Map result = command.collectProperties(environment)
+
+ then:
+ result['myapp.yaml.greeting'] == 'Hello'
+ result['myapp.groovy.name'] == 'TestApp'
+ }
+
+ def "collectProperties respects property source precedence"() {
+ given: 'two sources with the same key, higher-priority source listed first'
+ Map overrideProps = ['app.name': 'Override']
+ Map defaultProps = ['app.name': 'Default']
+ propertySources.addLast(new MapPropertySource('override', overrideProps))
+ propertySources.addLast(new MapPropertySource('default', defaultProps))
+ environment.getProperty('app.name') >> 'Override'
+
+ when:
+ Map result = command.collectProperties(environment)
+
+ then: 'the higher-priority value wins'
+ result['app.name'] == 'Override'
+ }
+
+ def "collectProperties skips properties that resolve to null"() {
+ given:
+ Map props = ['app.present': 'value', 'app.missing': 'placeholder']
+ propertySources.addFirst(new MapPropertySource('test', props))
+ environment.getProperty('app.present') >> 'value'
+ environment.getProperty('app.missing') >> null
+
+ when:
+ Map result = command.collectProperties(environment)
+
+ then:
+ result.containsKey('app.present')
+ !result.containsKey('app.missing')
+ }
+
+ def "collectProperties handles resolution errors gracefully"() {
+ given:
+ Map props = ['app.good': 'value', 'app.bad': '${unresolved}']
+ propertySources.addFirst(new MapPropertySource('test', props))
+ environment.getProperty('app.good') >> 'value'
+ environment.getProperty('app.bad') >> { throw new IllegalArgumentException('unresolved placeholder') }
+
+ when:
+ Map result = command.collectProperties(environment)
+
+ then: 'the good property is collected and the bad one is skipped'
+ result['app.good'] == 'value'
+ !result.containsKey('app.bad')
+ }
+
+ def "writeReport uses 3-column format with metadata categories"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ runtimeProperties.put('grails.gorm.autoFlush', 'true')
+ runtimeProperties.put('grails.profile', 'web')
+ runtimeProperties.put('server.port', '8080')
+
+ File reportFile = new File(tempDir, 'test-report.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+
+ and: "report has correct AsciiDoc header"
+ content.startsWith('= Grails Application Configuration Report')
+ content.contains(':toc: left')
+
+ and: "metadata categories are used as section headers"
+ content.contains('== Core Properties')
+ content.contains('== GORM')
+
+ and: "3-column table format is used for known properties"
+ content.contains('[cols="2,5,2", options="header"]')
+ content.contains('| Property | Description | Default')
+
+ and: "known properties appear with descriptions"
+ content.contains('`grails.profile`')
+ content.contains('`grails.gorm.autoFlush`')
+
+ and: "runtime values override static defaults for known properties"
+ content.contains('`web`')
+ content.contains('`true`')
+
+ and: "unknown runtime properties go to Other Properties section"
+ content.contains('== Other Properties')
+ content.contains('`server.port`')
+ content.contains('`8080`')
+ }
+
+ def "writeReport shows static defaults when no runtime value exists"() {
+ given: "no runtime properties provided"
+ Map runtimeProperties = new TreeMap()
+ File reportFile = new File(tempDir, 'defaults-report.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+
+ and: "metadata categories are still present with static defaults"
+ content.contains('== Core Properties')
+ content.contains('`grails.profile`')
+ content.contains('Set by project template')
+ }
+
+ def "writeReport runtime values override static defaults"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ runtimeProperties.put('grails.profile', 'rest-api')
+
+ File reportFile = new File(tempDir, 'override-report.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+
+ and: "runtime value overrides the static default"
+ content.contains('`rest-api`')
+ }
+
+ def "writeReport escapes pipe characters in values"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ runtimeProperties.put('test.key', 'value|with|pipes')
+
+ File reportFile = new File(tempDir, 'escape-test.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+ content.contains('value\\|with\\|pipes')
+ !content.contains('value|with|pipes')
+ }
+
+ def "writeReport handles empty configuration with no Other Properties"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ File reportFile = new File(tempDir, 'empty-report.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ reportFile.exists()
+ String content = reportFile.text
+ content.contains('= Grails Application Configuration Report')
+
+ and: "metadata categories still appear from the metadata"
+ content.contains('== Core Properties')
+
+ and: "no Other Properties section when no unknown runtime properties"
+ !content.contains('== Other Properties')
+ }
+
+ def "writeReport puts only unknown runtime properties in Other Properties"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ runtimeProperties.put('custom.app.setting', 'myvalue')
+ runtimeProperties.put('grails.profile', 'web')
+
+ File reportFile = new File(tempDir, 'other-props-report.adoc')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+
+ and: "known property is in its category, not in Other Properties"
+ content.contains('== Core Properties')
+ content.contains('`grails.profile`')
+
+ and: "unknown property appears in Other Properties"
+ content.contains('== Other Properties')
+ content.contains('`custom.app.setting`')
+ content.contains('`myvalue`')
+
+ and: "Other Properties uses 2-column format"
+ int otherIdx = content.indexOf('== Other Properties')
+ String otherSection = content.substring(otherIdx)
+ otherSection.contains('[cols="2,3", options="header"]')
+ otherSection.contains('| Property | Default')
+ }
+
+ def "writeReport moves environment-derived properties to environment section"() {
+ given:
+ Map runtimeProperties = new TreeMap()
+ runtimeProperties.put('my.custom.value', 'custom')
+ runtimeProperties.put('grails.profile', 'web')
+ File reportFile = new File(tempDir, 'env-report.adoc')
+
+ and:
+ String envKey = 'MY_CUSTOM_VALUE'
+ String originalValue = System.getenv(envKey)
+ setEnvVar(envKey, 'from-env')
+
+ when:
+ command.writeReport(runtimeProperties, reportFile)
+
+ then:
+ String content = reportFile.text
+ int envIndex = content.indexOf('== Environment Properties')
+ envIndex > -1
+ String envSection = content.substring(envIndex)
+ envSection.contains('`my.custom.value`')
+ envSection.contains('`custom`')
+
+ and:
+ int otherIndex = content.indexOf('== Other Properties')
+ String otherSection = otherIndex > -1 ? content.substring(otherIndex, envIndex) : ''
+ !otherSection.contains('`my.custom.value`')
+
+ cleanup:
+ if (originalValue != null) {
+ setEnvVar(envKey, originalValue)
+ }
+ else {
+ clearEnvVar(envKey)
+ }
+ }
+
+ def "loadPropertyMetadata returns properties from classpath JSON metadata"() {
+ when:
+ ConfigReportCommand.MetadataResult metadataResult = command.loadPropertyMetadata()
+
+ then: "metadata is loaded from spring-configuration-metadata.json on the classpath"
+ !metadataResult.properties.isEmpty()
+ metadataResult.properties.find { ConfigReportCommand.ConfigPropertyMetadata property -> property.name == 'grails.profile' }
+
+ and: "each entry has the expected fields"
+ ConfigReportCommand.ConfigPropertyMetadata profileEntry = metadataResult.properties.find { ConfigReportCommand.ConfigPropertyMetadata property -> property.name == 'grails.profile' }
+ profileEntry.name == 'grails.profile'
+ profileEntry.description != null
+ profileEntry.description.length() > 0
+ metadataResult.groupDescriptions.get('grails') == 'Core Properties'
+ }
+
+ def "escapeAsciidoc handles null and empty strings"() {
+ expect:
+ ConfigReportCommand.escapeAsciidoc(null) == null
+ ConfigReportCommand.escapeAsciidoc('') == ''
+ ConfigReportCommand.escapeAsciidoc('simple') == 'simple'
+ ConfigReportCommand.escapeAsciidoc('a|b') == 'a\\|b'
+ }
+
+ private void setEnvVar(String key, String value) {
+ setEnvironmentVariable(key, value)
+ }
+
+ private void clearEnvVar(String key) {
+ setEnvironmentVariable(key, null)
+ }
+
+ private void setEnvironmentVariable(String key, String value) {
+ Map env = System.getenv()
+ Class> envClass = env.getClass()
+ try {
+ java.lang.reflect.Field field = envClass.getDeclaredField('m')
+ field.setAccessible(true)
+ Map writable = (Map) field.get(env)
+ if (value == null) {
+ writable.remove(key)
+ }
+ else {
+ writable.put(key, value)
+ }
+ }
+ catch (NoSuchFieldException ignored) {
+ java.lang.reflect.Field field = envClass.getDeclaredField('delegate')
+ field.setAccessible(true)
+ Map writable = (Map) field.get(env)
+ if (value == null) {
+ writable.remove(key)
+ }
+ else {
+ writable.put(key, value)
+ }
+ }
+ }
+
+}
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/spring-configuration-metadata.json
new file mode 100644
index 00000000000..7a512604174
--- /dev/null
+++ b/grails-data-hibernate5/core/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,28 @@
+{
+ "groups": [
+ {
+ "name": "hibernate.cache",
+ "description": "Hibernate"
+ }
+ ],
+ "properties": [
+ {
+ "name": "hibernate.cache.queries",
+ "type": "java.lang.Boolean",
+ "description": "Whether to cache Hibernate queries.",
+ "defaultValue": false
+ },
+ {
+ "name": "hibernate.cache.use_second_level_cache",
+ "type": "java.lang.Boolean",
+ "description": "Whether to enable Hibernate's second-level cache.",
+ "defaultValue": false
+ },
+ {
+ "name": "hibernate.cache.use_query_cache",
+ "type": "java.lang.Boolean",
+ "description": "Whether to enable Hibernate's query cache.",
+ "defaultValue": false
+ }
+ ]
+}
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/spring-configuration-metadata.json
new file mode 100644
index 00000000000..302af8c095f
--- /dev/null
+++ b/grails-data-hibernate5/dbmigration/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,106 @@
+{
+ "groups": [
+ {
+ "name": "grails.plugin.databasemigration",
+ "description": "Database Migration Plugin"
+ },
+ {
+ "name": "grails.plugin.databasemigration.startup",
+ "description": "Database Migration Plugin - Startup"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.plugin.databasemigration.updateOnStart",
+ "type": "java.lang.Boolean",
+ "description": "Whether to run changesets from the specified file at application startup.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.plugin.databasemigration.updateAllOnStart",
+ "type": "java.lang.Boolean",
+ "description": "Whether to run changesets at startup for all configured datasources.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.plugin.databasemigration.updateOnStartFileName",
+ "type": "java.lang.String",
+ "description": "The changelog file name to run at startup. For named datasources uses `changelog-.groovy`.",
+ "defaultValue": "changelog.groovy"
+ },
+ {
+ "name": "grails.plugin.databasemigration.dropOnStart",
+ "type": "java.lang.Boolean",
+ "description": "Whether to drop all database tables before auto-running migrations at startup.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.plugin.databasemigration.updateOnStartContexts",
+ "type": "java.util.List",
+ "description": "Liquibase contexts to activate when running migrations at startup. Empty means all contexts.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.plugin.databasemigration.updateOnStartLabels",
+ "type": "java.util.List",
+ "description": "Liquibase labels to filter changesets when running migrations at startup. Empty means all labels.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.plugin.databasemigration.updateOnStartDefaultSchema",
+ "type": "java.lang.String",
+ "description": "The default database schema to use when running migrations at startup.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.plugin.databasemigration.databaseChangeLogTableName",
+ "type": "java.lang.String",
+ "description": "The name of the Liquibase changelog tracking table.",
+ "defaultValue": "DATABASECHANGELOG"
+ },
+ {
+ "name": "grails.plugin.databasemigration.databaseChangeLogLockTableName",
+ "type": "java.lang.String",
+ "description": "The name of the Liquibase lock table used to prevent concurrent migrations.",
+ "defaultValue": "DATABASECHANGELOGLOCK"
+ },
+ {
+ "name": "grails.plugin.databasemigration.changelogLocation",
+ "type": "java.lang.String",
+ "description": "The directory containing changelog files.",
+ "defaultValue": "grails-app/migrations"
+ },
+ {
+ "name": "grails.plugin.databasemigration.changelogFileName",
+ "type": "java.lang.String",
+ "description": "The name of the main changelog file. For named datasources uses `changelog-.groovy`.",
+ "defaultValue": "changelog.groovy"
+ },
+ {
+ "name": "grails.plugin.databasemigration.contexts",
+ "type": "java.lang.String",
+ "description": "Comma-delimited list of Liquibase contexts to use for all operations.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.plugin.databasemigration.excludeObjects",
+ "type": "java.lang.String",
+ "description": "Comma-delimited list of database objects to ignore in diff and generate operations.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.plugin.databasemigration.includeObjects",
+ "type": "java.lang.String",
+ "description": "Comma-delimited list of database objects to include in diff and generate operations (all others excluded).",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.plugin.databasemigration.skipUpdateOnStartMainClasses",
+ "type": "java.util.List",
+ "description": "List of main class names for which startup migrations are skipped, preventing migrations when running CLI commands.",
+ "defaultValue": [
+ "grails.ui.command.GrailsApplicationContextCommandRunner"
+ ]
+ }
+ ]
+}
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/spring-configuration-metadata.json
new file mode 100644
index 00000000000..998fb14858b
--- /dev/null
+++ b/grails-data-mongodb/grails-plugin/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,160 @@
+{
+ "groups": [
+ {
+ "name": "grails.mongodb",
+ "description": "MongoDB GORM Plugin"
+ },
+ {
+ "name": "grails.mongodb.options",
+ "description": "MongoDB GORM Plugin - Client Options"
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings",
+ "description": "MongoDB GORM Plugin - Connection Pool"
+ },
+ {
+ "name": "grails.mongodb.options.sslSettings",
+ "description": "MongoDB GORM Plugin - SSL"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.mongodb.url",
+ "type": "java.lang.String",
+ "description": "The MongoDB connection URL. Supports the full MongoDB connection string format.",
+ "defaultValue": "mongodb://localhost/test"
+ },
+ {
+ "name": "grails.mongodb.host",
+ "type": "java.lang.String",
+ "description": "The MongoDB server hostname. Ignored when `url` or `connectionString` is set.",
+ "defaultValue": "localhost"
+ },
+ {
+ "name": "grails.mongodb.port",
+ "type": "java.lang.Integer",
+ "description": "The MongoDB server port. Ignored when `url` or `connectionString` is set.",
+ "defaultValue": 27017
+ },
+ {
+ "name": "grails.mongodb.databaseName",
+ "type": "java.lang.String",
+ "description": "The MongoDB database name.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.username",
+ "type": "java.lang.String",
+ "description": "The username for MongoDB authentication.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.password",
+ "type": "java.lang.String",
+ "description": "The password for MongoDB authentication.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.stateless",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use stateless mode with no session-level caching of entities.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.mongodb.decimalType",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use Decimal128 for BigDecimal values instead of Double.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.mongodb.codecs",
+ "type": "java.util.List",
+ "description": "List of custom MongoDB Codec classes to register for BSON serialization.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.mongodb.default.mapping",
+ "type": "groovy.lang.Closure",
+ "description": "A closure applied as the default GORM mapping block for all MongoDB domain classes.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings.maxSize",
+ "type": "java.lang.Integer",
+ "description": "Maximum number of connections in the connection pool.",
+ "defaultValue": 100
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings.minSize",
+ "type": "java.lang.Integer",
+ "description": "Minimum number of connections in the connection pool.",
+ "defaultValue": 0
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings.maxWaitTime",
+ "type": "java.lang.Long",
+ "description": "Maximum time in milliseconds to wait for a connection from the pool.",
+ "defaultValue": 120000
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings.maxConnectionLifeTime",
+ "type": "java.lang.Long",
+ "description": "Maximum lifetime in milliseconds of a pooled connection (0 for unlimited).",
+ "defaultValue": 0
+ },
+ {
+ "name": "grails.mongodb.options.connectionPoolSettings.maxConnectionIdleTime",
+ "type": "java.lang.Long",
+ "description": "Maximum idle time in milliseconds before a pooled connection is closed (0 for unlimited).",
+ "defaultValue": 0
+ },
+ {
+ "name": "grails.mongodb.options.readPreference",
+ "type": "java.lang.String",
+ "description": "The MongoDB read preference (e.g., primary, secondary, nearest).",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.writeConcern",
+ "type": "java.lang.String",
+ "description": "The MongoDB write concern (e.g., majority, w1, journaled).",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.readConcern",
+ "type": "java.lang.String",
+ "description": "The MongoDB read concern level (e.g., local, majority, linearizable).",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.retryWrites",
+ "type": "java.lang.Boolean",
+ "description": "Whether to retry write operations on transient network errors.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.retryReads",
+ "type": "java.lang.Boolean",
+ "description": "Whether to retry read operations on transient network errors.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.applicationName",
+ "type": "java.lang.String",
+ "description": "Application name sent to MongoDB for server logs and profiling.",
+ "defaultValue": null
+ },
+ {
+ "name": "grails.mongodb.options.sslSettings.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Whether to enable TLS/SSL for connections to MongoDB.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.mongodb.options.sslSettings.invalidHostNameAllowed",
+ "type": "java.lang.Boolean",
+ "description": "Whether to allow connections to MongoDB servers with invalid hostnames in TLS certificates.",
+ "defaultValue": false
+ }
+ ]
+}
diff --git a/grails-databinding/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-databinding/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000000..44b3fab4fe7
--- /dev/null
+++ b/grails-databinding/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,40 @@
+{
+ "groups": [
+ {
+ "name": "grails.databinding",
+ "description": "Data Binding"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.databinding.trimStrings",
+ "type": "java.lang.Boolean",
+ "description": "Whether to trim whitespace from String values during data binding.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.databinding.convertEmptyStringsToNull",
+ "type": "java.lang.Boolean",
+ "description": "Whether empty String values are converted to null during data binding.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.databinding.autoGrowCollectionLimit",
+ "type": "java.lang.Integer",
+ "description": "The maximum size to which indexed collections can auto-grow during data binding.",
+ "defaultValue": 256
+ },
+ {
+ "name": "grails.databinding.dateFormats",
+ "type": "java.util.List",
+ "description": "List of date format strings used to parse date values during data binding.",
+ "defaultValue": ["yyyy-MM-dd HH:mm:ss.S", "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd HH:mm:ss.S z", "yyyy-MM-dd'T'HH:mm:ssX"]
+ },
+ {
+ "name": "grails.databinding.dateParsingLenient",
+ "type": "java.lang.Boolean",
+ "description": "Whether date parsing is lenient (accepting invalid dates like Feb 30).",
+ "defaultValue": false
+ }
+ ]
+}
diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle
index 8ba84684edb..6e7af021adc 100644
--- a/grails-doc/build.gradle
+++ b/grails-doc/build.gradle
@@ -20,6 +20,9 @@
import grails.doc.git.FetchTagsTask
import grails.doc.dropdown.CreateReleaseDropDownTask
import grails.doc.gradle.PublishGuideTask
+import groovy.json.JsonSlurper
+
+import java.util.zip.ZipFile
plugins {
id 'base'
@@ -166,9 +169,328 @@ generateBomDocumentation.configure { Task it ->
}
}
+def generateConfigReference = tasks.register('generateConfigReference')
+generateConfigReference.configure { Task it ->
+ it.dependsOn(
+ project(':grails-core').tasks.named('jar'),
+ project(':grails-web-core').tasks.named('jar'),
+ project(':grails-gsp').tasks.named('jar'),
+ project(':grails-databinding').tasks.named('jar'),
+ project(':grails-data-hibernate5-core').tasks.named('jar'),
+ project(':grails-cache').tasks.named('jar'),
+ project(':grails-data-hibernate5-dbmigration').tasks.named('jar'),
+ project(':grails-data-mongodb').tasks.named('jar'),
+ project(':grails-views-gson').tasks.named('jar')
+ )
+
+ it.description = 'Generates the Application Properties reference from configuration metadata.'
+ it.group = 'documentation'
+
+ def configDir = project.layout.projectDirectory.dir('src/en/ref/Configuration')
+ def outputFile = project.layout.projectDirectory.file('src/en/ref/Configuration/Application Properties.adoc')
+
+ it.inputs.files(
+ project(':grails-core').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-web-core').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-gsp').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-databinding').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-data-hibernate5-core').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-cache').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-data-hibernate5-dbmigration').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-data-mongodb').tasks.named('jar').flatMap { task -> task.archiveFile },
+ project(':grails-views-gson').tasks.named('jar').flatMap { task -> task.archiveFile }
+ )
+ it.outputs.file(outputFile)
+
+ it.doFirst {
+ configDir.asFile.mkdirs()
+ }
+
+ it.doLast {
+ List jarFiles = [
+ project(':grails-core').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-web-core').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-gsp').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-databinding').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-data-hibernate5-core').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-cache').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-data-hibernate5-dbmigration').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-data-mongodb').tasks.named('jar').get().archiveFile.get().asFile,
+ project(':grails-views-gson').tasks.named('jar').get().archiveFile.get().asFile
+ ]
+
+ Map groupDescriptions = new LinkedHashMap()
+ List> properties = new ArrayList>()
+ JsonSlurper slurper = new JsonSlurper()
+
+ jarFiles.each { File jarFile ->
+ if (!jarFile.exists()) {
+ return
+ }
+ ZipFile zipFile = new ZipFile(jarFile)
+ try {
+ def entry = zipFile.getEntry('META-INF/spring-configuration-metadata.json')
+ if (entry == null) {
+ return
+ }
+ String jsonText = zipFile.getInputStream(entry).getText('UTF-8')
+ Map jsonData = (Map) slurper.parseText(jsonText)
+ Object groupsObject = jsonData.get('groups')
+ if (groupsObject instanceof List) {
+ ((List) groupsObject).each { Object groupObject ->
+ if (groupObject instanceof Map) {
+ Map groupMap = (Map) groupObject
+ Object nameObject = groupMap.get('name')
+ Object descriptionObject = groupMap.get('description')
+ if (nameObject instanceof String && descriptionObject instanceof String) {
+ groupDescriptions.put((String) nameObject, (String) descriptionObject)
+ }
+ }
+ }
+ }
+ Object propertiesObject = jsonData.get('properties')
+ if (propertiesObject instanceof List) {
+ ((List) propertiesObject).each { Object propertyObject ->
+ if (!(propertyObject instanceof Map)) {
+ return
+ }
+ Map propertyMap = (Map) propertyObject
+ Object nameObject = propertyMap.get('name')
+ if (!(nameObject instanceof String)) {
+ return
+ }
+ String name = (String) nameObject
+ if (!(name.startsWith('grails.') || name.startsWith('dataSource.') || name.startsWith('hibernate.'))) {
+ return
+ }
+ String description = propertyMap.get('description') instanceof String ? (String) propertyMap.get('description') : ''
+ Object defaultValue = propertyMap.get('defaultValue')
+ String group = propertyMap.get('group') instanceof String ? (String) propertyMap.get('group') : null
+ properties.add([
+ name : name,
+ description : description,
+ defaultValue: defaultValue,
+ group : group
+ ])
+ }
+ }
+ }
+ finally {
+ zipFile.close()
+ }
+ }
+
+ Set groupNames = groupDescriptions.keySet()
+ Closure fallbackGroup = { String name ->
+ int delimiter = name.lastIndexOf('.')
+ if (delimiter <= 0) {
+ return name
+ }
+ name.substring(0, delimiter)
+ }
+ Closure resolveGroup = { String name, String group ->
+ if (group) {
+ return group
+ }
+ if (groupNames == null || groupNames.isEmpty()) {
+ return fallbackGroup(name)
+ }
+ String match = groupNames.findAll { String groupName ->
+ name == groupName || name.startsWith("${groupName}.")
+ }.sort { String left, String right -> right.length() <=> left.length() }
+ .find { String groupName -> groupName }
+ match ?: fallbackGroup(name)
+ }
+
+ Closure escapeAsciidoc = { String value ->
+ if (!value) {
+ return value
+ }
+ value.replace('|', '\\|')
+ }
+
+ Closure formatDefaultValue = { Object value ->
+ if (value == null) {
+ return ''
+ }
+ if (value instanceof List) {
+ List values = (List) value
+ String joined = values.collect { Object item -> "\"${item?.toString()}\"" }.join(', ')
+ return "[${joined}]"
+ }
+ if (value instanceof Map) {
+ Map mapValue = (Map) value
+ if (mapValue.isEmpty()) {
+ return '{}'
+ }
+ return mapValue.toString()
+ }
+ value.toString()
+ }
+
+ Map>> categories = new LinkedHashMap>>()
+ properties.each { Map property ->
+ String name = (String) property.get('name')
+ String resolvedGroup = resolveGroup(name, (String) property.get('group'))
+ String category = groupDescriptions.get(resolvedGroup) ?: resolvedGroup
+ if (!categories.containsKey(category)) {
+ categories.put(category, new ArrayList>())
+ }
+ categories.get(category).add([
+ name : name,
+ description : property.get('description') as String,
+ defaultValue: property.get('defaultValue')
+ ])
+ }
+
+ List preferredOrder = [
+ 'Core Properties',
+ 'Web & Controllers',
+ 'CORS',
+ 'Views & GSP',
+ 'Content Negotiation & MIME Types',
+ 'Data Binding',
+ 'Internationalization',
+ 'Static Resources',
+ 'URL Mappings',
+ 'Scaffolding',
+ 'Development & Reloading',
+ 'Events',
+ 'JSON & Converters',
+ 'GORM',
+ 'DataSource',
+ 'Hibernate',
+ 'Database Migration Plugin',
+ 'Cache Plugin',
+ 'MongoDB GORM Plugin'
+ ]
+ List orderedCategories = new ArrayList()
+ preferredOrder.each { String category ->
+ if (categories.containsKey(category)) {
+ orderedCategories.add(category)
+ }
+ }
+ categories.keySet().findAll { String category -> !orderedCategories.contains(category) }.sort().each { String category ->
+ orderedCategories.add(category)
+ }
+
+ def documentFile = outputFile.asFile
+ documentFile.withWriter('UTF-8') { writer ->
+ writer.writeLine('////')
+ writer.writeLine('Licensed to the Apache Software Foundation (ASF) under one')
+ writer.writeLine('or more contributor license agreements. See the NOTICE file')
+ writer.writeLine('distributed with this work for additional information')
+ writer.writeLine('regarding copyright ownership. The ASF licenses this file')
+ writer.writeLine('to you under the Apache License, Version 2.0 (the')
+ writer.writeLine('"License"); you may not use this file except in compliance')
+ writer.writeLine('with the License. You may obtain a copy of the License at')
+ writer.writeLine('')
+ writer.writeLine('https://www.apache.org/licenses/LICENSE-2.0')
+ writer.writeLine('')
+ writer.writeLine('Unless required by applicable law or agreed to in writing,')
+ writer.writeLine('software distributed under the License is distributed on an')
+ writer.writeLine('"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY')
+ writer.writeLine('KIND, either express or implied. See the License for the')
+ writer.writeLine('specific language governing permissions and limitations')
+ writer.writeLine('under the License.')
+ writer.writeLine('////')
+ writer.writeLine('')
+ writer.writeLine('== Application Properties')
+ writer.writeLine('')
+ writer.writeLine('A comprehensive reference of all configuration properties specific to Grails and its bundled modules. These properties are set in `grails-app/conf/application.yml` (or `application.groovy`).')
+ writer.writeLine('')
+ writer.writeLine('Since Grails is built on Spring Boot, all https://docs.spring.io/spring-boot/{springBootVersion}/appendix/application-properties/index.html[Spring Boot Common Application Properties] are also available (for example `server.port`, `logging.*`, `spring.datasource.hikari.*`, and `management.*`). This reference covers only Grails-specific properties.')
+ writer.writeLine('')
+ writer.writeLine('=== Environment-Specific Configuration')
+ writer.writeLine('')
+ writer.writeLine('Properties in `application.yml` (or `application.groovy`) are global by default - they apply to all environments. To override a property for a specific environment, nest it under an `environments` block:')
+ writer.writeLine('')
+ writer.writeLine('[source,yaml]')
+ writer.writeLine('----')
+ writer.writeLine('# Global (applies to all environments)')
+ writer.writeLine('grails:')
+ writer.writeLine(' serverURL: https://my.app.com')
+ writer.writeLine('')
+ writer.writeLine('# Environment-specific overrides')
+ writer.writeLine('environments:')
+ writer.writeLine(' development:')
+ writer.writeLine(' grails:')
+ writer.writeLine(' serverURL: http://localhost:8080')
+ writer.writeLine(' test:')
+ writer.writeLine(' grails:')
+ writer.writeLine(' serverURL: http://localhost:8080')
+ writer.writeLine(' production:')
+ writer.writeLine(' grails:')
+ writer.writeLine(' serverURL: https://my.app.com')
+ writer.writeLine('----')
+ writer.writeLine('')
+ writer.writeLine('Or in `application.groovy`:')
+ writer.writeLine('')
+ writer.writeLine('[source,groovy]')
+ writer.writeLine('----')
+ writer.writeLine("grails.serverURL = 'https://my.app.com'")
+ writer.writeLine('')
+ writer.writeLine('environments {')
+ writer.writeLine(' development {')
+ writer.writeLine(" grails.serverURL = 'http://localhost:8080'")
+ writer.writeLine(' }')
+ writer.writeLine(' test {')
+ writer.writeLine(" grails.serverURL = 'http://localhost:8080'")
+ writer.writeLine(' }')
+ writer.writeLine(' production {')
+ writer.writeLine(" grails.serverURL = 'https://my.app.com'")
+ writer.writeLine(' }')
+ writer.writeLine('}')
+ writer.writeLine('----')
+ writer.writeLine('')
+
+ orderedCategories.each { String category ->
+ List> categoryProperties = categories.get(category)
+ if (categoryProperties == null || categoryProperties.isEmpty()) {
+ return
+ }
+ writer.writeLine("=== ${category}")
+ writer.writeLine('')
+ writer.writeLine('[cols="3,5,2", options="header"]')
+ writer.writeLine('|===')
+ writer.writeLine('| Property | Description | Default')
+ writer.writeLine('')
+ categoryProperties.sort { Map left, Map right ->
+ (left.get('name') as String) <=> (right.get('name') as String)
+ }.each { Map property ->
+ String name = property.get('name') as String
+ String description = property.get('description') as String
+ String defaultValue = formatDefaultValue(property.get('defaultValue'))
+ writer.writeLine("| `${name}`")
+ writer.writeLine("| ${escapeAsciidoc(description)}")
+ if (defaultValue) {
+ writer.writeLine("| `${escapeAsciidoc(defaultValue)}`")
+ }
+ else {
+ writer.writeLine('|')
+ }
+ writer.writeLine('')
+ }
+ writer.writeLine('|===')
+ writer.writeLine('')
+ }
+
+ writer.writeLine('== Plugin References')
+ writer.writeLine('')
+ writer.writeLine('The following plugin documentation covers configuration properties that are not listed above:')
+ writer.writeLine('')
+ writer.writeLine('- https://apache.github.io/grails-spring-security/snapshot/core-plugin/guide/index.html#_configuration_properties[Spring Security Core Configuration Properties]')
+ writer.writeLine('- https://wondrify.github.io/asset-pipeline/[Asset Pipeline Configuration Properties]')
+ writer.writeLine('')
+ }
+
+ it.logger.lifecycle "Application Properties reference generated to: ${documentFile.absolutePath}"
+ }
+}
+
def publishGuideTask = tasks.register('publishGuide', PublishGuideTask)
publishGuideTask.configure { PublishGuideTask publish ->
- publish.dependsOn([generateBomDocumentation])
+ publish.dependsOn([generateBomDocumentation, generateConfigReference])
// No language setting because we want the English guide to be
// generated with a 'en' in the path, but the source is in 'en'
diff --git a/grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000000..98ee551a046
--- /dev/null
+++ b/grails-gsp/plugin/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,100 @@
+{
+ "groups": [
+ {
+ "name": "grails.views",
+ "description": "Views & GSP"
+ },
+ {
+ "name": "grails.views.gsp",
+ "description": "Views & GSP"
+ },
+ {
+ "name": "grails.views.gsp.codecs",
+ "description": "Views & GSP"
+ },
+ {
+ "name": "grails.gsp",
+ "description": "Views & GSP"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.views.default.codec",
+ "type": "java.lang.String",
+ "description": "The default encoding codec for GSP output. Set to `html` to reduce XSS risk. Options: `none`, `html`, `base64`.",
+ "defaultValue": "none"
+ },
+ {
+ "name": "grails.views.gsp.encoding",
+ "type": "java.lang.String",
+ "description": "The file encoding for GSP source files.",
+ "defaultValue": "UTF-8"
+ },
+ {
+ "name": "grails.views.gsp.htmlcodec",
+ "type": "java.lang.String",
+ "description": "The HTML codec for GSP output (xml or html).",
+ "defaultValue": "xml"
+ },
+ {
+ "name": "grails.views.gsp.codecs.expression",
+ "type": "java.lang.String",
+ "description": "The codec applied to GSP ${} expressions.",
+ "defaultValue": "html"
+ },
+ {
+ "name": "grails.views.gsp.codecs.scriptlet",
+ "type": "java.lang.String",
+ "description": "The codec applied to GSP <% %> scriptlet output.",
+ "defaultValue": "html"
+ },
+ {
+ "name": "grails.views.gsp.codecs.taglib",
+ "type": "java.lang.String",
+ "description": "The codec applied to tag library output.",
+ "defaultValue": "none"
+ },
+ {
+ "name": "grails.views.gsp.codecs.staticparts",
+ "type": "java.lang.String",
+ "description": "The codec applied to static HTML parts of GSP pages.",
+ "defaultValue": "none"
+ },
+ {
+ "name": "grails.views.gsp.layout.preprocess",
+ "type": "java.lang.Boolean",
+ "description": "Whether GSP layout preprocessing is enabled. Disabling allows Grails to parse rendered HTML but slows rendering.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.views.enable.jsessionid",
+ "type": "java.lang.Boolean",
+ "description": "Whether to include the jsessionid in rendered links.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.views.filteringCodecForContentType",
+ "type": "java.util.Map",
+ "description": "Map of content types to encoding codecs.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.gsp.disable.caching.resources",
+ "type": "java.lang.Boolean",
+ "description": "Whether to disable GSP resource caching.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.gsp.enable.reload",
+ "type": "java.lang.Boolean",
+ "description": "Whether to enable GSP reloading in production.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.gsp.view.dir",
+ "type": "java.lang.String",
+ "description": "Custom directory for GSP view resolution.",
+ "defaultValue": "grails-app/views"
+ }
+ ]
+}
diff --git a/grails-test-examples/config-report/build.gradle b/grails-test-examples/config-report/build.gradle
new file mode 100644
index 00000000000..231fef7d7da
--- /dev/null
+++ b/grails-test-examples/config-report/build.gradle
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+plugins {
+ id 'org.apache.grails.buildsrc.properties'
+ id 'org.apache.grails.buildsrc.compile'
+}
+
+version = '0.1'
+group = 'configreport'
+
+apply plugin: 'groovy'
+apply plugin: 'org.apache.grails.gradle.grails-web'
+
+dependencies {
+ implementation platform(project(':grails-bom'))
+
+ implementation 'org.apache.grails:grails-core'
+ implementation 'org.apache.grails:grails-logging'
+ implementation 'org.apache.grails:grails-databinding'
+ implementation 'org.apache.grails:grails-interceptors'
+ implementation 'org.apache.grails:grails-services'
+ implementation 'org.apache.grails:grails-url-mappings'
+ implementation 'org.apache.grails:grails-web-boot'
+ if (System.getenv('SITEMESH3_TESTING_ENABLED') == 'true') {
+ implementation 'org.apache.grails:grails-sitemesh3'
+ }
+ else {
+ implementation 'org.apache.grails:grails-layout'
+ }
+ implementation 'org.apache.grails:grails-data-hibernate5'
+ implementation 'org.springframework.boot:spring-boot-autoconfigure'
+ implementation 'org.springframework.boot:spring-boot-starter'
+ implementation 'org.springframework.boot:spring-boot-starter-logging'
+ implementation 'org.springframework.boot:spring-boot-starter-tomcat'
+ implementation 'org.springframework.boot:spring-boot-starter-validation'
+
+ runtimeOnly 'com.h2database:h2'
+ runtimeOnly 'org.apache.tomcat:tomcat-jdbc'
+
+ testImplementation 'org.apache.grails:grails-testing-support-web'
+ testImplementation 'org.spockframework:spock-core'
+}
+
+apply {
+ from rootProject.layout.projectDirectory.file('gradle/functional-test-config.gradle')
+ from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle')
+}
diff --git a/grails-test-examples/config-report/grails-app/conf/application.groovy b/grails-test-examples/config-report/grails-app/conf/application.groovy
new file mode 100644
index 00000000000..1ab156638c7
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/application.groovy
@@ -0,0 +1,29 @@
+/*
+ * 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.
+ */
+
+// Properties defined in Groovy config that should appear in the config report
+myapp {
+ groovy {
+ appName = 'Config Report Test App'
+ version = '1.2.3'
+ }
+}
+
+// A property with a pipe character to test AsciiDoc escaping
+myapp.groovy.delimitedValue = 'value1|value2|value3'
diff --git a/grails-test-examples/config-report/grails-app/conf/application.yml b/grails-test-examples/config-report/grails-app/conf/application.yml
new file mode 100644
index 00000000000..7eb0d4524ca
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/application.yml
@@ -0,0 +1,53 @@
+# 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.
+
+---
+grails:
+ profile: web
+ codegen:
+ defaultPackage: configreport
+
+---
+# Properties defined in YAML that should appear in the config report
+myapp:
+ yaml:
+ greeting: Hello from YAML
+ maxRetries: 5
+ feature:
+ enabled: true
+ timeout: 30000
+ typed:
+ name: Configured App
+ pageSize: 50
+ debugEnabled: true
+
+---
+# Server configuration for testing namespace grouping
+server:
+ port: 0
+
+---
+dataSource:
+ pooled: true
+ jmxExport: true
+ driverClassName: org.h2.Driver
+ username: sa
+ password:
+
+environments:
+ test:
+ dataSource:
+ dbCreate: create-drop
+ url: jdbc:h2:mem:configReportTestDb;LOCK_TIMEOUT=10000;DB_CLOSE_ON_EXIT=FALSE
diff --git a/grails-test-examples/config-report/grails-app/conf/logback.xml b/grails-test-examples/config-report/grails-app/conf/logback.xml
new file mode 100644
index 00000000000..0c32cbb056a
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/conf/logback.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+ %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n
+
+
+
+
+
+
diff --git a/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy b/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy
new file mode 100644
index 00000000000..7e141297b33
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/controllers/configreport/UrlMappings.groovy
@@ -0,0 +1,35 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package configreport
+
+class UrlMappings {
+
+ static mappings = {
+ "/$controller/$action?/$id?(.$format)?" {
+ constraints {
+ // apply constraints here
+ }
+ }
+
+ "/"(view: '/index')
+ "500"(view: '/error')
+ "404"(view: '/notFound')
+ }
+
+}
diff --git a/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy b/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy
new file mode 100644
index 00000000000..dfe6cc0ab1e
--- /dev/null
+++ b/grails-test-examples/config-report/grails-app/init/configreport/Application.groovy
@@ -0,0 +1,32 @@
+/*
+ * 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 configreport
+
+import grails.boot.GrailsApp
+import grails.boot.config.GrailsAutoConfiguration
+import org.springframework.boot.context.properties.EnableConfigurationProperties
+
+@EnableConfigurationProperties(AppProperties)
+class Application extends GrailsAutoConfiguration {
+
+ static void main(String[] args) {
+ GrailsApp.run(Application)
+ }
+
+}
diff --git a/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy b/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy
new file mode 100644
index 00000000000..c8b347f3a6c
--- /dev/null
+++ b/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy
@@ -0,0 +1,271 @@
+/*
+ * 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 configreport
+
+import org.springframework.beans.factory.annotation.Autowired
+import org.springframework.context.ConfigurableApplicationContext
+
+import grails.dev.commands.ConfigReportCommand
+import grails.dev.commands.ExecutionContext
+import grails.testing.mixin.integration.Integration
+import org.grails.build.parsing.CommandLine
+import spock.lang.Narrative
+import spock.lang.Specification
+
+
+/**
+ * Integration tests for {@link ConfigReportCommand} that verify the command
+ * correctly reports configuration from multiple sources:
+ *
+ * {@code application.yml} - YAML-based configuration
+ * {@code application.groovy} - Groovy-based configuration
+ * {@code @ConfigurationProperties} - Type-safe configuration beans
+ *
+ *
+ * The hybrid report uses curated property metadata (from {@code spring-configuration-metadata.json})
+ * to produce a 3-column AsciiDoc table (Property | Description | Default) for known
+ * Grails properties, with runtime values overriding static defaults. Properties not
+ * found in the metadata appear in a separate "Other Properties" section.
+ */
+@Integration
+@Narrative('Verifies that ConfigReportCommand generates a hybrid AsciiDoc report merging static property metadata with runtime-collected values')
+class ConfigReportCommandIntegrationSpec extends Specification {
+
+ @Autowired
+ ConfigurableApplicationContext applicationContext
+
+ @Autowired
+ AppProperties appProperties
+
+
+ private ConfigReportCommand createCommand() {
+ ConfigReportCommand command = new ConfigReportCommand()
+ command.applicationContext = applicationContext
+ return command
+ }
+
+ def "ConfigReportCommand generates a report file with hybrid format"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+
+ and: 'an execution context pointing to a temporary directory'
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ boolean result = command.handle(executionContext)
+
+ then: 'the command succeeds'
+ result
+
+ and: 'the report file is created'
+ reportFile.exists()
+ reportFile.length() > 0
+
+ and: 'the report has valid AsciiDoc structure with 3-column format'
+ String content = reportFile.text
+ content.startsWith('= Grails Application Configuration Report')
+ content.contains(':toc: left')
+ content.contains('[cols="2,5,2", options="header"]')
+ content.contains('| Property | Description | Default')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report shows known Grails properties in metadata categories"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'known metadata categories are present as section headers'
+ content.contains('== Core Properties')
+ content.contains('== Web & Controllers')
+ content.contains('== DataSource')
+
+ and: 'grails.profile appears in the Core Properties section with its description'
+ content.contains('`grails.profile`')
+
+ and: 'runtime value overrides the static default for grails.profile'
+ content.contains('`web`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report puts custom application properties in Other Properties section"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'YAML-defined custom properties appear in Other Properties'
+ content.contains('== Other Properties')
+ content.contains('`myapp.yaml.greeting`')
+ content.contains('`Hello from YAML`')
+
+ and: 'YAML numeric properties are in Other Properties'
+ content.contains('`myapp.yaml.maxRetries`')
+ content.contains('`5`')
+
+ and: 'YAML nested properties are in Other Properties'
+ content.contains('`myapp.yaml.feature.enabled`')
+ content.contains('`myapp.yaml.feature.timeout`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report contains properties from application.groovy in Other Properties"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'Groovy config properties are present in Other Properties'
+ content.contains('`myapp.groovy.appName`')
+ content.contains('`Config Report Test App`')
+
+ and: 'Groovy config version property is present'
+ content.contains('`myapp.groovy.version`')
+ content.contains('`1.2.3`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report escapes pipe characters from application.groovy values"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'pipe characters are escaped for valid AsciiDoc'
+ content.contains('`myapp.groovy.delimitedValue`')
+ content.contains('value1\\|value2\\|value3')
+ !content.contains('value1|value2|value3')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report contains properties bound via @ConfigurationProperties in Other Properties"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'the @ConfigurationProperties bean was correctly populated'
+ appProperties.name == 'Configured App'
+ appProperties.pageSize == 50
+ appProperties.debugEnabled == true
+
+ and: 'the typed properties appear in Other Properties'
+ content.contains('`myapp.typed.name`')
+ content.contains('`Configured App`')
+ content.contains('`myapp.typed.pageSize`')
+ content.contains('`50`')
+ content.contains('`myapp.typed.debugEnabled`')
+ content.contains('`true`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report separates known metadata properties from custom properties"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'known Grails properties appear in categorized sections before Other Properties'
+ int coreIdx = content.indexOf('== Core Properties')
+ int otherIdx = content.indexOf('== Other Properties')
+ coreIdx >= 0
+ otherIdx >= 0
+ coreIdx < otherIdx
+
+ and: 'grails.profile is in the Core Properties section (not Other Properties)'
+ String otherSection = content.substring(otherIdx)
+ !otherSection.contains('`grails.profile`')
+
+ and: 'custom myapp properties are in Other Properties (not in categorized sections)'
+ String beforeOther = content.substring(0, otherIdx)
+ !beforeOther.contains('`myapp.yaml.greeting`')
+ !beforeOther.contains('`myapp.groovy.appName`')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+ def "report contains properties from all three config sources"() {
+ given: 'a ConfigReportCommand wired to the live application context'
+ ConfigReportCommand command = createCommand()
+ ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine))
+ File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE)
+
+ when: 'the command is executed'
+ command.handle(executionContext)
+ String content = reportFile.text
+
+ then: 'YAML properties are present'
+ content.contains('`myapp.yaml.greeting`')
+
+ and: 'Groovy properties are present'
+ content.contains('`myapp.groovy.appName`')
+
+ and: 'typed @ConfigurationProperties are present'
+ content.contains('`myapp.typed.name`')
+
+ and: 'Other Properties section uses 2-column format'
+ int otherIdx = content.indexOf('== Other Properties')
+ String otherSection = content.substring(otherIdx)
+ otherSection.contains('[cols="2,3", options="header"]')
+ otherSection.contains('| Property | Default')
+
+ cleanup:
+ reportFile?.delete()
+ }
+
+}
diff --git a/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy b/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy
new file mode 100644
index 00000000000..80e0b34048c
--- /dev/null
+++ b/grails-test-examples/config-report/src/main/groovy/configreport/AppProperties.groovy
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package configreport
+
+import groovy.transform.CompileStatic
+import org.springframework.boot.context.properties.ConfigurationProperties
+import org.springframework.validation.annotation.Validated
+
+/**
+ * A Spring Boot {@code @ConfigurationProperties} bean that binds to
+ * the {@code myapp.typed} prefix.
+ *
+ *
Properties for this bean are defined in {@code application.yml}
+ * and verified in the ConfigReportCommand integration test.
+ */
+@CompileStatic
+@Validated
+@ConfigurationProperties(prefix = 'myapp.typed')
+class AppProperties {
+
+ /**
+ * The display name of the application.
+ */
+ String name = 'Default App'
+
+ /**
+ * The maximum number of items per page.
+ */
+ Integer pageSize = 25
+
+ /**
+ * Whether debug mode is active.
+ */
+ Boolean debugEnabled = false
+
+}
diff --git a/grails-views-gson/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-views-gson/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000000..62e7e7a143a
--- /dev/null
+++ b/grails-views-gson/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,67 @@
+{
+ "groups": [
+ {
+ "name": "grails.views.json",
+ "description": "JSON Views"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.views.json.mimeTypes",
+ "type": "java.util.List",
+ "description": "MIME types handled by JSON views.",
+ "defaultValue": [
+ "application/json",
+ "application/hal+json"
+ ]
+ },
+ {
+ "name": "grails.views.json.generator.escapeUnicode",
+ "type": "java.lang.Boolean",
+ "description": "Whether to escape Unicode characters in JSON output.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.views.json.generator.dateFormat",
+ "type": "java.lang.String",
+ "description": "The date format pattern for JSON serialization.",
+ "defaultValue": "yyyy-MM-dd'T'HH:mm:ss.SSSX"
+ },
+ {
+ "name": "grails.views.json.generator.timeZone",
+ "type": "java.lang.String",
+ "description": "The time zone for JSON date serialization.",
+ "defaultValue": "GMT"
+ },
+ {
+ "name": "grails.views.json.generator.locale",
+ "type": "java.lang.String",
+ "description": "The locale for JSON output formatting.",
+ "defaultValue": "en/US"
+ },
+ {
+ "name": "grails.views.json.compileStatic",
+ "type": "java.lang.Boolean",
+ "description": "Whether JSON views are statically compiled.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.views.json.encoding",
+ "type": "java.lang.String",
+ "description": "The character encoding for JSON views.",
+ "defaultValue": "UTF-8"
+ },
+ {
+ "name": "grails.views.json.prettyPrint",
+ "type": "java.lang.Boolean",
+ "description": "Whether to pretty-print JSON output with indentation.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.views.json.allowResourceExpansion",
+ "type": "java.lang.Boolean",
+ "description": "Whether to allow HAL resource expansion in JSON views.",
+ "defaultValue": true
+ }
+ ]
+}
diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json
new file mode 100644
index 00000000000..1d3a24f8386
--- /dev/null
+++ b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json
@@ -0,0 +1,248 @@
+{
+ "groups": [
+ {
+ "name": "grails.controllers",
+ "description": "Web & Controllers"
+ },
+ {
+ "name": "grails.web",
+ "description": "Web & Controllers"
+ },
+ {
+ "name": "grails.filter",
+ "description": "Web & Controllers"
+ },
+ {
+ "name": "grails.exceptionresolver",
+ "description": "Web & Controllers"
+ },
+ {
+ "name": "grails.logging",
+ "description": "Web & Controllers"
+ },
+ {
+ "name": "grails.cors",
+ "description": "CORS"
+ },
+ {
+ "name": "grails.mime",
+ "description": "Content Negotiation & MIME Types"
+ },
+ {
+ "name": "grails.converters",
+ "description": "Content Negotiation & MIME Types"
+ },
+ {
+ "name": "grails.resources",
+ "description": "Static Resources"
+ },
+ {
+ "name": "grails.urlmapping",
+ "description": "URL Mappings"
+ },
+ {
+ "name": "grails.scaffolding",
+ "description": "Scaffolding"
+ }
+ ],
+ "properties": [
+ {
+ "name": "grails.controllers.defaultScope",
+ "type": "java.lang.String",
+ "description": "The default scope for controllers (singleton, prototype, session).",
+ "defaultValue": "singleton"
+ },
+ {
+ "name": "grails.controllers.upload.location",
+ "type": "java.lang.String",
+ "description": "The directory for temporary file uploads.",
+ "defaultValue": "System.getProperty('java.io.tmpdir')"
+ },
+ {
+ "name": "grails.controllers.upload.maxFileSize",
+ "type": "java.lang.Integer",
+ "description": "Maximum file size for uploads (in bytes).",
+ "defaultValue": 1048576
+ },
+ {
+ "name": "grails.controllers.upload.maxRequestSize",
+ "type": "java.lang.Integer",
+ "description": "Maximum request size for multipart uploads (in bytes).",
+ "defaultValue": 10485760
+ },
+ {
+ "name": "grails.controllers.upload.fileSizeThreshold",
+ "type": "java.lang.Integer",
+ "description": "File size threshold (in bytes) above which uploads are written to disk.",
+ "defaultValue": 0
+ },
+ {
+ "name": "grails.web.url.converter",
+ "type": "java.lang.String",
+ "description": "The URL token converter strategy, use hyphenated for hyphen-separated URLs.",
+ "defaultValue": "camelCase"
+ },
+ {
+ "name": "grails.web.linkGenerator.useCache",
+ "type": "java.lang.Boolean",
+ "description": "Whether to cache links generated by the link generator.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.web.servlet.path",
+ "type": "java.lang.String",
+ "description": "The path the Grails dispatcher servlet is mapped to.",
+ "defaultValue": "/**"
+ },
+ {
+ "name": "grails.filter.encoding",
+ "type": "java.lang.String",
+ "description": "The character encoding for the Grails character encoding filter.",
+ "defaultValue": "UTF-8"
+ },
+ {
+ "name": "grails.filter.forceEncoding",
+ "type": "java.lang.Boolean",
+ "description": "Whether to force the encoding filter to set the encoding on the response.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.exceptionresolver.logRequestParameters",
+ "type": "java.lang.Boolean",
+ "description": "Whether to log request parameters in exception stack traces.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.exceptionresolver.params.exclude",
+ "type": "java.util.List",
+ "description": "List of parameter names to mask (replace with [*****]) in exception stack traces, typically used for password and creditCard.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.logging.stackTraceFiltererClass",
+ "type": "java.lang.String",
+ "description": "Fully qualified class name of a custom StackTraceFilterer implementation.",
+ "defaultValue": "org.grails.exceptions.reporting.DefaultStackTraceFilterer"
+ },
+ {
+ "name": "grails.cors.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Whether CORS support is enabled.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.cors.filter",
+ "type": "java.lang.Boolean",
+ "description": "Whether CORS is handled via a servlet filter (true) or an interceptor (false).",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.cors.allowedOrigins",
+ "type": "java.util.List",
+ "description": "List of allowed origins (e.g., http://localhost:5000), only applies when grails.cors.enabled is true.",
+ "defaultValue": ["*"]
+ },
+ {
+ "name": "grails.cors.allowedMethods",
+ "type": "java.util.List",
+ "description": "List of allowed HTTP methods.",
+ "defaultValue": ["*"]
+ },
+ {
+ "name": "grails.cors.allowedHeaders",
+ "type": "java.util.List",
+ "description": "List of allowed request headers.",
+ "defaultValue": ["*"]
+ },
+ {
+ "name": "grails.cors.exposedHeaders",
+ "type": "java.util.List",
+ "description": "List of response headers to expose to the client.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.cors.maxAge",
+ "type": "java.lang.Integer",
+ "description": "How long (in seconds) the preflight response can be cached.",
+ "defaultValue": 1800
+ },
+ {
+ "name": "grails.cors.allowCredentials",
+ "type": "java.lang.Boolean",
+ "description": "Whether credentials (cookies, authorization headers) are supported.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.cors.mappings",
+ "type": "java.util.Map",
+ "description": "Map of URL patterns to per-path CORS configuration where defining any mapping disables the global /** mapping.",
+ "defaultValue": {}
+ },
+ {
+ "name": "grails.mime.types",
+ "type": "java.util.Map",
+ "description": "Map of MIME type names to content type strings used for content negotiation.",
+ "defaultValue": "see web profile application.yml"
+ },
+ {
+ "name": "grails.mime.file.extensions",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use the file extension to determine the MIME type in content negotiation.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.mime.use.accept.header",
+ "type": "java.lang.Boolean",
+ "description": "Whether to use the Accept header for content negotiation.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.mime.disable.accept.header.userAgents",
+ "type": "java.util.List",
+ "description": "List of user agent substrings (e.g., Gecko, WebKit) for which Accept header processing is disabled.",
+ "defaultValue": []
+ },
+ {
+ "name": "grails.mime.disable.accept.header.userAgentsXhr",
+ "type": "java.lang.Boolean",
+ "description": "When true, XHR requests also respect the grails.mime.disable.accept.header.userAgents setting, while by default XHR requests ignore user agent filtering.",
+ "defaultValue": false
+ },
+ {
+ "name": "grails.converters.encoding",
+ "type": "java.lang.String",
+ "description": "The character encoding for converter output (JSON or XML).",
+ "defaultValue": "UTF-8"
+ },
+ {
+ "name": "grails.resources.enabled",
+ "type": "java.lang.Boolean",
+ "description": "Whether serving static files from src/main/resources/public is enabled.",
+ "defaultValue": true
+ },
+ {
+ "name": "grails.resources.pattern",
+ "type": "java.lang.String",
+ "description": "The URL path pattern for serving static resources.",
+ "defaultValue": "/static/**"
+ },
+ {
+ "name": "grails.resources.cachePeriod",
+ "type": "java.lang.Integer",
+ "description": "The cache period (in seconds) for static resource HTTP responses.",
+ "defaultValue": 0
+ },
+ {
+ "name": "grails.urlmapping.cache.maxsize",
+ "type": "java.lang.Integer",
+ "description": "The maximum size of the URL mapping cache.",
+ "defaultValue": 1000
+ },
+ {
+ "name": "grails.scaffolding.templates.domainSuffix",
+ "type": "java.lang.String",
+ "description": "The suffix appended to domain class names when generating scaffolding templates.",
+ "defaultValue": ""
+ }
+ ]
+}
diff --git a/settings.gradle b/settings.gradle
index d7a13e9d9aa..e117ca75d38 100644
--- a/settings.gradle
+++ b/settings.gradle
@@ -393,6 +393,7 @@ include(
'grails-test-examples-plugins-issue-11767',
'grails-test-examples-plugins-micronaut-singleton',
'grails-test-examples-cache',
+ 'grails-test-examples-config-report',
'grails-test-examples-scaffolding',
'grails-test-examples-scaffolding-fields',
'grails-test-examples-views-functional-tests',
@@ -430,6 +431,7 @@ project(':grails-test-examples-plugins-exploded').projectDir = file('grails-test
project(':grails-test-examples-plugins-issue-11767').projectDir = file('grails-test-examples/plugins/issue-11767')
project(':grails-test-examples-plugins-micronaut-singleton').projectDir = file('grails-test-examples/plugins/micronaut-singleton')
project(':grails-test-examples-cache').projectDir = file('grails-test-examples/cache')
+project(':grails-test-examples-config-report').projectDir = file('grails-test-examples/config-report')
project(':grails-test-examples-scaffolding').projectDir = file('grails-test-examples/scaffolding')
project(':grails-test-examples-scaffolding-fields').projectDir = file('grails-test-examples/scaffolding-fields')
project(':grails-test-examples-views-functional-tests').projectDir = file('grails-test-examples/views-functional-tests')