diff --git a/build-logic/plugins/build.gradle b/build-logic/plugins/build.gradle index c75af3b4ab6..1c0adaaa965 100644 --- a/build-logic/plugins/build.gradle +++ b/build-logic/plugins/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation "org.cyclonedx.bom:org.cyclonedx.bom.gradle.plugin:${gradleProperties.gradleCycloneDxPluginVersion}" implementation "com.github.spotbugs.snom:spotbugs-gradle-plugin:${gradleProperties.spotbugsPluginVersion}" implementation "org.sonatype.gradle.plugins:scan-gradle-plugin:${gradleProperties.sonatypeScanPluginVersion}" + implementation gradleBomDependencies['asm'] testImplementation "org.spockframework:spock-core:${gradleBomDependencyVersions['gradle-spock.version']}" testImplementation gradleTestKit() @@ -108,5 +109,9 @@ gradlePlugin { id = 'org.apache.grails.buildsrc.vulnerability-scan' implementationClass = 'org.apache.grails.buildsrc.VulnerabilityScanPlugin' } + register('configurationMetadataPlugin') { + id = 'org.apache.grails.buildsrc.configuration-metadata' + implementationClass = 'org.apache.grails.buildsrc.ConfigurationMetadataPlugin' + } } } diff --git a/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy new file mode 100644 index 00000000000..1dc5d9d460c --- /dev/null +++ b/build-logic/plugins/src/main/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPlugin.groovy @@ -0,0 +1,557 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import groovy.json.JsonOutput +import groovy.json.JsonSlurper +import org.gradle.api.DefaultTask +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.api.tasks.compile.JavaCompile +import org.objectweb.asm.AnnotationVisitor +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.RecordComponentVisitor +import org.objectweb.asm.Type + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.stream.Stream + +import javax.inject.Inject + +/** Generates standard Spring Boot configuration metadata from compiled classes without classloading them. */ +class ConfigurationMetadataPlugin implements Plugin { + + @Override + void apply(Project project) { + project.plugins.withId('java') { + JavaPluginExtension java = project.extensions.getByType(JavaPluginExtension) + project.tasks.withType(JavaCompile).configureEach { JavaCompile task -> + if (!task.options.compilerArgs.contains('-parameters')) { + task.options.compilerArgs.add('-parameters') + } + } + Project compilerProject = project.rootProject.findProject(':grails-configuration-metadata') + if (compilerProject == null) { + throw new IllegalStateException( + 'The configuration metadata plugin requires the :grails-configuration-metadata compiler project') + } + project.dependencies.add('compileOnly', compilerProject) + def main = java.sourceSets.named('main') + main.configure { sourceSet -> + sourceSet.resources.exclude('META-INF/spring-configuration-metadata.json') + } + def generate = project.tasks.register('generateConfigurationMetadata', GenerateConfigurationMetadataTask) { + it.classesDirs.from(main.map { sourceSet -> sourceSet.output.classesDirs }) + it.dependsOn(main.map { sourceSet -> sourceSet.output.classesDirs }) + it.dependsOn(project.tasks.matching { task -> task.name == 'copyAstClasses' }) + def overlay = project.layout.projectDirectory.file( + 'src/main/resources/META-INF/additional-spring-configuration-metadata.json') + if (overlay.asFile.isFile()) { + it.additionalMetadata.set(overlay) + } + it.outputDirectory.set(project.layout.buildDirectory.dir('generated/configurationMetadata')) + } + project.tasks.named(main.get().processResourcesTaskName) { + it.dependsOn(generate) + it.from(generate) + } + } + } +} + +@CacheableTask +abstract class GenerateConfigurationMetadataTask extends DefaultTask { + + static final String CONFIGURATION_PROPERTIES = + 'Lorg/springframework/boot/context/properties/ConfigurationProperties;' + static final String CONSTRUCTOR_BINDING = + 'Lorg/springframework/boot/context/properties/bind/ConstructorBinding;' + static final String PAYLOAD_FIELD = '__grailsConfigurationMetadata' + + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getClassesDirs() + + @InputFile + @Optional + @PathSensitive(PathSensitivity.RELATIVE) + abstract RegularFileProperty getAdditionalMetadata() + + @OutputDirectory + abstract DirectoryProperty getOutputDirectory() + + @Inject + abstract FileSystemOperations getFileSystemOperations() + + @TaskAction + void generate() { + Map models = readModels() + List> groups = [] + List> properties = [] + models.values().findAll { ClassModel model -> model.prefix != null }.sort { ClassModel model -> model.name }.each { + ClassModel model -> + if (model.prefix) { + groups << [name: model.prefix, type: model.name, sourceType: model.name] + } + if (model.payloadProperties != null) { + model.payloadGroups.each { Map group -> + Map entry = new LinkedHashMap<>(group) + entry.sourceType = model.name + groups << entry + } + model.payloadProperties.each { Map property -> + Map entry = new LinkedHashMap<>(property) + entry.sourceType = model.name + properties << entry + } + } else { + GenerateConfigurationMetadataTask.addProperties( + model, model.prefix, model.name, models, groups, properties, new LinkedHashSet()) + } + } + + Map metadata = merge(groups, properties, readOverlay()) + File output = outputDirectory.get().asFile + fileSystemOperations.delete { it.delete(output) } + File target = new File(output, 'META-INF/spring-configuration-metadata.json') + target.parentFile.mkdirs() + target.setText(JsonOutput.prettyPrint(JsonOutput.toJson(canonical(metadata))) + '\n', StandardCharsets.UTF_8.name()) + } + + private Map readModels() { + Map models = [:] + classesDirs.files.findAll { File file -> file.isDirectory() }.sort { File file -> file.absolutePath }.each { + File directory -> + Stream paths = Files.walk(directory.toPath()) + try { + paths.filter { java.nio.file.Path path -> Files.isRegularFile(path) && path.fileName.toString().endsWith('.class') } + .sorted() + .forEach { java.nio.file.Path path -> + ClassModel model = GenerateConfigurationMetadataTask.readClass(Files.readAllBytes(path)) + ClassModel previous = models.put(model.name, model) + if (previous != null && previous != model) { + throw new IllegalArgumentException( + "Duplicate compiled class '${model.name}' in configuration metadata inputs") + } + } + } finally { + paths.close() + } + } + models + } + + static ClassModel readClass(byte[] bytes) { + ClassModel model = new ClassModel() + new ClassReader(bytes).accept(new ClassVisitor(Opcodes.ASM9) { + @Override + void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { + model.name = name.replace('/', '.') + model.superName = superName?.replace('/', '.') + model.interfaces = interfaces.collect { String interfaceName -> interfaceName.replace('/', '.') } + } + + @Override + AnnotationVisitor visitAnnotation(String descriptor, boolean visible) { + if (descriptor != CONFIGURATION_PROPERTIES) { + return null + } + model.prefix = '' + new AnnotationVisitor(Opcodes.ASM9) { + @Override + void visit(String name, Object value) { + if (name == 'prefix' || name == 'value') { + model.prefix = String.valueOf(value) + } + } + } + } + + @Override + FieldVisitor visitField(int access, String name, String descriptor, String signature, Object value) { + int payloadAccess = Opcodes.ACC_PRIVATE | Opcodes.ACC_STATIC | Opcodes.ACC_FINAL | Opcodes.ACC_SYNTHETIC + if (name == PAYLOAD_FIELD && value instanceof String && (access & payloadAccess) == payloadAccess) { + model.payload = value as String + } + null + } + + @Override + RecordComponentVisitor visitRecordComponent(String name, String descriptor, String signature) { + model.properties[name] = new PropertyModel( + name: name, + type: fieldType(descriptor, signature), + constructorBound: true, + readable: true) + null + } + + @Override + MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { + Type method = Type.getMethodType(descriptor) + if (name == '' && (access & (Opcodes.ACC_PRIVATE | Opcodes.ACC_SYNTHETIC)) == 0) { + ConstructorModel constructor = new ConstructorModel() + model.constructors << constructor + Type[] argumentTypes = method.argumentTypes + List argumentTypeNames = methodArgumentTypes(descriptor, signature) + return new MethodVisitor(Opcodes.ASM9) { + private int parameterIndex + + @Override + void visitParameter(String parameterName, int parameterAccess) { + if (parameterName && parameterIndex < argumentTypes.length && + (parameterAccess & (Opcodes.ACC_SYNTHETIC | Opcodes.ACC_MANDATED)) == 0) { + constructor.properties[parameterName] = new PropertyModel( + name: parameterName, + type: argumentTypeNames[parameterIndex], + constructorBound: true) + } + parameterIndex++ + } + + @Override + AnnotationVisitor visitAnnotation(String annotationDescriptor, boolean visible) { + constructor.selected |= annotationDescriptor == CONSTRUCTOR_BINDING + null + } + } + } + if ((access & Opcodes.ACC_PUBLIC) == 0 || + (access & (Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC)) != 0 || name.contains('$')) { + return null + } + if (name.startsWith('get') && name.length() > 3 && method.argumentTypes.length == 0 && + method.returnType.sort != Type.VOID) { + addAccessor(model, decapitalize(name.substring(3)), method.returnType.descriptor, + methodReturnSignature(signature), false) + } else if (name.startsWith('is') && name.length() > 2 && method.argumentTypes.length == 0 && + method.returnType.sort == Type.BOOLEAN) { + addAccessor(model, decapitalize(name.substring(2)), method.returnType.descriptor, + methodReturnSignature(signature), false) + } else if (name.startsWith('set') && name.length() > 3 && method.argumentTypes.length == 1) { + addAccessor(model, decapitalize(name.substring(3)), method.argumentTypes[0].descriptor, + methodFirstArgumentSignature(signature), true) + } + null + } + }, ClassReader.SKIP_CODE | ClassReader.SKIP_FRAMES) + + if (model.payload != null) { + Map payload = new JsonSlurper().parseText(model.payload) as Map + model.prefix = payload.get('prefix') as String + model.name = payload.get('sourceType') as String + model.payloadGroups = ((payload.get('groups') ?: []) as List).collect { Map group -> + new LinkedHashMap(group) + } + model.payloadProperties = ((payload.get('properties') ?: []) as List).collect { Map property -> + new LinkedHashMap(property) + } + } else { + List selectedConstructors = model.constructors.findAll { ConstructorModel constructor -> + constructor.selected + } + ConstructorModel bindingConstructor = selectedConstructors.size() == 1 ? selectedConstructors[0] : + (model.constructors.size() == 1 && !model.constructors[0].properties.isEmpty() ? + model.constructors[0] : null) + bindingConstructor?.properties?.each { String name, PropertyModel constructorProperty -> + PropertyModel property = model.properties.computeIfAbsent(name) { new PropertyModel(name: name) } + property.type = property.type ?: constructorProperty.type + property.constructorBound = true + } + model.properties = model.properties.findAll { String name, PropertyModel property -> + property.writable || property.collectionOrMap() || property.constructorBound + } + } + model + } + + private static void addAccessor(ClassModel model, String name, String descriptor, String signature, boolean writable) { + PropertyModel property = model.properties.computeIfAbsent(name) { new PropertyModel(name: name) } + if (property.type == null || signature != null) { + property.type = fieldType(descriptor, signature) + } + property.writable |= writable + property.readable |= !writable + } + + static void addProperties(ClassModel model, String prefix, String sourceType, + Map models, List> groups, + List> properties, + Set visiting) { + if (!visiting.add(model.name)) { + return + } + propertiesFor(model, models, new LinkedHashSet()).values() + .sort { PropertyModel property -> property.name }.each { PropertyModel property -> + String name = prefix ? "${prefix}.${property.name}" : property.name + ClassModel nested = models[property.rawType()] + if (nested != null && !propertiesFor(nested, models, new LinkedHashSet()).isEmpty()) { + groups << [name: name, type: property.type, sourceType: sourceType] + addProperties(nested, name, sourceType, models, groups, properties, visiting) + } else { + properties << [name: name, type: property.type, sourceType: sourceType] + } + } + visiting.remove(model.name) + } + + private static Map propertiesFor(ClassModel model, Map models, + Set visited) { + if (model == null || !visited.add(model.name)) { + return [:] + } + Map properties = [:] + properties.putAll(propertiesFor(models[model.superName], models, visited)) + model.interfaces.each { String interfaceName -> + properties.putAll(propertiesFor(models[interfaceName], models, visited)) + } + properties.putAll(model.properties) + properties + } + + private Map readOverlay() { + File file = additionalMetadata.asFile.orNull + file?.isFile() ? new JsonSlurper().parse(file, StandardCharsets.UTF_8.name()) as Map : [:] + } + + private static Map merge(List> groups, + List> properties, Map overlay) { + Map result = [:] + result['groups'] = mergeNamed(groups, (overlay.get('groups') ?: []) as List, 'groups') + result['properties'] = mergeNamed(properties, (overlay.get('properties') ?: []) as List, 'properties') + if (overlay.containsKey('hints')) { + result['hints'] = mergeNamed([], overlay.get('hints') as List, 'hints') + } + overlay.each { Object keyValue, Object value -> + String key = keyValue.toString() + if (!(key in ['groups', 'properties', 'hints', 'ignored'])) { + result[key] = value + } + } + if (overlay.containsKey('ignored')) { + Map ignored = new LinkedHashMap((overlay.get('ignored') ?: [:]) as Map) + if (ignored.containsKey('properties')) { + ignored['properties'] = mergeNamed([], ignored.get('properties') as List, 'ignored.properties') + } + result['ignored'] = ignored + } + result + } + + private static List mergeNamed(List generated, List overlay, String category) { + Map generatedByName = indexByName(generated, category, 'generated') + Map overlayByName = indexByName(overlay, category, 'overlay') + Map merged = new LinkedHashMap<>(generatedByName) + overlayByName.each { String name, Object value -> + if (merged[name] instanceof Map && value instanceof Map) { + merged[name] = new LinkedHashMap((Map) merged[name]) + (Map) value + } else { + merged[name] = value + } + } + merged.keySet().sort().collect { String name -> merged[name] } + } + + private static Map indexByName(List source, String category, String sourceName) { + Map indexed = [:] + source.each { Object entry -> + String name = entry instanceof Map ? ((Map) entry).name as String : entry as String + if (!name) { + throw new IllegalArgumentException("${category} entry has no name") + } + if (indexed.containsKey(name) && indexed[name] != entry) { + throw new IllegalArgumentException("Conflicting ${sourceName} ${category} metadata for '${name}'") + } + indexed[name] = entry + } + indexed + } + + private static Object canonical(Object value) { + if (value instanceof Map) { + Map sorted = new LinkedHashMap<>() + ((Map) value).keySet().collect { Object key -> key.toString() }.sort().each { String key -> + sorted[key] = canonical(((Map) value)[key]) + } + return sorted + } + if (value instanceof List) { + return ((List) value).collect { Object entry -> canonical(entry) } + } + value + } + + private static String fieldType(String descriptor, String signature) { + signature ? new TypeSignatureParser(signature).parse() : Type.getType(descriptor).className + } + + private static String methodReturnSignature(String signature) { + signature ? signature.substring(signature.indexOf(')') + 1) : null + } + + private static String methodFirstArgumentSignature(String signature) { + signature ? signature.substring(signature.indexOf('(') + 1) : null + } + + private static List methodArgumentTypes(String descriptor, String signature) { + signature?.startsWith('(') ? new TypeSignatureParser(signature).parseMethodArguments() : + Type.getArgumentTypes(descriptor).collect { Type argument -> argument.className } + } + + private static String decapitalize(String value) { + value.length() > 1 && Character.isUpperCase(value.charAt(0)) && Character.isUpperCase(value.charAt(1)) ? + value : value[0].toLowerCase(Locale.ROOT) + value.substring(1) + } + + private static class ClassModel { + String name + String superName + List interfaces = [] + String prefix + String payload + List> payloadGroups = [] + List> payloadProperties + List constructors = [] + Map properties = [:] + } + + private static class ConstructorModel { + boolean selected + Map properties = [:] + } + + private static class PropertyModel { + String name + String type + boolean constructorBound + boolean readable + boolean writable + + String rawType() { + type.replaceFirst(/<.*/, '').replace('[]', '') + } + + boolean collectionOrMap() { + rawType() in [ + 'java.util.Collection', 'java.util.List', 'java.util.Set', 'java.util.SortedSet', + 'java.util.NavigableSet', 'java.util.Queue', 'java.util.Deque', 'java.util.Map', + 'java.util.SortedMap', 'java.util.NavigableMap' + ] + } + + } + + private static class TypeSignatureParser { + private static final Map PRIMITIVES = [ + (('B' as char)): 'byte', (('C' as char)): 'char', (('D' as char)): 'double', + (('F' as char)): 'float', (('I' as char)): 'int', (('J' as char)): 'long', + (('S' as char)): 'short', (('Z' as char)): 'boolean', (('V' as char)): 'void' + ] + + private final String signature + private int position + + TypeSignatureParser(String signature) { + this.signature = signature + } + + String parse() { + parseType() + } + + List parseMethodArguments() { + if (signature.charAt(position++) != '(' as char) { + throw new IllegalArgumentException("Not a JVM method signature '${signature}'") + } + List arguments = [] + while (signature.charAt(position) != ')' as char) { + arguments << parseType() + } + arguments + } + + private String parseType() { + char token = signature.charAt(position++) + if (PRIMITIVES.containsKey(token)) { + return PRIMITIVES[token] + } + if (token == '[' as char) { + return parseType() + '[]' + } + if (token == 'T' as char) { + return readUntil(';' as char) + } + if (token == '*' as char) { + return '?' + } + if (token == '+' as char) { + return '? extends ' + parseType() + } + if (token == '-' as char) { + return '? super ' + parseType() + } + if (token != 'L' as char) { + throw new IllegalArgumentException("Unsupported JVM type signature '${signature}'") + } + StringBuilder name = new StringBuilder() + while (position < signature.length()) { + char current = signature.charAt(position++) + if (current == ';' as char) { + return name.toString().replace('/', '.') + } + if (current == '<' as char) { + List arguments = [] + while (signature.charAt(position) != '>' as char) { + arguments << parseType() + } + position++ + name.append('<').append(arguments.join(',')).append('>') + } else { + name.append(current == '.' as char ? '$' : current) + } + } + throw new IllegalArgumentException("Incomplete JVM type signature '${signature}'") + } + + private String readUntil(char delimiter) { + int end = signature.indexOf(delimiter as int, position) + String value = signature.substring(position, end) + position = end + 1 + value + } + } +} diff --git a/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy new file mode 100644 index 00000000000..f56816bd304 --- /dev/null +++ b/build-logic/plugins/src/test/groovy/org/apache/grails/buildsrc/ConfigurationMetadataPluginSpec.groovy @@ -0,0 +1,351 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.buildsrc + +import groovy.json.JsonSlurper +import org.gradle.testkit.runner.BuildResult +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import spock.lang.Specification +import spock.lang.TempDir + +import java.nio.file.Path +import java.util.zip.ZipFile + +class ConfigurationMetadataPluginSpec extends Specification { + + @TempDir + Path projectDir + + def setup() { + File workspace = findWorkspace() + write('settings.gradle', "rootProject.name = 'metadata-fixture'\ninclude 'grails-configuration-metadata'\n") + write('grails-configuration-metadata/build.gradle', """ + plugins { + id 'groovy' + id 'java-library' + } + repositories { mavenCentral() } + dependencies { + implementation 'org.apache.groovy:groovy:5.0.7' + } + sourceSets.main.groovy.srcDir '${path(new File(workspace, 'grails-configuration-metadata/src/main/groovy'))}' + sourceSets.main.resources.srcDir '${path(new File(workspace, 'grails-configuration-metadata/src/main/resources'))}' + """.stripIndent()) + write('build.gradle', ''' + plugins { + id 'groovy' + id 'org.apache.grails.buildsrc.configuration-metadata' + } + + repositories { mavenCentral() } + + dependencies { + implementation 'org.apache.groovy:groovy:5.0.7' + implementation 'org.springframework.boot:spring-boot:4.1.0' + } + + '''.stripIndent()) + writeJavaConfiguration(false) + write('src/main/java/fixture/RootConfiguration.java', ''' + package fixture; + + import org.springframework.boot.context.properties.ConfigurationProperties; + + @ConfigurationProperties + public class RootConfiguration { + private String rootValue; + public String getRootValue() { return rootValue; } + public void setRootValue(String rootValue) { this.rootValue = rootValue; } + } + '''.stripIndent()) + write('src/main/groovy/fixture/GroovyConfiguration.groovy', """ + package fixture + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties('fixture.groovy') + class GroovyConfiguration { + String constantValue = 'constant' + String dynamicValue = System.getProperty('fixture.dynamic') + List names + GroovyNested nested + private String internalSecret + } + + class GroovyNested { + boolean enabled + } + """.stripIndent()) + write('src/main/resources/META-INF/spring-configuration-metadata.json', '{"obsolete":true}') + write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', ''' + { + "groups": [ + {"name":"fixture.java","description":"Java overlay","x-group":"retained"}, + {"name":"overlay.only","type":"overlay.Only","sourceType":"overlay.Source"} + ], + "properties": [ + {"name":"fixture.java.names","description":"Names overlay","defaultValue":["a"],"deprecation":{"level":"warning","reason":"test"},"x-property":true}, + {"name":"overlay.only.value","type":"java.lang.String","sourceType":"overlay.Source","defaultValue":"only","description":"Overlay only"} + ], + "hints": [ + {"name":"fixture.java.names","values":[{"value":"second"},{"value":"first"}],"providers":[{"name":"any","parameters":{"z":1,"a":2}}],"x-hint":"retained"} + ], + "ignored": { + "properties": [ + {"name":"ignored.b","reason":"b"}, + {"name":"ignored.a","reason":"a","x-ignored":true} + ], + "x-ignored-root":"retained" + }, + "custom": {"group":"current-custom-group","z":1,"a":2} + } + '''.stripIndent()) + } + + def "generates canonical metadata across clean incremental edit and source deletion builds"() { + when: 'a clean jar is built' + BuildResult clean = run('clean', 'jar') + Map metadata = readMetadata() + + then: 'equivalent Java and Groovy bean shapes are discovered' + clean.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS + metadata.groups*.name == [ + 'fixture.groovy', + 'fixture.groovy.nested', + 'fixture.java', + 'fixture.java.nested', + 'overlay.only' + ] + property(metadata, 'fixture.java.names').type == 'java.util.List' + property(metadata, 'fixture.groovy.names').type == 'java.util.List' + property(metadata, 'fixture.groovy.constantValue').defaultValue == 'constant' + !property(metadata, 'fixture.groovy.dynamicValue').containsKey('defaultValue') + group(metadata, 'fixture.java.nested').type == 'fixture.JavaNested' + group(metadata, 'fixture.java.nested').sourceType == 'fixture.JavaConfiguration' + group(metadata, 'fixture.groovy.nested').type == 'fixture.GroovyNested' + group(metadata, 'fixture.groovy.nested').sourceType == 'fixture.GroovyConfiguration' + property(metadata, 'fixture.java.nested') == null + property(metadata, 'fixture.groovy.nested') == null + property(metadata, 'fixture.java.nested.enabled').type == 'boolean' + property(metadata, 'fixture.groovy.nested.enabled').type == 'boolean' + property(metadata, 'rootValue').type == 'java.lang.String' + !metadata.groups*.name.contains('') + property(metadata, 'fixture.java.readOnlyNames').type == 'java.util.List' + property(metadata, 'fixture.java.resource').type == 'org.springframework.core.io.Resource' + property(metadata, 'fixture.java.inheritedValue').type == 'java.lang.String' + property(metadata, 'fixture.java.immutableValue').type == 'java.lang.String' + property(metadata, 'fixture.java.immutableNames').type == 'java.util.List' + property(metadata, 'fixture.java.computedValue') == null + group(metadata, 'fixture.java.resource') == null + property(metadata, 'fixture.java.internalSecret') == null + property(metadata, 'fixture.java.privateValue') == null + property(metadata, 'fixture.groovy.internalSecret') == null + + and: 'the overlay wins fields while every supported and unknown field is retained' + metadata.groups.find { it.name == 'fixture.java' }.description == 'Java overlay' + metadata.groups.find { it.name == 'fixture.java' }.'x-group' == 'retained' + property(metadata, 'fixture.java.names').defaultValue == ['a'] + property(metadata, 'fixture.java.names').deprecation.reason == 'test' + property(metadata, 'fixture.java.names').'x-property' + property(metadata, 'overlay.only.value').description == 'Overlay only' + metadata.hints[0].values*.value == ['second', 'first'] + metadata.hints[0].providers[0].parameters.keySet() as List == ['a', 'z'] + metadata.hints[0].'x-hint' == 'retained' + metadata.ignored.properties*.name == ['ignored.a', 'ignored.b'] + metadata.ignored.properties[0].'x-ignored' + metadata.ignored.'x-ignored-root' == 'retained' + metadata.custom == [a: 2, group: 'current-custom-group', z: 1] + + and: 'the source metadata is replaced by exactly one generated jar entry' + metadataFile().text != '{"obsolete":true}' + metadataEntryCount() == 1 + + when: 'nothing changes' + BuildResult unchanged = run('jar') + + then: + unchanged.task(':generateConfigurationMetadata').outcome == TaskOutcome.UP_TO_DATE + + when: 'one Java source is edited without cleaning' + writeJavaConfiguration(true) + BuildResult edited = run('jar') + Map editedMetadata = readMetadata() + + then: + edited.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS + property(editedMetadata, 'fixture.java.timeout').type == 'java.time.Duration' + property(editedMetadata, 'fixture.java.names').description == 'Names overlay' + property(editedMetadata, 'fixture.groovy.names').type == 'java.util.List' + + when: 'the Groovy source is deleted without cleaning' + projectDir.resolve('src/main/groovy/fixture/GroovyConfiguration.groovy').toFile().delete() + BuildResult deleted = run('jar') + Map deletedMetadata = readMetadata() + + then: + deleted.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS + !deletedMetadata.groups*.name.contains('fixture.groovy') + !deletedMetadata.properties*.name.any { String name -> name.startsWith('fixture.groovy.') } + property(deletedMetadata, 'fixture.java.timeout').type == 'java.time.Duration' + metadataEntryCount() == 1 + } + + def "fails on conflicting duplicate identities in an overlay source"() { + given: + write('src/main/resources/META-INF/additional-spring-configuration-metadata.json', ''' + {"properties":[ + {"name":"duplicate","type":"java.lang.String"}, + {"name":"duplicate","type":"java.lang.Integer"} + ]} + '''.stripIndent()) + + when: + BuildResult result = runner('generateConfigurationMetadata').buildAndFail() + + then: + result.output.contains("Conflicting overlay properties metadata for 'duplicate'") + } + + def "generates metadata when no additional metadata file exists"() { + given: + projectDir.resolve('src/main/resources/META-INF/additional-spring-configuration-metadata.json').toFile().delete() + + when: + BuildResult result = run('generateConfigurationMetadata') + + then: + result.task(':generateConfigurationMetadata').outcome == TaskOutcome.SUCCESS + property(readMetadata(), 'fixture.java.names').type == 'java.util.List' + } + + private void writeJavaConfiguration(boolean includeTimeout) { + String timeout = includeTimeout ? ''' + private java.time.Duration timeout; + public java.time.Duration getTimeout() { return timeout; } + public void setTimeout(java.time.Duration timeout) { this.timeout = timeout; } + ''' : '' + write('src/main/java/fixture/JavaConfiguration.java', """ + package fixture; + + import java.util.List; + import org.springframework.boot.context.properties.ConfigurationProperties; + import org.springframework.core.io.Resource; + + @ConfigurationProperties("fixture.java") + public class JavaConfiguration extends JavaBaseConfiguration { + private List names; + private JavaNested nested; + private String internalSecret; + private String privateValue; + private final List readOnlyNames = new java.util.ArrayList<>(); + private final String immutableValue; + private final List immutableNames; + private Resource resource; + public JavaConfiguration(String immutableValue, List immutableNames) { + this.immutableValue = immutableValue; + this.immutableNames = immutableNames; + } + public List getNames() { return names; } + public void setNames(List names) { this.names = names; } + public JavaNested getNested() { return nested; } + public void setNested(JavaNested nested) { this.nested = nested; } + public List getReadOnlyNames() { return readOnlyNames; } + public String getImmutableValue() { return immutableValue; } + public String getComputedValue() { return "computed"; } + public Resource getResource() { return resource; } + public void setResource(Resource resource) { this.resource = resource; } + private void setPrivateValue(String privateValue) { this.privateValue = privateValue; } + ${timeout} + } + + class JavaNested { + private boolean enabled; + public boolean isEnabled() { return enabled; } + public void setEnabled(boolean enabled) { this.enabled = enabled; } + } + + class JavaBaseConfiguration { + private String inheritedValue; + public String getInheritedValue() { return inheritedValue; } + public void setInheritedValue(String inheritedValue) { this.inheritedValue = inheritedValue; } + } + + class GenericConstructor { + GenericConstructor(T value) { } + } + """.stripIndent()) + } + + private BuildResult run(String... arguments) { + runner(arguments).build() + } + + private GradleRunner runner(String... arguments) { + GradleRunner.create() + .withProjectDir(projectDir.toFile()) + .withArguments(*arguments, '--stacktrace') + .withPluginClasspath() + } + + private void write(String relativePath, String contents) { + File file = projectDir.resolve(relativePath).toFile() + file.parentFile.mkdirs() + file.text = contents + } + + private File metadataFile() { + projectDir.resolve('build/generated/configurationMetadata/META-INF/spring-configuration-metadata.json').toFile() + } + + private Map readMetadata() { + new JsonSlurper().parse(metadataFile()) as Map + } + + private static Map property(Map metadata, String name) { + metadata.properties.find { Map property -> property.name == name } as Map + } + + private static Map group(Map metadata, String name) { + metadata.groups.find { Map group -> group.name == name } as Map + } + + private int metadataEntryCount() { + File jar = projectDir.resolve('build/libs/metadata-fixture.jar').toFile() + ZipFile zip = new ZipFile(jar) + try { + zip.entries().toList().count { it.name == 'META-INF/spring-configuration-metadata.json' } + } finally { + zip.close() + } + } + + private static File findWorkspace() { + File current = new File(System.getProperty('user.dir')).absoluteFile + while (current != null && !new File(current, 'grails-configuration-metadata').isDirectory()) { + current = current.parentFile + } + assert current != null + current + } + + private static String path(File file) { + file.absolutePath.replace('\\', '/') + } +} diff --git a/dependencies.gradle b/dependencies.gradle index 80831e09637..5a701fca572 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -23,6 +23,7 @@ ext { gradleBomDependencyVersions = [ 'ant.version' : '1.10.17', + 'asm.version' : '9.10.1', 'asciidoctor-gradle-jvm.version': '4.0.5', 'asciidoctorj.version' : '3.0.0', 'asset-pipeline-gradle.version' : '5.2.0-M1', @@ -52,6 +53,7 @@ ext { gradleBomDependencies = [ 'ant' : "org.apache.ant:ant:${gradleBomDependencyVersions['ant.version']}", 'ant-junit' : "org.apache.ant:ant-junit:${gradleBomDependencyVersions['ant.version']}", + 'asm' : "org.ow2.asm:asm:${gradleBomDependencyVersions['asm.version']}", 'asciidoctor-gradle-jvm' : "org.asciidoctor:asciidoctor-gradle-jvm:${gradleBomDependencyVersions['asciidoctor-gradle-jvm.version']}", 'asciidoctorj' : "org.asciidoctor:asciidoctorj:${gradleBomDependencyVersions['asciidoctorj.version']}", 'asset-pipeline-gradle' : "cloud.wondrify:asset-pipeline-gradle:${gradleBomDependencyVersions['asset-pipeline-gradle.version']}", diff --git a/gradle/publish-root-config.gradle b/gradle/publish-root-config.gradle index 17f2cede7ba..f53bf04a7c3 100644 --- a/gradle/publish-root-config.gradle +++ b/gradle/publish-root-config.gradle @@ -39,6 +39,7 @@ def publishedProjects = [ 'grails-codecs', 'grails-codecs-core', 'grails-common', + 'grails-configuration-metadata', 'grails-console', 'grails-controllers', 'grails-converters', diff --git a/grails-cache/build.gradle b/grails-cache/build.gradle index 5534081f00d..e72e8be912f 100644 --- a/grails-cache/build.gradle +++ b/grails-cache/build.gradle @@ -23,6 +23,7 @@ plugins { id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.gradle.grails-plugin' id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.configuration-metadata' id 'org.apache.grails.buildsrc.publish' id 'org.apache.grails.buildsrc.sbom' id 'org.apache.grails.buildsrc.vulnerability-scan' diff --git a/grails-cache/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-cache/src/main/resources/META-INF/additional-spring-configuration-metadata.json similarity index 100% rename from grails-cache/src/main/resources/META-INF/spring-configuration-metadata.json rename to grails-cache/src/main/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/grails-configuration-metadata/build.gradle b/grails-configuration-metadata/build.gradle new file mode 100644 index 00000000000..5aee9c1ba17 --- /dev/null +++ b/grails-configuration-metadata/build.gradle @@ -0,0 +1,48 @@ +/* + * 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 'groovy' + id 'java-library' + id 'org.apache.grails.buildsrc.properties' + id 'org.apache.grails.buildsrc.dependency-validator' + id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.publish' + id 'org.apache.grails.buildsrc.sbom' + id 'org.apache.grails.buildsrc.vulnerability-scan' + id 'org.apache.grails.gradle.grails-code-style' + id 'org.apache.grails.gradle.grails-jacoco' +} + +version = projectVersion +group = 'org.apache.grails' + +dependencies { + implementation platform(project(':grails-bom')) + api 'org.apache.groovy:groovy' + compileOnly 'org.springframework.boot:spring-boot' + testImplementation 'org.springframework.boot:spring-boot' + testImplementation 'org.apache.groovy:groovy-json' + testImplementation 'org.spockframework:spock-core' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +apply { + from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') +} diff --git a/grails-configuration-metadata/src/main/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformation.groovy b/grails-configuration-metadata/src/main/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformation.groovy new file mode 100644 index 00000000000..21605010aab --- /dev/null +++ b/grails-configuration-metadata/src/main/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformation.groovy @@ -0,0 +1,277 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.configuration.metadata + +import groovy.transform.CompileStatic +import org.codehaus.groovy.ast.ASTNode +import org.codehaus.groovy.ast.ClassHelper +import org.codehaus.groovy.ast.ClassNode +import org.codehaus.groovy.ast.FieldNode +import org.codehaus.groovy.ast.GenericsType +import org.codehaus.groovy.ast.PropertyNode +import org.codehaus.groovy.ast.expr.ConstantExpression +import org.codehaus.groovy.ast.expr.Expression +import org.codehaus.groovy.ast.MethodNode +import org.codehaus.groovy.control.CompilePhase +import org.codehaus.groovy.control.SourceUnit +import org.codehaus.groovy.syntax.SyntaxException +import org.codehaus.groovy.transform.ASTTransformation +import org.codehaus.groovy.transform.GroovyASTTransformation + +import static java.lang.reflect.Modifier.PRIVATE +import static java.lang.reflect.Modifier.FINAL +import static java.lang.reflect.Modifier.STATIC + +/** + * Embeds configuration metadata in each annotated Groovy class. Aggregation is deliberately + * deferred to the Gradle task so incremental Groovy compilation never writes shared output. + */ +@CompileStatic +@GroovyASTTransformation(phase = CompilePhase.SEMANTIC_ANALYSIS) +class ConfigurationMetadataTransformation implements ASTTransformation { + + static final String PAYLOAD_FIELD = '__grailsConfigurationMetadata' + private static final String CONSTRUCTOR_BINDING = + 'org.springframework.boot.context.properties.bind.ConstructorBinding' + private static final int SYNTHETIC = 0x00001000 + private static final String CONFIGURATION_PROPERTIES = 'org.springframework.boot.context.properties.ConfigurationProperties' + + @Override + void visit(ASTNode[] nodes, SourceUnit source) { + source.AST.classes.findAll { ClassNode node -> + !node.interface && node.getAnnotations(ClassHelper.make(CONFIGURATION_PROPERTIES)) + }.each { ClassNode node -> addPayload(node, source) } + } + + private static void addPayload(ClassNode node, SourceUnit source) { + FieldNode existingField = node.getDeclaredField(PAYLOAD_FIELD) + if (existingField != null) { + source.addError(new SyntaxException( + "Configuration properties classes cannot declare reserved field '${PAYLOAD_FIELD}'", + existingField.lineNumber, existingField.columnNumber)) + return + } + def annotation = node.getAnnotations(ClassHelper.make(CONFIGURATION_PROPERTIES))[0] + Expression prefixExpression = annotation.getMember('prefix') ?: annotation.getMember('value') + if (prefixExpression != null && !(prefixExpression instanceof ConstantExpression)) { + return + } + String prefix = prefixExpression == null ? '' : String.valueOf(((ConstantExpression) prefixExpression).value) + Map>> metadata = metadata( + node, prefix, node.name, new LinkedHashSet()) + String payload = toJson([ + prefix: prefix, + sourceType: node.name, + groups: metadata.get('groups'), + properties: metadata.get('properties') + ]) + FieldNode field = node.addField(PAYLOAD_FIELD, PRIVATE | STATIC | FINAL | SYNTHETIC, + ClassHelper.STRING_TYPE, new ConstantExpression(payload)) + field.synthetic = true + } + + private static Map>> metadata(ClassNode node, String prefix, + String sourceType, Set visiting) { + if (!visiting.add(node.name)) { + return [groups: [], properties: []] + } + Map> bindable = [:] + node.properties.findAll { PropertyNode property -> isBindableProperty(property) }.each { + PropertyNode propertyNode -> + FieldNode field = propertyNode.field + Map propertyMetadata = [ + propertyName: field.name, type: typeName(field.type), classNode: field.type, nested: true] + Expression initialExpression = field.initialExpression + if (initialExpression instanceof ConstantExpression && !initialExpression.isNullExpression()) { + propertyMetadata.put('defaultValue', ((ConstantExpression) initialExpression).value) + } + bindable.put(field.name, propertyMetadata) + } + collectSetterProperties(node, bindable, new LinkedHashSet()) + + List> groups = [] + List> properties = [] + bindable.values().sort { Map property -> property.propertyName as String }.each { + Map property -> + String propertyName = property.propertyName as String + String name = prefix ? "${prefix}.${propertyName}" : propertyName + ClassNode propertyType = property.classNode as ClassNode + if (property.nested && isNested(propertyType)) { + Map>> nested = metadata(propertyType, name, sourceType, visiting) + if (nested.get('groups') || nested.get('properties')) { + groups << [name: name, type: property.type, sourceType: sourceType] + groups.addAll(nested.get('groups')) + properties.addAll(nested.get('properties')) + } else { + properties << scalarProperty(name, property) + } + } else { + properties << scalarProperty(name, property) + } + } + visiting.remove(node.name) + [ + groups: groups.sort { Map group -> group.name as String }, + properties: properties.sort { Map property -> property.name as String } + ] + } + + private static Map scalarProperty(String name, Map property) { + Map entry = [name: name, type: property.type] + if (property.containsKey('defaultValue')) { + entry.put('defaultValue', property.get('defaultValue')) + } + entry + } + + private static boolean isBindableProperty(PropertyNode property) { + FieldNode field = property.field + boolean constructorBound = !field.final || constructorBoundProperties(field.owner).contains(field.name) + !field.static && constructorBound && !field.name.startsWith('$') && + field.name != 'metaClass' && field.name != PAYLOAD_FIELD && + !field.annotations.any { annotation -> + annotation.classNode.name in ['groovy.lang.Delegate', 'groovy.transform.Delegate'] + } + } + + private static Set constructorBoundProperties(ClassNode owner) { + List constructors = owner.declaredConstructors.findAll { constructor -> + !constructor.synthetic && !constructor.private + } + List candidates = constructors.findAll { constructor -> constructor.parameters.length > 0 } + List selected = candidates.findAll { constructor -> + constructor.annotations.any { annotation -> annotation.classNode.name == CONSTRUCTOR_BINDING } + } + def bindingConstructor = selected.size() == 1 ? selected[0] : + (!constructors.any { constructor -> constructor.parameters.length == 0 } && candidates.size() == 1 ? + candidates[0] : null) + bindingConstructor ? bindingConstructor.parameters*.name as Set : Collections.emptySet() + } + + private static void collectSetterProperties(ClassNode node, Map> bindable, + Set visited) { + if (node == null || node.name == Object.name || !visited.add(node.name)) { + return + } + node.methods.findAll { MethodNode method -> + method.public && !method.static && method.name.startsWith('set') && method.name.length() > 3 && + !(method.name in ['setGrailsApplication', 'setMetaClass']) && method.parameters.length == 1 + }.each { MethodNode method -> + String propertyName = decapitalize(method.name.substring(3)) + ClassNode propertyType = method.parameters[0].type + bindable.putIfAbsent(propertyName, + [propertyName: propertyName, type: typeName(propertyType), classNode: propertyType, nested: true]) + } + collectSetterProperties(node.superClass, bindable, visited) + node.interfaces.each { ClassNode interfaceNode -> collectSetterProperties(interfaceNode, bindable, visited) } + } + + private static boolean isNested(ClassNode type) { + !type.array && !type.enum && !ClassHelper.isPrimitiveType(type) && + !type.name.startsWith('java.') && !type.name.startsWith('groovy.') + } + + private static String typeName(ClassNode type) { + if (type.array) { + return "${typeName(type.componentType)}[]" + } + String name = type.name + GenericsType[] genericsTypes = type.genericsTypes + if (genericsTypes) { + name += '<' + genericsTypes.collect { GenericsType generic -> genericTypeName(generic) }.join(',') + '>' + } + name + } + + private static String genericTypeName(GenericsType generic) { + if (generic.wildcard) { + if (generic.lowerBound) { + return "? super ${typeName(generic.lowerBound)}" + } + if (generic.upperBounds && generic.upperBounds[0].name != Object.name) { + return "? extends ${typeName(generic.upperBounds[0])}" + } + return '?' + } + generic.placeholder ? generic.name : typeName(generic.type) + } + + private static String decapitalize(String value) { + value.length() > 1 && Character.isUpperCase(value.charAt(0)) && Character.isUpperCase(value.charAt(1)) ? + value : value[0].toLowerCase(Locale.ROOT) + value.substring(1) + } + + private static String toJson(Object value) { + if (value == null) { + return 'null' + } + if (value instanceof CharSequence || value instanceof Character) { + return '"' + escapeJson(value.toString()) + '"' + } + if (value instanceof Number || value instanceof Boolean) { + return value.toString() + } + if (value instanceof Map) { + return '{' + ((Map) value).collect { Object key, Object item -> + '"' + escapeJson(key.toString()) + '":' + toJson(item) + }.join(',') + '}' + } + if (value instanceof Iterable) { + return '[' + ((Iterable) value).collect { Object item -> toJson(item) }.join(',') + ']' + } + '"' + escapeJson(value.toString()) + '"' + } + + private static String escapeJson(String value) { + StringBuilder escaped = new StringBuilder(value.length()) + for (int index = 0; index < value.length(); index++) { + char character = value.charAt(index) + switch (character) { + case '"' as char: + escaped.append('\\"') + break + case '\\' as char: + escaped.append('\\\\') + break + case '\b' as char: + escaped.append('\\b') + break + case '\f' as char: + escaped.append('\\f') + break + case '\n' as char: + escaped.append('\\n') + break + case '\r' as char: + escaped.append('\\r') + break + case '\t' as char: + escaped.append('\\t') + break + default: + if (character < 0x20) { + escaped.append(String.format('\\u%04x', (int) character)) + } else { + escaped.append(character) + } + } + } + escaped.toString() + } +} diff --git a/grails-configuration-metadata/src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation b/grails-configuration-metadata/src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation new file mode 100644 index 00000000000..40c5d3c190c --- /dev/null +++ b/grails-configuration-metadata/src/main/resources/META-INF/services/org.codehaus.groovy.transform.ASTTransformation @@ -0,0 +1 @@ +org.apache.grails.configuration.metadata.ConfigurationMetadataTransformation diff --git a/grails-configuration-metadata/src/test/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformationSpec.groovy b/grails-configuration-metadata/src/test/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformationSpec.groovy new file mode 100644 index 00000000000..c12f60f692b --- /dev/null +++ b/grails-configuration-metadata/src/test/groovy/org/apache/grails/configuration/metadata/ConfigurationMetadataTransformationSpec.groovy @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.grails.configuration.metadata + +import groovy.json.JsonSlurper +import org.codehaus.groovy.control.MultipleCompilationErrorsException +import spock.lang.Specification + +import java.lang.reflect.Field +import java.lang.reflect.Modifier + +class ConfigurationMetadataTransformationSpec extends Specification { + + def "global transform emits actual properties and nested groups with only constant defaults"() { + given: + GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader) + + when: + Class configuration = loader.parseClass(''' + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties('sample.service') + class SampleConfiguration { + String displayName = 'Grails' + int port = 8080 + List labels = ['one'] + final String immutableValue + Nested nested = new Nested() + String dynamicValue = UUID.randomUUID().toString() + private String internalSecret = 'secret' + + SampleConfiguration(String immutableValue) { + this.immutableValue = immutableValue + } + } + + class Nested { + boolean enabled = true + } + ''') + Field payloadField = configuration.getDeclaredField(ConfigurationMetadataTransformation.PAYLOAD_FIELD) + payloadField.accessible = true + Map payload = new JsonSlurper().parseText(payloadField.get(null) as String) as Map + + then: + Modifier.isPrivate(payloadField.modifiers) + Modifier.isStatic(payloadField.modifiers) + Modifier.isFinal(payloadField.modifiers) + payloadField.synthetic + payload.prefix == 'sample.service' + payload.sourceType == 'example.SampleConfiguration' + payload.get('groups') == [[ + name: 'sample.service.nested', + sourceType: 'example.SampleConfiguration', + type: 'example.Nested' + ]] + payload.get('properties')*.name == [ + 'sample.service.displayName', + 'sample.service.dynamicValue', + 'sample.service.immutableValue', + 'sample.service.labels', + 'sample.service.nested.enabled', + 'sample.service.port' + ] + !payload.get('properties')*.name.contains('sample.service.internalSecret') + payload.get('properties').find { it.name == 'sample.service.displayName' }.defaultValue == 'Grails' + payload.get('properties').find { it.name == 'sample.service.port' }.defaultValue == 8080 + payload.get('properties').find { it.name == 'sample.service.nested.enabled' }.defaultValue + payload.get('properties').find { it.name == 'sample.service.labels' }.type == 'java.util.List' + !payload.get('properties').find { it.name == 'sample.service.dynamicValue' }.containsKey('defaultValue') + !payload.get('properties').find { it.name == 'sample.service.immutableValue' }.containsKey('defaultValue') + !payload.get('properties').find { it.name == 'sample.service.labels' }.containsKey('defaultValue') + + cleanup: + loader.close() + } + + def "global transform rejects a user property that collides with its metadata payload"() { + given: + GroovyClassLoader loader = new GroovyClassLoader(getClass().classLoader) + + when: + loader.parseClass(''' + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties('sample') + class CollidingConfiguration { + String __grailsConfigurationMetadata + } + ''') + + then: + MultipleCompilationErrorsException error = thrown() + error.message.contains("reserved field '__grailsConfigurationMetadata'") + + cleanup: + loader.close() + } +} diff --git a/grails-databinding/build.gradle b/grails-databinding/build.gradle index c80aaa7ecc4..ef16fed679e 100644 --- a/grails-databinding/build.gradle +++ b/grails-databinding/build.gradle @@ -24,6 +24,7 @@ plugins { id 'org.apache.grails.buildsrc.properties' id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.configuration-metadata' id 'org.apache.grails.buildsrc.publish' id 'org.apache.grails.buildsrc.sbom' id 'org.apache.grails.buildsrc.vulnerability-scan' @@ -75,4 +76,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} diff --git a/grails-databinding/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-databinding/src/main/resources/META-INF/additional-spring-configuration-metadata.json similarity index 100% rename from grails-databinding/src/main/resources/META-INF/spring-configuration-metadata.json rename to grails-databinding/src/main/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/grails-doc/build.gradle b/grails-doc/build.gradle index 029d1e2d6a9..8d1435a188c 100644 --- a/grails-doc/build.gradle +++ b/grails-doc/build.gradle @@ -292,7 +292,9 @@ generateConfigReference.configure { Task it -> project(':grails-cache').tasks.named('jar'), project(':grails-data-hibernate5-dbmigration').tasks.named('jar'), project(':grails-data-mongodb').tasks.named('jar'), - project(':grails-views-gson').tasks.named('jar') + project(':grails-views-gson').tasks.named('jar'), + project(':grails-views-markup').tasks.named('jar'), + project(':grails-web-url-mappings').tasks.named('jar') ) it.description = 'Generates the Application Properties reference from configuration metadata.' @@ -310,7 +312,9 @@ generateConfigReference.configure { Task it -> project(':grails-cache').tasks.named('jar').flatMap { task -> task.archiveFile }, project(':grails-data-hibernate5-dbmigration').tasks.named('jar').flatMap { task -> task.archiveFile }, project(':grails-data-mongodb').tasks.named('jar').flatMap { task -> task.archiveFile }, - project(':grails-views-gson').tasks.named('jar').flatMap { task -> task.archiveFile } + project(':grails-views-gson').tasks.named('jar').flatMap { task -> task.archiveFile }, + project(':grails-views-markup').tasks.named('jar').flatMap { task -> task.archiveFile }, + project(':grails-web-url-mappings').tasks.named('jar').flatMap { task -> task.archiveFile } ) it.outputs.file(outputFile) @@ -328,7 +332,9 @@ generateConfigReference.configure { Task it -> project(':grails-cache').tasks.named('jar').get().archiveFile.get().asFile, project(':grails-data-hibernate5-dbmigration').tasks.named('jar').get().archiveFile.get().asFile, project(':grails-data-mongodb').tasks.named('jar').get().archiveFile.get().asFile, - project(':grails-views-gson').tasks.named('jar').get().archiveFile.get().asFile + project(':grails-views-gson').tasks.named('jar').get().archiveFile.get().asFile, + project(':grails-views-markup').tasks.named('jar').get().archiveFile.get().asFile, + project(':grails-web-url-mappings').tasks.named('jar').get().archiveFile.get().asFile ] Map groupDescriptions = new LinkedHashMap() diff --git a/grails-doc/src/en/guide/conf/config.adoc b/grails-doc/src/en/guide/conf/config.adoc index 7a20585f1da..d5da23da105 100644 --- a/grails-doc/src/en/guide/conf/config.adoc +++ b/grails-doc/src/en/guide/conf/config.adoc @@ -112,6 +112,28 @@ class MyService { } ---- +=== Configuration Metadata + +Grails framework modules publish standard Spring Boot configuration metadata in +`META-INF/spring-configuration-metadata.json`. IDEs use this metadata for key completion, types, descriptions, defaults, +and value hints. The Grails configuration report command and the +link:../ref/Configuration/Application%20Properties.html[Application Properties reference] use its property and group +entries. + +For configuration classes written in Groovy, Grails collects metadata with a compiler AST transformation. The +transformation stores metadata independently in each compiled `@ConfigurationProperties` class, and the build merges +those class payloads after compilation. Java configuration classes are inspected from their compiled bytecode. This +avoids Java annotation processing on Groovy sources, which would interfere with incremental Groovy compilation. + +The generated metadata describes bindable fields and bean properties. A module can also provide +`META-INF/additional-spring-configuration-metadata.json` for descriptions, hints, legacy or indirect properties, and +other details that cannot be derived safely from bytecode. Curated values override generated values for the same +property. + +Only compile-time constant Groovy initializers are emitted as generated defaults. Defaults calculated by constructors, +method calls, environment checks, system properties, or other dynamic expressions are intentionally omitted unless a +curated metadata overlay supplies an authoritative value. + === GrailsConfigurationAware Interface Accessing configuration dynamically at runtime can have a small effect on application performance. An alternative approach is to implement the link:{api}grails/core/support/GrailsConfigurationAware.html[GrailsConfigurationAware] interface, which provides a `setConfiguration` method that accepts the application configuration as a parameter when the class is initialized. You can then assign relevant configuration properties to instance properties on the class for later usage. diff --git a/grails-views-gson/build.gradle b/grails-views-gson/build.gradle index ddd33fb9e61..ab609c0274f 100644 --- a/grails-views-gson/build.gradle +++ b/grails-views-gson/build.gradle @@ -22,6 +22,7 @@ plugins { id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.gradle.grails-plugin' id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.configuration-metadata' id 'org.apache.grails.buildsrc.publish' id 'org.apache.grails.buildsrc.sbom' id 'org.apache.grails.buildsrc.vulnerability-scan' @@ -75,4 +76,4 @@ apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') -} \ No newline at end of file +} diff --git a/grails-views-gson/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-views-gson/src/main/resources/META-INF/additional-spring-configuration-metadata.json similarity index 100% rename from grails-views-gson/src/main/resources/META-INF/spring-configuration-metadata.json rename to grails-views-gson/src/main/resources/META-INF/additional-spring-configuration-metadata.json diff --git a/grails-views-markup/build.gradle b/grails-views-markup/build.gradle index 4f520c3d95f..31e96cbdc72 100644 --- a/grails-views-markup/build.gradle +++ b/grails-views-markup/build.gradle @@ -22,6 +22,7 @@ plugins { id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.gradle.grails-plugin' id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.configuration-metadata' id 'org.apache.grails.buildsrc.publish' id 'org.apache.grails.buildsrc.sbom' id 'org.apache.grails.buildsrc.vulnerability-scan' @@ -60,4 +61,4 @@ apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') from rootProject.layout.projectDirectory.file('gradle/grails-extension-gradle-config.gradle') -} \ No newline at end of file +} diff --git a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json index 831f5866b67..6b5ec41feec 100644 --- a/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json +++ b/grails-web-core/src/main/resources/META-INF/spring-configuration-metadata.json @@ -20,10 +20,6 @@ "name": "grails.logging", "description": "Web & Controllers" }, - { - "name": "grails.cors", - "description": "CORS" - }, { "name": "grails.mime", "description": "Content Negotiation & MIME Types" @@ -123,60 +119,6 @@ "description": "Fully qualified class name of a custom StackTraceFilterer implementation.", "defaultValue": "org.grails.exceptions.reporting.DefaultStackTraceFilterer" }, - { - "name": "grails.cors.enabled", - "type": "java.lang.Boolean", - "description": "Whether CORS support is enabled.", - "defaultValue": false - }, - { - "name": "grails.cors.filter", - "type": "java.lang.Boolean", - "description": "Whether CORS is handled via a servlet filter (true) or an interceptor (false).", - "defaultValue": true - }, - { - "name": "grails.cors.allowedOrigins", - "type": "java.util.List", - "description": "List of allowed origins (e.g., http://localhost:5000), only applies when grails.cors.enabled is true.", - "defaultValue": ["*"] - }, - { - "name": "grails.cors.allowedMethods", - "type": "java.util.List", - "description": "List of allowed HTTP methods.", - "defaultValue": ["*"] - }, - { - "name": "grails.cors.allowedHeaders", - "type": "java.util.List", - "description": "List of allowed request headers.", - "defaultValue": ["*"] - }, - { - "name": "grails.cors.exposedHeaders", - "type": "java.util.List", - "description": "List of response headers to expose to the client.", - "defaultValue": [] - }, - { - "name": "grails.cors.maxAge", - "type": "java.lang.Integer", - "description": "How long (in seconds) the preflight response can be cached.", - "defaultValue": 1800 - }, - { - "name": "grails.cors.allowCredentials", - "type": "java.lang.Boolean", - "description": "Whether credentials (cookies, authorization headers) are supported.", - "defaultValue": false - }, - { - "name": "grails.cors.mappings", - "type": "java.util.Map", - "description": "Map of URL patterns to per-path CORS configuration where defining any mapping disables the global /** mapping.", - "defaultValue": {} - }, { "name": "grails.mime.types", "type": "java.util.Map", diff --git a/grails-web-url-mappings/build.gradle b/grails-web-url-mappings/build.gradle index 9e9e27762a5..e5d58cb94d3 100644 --- a/grails-web-url-mappings/build.gradle +++ b/grails-web-url-mappings/build.gradle @@ -25,6 +25,7 @@ plugins { id 'org.apache.grails.buildsrc.properties' id 'org.apache.grails.buildsrc.dependency-validator' id 'org.apache.grails.buildsrc.compile' + id 'org.apache.grails.buildsrc.configuration-metadata' id 'org.apache.grails.buildsrc.publish' id 'org.apache.grails.buildsrc.sbom' id 'org.apache.grails.buildsrc.vulnerability-scan' @@ -101,4 +102,4 @@ dependencies { apply { from rootProject.layout.projectDirectory.file('gradle/docs-config.gradle') from rootProject.layout.projectDirectory.file('gradle/test-config.gradle') -} \ No newline at end of file +} diff --git a/grails-web-url-mappings/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/grails-web-url-mappings/src/main/resources/META-INF/additional-spring-configuration-metadata.json new file mode 100644 index 00000000000..ff1754b610b --- /dev/null +++ b/grails-web-url-mappings/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -0,0 +1,64 @@ +{ + "groups": [ + { + "name": "grails.cors", + "description": "CORS" + } + ], + "properties": [ + { + "name": "grails.cors.enabled", + "type": "java.lang.Boolean", + "description": "Whether CORS support is enabled.", + "defaultValue": false + }, + { + "name": "grails.cors.filter", + "type": "java.lang.Boolean", + "description": "Whether CORS is handled via a servlet filter (true) or an interceptor (false).", + "defaultValue": true + }, + { + "name": "grails.cors.allowedOrigins", + "type": "java.util.List", + "description": "List of allowed origins (e.g., http://localhost:5000), only applies when grails.cors.enabled is true.", + "defaultValue": ["*"] + }, + { + "name": "grails.cors.allowedMethods", + "type": "java.util.List", + "description": "List of allowed HTTP methods.", + "defaultValue": ["*"] + }, + { + "name": "grails.cors.allowedHeaders", + "type": "java.util.List", + "description": "List of allowed request headers.", + "defaultValue": ["*"] + }, + { + "name": "grails.cors.exposedHeaders", + "type": "java.util.List", + "description": "List of response headers to expose to the client.", + "defaultValue": [] + }, + { + "name": "grails.cors.maxAge", + "type": "java.lang.Integer", + "description": "How long (in seconds) the preflight response can be cached.", + "defaultValue": 1800 + }, + { + "name": "grails.cors.allowCredentials", + "type": "java.lang.Boolean", + "description": "Whether credentials (cookies, authorization headers) are supported.", + "defaultValue": false + }, + { + "name": "grails.cors.mappings", + "type": "java.util.Map", + "description": "Map of URL patterns to per-path CORS configuration where defining any mapping disables the global /** mapping.", + "defaultValue": {} + } + ] +} diff --git a/settings.gradle b/settings.gradle index 011380687f1..97b3362319d 100644 --- a/settings.gradle +++ b/settings.gradle @@ -111,6 +111,7 @@ include( 'grails-codecs-core', 'grails-codecs', 'grails-common', + 'grails-configuration-metadata', 'grails-console', 'grails-controllers', 'grails-converters',