This is an automated email from the ASF dual-hosted git repository. jamesfredley pushed a commit to branch docs/configuration-hybrid in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit 58327298862d376ee69b27c21d646d4dc041da36 Author: James Fredley <[email protected]> AuthorDate: Fri Feb 20 19:28:00 2026 -0500 feat: hybrid ConfigReportCommand with static metadata and runtime values Merge curated property metadata from the Application Properties documentation with runtime-collected values from the Spring Environment. The command now outputs a 3-column AsciiDoc table (Property, Description, Default) organized by functional category (Core Properties, Web & Controllers, CORS, GORM, etc.). Runtime values override static defaults for known properties. Properties not found in the metadata appear in a separate Other Properties section. - Add config-properties.yml with ~160 properties across 21 categories - Modify ConfigReportCommand to load YAML metadata and merge with runtime - Update unit tests for 3-column hybrid format - Update integration tests for hybrid category-based layout Assisted-by: Claude Code <[email protected]> --- .../grails/dev/commands/ConfigReportCommand.groovy | 130 +++- .../META-INF/grails/config-properties.yml | 799 +++++++++++++++++++++ .../dev/commands/ConfigReportCommandSpec.groovy | 149 +++- .../ConfigReportCommandIntegrationSpec.groovy | 111 +-- 4 files changed, 1099 insertions(+), 90 deletions(-) diff --git a/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy index e10da60382..67e3aaf316 100644 --- a/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy +++ b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy @@ -21,6 +21,8 @@ package grails.dev.commands import groovy.transform.CompileStatic import groovy.util.logging.Slf4j +import org.yaml.snakeyaml.Yaml + import org.springframework.core.env.ConfigurableEnvironment import org.springframework.core.env.EnumerablePropertySource import org.springframework.core.env.PropertySource @@ -108,7 +110,26 @@ class ConfigReportCommand implements ApplicationCommand { * @param sorted the sorted configuration properties * @param reportFile the file to write the report to */ - void writeReport(Map<String, String> sorted, File reportFile) { + void writeReport(Map<String, String> runtimeProperties, File reportFile) { + Map<String, Map<String, String>> metadata = loadPropertyMetadata() + Map<String, List<Map<String, String>>> categories = new LinkedHashMap<String, List<Map<String, String>>>() + for (Map.Entry<String, Map<String, String>> entry : metadata.entrySet()) { + Map<String, String> property = entry.value + String category = property.get('category') + if (!categories.containsKey(category)) { + categories.put(category, new ArrayList<Map<String, String>>()) + } + categories.get(category).add(property) + } + + Set<String> knownKeys = metadata.keySet() + Map<String, String> otherProperties = new TreeMap<String, String>() + runtimeProperties.each { String key, String value -> + if (!knownKeys.contains(key)) { + otherProperties.put(key, value) + } + } + reportFile.withWriter('UTF-8') { BufferedWriter writer -> writer.writeLine('= Grails Application Configuration Report') writer.writeLine(':toc: left') @@ -116,32 +137,107 @@ class ConfigReportCommand implements ApplicationCommand { writer.writeLine(':source-highlighter: coderay') writer.writeLine('') - String currentSection = '' - sorted.each { String key, String value -> - String section = key.contains('.') ? key.substring(0, key.indexOf('.')) : key - if (section != currentSection) { - if (currentSection) { - writer.writeLine('|===') - writer.writeLine('') + categories.each { String categoryName, List<Map<String, String>> 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 { Map<String, String> property -> + String key = property.get('key') + String description = property.get('description') + String defaultValue = property.get('default') + String resolvedValue + if (runtimeProperties.containsKey(key)) { + resolvedValue = "`${escapeAsciidoc(runtimeProperties.get(key))}`" } - currentSection = section - writer.writeLine("== ${section}") - writer.writeLine('') - writer.writeLine('[cols="2,3", options="header"]') - writer.writeLine('|===') - writer.writeLine('| Property | Value') + else { + resolvedValue = escapeAsciidoc(defaultValue) + } + writer.writeLine("| `${key}`") + writer.writeLine("| ${escapeAsciidoc(description)}") + writer.writeLine("| ${resolvedValue}") writer.writeLine('') } - writer.writeLine("| `${key}`") - writer.writeLine("| `${escapeAsciidoc(value)}`") + writer.writeLine('|===') writer.writeLine('') } - if (currentSection) { + + 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('|===') } } } + Map<String, Map<String, String>> loadPropertyMetadata() { + InputStream stream = ConfigReportCommand.classLoader.getResourceAsStream('META-INF/grails/config-properties.yml') + if (stream == null) { + return new LinkedHashMap<String, Map<String, String>>() + } + Map<String, Map<String, String>> metadata = new LinkedHashMap<String, Map<String, String>>() + Map<String, Object> yamlData + try { + yamlData = (Map<String, Object>) new Yaml().load(stream) + } + finally { + stream.close() + } + Object categories = yamlData.get('categories') + if (!(categories instanceof List)) { + return metadata + } + for (Object categoryObject : (List<Object>) categories) { + if (!(categoryObject instanceof Map)) { + continue + } + Map<String, Object> categoryMap = (Map<String, Object>) categoryObject + Object categoryNameObject = categoryMap.get('name') + if (!(categoryNameObject instanceof String)) { + continue + } + String categoryName = (String) categoryNameObject + Object properties = categoryMap.get('properties') + if (!(properties instanceof List)) { + continue + } + for (Object propertyObject : (List<Object>) properties) { + if (!(propertyObject instanceof Map)) { + continue + } + Map<String, Object> propertyMap = (Map<String, Object>) propertyObject + Object keyObject = propertyMap.get('key') + Object descriptionObject = propertyMap.get('description') + Object defaultObject = propertyMap.get('default') + if (!(keyObject instanceof String)) { + continue + } + String key = (String) keyObject + String description = descriptionObject instanceof String ? (String) descriptionObject : '' + String defaultValue = defaultObject instanceof String ? (String) defaultObject : '' + Map<String, String> entry = new LinkedHashMap<String, String>() + entry.put('key', key) + entry.put('description', description) + entry.put('default', defaultValue) + entry.put('category', categoryName) + metadata.put(key, entry) + } + } + metadata + } + /** * Escapes special AsciiDoc characters in a value string. * diff --git a/grails-core/src/main/resources/META-INF/grails/config-properties.yml b/grails-core/src/main/resources/META-INF/grails/config-properties.yml new file mode 100644 index 0000000000..f82c1b38e4 --- /dev/null +++ b/grails-core/src/main/resources/META-INF/grails/config-properties.yml @@ -0,0 +1,799 @@ +categories: + - name: "Core Properties" + properties: + - key: "grails.profile" + description: "The active Grails application profile (e.g., web, rest-api, plugin)." + default: "Set by project template" + - key: "grails.codegen.defaultPackage" + description: "The default package used when generating artefacts with grails create-* commands." + default: "Set by project template" + - key: "grails.serverURL" + description: "The server URL used to generate absolute links (e.g., https://my.app.com) and used by redirects." + default: "_(derived from request)_" + - key: "grails.enable.native2ascii" + description: "Whether to perform native2ascii conversion of i18n properties files." + default: "`true`" + - key: "grails.bootstrap.skip" + description: "Whether to skip execution of BootStrap.groovy classes on startup." + default: "`false`" + - key: "grails.spring.bean.packages" + description: "List of packages to scan for Spring beans." + default: "`[]`" + - key: "grails.spring.disable.aspectj.autoweaving" + description: "Whether to disable AspectJ auto-weaving." + default: "`false`" + - key: "grails.spring.placeholder.prefix" + description: "The prefix for property placeholder resolution." + default: "`${`" + - key: "grails.spring.transactionManagement.proxies" + description: "Whether to enable Spring proxy-based transaction management since @Transactional uses an AST transform and proxies are typically redundant." + default: "`false`" + - key: "grails.plugin.includes" + description: "List of plugin names to include in the plugin manager (all others excluded)." + default: "`[]` _(all plugins)_" + - key: "grails.plugin.excludes" + description: "List of plugin names to exclude from the plugin manager." + default: "`[]`" + - name: "Web & Controllers" + properties: + - key: "grails.controllers.defaultScope" + description: "The default scope for controllers (singleton, prototype, session)." + default: "`singleton`" + - key: "grails.controllers.upload.location" + description: "The directory for temporary file uploads." + default: "`System.getProperty('java.io.tmpdir')`" + - key: "grails.controllers.upload.maxFileSize" + description: "Maximum file size for uploads (in bytes)." + default: "`1048576` (1 MB)" + - key: "grails.controllers.upload.maxRequestSize" + description: "Maximum request size for multipart uploads (in bytes)." + default: "`10485760` (10 MB)" + - key: "grails.controllers.upload.fileSizeThreshold" + description: "File size threshold (in bytes) above which uploads are written to disk." + default: "`0`" + - key: "grails.web.url.converter" + description: "The URL token converter strategy, use hyphenated for hyphen-separated URLs." + default: "`camelCase`" + - key: "grails.web.linkGenerator.useCache" + description: "Whether to cache links generated by the link generator." + default: "`true`" + - key: "grails.web.servlet.path" + description: "The path the Grails dispatcher servlet is mapped to." + default: "`/+++*+++`" + - key: "grails.filter.encoding" + description: "The character encoding for the Grails character encoding filter." + default: "`UTF-8`" + - key: "grails.filter.forceEncoding" + description: "Whether to force the encoding filter to set the encoding on the response." + default: "`true`" + - key: "grails.exceptionresolver.logRequestParameters" + description: "Whether to log request parameters in exception stack traces." + default: "`true`" + - key: "grails.exceptionresolver.params.exclude" + description: "List of parameter names to mask (replace with [*****]) in exception stack traces, typically used for password and creditCard." + default: "`[]`" + - key: "grails.logging.stackTraceFiltererClass" + description: "Fully qualified class name of a custom StackTraceFilterer implementation." + default: "`org.grails.exceptions.reporting.DefaultStackTraceFilterer`" + - name: "CORS" + properties: + - key: "grails.cors.enabled" + description: "Whether CORS support is enabled." + default: "`false`" + - key: "grails.cors.filter" + description: "Whether CORS is handled via a servlet filter (true) or an interceptor (false)." + default: "`true`" + - key: "grails.cors.allowedOrigins" + description: "List of allowed origins (e.g., http://localhost:5000), only applies when grails.cors.enabled is true." + default: "`['*']`" + - key: "grails.cors.allowedMethods" + description: "List of allowed HTTP methods." + default: "`['*']`" + - key: "grails.cors.allowedHeaders" + description: "List of allowed request headers." + default: "`['*']`" + - key: "grails.cors.exposedHeaders" + description: "List of response headers to expose to the client." + default: "`[]`" + - key: "grails.cors.maxAge" + description: "How long (in seconds) the preflight response can be cached." + default: "`1800`" + - key: "grails.cors.allowCredentials" + description: "Whether credentials (cookies, authorization headers) are supported." + default: "`false`" + - key: "grails.cors.mappings" + description: "Map of URL patterns to per-path CORS configuration where defining any mapping disables the global /** mapping." + default: "`{}` _(global `/**` mapping)_" + - name: "Views & GSP" + properties: + - key: "grails.views.default.codec" + description: "The default encoding codec for GSP output where html reduces XSS risk and options are none, html, base64." + default: "`none`" + - key: "grails.views.gsp.encoding" + description: "The file encoding for GSP source files." + default: "`UTF-8`" + - key: "grails.views.gsp.htmlcodec" + description: "The HTML codec for GSP output (xml or html)." + default: "`xml`" + - key: "grails.views.gsp.codecs.expression" + description: "The codec applied to GSP ${} expressions." + default: "`html`" + - key: "grails.views.gsp.codecs.scriptlet" + description: "The codec applied to GSP <% %> scriptlet output." + default: "`html`" + - key: "grails.views.gsp.codecs.taglib" + description: "The codec applied to tag library output." + default: "`none`" + - key: "grails.views.gsp.codecs.staticparts" + description: "The codec applied to static HTML parts of GSP pages." + default: "`none`" + - key: "grails.views.gsp.layout.preprocess" + description: "Whether GSP layout preprocessing is enabled, where disabling allows Grails to parse rendered HTML but slows rendering." + default: "`true`" + - key: "grails.views.enable.jsessionid" + description: "Whether to include the jsessionid in rendered links." + default: "`false`" + - key: "grails.views.filteringCodecForContentType" + description: "Map of content types to encoding codecs." + default: "`{}`" + - key: "grails.gsp.disable.caching.resources" + description: "Whether to disable GSP resource caching." + default: "`false`" + - key: "grails.gsp.enable.reload" + description: "Whether to enable GSP reloading in production." + default: "`false`" + - key: "grails.gsp.view.dir" + description: "Custom directory for GSP view resolution." + default: "`grails-app/views`" + - name: "Content Negotiation & MIME Types" + properties: + - key: "grails.mime.types" + description: "Map of MIME type names to content type strings used for content negotiation." + default: "_(see web profile `application.yml`)_" + - key: "grails.mime.file.extensions" + description: "Whether to use the file extension to determine the MIME type in content negotiation." + default: "`true`" + - key: "grails.mime.use.accept.header" + description: "Whether to use the Accept header for content negotiation." + default: "`true`" + - key: "grails.mime.disable.accept.header.userAgents" + description: "List of user agent substrings (e.g., Gecko, WebKit) for which Accept header processing is disabled." + default: "`[]`" + - key: "grails.mime.disable.accept.header.userAgentsXhr" + description: "When true, XHR requests also respect the grails.mime.disable.accept.header.userAgents setting, while by default XHR requests ignore user agent filtering." + default: "`false`" + - key: "grails.converters.encoding" + description: "The character encoding for converter output (JSON or XML)." + default: "`UTF-8`" + - name: "Data Binding" + properties: + - key: "grails.databinding.trimStrings" + description: "Whether to trim whitespace from String values during data binding." + default: "`true`" + - key: "grails.databinding.convertEmptyStringsToNull" + description: "Whether empty String values are converted to null during data binding." + default: "`true`" + - key: "grails.databinding.autoGrowCollectionLimit" + description: "The maximum size to which indexed collections can auto-grow during data binding." + default: "`256`" + - key: "grails.databinding.dateFormats" + description: "List of date format strings used to parse date values during data binding." + default: "`[\"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\"]`" + - key: "grails.databinding.dateParsingLenient" + description: "Whether date parsing is lenient (accepting invalid dates like Feb 30)." + default: "`false`" + - name: "Internationalization" + properties: + - key: "grails.i18n.cache.seconds" + description: "How long (in seconds) to cache resolved message bundles with -1 to cache indefinitely and 0 to disable caching." + default: "`-1`" + - key: "grails.i18n.filecache.seconds" + description: "How long (in seconds) to cache the message bundle file lookup with -1 to cache indefinitely." + default: "`-1`" + - name: "Static Resources" + properties: + - key: "grails.resources.enabled" + description: "Whether serving static files from src/main/resources/public is enabled." + default: "`true`" + - key: "grails.resources.pattern" + description: "The URL path pattern for serving static resources." + default: "`/static/**`" + - key: "grails.resources.cachePeriod" + description: "The cache period (in seconds) for static resource HTTP responses." + default: "`0` _(no caching)_" + - name: "URL Mappings" + properties: + - key: "grails.urlmapping.cache.maxsize" + description: "The maximum size of the URL mapping cache." + default: "`1000`" + - name: "Scaffolding" + properties: + - key: "grails.scaffolding.templates.domainSuffix" + description: "The suffix appended to domain class names when generating scaffolding templates." + default: "`\"\"`" + - name: "Development & Reloading" + properties: + - key: "grails.reload.includes" + description: "List of fully qualified class names to include in development reloading, when set only these classes are reloaded." + default: "`[]` _(all project classes)_" + - key: "grails.reload.excludes" + description: "List of fully qualified class names to exclude from development reloading." + default: "`[]`" + - name: "Events" + properties: + - key: "grails.events.spring" + description: "Whether to bridge GORM/Grails events to the Spring ApplicationEventPublisher, allowing EventListener methods to receive domain events." + default: "`true`" + - name: "JSON & Converters" + properties: + - key: "grails.json.legacy.builder" + description: "Whether to use the legacy JSON builder." + default: "`false`" + - key: "grails.converters.json.domain.include.class" + description: "Whether to include the class property when marshalling domain objects to JSON." + default: "`false`" + - key: "grails.converters.xml.domain.include.class" + description: "Whether to include the class attribute when marshalling domain objects to XML." + default: "`false`" + - name: "GORM" + properties: + - key: "grails.gorm.failOnError" + 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." + default: "`false`" + - key: "grails.gorm.autoFlush" + description: "Whether to automatically flush the Hibernate session between queries." + default: "`false`" + - key: "grails.gorm.flushMode" + description: "The default Hibernate flush mode (AUTO, COMMIT, MANUAL)." + default: "`AUTO`" + - key: "grails.gorm.markDirty" + description: "Whether to mark a domain instance as dirty on an explicit save() call." + default: "`true`" + - key: "grails.gorm.autowire" + description: "Whether to autowire Spring beans into domain class instances." + default: "`true`" + - key: "grails.gorm.default.mapping" + description: "A closure applied as the default mapping block for all domain classes." + default: "`{}`" + - key: "grails.gorm.default.constraints" + description: "A closure applied as the default constraints for all domain classes." + default: "`{}`" + - key: "grails.gorm.custom.types" + description: "Map of custom GORM types." + default: "`{}`" + - key: "grails.gorm.reactor.events" + description: "Whether to translate GORM events into Reactor events, which is disabled by default for performance." + default: "`false`" + - key: "grails.gorm.events.autoTimestampInsertOverwrite" + description: "Whether auto-timestamp (dateCreated) overwrites a user-provided value on insert." + default: "`true`" + - key: "grails.gorm.multiTenancy.mode" + description: "The multi-tenancy mode: DISCRIMINATOR, DATABASE, SCHEMA, or NONE." + default: "`NONE`" + - key: "grails.gorm.multiTenancy.tenantResolverClass" + description: "Fully qualified class name of the TenantResolver implementation." + default: "_(required when mode is not NONE)_" + - name: "DataSource" + properties: + - key: "dataSource.driverClassName" + description: "The JDBC driver class name." + default: "`org.h2.Driver`" + - key: "dataSource.username" + description: "The database username." + default: "`sa`" + - key: "dataSource.password" + description: "The database password." + default: "`''`" + - key: "dataSource.url" + description: "The JDBC connection URL." + default: "`jdbc:h2:mem:devDb` (dev)" + - key: "dataSource.dbCreate" + description: "The schema generation strategy: create-drop, create, update, validate, or none, use none in production with a migration tool." + default: "`create-drop` (dev), `none` (prod)" + - key: "dataSource.pooled" + description: "Whether to use a connection pool." + default: "`true`" + - key: "dataSource.logSql" + description: "Whether to log SQL statements to stdout." + default: "`false`" + - key: "dataSource.formatSql" + description: "Whether to format logged SQL for readability." + default: "`false`" + - key: "dataSource.dialect" + description: "The Hibernate dialect class name or class." + default: "_(auto-detected from driver)_" + - key: "dataSource.readOnly" + description: "Whether the DataSource is read-only (calls setReadOnly(true) on connections)." + default: "`false`" + - key: "dataSource.transactional" + description: "For additional datasources, whether to include in the chained transaction manager." + default: "`true`" + - key: "dataSource.persistenceInterceptor" + description: "For additional datasources, whether to wire up the persistence interceptor (the default datasource is always wired)." + default: "`false`" + - key: "dataSource.jmxExport" + description: "Whether to register JMX MBeans for the DataSource." + default: "`true`" + - key: "dataSource.type" + description: "The connection pool implementation class when multiple are on the classpath." + default: "`com.zaxxer.hikari.HikariDataSource`" + - name: "Hibernate" + properties: + - key: "hibernate.cache.queries" + description: "Whether to cache Hibernate queries." + default: "`false`" + - key: "hibernate.cache.use_second_level_cache" + description: "Whether to enable Hibernate's second-level cache." + default: "`false`" + - key: "hibernate.cache.use_query_cache" + description: "Whether to enable Hibernate's query cache." + default: "`false`" + - name: "Database Migration Plugin" + properties: + - key: "grails.plugin.databasemigration.updateOnStart" + description: "Whether to automatically apply pending migrations on application startup." + default: "`false`" + - key: "grails.plugin.databasemigration.updateAllOnStart" + description: "Whether to apply migrations for all datasources on startup (overrides per-datasource updateOnStart)." + default: "`false`" + - key: "grails.plugin.databasemigration.updateOnStartFileName" + description: "The changelog filename to use when applying migrations on startup." + default: "`changelog.groovy` (default ds), `changelog-<name>.groovy` (named ds)" + - key: "grails.plugin.databasemigration.dropOnStart" + description: "Whether to drop and recreate the schema before applying migrations on startup." + default: "`false`" + - key: "grails.plugin.databasemigration.updateOnStartContexts" + description: "List of Liquibase contexts to apply during startup migration." + default: "`[]` _(all contexts)_" + - key: "grails.plugin.databasemigration.updateOnStartLabels" + description: "List of Liquibase labels to apply during startup migration." + default: "`[]` _(all labels)_" + - key: "grails.plugin.databasemigration.updateOnStartDefaultSchema" + description: "The default schema to use when applying migrations on startup." + default: "_(database default schema)_" + - key: "grails.plugin.databasemigration.databaseChangeLogTableName" + description: "Custom name for the Liquibase changelog tracking table." + default: "`DATABASECHANGELOG`" + - key: "grails.plugin.databasemigration.databaseChangeLogLockTableName" + description: "Custom name for the Liquibase lock table." + default: "`DATABASECHANGELOGLOCK`" + - key: "grails.plugin.databasemigration.changelogLocation" + description: "The directory containing migration changelog files." + default: "`grails-app/migrations`" + - key: "grails.plugin.databasemigration.changelogFileName" + description: "The default changelog filename for CLI commands." + default: "`changelog.groovy` (default ds), `changelog-<name>.groovy` (named ds)" + - key: "grails.plugin.databasemigration.contexts" + description: "List of Liquibase contexts for CLI commands." + default: "`[]` _(all contexts)_" + - key: "grails.plugin.databasemigration.excludeObjects" + description: "Comma-separated list of database object names to exclude from dbm-gorm-diff and dbm-generate-changelog output, cannot be combined with includeObjects." + default: "`''`" + - key: "grails.plugin.databasemigration.includeObjects" + description: "Comma-separated list of database object names to include in diff and changelog output (all others excluded), cannot be combined with excludeObjects." + default: "`''`" + - key: "grails.plugin.databasemigration.skipUpdateOnStartMainClasses" + description: "List of main class names that should skip auto-migration on startup (e.g., CLI command runners)." + default: "`['grails.ui.command.GrailsApplicationContextCommandRunner']`" + - name: "Cache Plugin" + properties: + - key: "grails.cache.enabled" + description: "Whether the cache plugin is enabled." + default: "`true`" + - key: "grails.cache.cleanAtStartup" + description: "Whether to clear all caches on application startup." + default: "`false`" + - key: "grails.cache.cacheManager" + description: "Fully qualified class name of the CacheManager implementation." + default: "`grails.plugin.cache.GrailsConcurrentMapCacheManager`" + - key: "grails.cache.clearAtStartup" + description: "Alias for cleanAtStartup (both are supported)." + default: "`false`" + - key: "grails.cache.ehcache.ehcacheXmlLocation" + description: "Classpath location of the ehcache.xml configuration file." + default: "`classpath:ehcache.xml`" + - key: "grails.cache.ehcache.lockTimeout" + description: "Timeout (in milliseconds) for cache lock acquisition." + default: "`200`" + - name: "Asset Pipeline Plugin" + properties: + - key: "grails.assets.mapping" + description: "The URL path segment for serving assets (e.g., /assets/*) and must be one level deep where foo is valid and foo/bar is not." + default: "`assets`" + - key: "grails.assets.bundle" + description: "Whether assets are bundled in development mode where false loads individual files for easier debugging." + default: "`false`" + - key: "grails.assets.url" + description: "Base URL for assets, useful for CDN integration (e.g., https://cdn.example.com/) and can also be a Closure accepting HttpServletRequest for dynamic URL generation in application.groovy." + default: "_(derived from request)_" + - key: "grails.assets.storagePath" + description: "Directory path to copy compiled assets on application startup (e.g., for CDN upload)." + default: "_(none)_" + - key: "grails.assets.useManifest" + description: "Whether to use the manifest.properties file for asset resolution in production." + default: "`true`" + - key: "grails.assets.skipNotFound" + description: "If true, missing assets pass through to the next filter instead of returning 404." + default: "`false`" + - key: "grails.assets.allowDebugParam" + description: "If true, allows ?_debugAssets=y query parameter to force non-bundled mode in production for debugging." + default: "`false`" + - key: "grails.assets.cacheLocation" + description: "Directory for caching compiled assets during development." + default: "`build/assetCache`" + - key: "grails.assets.minifyJs" + description: "Whether to minify JavaScript using Google Closure Compiler." + default: "`true`" + - key: "grails.assets.minifyCss" + description: "Whether to minify CSS." + default: "`true`" + - key: "grails.assets.enableSourceMaps" + description: "Whether to generate source maps for minified JavaScript files (.js.map)." + default: "`true`" + - key: "grails.assets.enableDigests" + description: "Whether to generate digest or fingerprinted filenames (e.g., app-abc123.js)." + default: "`true`" + - key: "grails.assets.skipNonDigests" + description: "If true, only digested filenames are generated and non-digested names are served via manifest mapping, reducing storage by 50%." + default: "`true`" + - key: "grails.assets.enableGzip" + description: "Whether to generate gzipped versions of assets (.gz files)." + default: "`true`" + - key: "grails.assets.excludesGzip" + description: "List of GLOB patterns for files to exclude from gzip compression (e.g., ['**/*.png', '**/*.jpg'])." + default: "`['+++**+++/+++*+++.png', '+++**+++/+++*+++.jpg']`" + - key: "grails.assets.minifyOptions" + description: "Map of options passed to Google Closure Compiler with keys languageMode, targetLanguage, and optimizationLevel." + default: "`{languageMode: 'ES5', targetLanguage: 'ES5', optimizationLevel: 'SIMPLE'}`" + - key: "grails.assets.excludes" + description: "List of GLOB patterns (or regex: prefixed) for files to exclude from compilation, excluded files can still be included via require directives." + default: "`[]`" + - key: "grails.assets.includes" + description: "List of GLOB patterns to override excludes and allow specific files to be compiled even if they match an exclude pattern." + default: "`[]`" + - key: "grails.assets.enableES6" + description: "Enable ES6+ transpilation via Babel/SWC where if not set it auto-detects ES6 syntax." + default: "_(auto-detect)_" + - key: "grails.assets.commonJs" + description: "Whether to enable CommonJS module support (require() / module.exports)." + default: "`true`" + - key: "grails.assets.nodeEnv" + description: "Value injected as process.env.NODE_ENV in JavaScript files (used by libraries like React)." + default: "`development`" + - name: "Spring Security Plugin" + properties: + - key: "grails.plugin.springsecurity.active" + description: "Whether the security plugin is active." + default: "`true`" + - key: "grails.plugin.springsecurity.printStatusMessages" + description: "Whether to print startup and status messages to the console." + default: "`true`" + - key: "grails.plugin.springsecurity.rejectIfNoRule" + description: "Whether to reject requests if no matching security rule is found." + default: "`true`" + - key: "grails.plugin.springsecurity.securityConfigType" + description: "The security configuration strategy (Annotation, Requestmap, InterceptUrlMap)." + default: "`Annotation`" + - key: "grails.plugin.springsecurity.roleHierarchy" + description: "The role hierarchy definition string." + default: "`''`" + - key: "grails.plugin.springsecurity.cacheUsers" + description: "Whether to cache user details in the user details service." + default: "`false`" + - key: "grails.plugin.springsecurity.useHttpSessionEventPublisher" + description: "Whether to register a HttpSessionEventPublisher bean." + default: "`false`" + - key: "grails.plugin.springsecurity.useSecurityEventListener" + description: "Whether to publish security events to the Grails event system." + default: "`false`" + - key: "grails.plugin.springsecurity.userLookup.userDomainClassName" + description: "The fully qualified name of the user domain class." + default: "`null` _(must be set)_" + - key: "grails.plugin.springsecurity.userLookup.usernamePropertyName" + description: "The property name for the username in the user domain class." + default: "`username`" + - key: "grails.plugin.springsecurity.userLookup.enabledPropertyName" + description: "The property name for the enabled status in the user domain class." + default: "`enabled`" + - key: "grails.plugin.springsecurity.userLookup.passwordPropertyName" + description: "The property name for the password in the user domain class." + default: "`password`" + - key: "grails.plugin.springsecurity.userLookup.authoritiesPropertyName" + description: "The property name for the authorities collection in the user domain class." + default: "`authorities`" + - key: "grails.plugin.springsecurity.userLookup.accountExpiredPropertyName" + description: "The property name for the account expired status in the user domain class." + default: "`accountExpired`" + - key: "grails.plugin.springsecurity.userLookup.accountLockedPropertyName" + description: "The property name for the account locked status in the user domain class." + default: "`accountLocked`" + - key: "grails.plugin.springsecurity.userLookup.passwordExpiredPropertyName" + description: "The property name for the password expired status in the user domain class." + default: "`passwordExpired`" + - key: "grails.plugin.springsecurity.userLookup.authorityJoinClassName" + description: "The fully qualified name of the user-authority join domain class." + default: "`null` _(must be set)_" + - key: "grails.plugin.springsecurity.userLookup.usernameIgnoreCase" + description: "Whether to ignore case when looking up users by username." + default: "`false`" + - key: "grails.plugin.springsecurity.authority.className" + description: "The fully qualified name of the authority (role) domain class." + default: "`null` _(must be set)_" + - key: "grails.plugin.springsecurity.authority.nameField" + description: "The property name for the authority name in the authority domain class." + default: "`authority`" + - key: "grails.plugin.springsecurity.useRoleGroups" + description: "Whether to enable support for role groups." + default: "`false`" + - key: "grails.plugin.springsecurity.apf.filterProcessesUrl" + description: "The URL the authentication processing filter handles." + default: "`/login/authenticate`" + - key: "grails.plugin.springsecurity.apf.usernameParameter" + description: "The HTTP parameter name for the username in login requests." + default: "`username`" + - key: "grails.plugin.springsecurity.apf.passwordParameter" + description: "The HTTP parameter name for the password in login requests." + default: "`password`" + - key: "grails.plugin.springsecurity.apf.postOnly" + description: "Whether to restrict authentication requests to HTTP POST." + default: "`true`" + - key: "grails.plugin.springsecurity.apf.allowSessionCreation" + description: "Whether to allow the authentication filter to create a new HTTP session." + default: "`true`" + - key: "grails.plugin.springsecurity.apf.storeLastUsername" + description: "Whether to store the last used username in the session after a failed login." + default: "`false`" + - key: "grails.plugin.springsecurity.auth.loginFormUrl" + description: "The URL of the login form page." + default: "`/login/auth`" + - key: "grails.plugin.springsecurity.auth.forceHttps" + description: "Whether to force HTTPS for the login page." + default: "`false`" + - key: "grails.plugin.springsecurity.auth.ajaxLoginFormUrl" + description: "The URL of the AJAX login form." + default: "`/login/authAjax`" + - key: "grails.plugin.springsecurity.auth.useForward" + description: "Whether to use a forward instead of a redirect to the login page." + default: "`false`" + - key: "grails.plugin.springsecurity.successHandler.defaultTargetUrl" + description: "The default URL to redirect to after a successful login." + default: "`/`" + - key: "grails.plugin.springsecurity.successHandler.alwaysUseDefault" + description: "Whether to always redirect to the default target URL after login." + default: "`false`" + - key: "grails.plugin.springsecurity.successHandler.ajaxSuccessUrl" + description: "The URL used for AJAX success responses." + default: "`/login/ajaxSuccess`" + - key: "grails.plugin.springsecurity.successHandler.useReferer" + description: "Whether to redirect to the Referer header URL after login." + default: "`false`" + - key: "grails.plugin.springsecurity.failureHandler.defaultFailureUrl" + description: "The default URL to redirect to after a failed login." + default: "`/login/authfail?login_error=1`" + - key: "grails.plugin.springsecurity.failureHandler.ajaxAuthFailUrl" + description: "The URL used for AJAX failure responses." + default: "`/login/authfail?ajax=true`" + - key: "grails.plugin.springsecurity.failureHandler.useForward" + description: "Whether to use a forward for authentication failure." + default: "`false`" + - key: "grails.plugin.springsecurity.logout.afterLogoutUrl" + description: "The URL to redirect to after logging out." + default: "`/`" + - key: "grails.plugin.springsecurity.logout.filterProcessesUrl" + description: "The URL the logout filter handles." + default: "`/logoff`" + - key: "grails.plugin.springsecurity.logout.postOnly" + description: "Whether to restrict logout requests to HTTP POST." + default: "`true`" + - key: "grails.plugin.springsecurity.logout.invalidateHttpSession" + description: "Whether to invalidate the HTTP session on logout." + default: "`true`" + - key: "grails.plugin.springsecurity.logout.clearAuthentication" + description: "Whether to clear the authentication from the security context on logout." + default: "`true`" + - key: "grails.plugin.springsecurity.logout.redirectToReferer" + description: "Whether to redirect to the Referer header URL after logout." + default: "`false`" + - key: "grails.plugin.springsecurity.adh.errorPage" + description: "The URL of the access denied page." + default: "`/login/denied`" + - key: "grails.plugin.springsecurity.adh.ajaxErrorPage" + description: "The URL of the AJAX access denied page." + default: "`/login/ajaxDenied`" + - key: "grails.plugin.springsecurity.adh.useForward" + description: "Whether to use a forward for access denied errors." + default: "`true`" + - key: "grails.plugin.springsecurity.password.algorithm" + description: "The password hashing algorithm." + default: "`bcrypt`" + - key: "grails.plugin.springsecurity.password.encodeHashAsBase64" + description: "Whether to encode the hashed password as Base64." + default: "`false`" + - key: "grails.plugin.springsecurity.password.bcrypt.logrounds" + description: "The number of log rounds for the BCrypt algorithm." + default: "`10` _(4 in test)_" + - key: "grails.plugin.springsecurity.password.hash.iterations" + description: "The number of hash iterations for algorithms that support it." + default: "`10000` _(1 in test)_" + - key: "grails.plugin.springsecurity.rememberMe.cookieName" + description: "The name of the remember-me cookie." + default: "`grails_remember_me`" + - key: "grails.plugin.springsecurity.rememberMe.alwaysRemember" + description: "Whether to always remember the user, even if the checkbox is not checked." + default: "`false`" + - key: "grails.plugin.springsecurity.rememberMe.tokenValiditySeconds" + description: "The validity period (in seconds) of the remember-me token." + default: "`1209600` _(14 days)_" + - key: "grails.plugin.springsecurity.rememberMe.parameter" + description: "The HTTP parameter name for the remember-me checkbox." + default: "`remember-me`" + - key: "grails.plugin.springsecurity.rememberMe.key" + description: "The secret key used to sign remember-me cookies." + default: "`grailsRocks`" + - key: "grails.plugin.springsecurity.rememberMe.persistent" + description: "Whether to use persistent (database-backed) remember-me tokens." + default: "`false`" + - key: "grails.plugin.springsecurity.rememberMe.useSecureCookie" + description: "Whether to use the Secure flag on the remember-me cookie." + default: "`null`" + - key: "grails.plugin.springsecurity.rememberMe.persistentToken.domainClassName" + description: "The fully qualified name of the persistent token domain class." + default: "`null`" + - key: "grails.plugin.springsecurity.controllerAnnotations.staticRules" + description: "Map of static URL rules for controller-based security." + default: "`[]`" + - key: "grails.plugin.springsecurity.interceptUrlMap" + description: "Map of URL patterns to security rules." + default: "`[]`" + - key: "grails.plugin.springsecurity.requestMap.className" + description: "The fully qualified name of the Requestmap domain class." + default: "`null`" + - key: "grails.plugin.springsecurity.requestMap.urlField" + description: "The property name for the URL in the Requestmap domain class." + default: "`url`" + - key: "grails.plugin.springsecurity.requestMap.configAttributeField" + description: "The property name for the config attribute in the Requestmap domain class." + default: "`configAttribute`" + - key: "grails.plugin.springsecurity.fii.rejectPublicInvocations" + description: "Whether to reject invocations that do not match any security rule." + default: "`true`" + - key: "grails.plugin.springsecurity.fii.alwaysReauthenticate" + description: "Whether to always re-authenticate on every request." + default: "`false`" + - key: "grails.plugin.springsecurity.fii.validateConfigAttributes" + description: "Whether to validate configuration attributes at startup." + default: "`true`" + - key: "grails.plugin.springsecurity.fii.observeOncePerRequest" + description: "Whether to ensure security checks are performed only once per request." + default: "`true`" + - key: "grails.plugin.springsecurity.useSessionFixationPrevention" + description: "Whether to enable session fixation prevention." + default: "`true`" + - key: "grails.plugin.springsecurity.sessionFixationPrevention.migrate" + description: "Whether to migrate session attributes to the new session after login." + default: "`true`" + - key: "grails.plugin.springsecurity.scr.allowSessionCreation" + description: "Whether the security context repository is allowed to create a session." + default: "`true`" + - key: "grails.plugin.springsecurity.scr.disableUrlRewriting" + description: "Whether to disable URL rewriting for session IDs." + default: "`true`" + - key: "grails.plugin.springsecurity.scpf.forceEagerSessionCreation" + description: "Whether to force eager creation of the HTTP session." + default: "`false`" + - key: "grails.plugin.springsecurity.sch.strategyName" + description: "The security context holder strategy (MODE_THREADLOCAL, MODE_INHERITABLETHREADLOCAL)." + default: "`MODE_THREADLOCAL`" + - key: "grails.plugin.springsecurity.useBasicAuth" + description: "Whether to enable HTTP Basic authentication." + default: "`false`" + - key: "grails.plugin.springsecurity.basic.realmName" + description: "The realm name used in HTTP Basic authentication." + default: "`Grails Realm`" + - key: "grails.plugin.springsecurity.useSwitchUserFilter" + description: "Whether to enable the switch user filter for user impersonation." + default: "`false`" + - key: "grails.plugin.springsecurity.switchUser.switchUserUrl" + description: "The URL used to initiate a user switch." + default: "`/login/impersonate`" + - key: "grails.plugin.springsecurity.switchUser.exitUserUrl" + description: "The URL used to exit a user switch and return to the original user." + default: "`/logout/impersonate`" + - key: "grails.plugin.springsecurity.gsp.layoutAuth" + description: "The Sitemesh layout used for the authentication page." + default: "`main`" + - key: "grails.plugin.springsecurity.gsp.layoutDenied" + description: "The Sitemesh layout used for the access denied page." + default: "`main`" + - key: "grails.plugin.springsecurity.ajaxHeader" + description: "The HTTP header name used to identify AJAX requests." + default: "`X-Requested-With`" + - key: "grails.plugin.springsecurity.ipRestrictions" + description: "List of IP-based restriction rules." + default: "`[]`" + - key: "grails.plugin.springsecurity.portMapper.httpPort" + description: "The standard HTTP port used for redirecting between secure and insecure pages." + default: "`8080`" + - key: "grails.plugin.springsecurity.portMapper.httpsPort" + description: "The standard HTTPS port used for redirecting between secure and insecure pages." + default: "`8443`" + - key: "grails.plugin.springsecurity.dao.hideUserNotFoundExceptions" + description: "Whether to hide UsernameNotFoundException and instead throw BadCredentialsException." + default: "`true`" + - key: "grails.plugin.springsecurity.providerManager.eraseCredentialsAfterAuthentication" + description: "Whether to erase password credentials from the Authentication object after successful authentication." + default: "`true`" + - key: "grails.plugin.springsecurity.debug.useFilter" + description: "Whether to enable the Spring Security debug filter." + default: "`false`" + - name: "MongoDB GORM Plugin" + properties: + - key: "grails.mongodb.url" + description: "The MongoDB connection string." + default: "`mongodb://localhost/test`" + - key: "grails.mongodb.host" + description: "The MongoDB server host." + default: "`localhost`" + - key: "grails.mongodb.port" + description: "The MongoDB server port." + default: "`27017`" + - key: "grails.mongodb.databaseName" + description: "The name of the MongoDB database." + default: "_(application name)_" + - key: "grails.mongodb.username" + description: "The database username." + default: "_(none)_" + - key: "grails.mongodb.password" + description: "The database password." + default: "_(none)_" + - key: "grails.mongodb.stateless" + description: "Whether the GORM implementation is stateless (disabling persistence context)." + default: "`false`" + - key: "grails.mongodb.decimalType" + description: "Whether to use the Decimal128 type for BigDecimal properties." + default: "`false`" + - key: "grails.mongodb.codecs" + description: "List of custom MongoDB codec classes." + default: "`[]`" + - key: "grails.mongodb.default.mapping" + description: "A closure applied as the default mapping block for MongoDB domain classes." + default: "`{}`" + - key: "grails.mongodb.options.connectionPoolSettings.maxSize" + description: "The maximum number of connections in the pool." + default: "`100`" + - key: "grails.mongodb.options.connectionPoolSettings.minSize" + description: "The minimum number of connections in the pool." + default: "`0`" + - key: "grails.mongodb.options.connectionPoolSettings.maxWaitTime" + description: "The maximum time (in milliseconds) a thread will wait for a connection." + default: "`120000`" + - key: "grails.mongodb.options.connectionPoolSettings.maxConnectionLifeTime" + description: "The maximum life time (in milliseconds) of a pooled connection." + default: "`0` _(unlimited)_" + - key: "grails.mongodb.options.connectionPoolSettings.maxConnectionIdleTime" + description: "The maximum idle time (in milliseconds) of a pooled connection." + default: "`0` _(unlimited)_" + - key: "grails.mongodb.options.readPreference" + description: "The read preference strategy." + default: "_(none)_" + - key: "grails.mongodb.options.writeConcern" + description: "The write concern strategy." + default: "_(none)_" + - key: "grails.mongodb.options.readConcern" + description: "The read concern strategy." + default: "_(none)_" + - key: "grails.mongodb.options.retryWrites" + description: "Whether to retry write operations on failure." + default: "_(none)_" + - key: "grails.mongodb.options.retryReads" + description: "Whether to retry read operations on failure." + default: "_(none)_" + - key: "grails.mongodb.options.applicationName" + description: "The name of the application (used for logging and monitoring)." + default: "_(none)_" + - key: "grails.mongodb.options.sslSettings.enabled" + description: "Whether SSL is enabled for connections." + default: "`false`" + - key: "grails.mongodb.options.sslSettings.invalidHostNameAllowed" + description: "Whether invalid hostnames are allowed in SSL certificates." + default: "`false`" diff --git a/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy index 4bd4f67370..3c05ddd910 100644 --- a/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy +++ b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy @@ -86,16 +86,20 @@ class ConfigReportCommandSpec extends Specification { File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE) reportFile.exists() - and: + and: "report has correct AsciiDoc structure" String content = reportFile.text content.contains('= Grails Application Configuration Report') - content.contains('== grails') - content.contains('== server') - content.contains('== spring') + + and: "known Grails properties appear in their metadata category sections" content.contains('`grails.profile`') - content.contains('`web`') + 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`') cleanup: reportFile?.delete() @@ -178,50 +182,90 @@ class ConfigReportCommandSpec extends Specification { !result.containsKey('app.bad') } - def "writeReport groups properties by top-level namespace"() { + def "writeReport uses 3-column format with metadata categories"() { given: - Map<String, String> sorted = new TreeMap<String, String>() - sorted.put('grails.controllers.defaultScope', 'singleton') - sorted.put('grails.profile', 'web') - sorted.put('server.port', '8080') + Map<String, String> runtimeProperties = new TreeMap<String, String>() + runtimeProperties.put('grails.controllers.defaultScope', 'singleton') + runtimeProperties.put('grails.profile', 'web') + runtimeProperties.put('server.port', '8080') File reportFile = new File(tempDir, 'test-report.adoc') when: - command.writeReport(sorted, reportFile) + command.writeReport(runtimeProperties, reportFile) then: String content = reportFile.text - and: "report has correct AsciiDoc structure" + and: "report has correct AsciiDoc header" content.startsWith('= Grails Application Configuration Report') content.contains(':toc: left') - content.contains('[cols="2,3", options="header"]') - content.contains('| Property | Value') - and: "properties are grouped by namespace" - content.contains('== grails') - content.contains('== server') + and: "metadata categories are used as section headers" + content.contains('== Core Properties') + content.contains('== Web & Controllers') - and: "grails section appears before server section (alphabetical)" - content.indexOf('== grails') < content.indexOf('== server') + and: "3-column table format is used for known properties" + content.contains('[cols="2,5,2", options="header"]') + content.contains('| Property | Description | Default') - and: "properties are listed under correct sections" + and: "known properties appear with descriptions" + content.contains('`grails.profile`') content.contains('`grails.controllers.defaultScope`') + + and: "runtime values override static defaults for known properties" + content.contains('`web`') content.contains('`singleton`') + + 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<String, String> runtimeProperties = new TreeMap<String, String>() + 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<String, String> runtimeProperties = new TreeMap<String, String>() + 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<String, String> sorted = new TreeMap<String, String>() - sorted.put('test.key', 'value|with|pipes') + Map<String, String> runtimeProperties = new TreeMap<String, String>() + runtimeProperties.put('test.key', 'value|with|pipes') File reportFile = new File(tempDir, 'escape-test.adoc') when: - command.writeReport(sorted, reportFile) + command.writeReport(runtimeProperties, reportFile) then: String content = reportFile.text @@ -229,19 +273,70 @@ class ConfigReportCommandSpec extends Specification { !content.contains('value|with|pipes') } - def "writeReport handles empty configuration"() { + def "writeReport handles empty configuration with no Other Properties"() { given: - Map<String, String> sorted = new TreeMap<String, String>() + Map<String, String> runtimeProperties = new TreeMap<String, String>() File reportFile = new File(tempDir, 'empty-report.adoc') when: - command.writeReport(sorted, reportFile) + command.writeReport(runtimeProperties, reportFile) then: reportFile.exists() String content = reportFile.text content.contains('= Grails Application Configuration Report') - !content.contains('|===') + + and: "metadata categories still appear from the YAML" + 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<String, String> runtimeProperties = new TreeMap<String, String>() + 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 "loadPropertyMetadata returns properties from classpath YAML"() { + when: + Map<String, Map<String, String>> metadata = command.loadPropertyMetadata() + + then: "metadata is loaded from the config-properties.yml on the classpath" + !metadata.isEmpty() + metadata.containsKey('grails.profile') + + and: "each entry has the expected fields" + Map<String, String> profileEntry = metadata.get('grails.profile') + profileEntry.get('key') == 'grails.profile' + profileEntry.get('description') != null + profileEntry.get('description').length() > 0 + profileEntry.get('category') == 'Core Properties' } def "escapeAsciidoc handles null and empty strings"() { 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 index fd85880a79..19a4362141 100644 --- 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 @@ -37,9 +37,14 @@ import spock.lang.TempDir * <li>{@code application.groovy} - Groovy-based configuration</li> * <li>{@code @ConfigurationProperties} - Type-safe configuration beans</li> * </ul> + * + * <p>The hybrid report uses curated property metadata (from {@code config-properties.yml}) + * 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 an AsciiDoc report containing properties from application.yml, application.groovy, and @ConfigurationProperties sources') +@Narrative('Verifies that ConfigReportCommand generates a hybrid AsciiDoc report merging static property metadata with runtime-collected values') class ConfigReportCommandIntegrationSpec extends Specification { @Autowired @@ -57,14 +62,7 @@ class ConfigReportCommandIntegrationSpec extends Specification { return command } - private File executeCommand(ConfigReportCommand command) { - ExecutionContext executionContext = new ExecutionContext(Mock(CommandLine)) - File reportFile = new File(executionContext.baseDir, ConfigReportCommand.DEFAULT_REPORT_FILE) - command.handle(executionContext) - return reportFile - } - - def "ConfigReportCommand generates a report file"() { + def "ConfigReportCommand generates a report file with hybrid format"() { given: 'a ConfigReportCommand wired to the live application context' ConfigReportCommand command = createCommand() @@ -82,18 +80,18 @@ class ConfigReportCommandIntegrationSpec extends Specification { reportFile.exists() reportFile.length() > 0 - and: 'the report has valid AsciiDoc structure' + 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,3", options="header"]') - content.contains('| Property | Value') + content.contains('[cols="2,5,2", options="header"]') + content.contains('| Property | Description | Default') cleanup: reportFile?.delete() } - def "report contains properties from application.yml"() { + 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)) @@ -103,29 +101,49 @@ class ConfigReportCommandIntegrationSpec extends Specification { command.handle(executionContext) String content = reportFile.text - then: 'YAML-defined properties are present in the report' + 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 present' + and: 'YAML numeric properties are in Other Properties' content.contains('`myapp.yaml.maxRetries`') content.contains('`5`') - and: 'YAML nested properties are present' + and: 'YAML nested properties are in Other Properties' content.contains('`myapp.yaml.feature.enabled`') - content.contains('`true`') content.contains('`myapp.yaml.feature.timeout`') - content.contains('`30000`') - - and: 'standard Grails YAML properties are present' - content.contains('`grails.profile`') - content.contains('`web`') cleanup: reportFile?.delete() } - def "report contains properties from application.groovy"() { + 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)) @@ -135,7 +153,7 @@ class ConfigReportCommandIntegrationSpec extends Specification { command.handle(executionContext) String content = reportFile.text - then: 'Groovy config properties are present in the report' + then: 'Groovy config properties are present in Other Properties' content.contains('`myapp.groovy.appName`') content.contains('`Config Report Test App`') @@ -166,7 +184,7 @@ class ConfigReportCommandIntegrationSpec extends Specification { reportFile?.delete() } - def "report contains properties bound via @ConfigurationProperties"() { + 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)) @@ -181,7 +199,7 @@ class ConfigReportCommandIntegrationSpec extends Specification { appProperties.pageSize == 50 appProperties.debugEnabled == true - and: 'the typed properties appear in the config report' + and: 'the typed properties appear in Other Properties' content.contains('`myapp.typed.name`') content.contains('`Configured App`') content.contains('`myapp.typed.pageSize`') @@ -193,7 +211,7 @@ class ConfigReportCommandIntegrationSpec extends Specification { reportFile?.delete() } - def "report groups properties by top-level namespace"() { + 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)) @@ -203,20 +221,27 @@ class ConfigReportCommandIntegrationSpec extends Specification { command.handle(executionContext) String content = reportFile.text - then: 'properties are organized into namespace sections' - content.contains('== grails') - content.contains('== myapp') - content.contains('== dataSource') + 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: 'sections are in alphabetical order' - content.indexOf('== dataSource') < content.indexOf('== grails') - content.indexOf('== grails') < content.indexOf('== myapp') + 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 simultaneously"() { + 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)) @@ -235,17 +260,11 @@ class ConfigReportCommandIntegrationSpec extends Specification { and: 'typed @ConfigurationProperties are present' content.contains('`myapp.typed.name`') - and: 'all properties are in the same myapp section' - int myappSectionIndex = content.indexOf('== myapp') - myappSectionIndex >= 0 - - and: 'each table row has the correct AsciiDoc format' - content.contains('| `myapp.yaml.greeting`') - content.contains('| `Hello from YAML`') - content.contains('| `myapp.groovy.appName`') - content.contains('| `Config Report Test App`') - content.contains('| `myapp.typed.name`') - content.contains('| `Configured App`') + 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()
