jdaugherty commented on code in PR #16047:
URL: https://github.com/apache/grails-core/pull/16047#discussion_r3683039239
##########
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'),
Review Comment:
Wiring these two jars into `generateConfigReference` publishes the generated
metadata straight into the Application Properties reference, and
`grails-views-markup` has no curated overlay. Generating the reference from
this branch:
- 33 of 187 rows now have an empty Description **and** an empty Default: all
24 `grails.views.markup.*`, plus the 9 newly inferred `grails.views.json.*`
entries (`baseTemplateClass`, `cache`, `enableReloading`, `extension`,
`packageImports`, `packageName`, `staticImports`, `templatePath`,
`useAbsoluteLinks`). Every pre-existing row in that file has a description, so
all 33 blanks are new.
- The markup rows land inside the existing "Views & GSP" section,
interleaved with fully documented `grails.views.gsp.*` rows, so they read as
holes in an otherwise complete table.
Every one of these is settable, so every one needs documenting. Can we add
`grails-views-markup/src/main/resources/META-INF/additional-spring-configuration-metadata.json`
with a group description plus a description and default for each of the 24,
and descriptions for the 9 new JSON Views entries, before these jars feed the
reference?
##########
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)
Review Comment:
`SEMANTIC_ANALYSIS` runs before `@Delegate` composes
(`DelegateASTTransformation` is `CANONICALIZATION`), so delegate-generated
setters are invisible to `collectSetterProperties`. CORS is exactly that case:
running `:grails-web-url-mappings:generateConfigurationMetadata` on this
branch, the generated half contributes only `grails.cors.enabled` and
`grails.cors.mappings`. `allowedOrigins`, `allowedMethods`, `allowedHeaders`,
`exposedHeaders`, `maxAge` and `allowCredentials` — all bindable through the
`@Delegate GrailsDefaultCorsConfiguration` and Spring's `CorsConfiguration`
setters — survive only because the new overlay lists them by hand.
That is a reasonable limitation to accept, but today it fails silently: add
a delegated property and the metadata simply will not have it, with nothing in
the build to say so. Can we state the limitation in the class documentation,
and ideally have the transform warn (or the task fail) when a
`@ConfigurationProperties` class carries a `@Delegate` field, so whoever adds
one is told to extend the overlay?
##########
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'
Review Comment:
Applying `buildsrc.publish` (with the matching `publish-root-config.gradle`
entry) puts this module on Maven Central, and because `grails-bom/base` adds
every published subproject as a constraint it lands in `grails-bom` too. It is
also the only new module that deliberately skips `gradle/docs-config.gradle`,
so it ships as a published artifact with no API docs. It is `compileOnly` on
the five framework modules, so it never reaches their POMs, and nothing outside
this build can use it without the unpublished `build-logic` plugin.
It also registers a **global** transform via
`META-INF/services/org.codehaus.groovy.transform.ASTTransformation`. Once
published, anyone who ends up with it on a compile classpath silently gets
`__grailsConfigurationMetadata` injected into their own
`@ConfigurationProperties` classes, and a hard compile error if they happen to
declare a field with that name. Is there a consumer that needs the artifact? If
not, dropping the publish/sbom/vulnerability-scan plugins and the
`publishedProjects` entry keeps it internal to the build.
Separately, `compileOnly 'org.springframework.boot:spring-boot'` on line 39
looks unused — the transform matches `ConfigurationProperties` and
`ConstructorBinding` by name string and imports nothing from Spring.
##########
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",
Review Comment:
With CORS moved out, this file still carries 10 groups and 24 properties for
prefixes spanning URL mappings, content negotiation, static resources and
scaffolding. Not a blocker for this PR, but is the intent to keep relocating
those to the modules that own them as they gain `@ConfigurationProperties`
classes? Worth capturing in #15469 so this file does not stay the central
fallback by default.
##########
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 {
Review Comment:
Two feature methods for a 277-line transform leaves most of the logic that
decides what gets published unexercised. The TestKit spec covers the Java/ASM
half thoroughly, but the Groovy half is only tested through one happy-path
class, so the branches below can change behaviour without any test noticing:
- `@Delegate` field exclusion, and the `setGrailsApplication`/`setMetaClass`
exclusions.
- `collectSetterProperties` walking superclasses and interfaces — a config
class extending a base class and implementing a trait is the shape both view
configurations actually have, and it is the source of most generated properties.
- `constructorBoundProperties` selection: `@ConstructorBinding` on one of
several constructors, the "no no-arg constructor plus exactly one candidate"
rule, and private/synthetic constructors being ignored.
- A non-constant `prefix` expression, which makes `addPayload` bail out
entirely and silently hands the class to the bytecode bean-scan path instead —
worth pinning, since the two paths do not infer the same set.
- `typeName`/`genericTypeName` for arrays, wildcards (`? extends` / `?
super`) and type variables.
- The hand-rolled `toJson`/`escapeJson`. A constant default containing a
quote, backslash, newline or control character is written into a class-file
string constant and parsed back by `JsonSlurper` in the Gradle task; if the
escaping is ever wrong the failure surfaces as a JSON parse error during
someone else's module build.
- An interface annotated `@ConfigurationProperties` (skipped by
`!node.interface`), and a prefix-less `@ConfigurationProperties`.
Each is a few lines of `parseClass` in this spec, and they are what makes
the mechanism safe to change later.
##########
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<Project> {
+
+ @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<String, ClassModel> models = readModels()
+ List<Map<String, Object>> groups = []
+ List<Map<String, Object>> 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<String, Object> group ->
+ Map<String, Object> entry = new LinkedHashMap<>(group)
+ entry.sourceType = model.name
+ groups << entry
+ }
+ model.payloadProperties.each { Map<String, Object> property ->
+ Map<String, Object> entry = new LinkedHashMap<>(property)
+ entry.sourceType = model.name
+ properties << entry
+ }
+ } else {
+ GenerateConfigurationMetadataTask.addProperties(
+ model, model.prefix, model.name, models, groups,
properties, new LinkedHashSet<String>())
+ }
+ }
+
+ Map<String, Object> 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<String, ClassModel> readModels() {
+ Map<String, ClassModel> models = [:]
+ classesDirs.files.findAll { File file -> file.isDirectory() }.sort {
File file -> file.absolutePath }.each {
+ File directory ->
+ Stream<java.nio.file.Path> 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 == '<init>' && (access & (Opcodes.ACC_PRIVATE |
Opcodes.ACC_SYNTHETIC)) == 0) {
+ ConstructorModel constructor = new ConstructorModel()
+ model.constructors << constructor
+ Type[] argumentTypes = method.argumentTypes
+ List<String> 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<String, Object>(group)
+ }
+ model.payloadProperties = ((payload.get('properties') ?: []) as
List).collect { Map property ->
+ new LinkedHashMap<String, Object>(property)
+ }
+ } else {
+ List<ConstructorModel> 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<String, ClassModel> models,
List<Map<String, Object>> groups,
+ List<Map<String, Object>> properties,
+ Set<String> visiting) {
+ if (!visiting.add(model.name)) {
+ return
+ }
+ propertiesFor(model, models, new LinkedHashSet<String>()).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<String>()).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<String, PropertyModel> propertiesFor(ClassModel model,
Map<String, ClassModel> models,
+ Set<String>
visited) {
+ if (model == null || !visited.add(model.name)) {
+ return [:]
+ }
+ Map<String, PropertyModel> 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<String, Object> merge(List<Map<String, Object>> groups,
+ List<Map<String, Object>>
properties, Map overlay) {
+ Map<String, Object> 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<Object> mergeNamed(List generated, List overlay,
String category) {
+ Map<String, Object> generatedByName = indexByName(generated, category,
'generated')
+ Map<String, Object> overlayByName = indexByName(overlay, category,
'overlay')
+ Map<String, Object> 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<String, Object> indexByName(List source, String
category, String sourceName) {
Review Comment:
`indexByName` enforces name uniqueness per category. That is right for
`properties`, but Boot's format allows repeated group names distinguished by
`sourceType`/`sourceMethod`, and `generate()` emits one group per
`@ConfigurationProperties` class (line 128) plus one per nested holder. Two
config classes sharing a prefix in the same module, or a nested prefix
reachable from two classes, would fail the build with `Conflicting generated
groups metadata for '...'` on metadata that is legal. Worth keying groups on
name + `sourceType`, or merging instead of rejecting.
##########
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<Project> {
+
+ @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<String, ClassModel> models = readModels()
+ List<Map<String, Object>> groups = []
+ List<Map<String, Object>> 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<String, Object> group ->
+ Map<String, Object> entry = new LinkedHashMap<>(group)
+ entry.sourceType = model.name
+ groups << entry
+ }
+ model.payloadProperties.each { Map<String, Object> property ->
+ Map<String, Object> entry = new LinkedHashMap<>(property)
+ entry.sourceType = model.name
+ properties << entry
+ }
+ } else {
+ GenerateConfigurationMetadataTask.addProperties(
+ model, model.prefix, model.name, models, groups,
properties, new LinkedHashSet<String>())
+ }
+ }
+
+ Map<String, Object> 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<String, ClassModel> readModels() {
+ Map<String, ClassModel> models = [:]
+ classesDirs.files.findAll { File file -> file.isDirectory() }.sort {
File file -> file.absolutePath }.each {
+ File directory ->
+ Stream<java.nio.file.Path> 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) {
Review Comment:
`ClassModel` does not implement `equals`, so `previous != model` is identity
comparison and is always true for two distinct instances. This reduces to "fail
on any duplicate class name across the input directories", which is not what
the guard reads as. Either give `ClassModel` value equality (or compare
prefix/payload) so a genuinely identical duplicate is tolerated as intended, or
drop the second condition so the code says what it does.
##########
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<String, List<Map<String, Object>>> metadata = metadata(
+ node, prefix, node.name, new LinkedHashSet<String>())
+ 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<String, List<Map<String, Object>>> metadata(ClassNode
node, String prefix,
+ String
sourceType, Set<String> visiting) {
+ if (!visiting.add(node.name)) {
+ return [groups: [], properties: []]
+ }
+ Map<String, Map<String, Object>> bindable = [:]
+ node.properties.findAll { PropertyNode property ->
isBindableProperty(property) }.each {
+ PropertyNode propertyNode ->
+ FieldNode field = propertyNode.field
+ Map<String, Object> 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<String>())
+
+ List<Map<String, Object>> groups = []
+ List<Map<String, Object>> properties = []
+ bindable.values().sort { Map<String, Object> property ->
property.propertyName as String }.each {
+ Map<String, Object> 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<String, List<Map<String, Object>>> 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<String, Object> group -> group.name
as String },
+ properties: properties.sort { Map<String, Object> property ->
property.name as String }
+ ]
+ }
+
+ private static Map<String, Object> scalarProperty(String name, Map<String,
Object> property) {
+ Map<String, Object> 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<String> 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<String> : Collections.emptySet()
+ }
+
+ private static void collectSetterProperties(ClassNode node, Map<String,
Map<String, Object>> bindable,
+ Set<String> 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) {
Review Comment:
`isNested` treats every type outside `java.*`/`groovy.*` as a nested
configuration group, and `collectSetterProperties` walks superclasses and
interfaces without applying that filter at all — it only stops at `Object`. The
two hardcoded names on line 174 are what keeps that from exploding today:
`JsonViewConfiguration` and `MarkupViewConfiguration` implement the
`GenericViewConfiguration` trait, which extends `GrailsApplicationAware`, so
without the `setGrailsApplication` exclusion the transform would recurse into
`GrailsApplication`.
Boot's own processor keys nesting off inner classes and
`@NestedConfigurationProperty` rather than a package-name heuristic, and the
ASM path in this PR is already narrower — it nests only into types found in the
same module's output (`models[property.rawType()]`). Could the Groovy path use
the same restriction, and honour `@NestedConfigurationProperty`? As written,
the next config class with a setter taking a framework or third-party bean type
silently emits a nested group tree, and the remedy will be another name added
to that blocklist.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]