This is an automated email from the ASF dual-hosted git repository. jdaugherty pushed a commit to branch configuration-command in repository https://gitbox.apache.org/repos/asf/grails-core.git
commit bdfbc7ad106944e86a3270a5742fb939768e8871 Author: James Daugherty <[email protected]> AuthorDate: Thu Feb 19 11:09:38 2026 -0500 proof of concept for a config report command --- .../grails/dev/commands/ConfigReportCommand.groovy | 158 +++++++++++++ .../dev/commands/ConfigReportCommandSpec.groovy | 255 +++++++++++++++++++++ grails-test-examples/config-report/build.gradle | 63 +++++ .../grails-app/conf/application.groovy | 29 +++ .../config-report/grails-app/conf/application.yml | 53 +++++ .../config-report/grails-app/conf/logback.xml | 10 + .../controllers/configreport/UrlMappings.groovy | 35 +++ .../init/configreport/Application.groovy | 32 +++ .../ConfigReportCommandIntegrationSpec.groovy | 254 ++++++++++++++++++++ .../main/groovy/configreport/AppProperties.groovy | 52 +++++ settings.gradle | 2 + 11 files changed, 943 insertions(+) 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 0000000000..e10da60382 --- /dev/null +++ b/grails-core/src/main/groovy/grails/dev/commands/ConfigReportCommand.groovy @@ -0,0 +1,158 @@ +/* + * 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.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. + * + * <p>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.). + * + * <p>Usage: + * <pre> + * grails config-report + * ./gradlew configReport + * </pre> + * + * <p>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<String, String> 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<String, String> collectProperties(ConfigurableEnvironment environment) { + Map<String, String> sorted = new TreeMap<String, String>() + 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<String, String> sorted, File reportFile) { + 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('') + + 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('') + } + currentSection = section + writer.writeLine("== ${section}") + writer.writeLine('') + writer.writeLine('[cols="2,3", options="header"]') + writer.writeLine('|===') + writer.writeLine('| Property | Value') + writer.writeLine('') + } + writer.writeLine("| `${key}`") + writer.writeLine("| `${escapeAsciidoc(value)}`") + writer.writeLine('') + } + if (currentSection) { + writer.writeLine('|===') + } + } + } + + /** + * 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('|', '\\|') + } + +} 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 0000000000..4bd4f67370 --- /dev/null +++ b/grails-core/src/test/groovy/grails/dev/commands/ConfigReportCommandSpec.groovy @@ -0,0 +1,255 @@ +/* + * 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<String, Object> props = [ + 'grails.profile': 'web', + 'grails.codegen.defaultPackage': 'myapp', + 'server.port': '8080', + 'spring.main.banner-mode': 'off' + ] + 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: + String content = reportFile.text + content.contains('= Grails Application Configuration Report') + content.contains('== grails') + content.contains('== server') + content.contains('== spring') + content.contains('`grails.profile`') + content.contains('`web`') + content.contains('`server.port`') + content.contains('`8080`') + + 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<String, Object> yamlProps = ['myapp.yaml.greeting': 'Hello'] + Map<String, Object> 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<String, String> 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<String, Object> overrideProps = ['app.name': 'Override'] + Map<String, Object> defaultProps = ['app.name': 'Default'] + propertySources.addLast(new MapPropertySource('override', overrideProps)) + propertySources.addLast(new MapPropertySource('default', defaultProps)) + environment.getProperty('app.name') >> 'Override' + + when: + Map<String, String> result = command.collectProperties(environment) + + then: 'the higher-priority value wins' + result['app.name'] == 'Override' + } + + def "collectProperties skips properties that resolve to null"() { + given: + Map<String, Object> 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<String, String> result = command.collectProperties(environment) + + then: + result.containsKey('app.present') + !result.containsKey('app.missing') + } + + def "collectProperties handles resolution errors gracefully"() { + given: + Map<String, Object> 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<String, String> 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 groups properties by top-level namespace"() { + 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') + + File reportFile = new File(tempDir, 'test-report.adoc') + + when: + command.writeReport(sorted, reportFile) + + then: + String content = reportFile.text + + and: "report has correct AsciiDoc structure" + 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: "grails section appears before server section (alphabetical)" + content.indexOf('== grails') < content.indexOf('== server') + + and: "properties are listed under correct sections" + content.contains('`grails.controllers.defaultScope`') + content.contains('`singleton`') + content.contains('`server.port`') + content.contains('`8080`') + } + + def "writeReport escapes pipe characters in values"() { + given: + Map<String, String> sorted = new TreeMap<String, String>() + sorted.put('test.key', 'value|with|pipes') + + File reportFile = new File(tempDir, 'escape-test.adoc') + + when: + command.writeReport(sorted, reportFile) + + then: + String content = reportFile.text + content.contains('value\\|with\\|pipes') + !content.contains('value|with|pipes') + } + + def "writeReport handles empty configuration"() { + given: + Map<String, String> sorted = new TreeMap<String, String>() + File reportFile = new File(tempDir, 'empty-report.adoc') + + when: + command.writeReport(sorted, reportFile) + + then: + reportFile.exists() + String content = reportFile.text + content.contains('= Grails Application Configuration Report') + !content.contains('|===') + } + + def "escapeAsciidoc handles null and empty strings"() { + expect: + ConfigReportCommand.escapeAsciidoc(null) == null + ConfigReportCommand.escapeAsciidoc('') == '' + ConfigReportCommand.escapeAsciidoc('simple') == 'simple' + ConfigReportCommand.escapeAsciidoc('a|b') == 'a\\|b' + } + +} diff --git a/grails-test-examples/config-report/build.gradle b/grails-test-examples/config-report/build.gradle new file mode 100644 index 0000000000..231fef7d7d --- /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 0000000000..1ab156638c --- /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 0000000000..7eb0d4524c --- /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 0000000000..bcc455d190 --- /dev/null +++ b/grails-test-examples/config-report/grails-app/conf/logback.xml @@ -0,0 +1,10 @@ +<configuration> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> + </encoder> + </appender> + <root level="error"> + <appender-ref ref="STDOUT" /> + </root> +</configuration> 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 0000000000..7e141297b3 --- /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 0000000000..dfe6cc0ab1 --- /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 0000000000..fd85880a79 --- /dev/null +++ b/grails-test-examples/config-report/src/integration-test/groovy/configreport/ConfigReportCommandIntegrationSpec.groovy @@ -0,0 +1,254 @@ +/* + * 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 +import spock.lang.TempDir + +/** + * Integration tests for {@link ConfigReportCommand} that verify the command + * correctly reports configuration from multiple sources: + * <ul> + * <li>{@code application.yml} - YAML-based configuration</li> + * <li>{@code application.groovy} - Groovy-based configuration</li> + * <li>{@code @ConfigurationProperties} - Type-safe configuration beans</li> + * </ul> + */ +@Integration +@Narrative('Verifies that ConfigReportCommand generates an AsciiDoc report containing properties from application.yml, application.groovy, and @ConfigurationProperties sources') +class ConfigReportCommandIntegrationSpec extends Specification { + + @Autowired + ConfigurableApplicationContext applicationContext + + @Autowired + AppProperties appProperties + + @TempDir + File tempDir + + private ConfigReportCommand createCommand() { + ConfigReportCommand command = new ConfigReportCommand() + command.applicationContext = applicationContext + 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"() { + 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' + 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') + + cleanup: + reportFile?.delete() + } + + def "report contains properties from application.yml"() { + 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 properties are present in the report' + content.contains('`myapp.yaml.greeting`') + content.contains('`Hello from YAML`') + + and: 'YAML numeric properties are present' + content.contains('`myapp.yaml.maxRetries`') + content.contains('`5`') + + and: 'YAML nested properties are present' + 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"() { + 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 the report' + 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"() { + 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 the config report' + 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 groups properties by top-level namespace"() { + 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: 'properties are organized into namespace sections' + content.contains('== grails') + content.contains('== myapp') + content.contains('== dataSource') + + and: 'sections are in alphabetical order' + content.indexOf('== dataSource') < content.indexOf('== grails') + content.indexOf('== grails') < content.indexOf('== myapp') + + cleanup: + reportFile?.delete() + } + + def "report contains properties from all three config sources simultaneously"() { + 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: '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`') + + 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 0000000000..80e0b34048 --- /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. + * + * <p>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/settings.gradle b/settings.gradle index 0509aef041..12f8eef8ac 100644 --- a/settings.gradle +++ b/settings.gradle @@ -378,6 +378,7 @@ include( 'grails-test-examples-plugins-exploded', 'grails-test-examples-plugins-issue-11767', 'grails-test-examples-cache', + 'grails-test-examples-config-report', 'grails-test-examples-scaffolding', 'grails-test-examples-scaffolding-fields', 'grails-test-examples-views-functional-tests', @@ -412,6 +413,7 @@ project(':grails-test-examples-issue-15228').projectDir = file('grails-test-exam project(':grails-test-examples-plugins-exploded').projectDir = file('grails-test-examples/plugins/exploded') project(':grails-test-examples-plugins-issue-11767').projectDir = file('grails-test-examples/plugins/issue-11767') 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')
