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