From d43b0f52f3b59eb164ebd6ba9a5670773912b08f Mon Sep 17 00:00:00 2001 From: Areg Iazychian Date: Wed, 12 Aug 2026 16:07:11 +0300 Subject: [PATCH 1/3] Add symbol processor for configuration metadata Add spring-boot-configuration-symbol-processor, a Kotlin Symbol Processing (KSP) processor that writes the configuration metadata of Kotlin types. It reuses the metadata model of the annotation processor, so the generated META-INF/spring-configuration-metadata.json is identical in format, and it lets a Kotlin project generate its metadata without kapt. The processor supports constructor binding and JavaBean binding, nested groups, @ConfigurationProperties on a method, actuator endpoints, descriptions taken from KDoc, deprecations, and the merging of META-INF/additional-spring-configuration-metadata.json. A type annotated with @ConfigurationPropertiesSource is described in a file of its own. Kotlin types are reported using their JVM names, so that a List property is described as java.util.List as it is when the annotation processor runs. See gh-28046 Signed-off-by: Areg Iazychian --- .../build.gradle | 35 + .../ConfigurationMetadataSymbolProcessor.kt | 289 ++++++++ ...gurationMetadataSymbolProcessorProvider.kt | 34 + .../ksp/ConfigurationPropertiesAnnotations.kt | 70 ++ .../ksp/DefaultValues.kt | 114 ++++ .../ksp/EndpointMetadataResolver.kt | 114 ++++ .../ksp/JavaTypeNameResolver.kt | 179 +++++ .../ksp/KspAnnotations.kt | 85 +++ .../ksp/MetadataGenerationContext.kt | 168 +++++ .../ksp/PropertyDescriptor.kt | 266 ++++++++ .../ksp/PropertyDescriptorResolver.kt | 143 ++++ ...ols.ksp.processing.SymbolProcessorProvider | 1 + .../boot/actuate/endpoint/Access.kt | 33 + .../annotation/TestEndpointAnnotations.kt | 36 + ...nfigurationMetadataSymbolProcessorTests.kt | 620 ++++++++++++++++++ .../ksp/MetadataCompiler.kt | 121 ++++ .../ksp/RecordingLogger.kt | 51 ++ .../configurationprocessor/ksp/SourceFile.kt | 35 + .../TestConfigurationPropertiesAnnotations.kt | 59 ++ .../properties/bind/TestBindAnnotations.kt | 42 ++ gradle.properties | 1 + settings.gradle | 1 + 22 files changed, 2497 insertions(+) create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/build.gradle create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessor.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorProvider.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationPropertiesAnnotations.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/DefaultValues.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/EndpointMetadataResolver.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/JavaTypeNameResolver.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/KspAnnotations.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataGenerationContext.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptor.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptorResolver.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/Access.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/annotation/TestEndpointAnnotations.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorTests.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataCompiler.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/RecordingLogger.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/SourceFile.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/TestConfigurationPropertiesAnnotations.kt create mode 100644 configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/bind/TestBindAnnotations.kt diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/build.gradle b/configuration-metadata/spring-boot-configuration-symbol-processor/build.gradle new file mode 100644 index 000000000000..ba0777d2c9de --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/build.gradle @@ -0,0 +1,35 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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 "java-library" + id "org.jetbrains.kotlin.jvm" + id "org.springframework.boot.deployed" +} + +description = "Spring Boot Configuration Symbol Processor" + +dependencies { + api(project(":configuration-metadata:spring-boot-configuration-processor")) + + compileOnly("com.google.devtools.ksp:symbol-processing-api:${kspVersion}") + + testImplementation(enforcedPlatform(project(":platform:spring-boot-dependencies"))) + testImplementation(project(":test-support:spring-boot-test-support")) + testImplementation("com.google.devtools.ksp:symbol-processing-api:${kspVersion}") + testImplementation("com.google.devtools.ksp:symbol-processing-aa-embeddable:${kspVersion}") + testImplementation("com.google.devtools.ksp:symbol-processing-common-deps:${kspVersion}") +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessor.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessor.kt new file mode 100644 index 000000000000..da0db732d6c9 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessor.kt @@ -0,0 +1,289 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.isPrivate +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.CONFIGURATION_PROPERTIES +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.CONFIGURATION_PROPERTIES_SOURCE +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.ENDPOINT_ANNOTATIONS +import org.springframework.boot.configurationprocessor.json.JSONException +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata +import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller +import java.io.File +import java.io.IOException + +/** + * [SymbolProcessor] that writes the configuration metadata of Kotlin classes annotated + * with `@ConfigurationProperties`. + * + * Metadata is collected across all processing rounds and written as a single + * `META-INF/spring-configuration-metadata.json` resource once processing has completed. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +class ConfigurationMetadataSymbolProcessor( + private val codeGenerator: CodeGenerator, + logger: KSPLogger, + private val options: Map = emptyMap(), +) : SymbolProcessor { + + private val context = MetadataGenerationContext(logger) + + private val propertyDescriptorResolver = PropertyDescriptorResolver(this.context) + + private val endpointMetadataResolver = EndpointMetadataResolver(this.context) + + private val metadata = ConfigurationMetadata() + + private val sourceFiles = LinkedHashSet() + + private val sourceMetadata = LinkedHashMap() + + override fun process(resolver: Resolver): List { + val symbols = resolver.getSymbolsWithAnnotation(CONFIGURATION_PROPERTIES).toList() + symbols.filterIsInstance().forEach(::processConfigurationProperties) + symbols.filterIsInstance().forEach(::processConfigurationPropertiesMethod) + resolver.getSymbolsWithAnnotation(CONFIGURATION_PROPERTIES_SOURCE) + .filterIsInstance() + .forEach(::processConfigurationPropertiesSource) + ENDPOINT_ANNOTATIONS.asSequence() + .flatMap { resolver.getSymbolsWithAnnotation(it) } + .filterIsInstance() + .distinct() + .forEach(::processEndpoint) + return emptyList() + } + + override fun finish() { + writeSourceMetadata() + val metadata = mergeAdditionalMetadata() + removeIgnored(metadata) + if (metadata.items.isEmpty()) { + return + } + write(metadata, METADATA_PATH) + } + + private fun writeSourceMetadata() { + this.sourceMetadata.forEach { (type, metadata) -> + removeIgnored(metadata) + if (metadata.items.isNotEmpty()) { + write(metadata, SOURCE_METADATA_PATH + type) + } + } + } + + private fun write(metadata: ConfigurationMetadata, path: String) { + val dependencies = Dependencies(true, *this.sourceFiles.toTypedArray()) + this.codeGenerator.createNewFileByPath(dependencies, path, METADATA_EXTENSION).use { output -> + JsonMarshaller().write(metadata, output) + } + } + + /** + * Process a type annotated with `@ConfigurationPropertiesSource`, whose properties are + * described in a file of their own so that other modules can reuse them. + */ + private fun processConfigurationPropertiesSource(declaration: KSClassDeclaration) { + val type = this.context.typeNames.resolve(declaration) + val metadata = this.sourceMetadata.getOrPut(type) { ConfigurationMetadata() } + processType("", declaration, ArrayDeque(), target = metadata) + } + + /** + * Merge the metadata contributed by `META-INF/additional-spring-configuration-metadata.json`. + * + * As KSP gives a processor no access to the resources of the module, the directories + * to look into have to be provided using the + * `org.springframework.boot.configurationprocessor.additionalMetadataLocations` option. + */ + private fun mergeAdditionalMetadata(): ConfigurationMetadata { + val additional = readAdditionalMetadata() ?: return this.metadata + val merged = ConfigurationMetadata(this.metadata) + merged.merge(additional) + return merged + } + + private fun readAdditionalMetadata(): ConfigurationMetadata? { + val locations = this.options[ADDITIONAL_METADATA_LOCATIONS_OPTION] ?: return null + val file = locations.split(",") + .map(String::trim) + .filter(String::isNotEmpty) + .map { File(it, ADDITIONAL_METADATA_PATH) } + .firstOrNull(File::isFile) ?: return null + return try { + file.inputStream().use { JsonMarshaller().read(it) } + } + catch (ex: IOException) { + this.context.logger.warn("Unable to read additional metadata from '$file': ${ex.message}") + null + } + catch (ex: JSONException) { + this.context.logger.error("Invalid additional meta-data in '$file': ${ex.message}") + null + } + } + + private fun removeIgnored(metadata: ConfigurationMetadata) { + metadata.ignored.forEach { metadata.removeMetadata(it.type, it.name) } + } + + private fun processConfigurationProperties(declaration: KSClassDeclaration) { + val annotation = declaration.findAnnotation(CONFIGURATION_PROPERTIES) ?: return + val prefix = prefixOf(annotation) + val type = this.context.typeNames.resolve(declaration) + this.metadata.add(ItemMetadata.newGroup(prefix, type, type, null)) + processType(prefix, declaration, ArrayDeque()) + } + + /** + * Process a method annotated with `@ConfigurationProperties`, whose return type carries + * the properties. + */ + private fun processConfigurationPropertiesMethod(function: KSFunctionDeclaration) { + val annotation = function.findAnnotation(CONFIGURATION_PROPERTIES) ?: return + if (function.isPrivate()) { + return + } + val returnType = function.returnType?.resolve() ?: return + val declaration = returnType.declaration as? KSClassDeclaration ?: return + if (declaration.qualifiedName?.asString() == KOTLIN_UNIT) { + return + } + val owner = function.parentDeclaration as? KSClassDeclaration ?: return + val prefix = prefixOf(annotation) + val group = ItemMetadata.newGroup( + prefix, + this.context.typeNames.resolve(declaration), + this.context.typeNames.resolve(owner), + sourceMethod(function), + ) + if (hasSimilarGroup(group)) { + this.context.logger.error("Duplicate @ConfigurationProperties definition for prefix '$prefix'", function) + return + } + this.metadata.add(group) + function.containingFile?.let(this.sourceFiles::add) + processType(prefix, declaration, ArrayDeque(), function) + } + + private fun processType( + prefix: String, + declaration: KSClassDeclaration, + seen: ArrayDeque, + factoryMethod: KSFunctionDeclaration? = null, + target: ConfigurationMetadata = this.metadata, + ) { + val type = this.context.typeNames.resolve(declaration) + if (seen.contains(type)) { + return + } + seen.addLast(type) + declaration.containingFile?.let(this.sourceFiles::add) + this.propertyDescriptorResolver.resolve(declaration, factoryMethod).forEach { descriptor -> + descriptor.toItemMetadata(prefix, this.context)?.let(target::add) + if (descriptor.isNested(this.context)) { + processNestedType(prefix, descriptor, seen, factoryMethod, target) + } + } + seen.removeLast() + } + + private fun processNestedType( + prefix: String, + descriptor: PropertyDescriptor, + seen: ArrayDeque, + factoryMethod: KSFunctionDeclaration?, + target: ConfigurationMetadata, + ) { + val nestedType = descriptor.type.declaration as? KSClassDeclaration ?: return + val nestedPrefix = ConfigurationMetadata.nestedPrefix(prefix, descriptor.name) + processType(nestedPrefix, nestedType, seen, factoryMethod, target) + } + + /** + * Process a type annotated with one of the actuator endpoint annotations. + */ + private fun processEndpoint(declaration: KSClassDeclaration) { + val endpoint = this.endpointMetadataResolver.resolve(declaration) ?: return + declaration.containingFile?.let(this.sourceFiles::add) + this.metadata.addIfMissing(endpoint.group) + endpoint.properties.forEach { property -> addEndpointProperty(property, declaration) } + } + + private fun addEndpointProperty(property: ItemMetadata, declaration: KSClassDeclaration) { + val existing = this.metadata.items.firstOrNull { + it.isOfItemType(ItemMetadata.ItemType.PROPERTY) && it.name == property.name + } + if (existing == null) { + this.metadata.add(property) + return + } + if (existing.defaultValue != property.defaultValue) { + this.context.logger.error( + "Existing property '${existing.name}' from type ${existing.sourceType} has a conflicting value. " + + "Existing value: ${existing.defaultValue}, new value from type ${property.sourceType}: " + + "${property.defaultValue}", + declaration, + ) + } + } + + private fun prefixOf(annotation: KSAnnotation): String = + annotation.findStringValue("prefix") ?: annotation.findStringValue("value") ?: "" + + private fun hasSimilarGroup(group: ItemMetadata): Boolean = this.metadata.items.any { + it.isOfItemType(ItemMetadata.ItemType.GROUP) && it.name == group.name && it.type == group.type + } + + private fun sourceMethod(function: KSFunctionDeclaration): String { + val parameters = function.parameters.joinToString(separator = ",") { + this.context.typeNames.resolve(it.type.resolve()) + } + return "${function.simpleName.asString()}($parameters)" + } + + private companion object { + + private const val METADATA_PATH = "META-INF/spring-configuration-metadata" + + private const val SOURCE_METADATA_PATH = "META-INF/spring/configuration-metadata/" + + private const val METADATA_EXTENSION = "json" + + private const val KOTLIN_UNIT = "kotlin.Unit" + + private const val ADDITIONAL_METADATA_PATH = "META-INF/additional-spring-configuration-metadata.json" + + private const val ADDITIONAL_METADATA_LOCATIONS_OPTION = + "org.springframework.boot.configurationprocessor.additionalMetadataLocations" + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorProvider.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorProvider.kt new file mode 100644 index 000000000000..46f5188af527 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider + +/** + * [SymbolProcessorProvider] for [ConfigurationMetadataSymbolProcessor]. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +class ConfigurationMetadataSymbolProcessorProvider : SymbolProcessorProvider { + + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor = + ConfigurationMetadataSymbolProcessor(environment.codeGenerator, environment.logger, environment.options) + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationPropertiesAnnotations.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationPropertiesAnnotations.kt new file mode 100644 index 000000000000..71d48df178c8 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationPropertiesAnnotations.kt @@ -0,0 +1,70 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +/** + * Fully qualified names of the annotations that drive configuration metadata generation. + * + * The names are referenced as strings so that the processor can run without Spring Boot on + * its classpath, mirroring the behavior of the Java annotation processor. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal object ConfigurationPropertiesAnnotations { + + const val CONFIGURATION_PROPERTIES = "org.springframework.boot.context.properties.ConfigurationProperties" + + const val CONFIGURATION_PROPERTIES_SOURCE = + "org.springframework.boot.context.properties.ConfigurationPropertiesSource" + + const val NESTED_CONFIGURATION_PROPERTY = + "org.springframework.boot.context.properties.NestedConfigurationProperty" + + const val DEPRECATED_CONFIGURATION_PROPERTY = + "org.springframework.boot.context.properties.DeprecatedConfigurationProperty" + + const val CONSTRUCTOR_BINDING = "org.springframework.boot.context.properties.bind.ConstructorBinding" + + const val DEFAULT_VALUE = "org.springframework.boot.context.properties.bind.DefaultValue" + + const val NAME = "org.springframework.boot.context.properties.bind.Name" + + const val AUTOWIRED = "org.springframework.beans.factory.annotation.Autowired" + + const val READ_OPERATION = "org.springframework.boot.actuate.endpoint.annotation.ReadOperation" + + const val ENDPOINT_ACCESS_ENUM = "org.springframework.boot.actuate.endpoint.Access" + + /** + * The annotations that declare an actuator endpoint, in the order in which they are + * considered. + */ + val ENDPOINT_ANNOTATIONS = listOf( + "org.springframework.boot.actuate.endpoint.annotation.Endpoint", + "org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpoint", + "org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpoint", + "org.springframework.boot.actuate.endpoint.web.annotation.RestControllerEndpoint", + "org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpoint", + "org.springframework.boot.actuate.endpoint.web.annotation.WebEndpoint", + ) + + const val JAVA_DEPRECATED = "java.lang.Deprecated" + + const val KOTLIN_DEPRECATED = "kotlin.Deprecated" + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/DefaultValues.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/DefaultValues.kt new file mode 100644 index 000000000000..4e048b5bc13b --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/DefaultValues.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSType + +/** + * Resolves the default value of a property from a `@DefaultValue` annotation or from the + * type itself. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal object DefaultValues { + + /** + * Return the value declared by the given `@DefaultValue` [annotation], coerced to the + * type of the property. + * @param annotation the `@DefaultValue` annotation + * @param type the type of the property + * @param node the node to report coercion failures against + * @param context the metadata generation context + * @return the default value, a list of values for a collection, or `null` if the + * annotation does not declare a value + */ + fun fromAnnotation( + annotation: KSAnnotation, + type: KSType, + node: KSNode, + context: MetadataGenerationContext, + ): Any? { + val values = annotation.findValue("value").asStringList() ?: return null + if (values.isEmpty()) { + return null + } + val targetType = elementTypeOf(type, context) ?: type + val coerced = values.map { coerce(it, targetType, node, context) } + return if (coerced.size == 1) coerced[0] else coerced + } + + /** + * Return the value a property of the given [type] defaults to when it is bound to a + * type that has no `null` representation on the JVM, or `null` when there is no such + * default. + */ + fun fromType(type: KSType): Any? { + if (type.isMarkedNullable) { + return null + } + return PRIMITIVE_DEFAULTS[type.declaration.qualifiedName?.asString()] + } + + private fun elementTypeOf(type: KSType, context: MetadataGenerationContext): KSType? { + if (!context.isCollectionOrMap(type)) { + return null + } + return type.arguments.firstOrNull()?.type?.resolve() + } + + private fun coerce(value: String, type: KSType, node: KSNode, context: MetadataGenerationContext): Any { + val coercion = COERCIONS[type.declaration.qualifiedName?.asString()] ?: return value + val coerced = coercion.invoke(value) + if (coerced == null) { + context.logger.error("Invalid ${type.declaration.simpleName.asString()} representation '$value'", node) + return value + } + return coerced + } + + private fun Any?.asStringList(): List? = when (this) { + is String -> listOf(this) + is List<*> -> filterIsInstance() + else -> null + } + + private val PRIMITIVE_DEFAULTS = mapOf( + "kotlin.Boolean" to false, + "kotlin.Byte" to 0.toByte(), + "kotlin.Double" to 0.0, + "kotlin.Float" to 0.0f, + "kotlin.Int" to 0, + "kotlin.Long" to 0L, + "kotlin.Short" to 0.toShort(), + ) + + private val COERCIONS = mapOf Any?>( + // Matches Boolean.parseBoolean, which never fails + "kotlin.Boolean" to { value: String -> value.toBoolean() }, + "kotlin.Byte" to String::toByteOrNull, + "kotlin.Char" to { value: String -> if (value.length > 1) null else value }, + "kotlin.Double" to String::toDoubleOrNull, + "kotlin.Float" to String::toFloatOrNull, + "kotlin.Int" to String::toIntOrNull, + "kotlin.Long" to String::toLongOrNull, + "kotlin.Short" to String::toShortOrNull, + ) + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/EndpointMetadataResolver.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/EndpointMetadataResolver.kt new file mode 100644 index 000000000000..d2f0c6904602 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/EndpointMetadataResolver.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.getDeclaredFunctions +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSType +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.ENDPOINT_ACCESS_ENUM +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.ENDPOINT_ANNOTATIONS +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.READ_OPERATION +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata + +/** + * Resolves the metadata contributed by a type annotated with one of the actuator endpoint + * annotations. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal class EndpointMetadataResolver(private val context: MetadataGenerationContext) { + + /** + * Return the metadata of the given [declaration] when it is an endpoint, or `null` + * when it is not an endpoint or declares no identifier. + */ + fun resolve(declaration: KSClassDeclaration): EndpointMetadata? { + val annotation = ENDPOINT_ANNOTATIONS.firstNotNullOfOrNull { declaration.findAnnotation(it) } + ?: return null + val id = annotation.findStringValue("id") ?: return null + val key = ItemMetadata.newItemMetadataPrefix(ENDPOINT_PREFIX, id) + val type = this.context.typeNames.resolve(declaration) + val defaultAccess = defaultAccessOf(annotation.findValue("defaultAccess")) + val items = mutableListOf( + ItemMetadata.newProperty( + key, + "access", + ENDPOINT_ACCESS_ENUM, + type, + null, + "Permitted level of access for the $id endpoint.", + defaultAccess, + null, + ), + ) + if (hasMainReadOperation(declaration)) { + items += ItemMetadata.newProperty( + key, + "cache.time-to-live", + "java.time.Duration", + type, + null, + "Maximum time that a response can be cached.", + "0ms", + null, + ) + } + return EndpointMetadata(ItemMetadata.newGroup(key, type, type, null), items, defaultAccess) + } + + private fun defaultAccessOf(value: Any?): String { + val name = when (value) { + null -> DEFAULT_ACCESS + is KSType -> value.declaration.simpleName.asString() + is KSClassDeclaration -> value.simpleName.asString() + else -> value.toString() + } + return name.lowercase() + } + + private fun hasMainReadOperation(declaration: KSClassDeclaration): Boolean = + declaration.getDeclaredFunctions().any { function -> + function.hasAnnotation(READ_OPERATION) && !returnsUnit(function) && hasNoMandatoryParameters(function) + } + + private fun returnsUnit(function: KSFunctionDeclaration): Boolean = + function.returnType?.resolve()?.declaration?.qualifiedName?.asString() == KOTLIN_UNIT + + private fun hasNoMandatoryParameters(function: KSFunctionDeclaration): Boolean = + function.parameters.all { it.type.resolve().isMarkedNullable || it.hasDefault } + + /** + * The metadata contributed by a single endpoint. + * + * @author Areg Iazychian + * @since 4.2.0 + */ + data class EndpointMetadata(val group: ItemMetadata, val properties: List, val defaultAccess: String) + + private companion object { + + private const val ENDPOINT_PREFIX = "management.endpoint." + + private const val DEFAULT_ACCESS = "unrestricted" + + private const val KOTLIN_UNIT = "kotlin.Unit" + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/JavaTypeNameResolver.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/JavaTypeNameResolver.kt new file mode 100644 index 000000000000..5fc77e38b5a6 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/JavaTypeNameResolver.kt @@ -0,0 +1,179 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeAlias +import com.google.devtools.ksp.symbol.KSTypeArgument +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.Variance + +/** + * Renders a [KSType] using the Java type names that configuration metadata is expected to + * contain. + * + * Kotlin types are mapped to their JVM counterparts (`kotlin.Int` becomes + * `java.lang.Integer`, `kotlin.collections.List` becomes `java.util.List`), nested classes + * use the binary `$` separator and generic information is retained, matching the output of + * the Java annotation processor. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal class JavaTypeNameResolver { + + /** + * Return the Java type name of the given [type]. + * @param type the type to render + * @return the fully qualified Java name, including generic information + */ + fun resolve(type: KSType): String = resolve(type, mutableSetOf()) + + /** + * Return the binary name of the given [declaration], using `$` to separate nested + * classes. + * @param declaration the declaration to render + * @return the fully qualified binary name + */ + fun resolve(declaration: KSClassDeclaration): String { + val parent = declaration.parentDeclaration + if (parent is KSClassDeclaration) { + return resolve(parent) + '$' + declaration.simpleName.asString() + } + return declaration.qualifiedName?.asString() ?: declaration.simpleName.asString() + } + + private fun resolve(type: KSType, visitedTypeParameters: MutableSet): String = + when (val declaration = type.declaration) { + is KSTypeAlias -> resolve(declaration.type.resolve(), visitedTypeParameters) + is KSTypeParameter -> resolve(declaration, visitedTypeParameters) + is KSClassDeclaration -> resolve(type, declaration, visitedTypeParameters) + else -> declaration.simpleName.asString() + } + + private fun resolve(typeParameter: KSTypeParameter, visitedTypeParameters: MutableSet): String { + if (!visitedTypeParameters.add(typeParameter)) { + // Self-referencing bound such as `T : Comparable`, keep the variable name + return typeParameter.name.asString() + } + try { + val bound = typeParameter.bounds.firstOrNull()?.resolve() + return if (bound != null) resolve(bound, visitedTypeParameters) else JAVA_OBJECT + } + finally { + visitedTypeParameters.remove(typeParameter) + } + } + + private fun resolve( + type: KSType, + declaration: KSClassDeclaration, + visitedTypeParameters: MutableSet, + ): String { + val qualifiedName = declaration.qualifiedName?.asString() + if (qualifiedName == KOTLIN_ARRAY) { + val component = type.arguments.firstOrNull()?.type?.resolve() + return (if (component != null) resolve(component, visitedTypeParameters) else JAVA_OBJECT) + "[]" + } + PRIMITIVE_ARRAYS[qualifiedName]?.let { return it } + val javaName = JAVA_NAMES[qualifiedName] ?: resolve(declaration) + if (type.arguments.isEmpty()) { + return javaName + } + val arguments = type.arguments.joinToString(separator = ",", prefix = "<", postfix = ">") { + resolve(it, visitedTypeParameters) + } + return javaName + arguments + } + + private fun resolve(argument: KSTypeArgument, visitedTypeParameters: MutableSet): String { + if (argument.variance == Variance.STAR) { + return "?" + } + val type = argument.type?.resolve() ?: return "?" + val name = resolve(type, visitedTypeParameters) + return when (argument.variance) { + Variance.COVARIANT -> "? extends $name" + Variance.CONTRAVARIANT -> "? super $name" + else -> name + } + } + + private companion object { + + private const val JAVA_OBJECT = "java.lang.Object" + + private const val KOTLIN_ARRAY = "kotlin.Array" + + /** + * Kotlin types that are mapped to a different type on the JVM. Primitives are + * mapped to their boxed counterpart as the Java annotation processor boxes them + * too. + */ + private val JAVA_NAMES = mapOf( + "kotlin.Any" to JAVA_OBJECT, + "kotlin.Boolean" to "java.lang.Boolean", + "kotlin.Byte" to "java.lang.Byte", + "kotlin.Char" to "java.lang.Character", + "kotlin.CharSequence" to "java.lang.CharSequence", + "kotlin.Comparable" to "java.lang.Comparable", + "kotlin.Double" to "java.lang.Double", + "kotlin.Enum" to "java.lang.Enum", + "kotlin.Float" to "java.lang.Float", + "kotlin.Int" to "java.lang.Integer", + "kotlin.Long" to "java.lang.Long", + "kotlin.Number" to "java.lang.Number", + "kotlin.Short" to "java.lang.Short", + "kotlin.String" to "java.lang.String", + "kotlin.Throwable" to "java.lang.Throwable", + "kotlin.Unit" to "void", + "kotlin.collections.Collection" to "java.util.Collection", + "kotlin.collections.Iterable" to "java.lang.Iterable", + "kotlin.collections.Iterator" to "java.util.Iterator", + "kotlin.collections.List" to "java.util.List", + "kotlin.collections.ListIterator" to "java.util.ListIterator", + "kotlin.collections.Map" to "java.util.Map", + "kotlin.collections.Map.Entry" to "java.util.Map\$Entry", + "kotlin.collections.MutableCollection" to "java.util.Collection", + "kotlin.collections.MutableIterable" to "java.lang.Iterable", + "kotlin.collections.MutableIterator" to "java.util.Iterator", + "kotlin.collections.MutableList" to "java.util.List", + "kotlin.collections.MutableListIterator" to "java.util.ListIterator", + "kotlin.collections.MutableMap" to "java.util.Map", + "kotlin.collections.MutableMap.MutableEntry" to "java.util.Map\$Entry", + "kotlin.collections.MutableSet" to "java.util.Set", + "kotlin.collections.Set" to "java.util.Set", + ) + + /** + * Kotlin primitive arrays, which are plain JVM arrays of the matching primitive. + */ + private val PRIMITIVE_ARRAYS = mapOf( + "kotlin.BooleanArray" to "java.lang.Boolean[]", + "kotlin.ByteArray" to "java.lang.Byte[]", + "kotlin.CharArray" to "java.lang.Character[]", + "kotlin.DoubleArray" to "java.lang.Double[]", + "kotlin.FloatArray" to "java.lang.Float[]", + "kotlin.IntArray" to "java.lang.Integer[]", + "kotlin.LongArray" to "java.lang.Long[]", + "kotlin.ShortArray" to "java.lang.Short[]", + ) + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/KspAnnotations.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/KspAnnotations.kt new file mode 100644 index 000000000000..2d74d634c6f8 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/KspAnnotations.kt @@ -0,0 +1,85 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration + +/** + * Return the annotation with the given [qualifiedName] that is directly present on this + * element, or `null` if there is no such annotation. + * + * @author Areg Iazychian + */ +internal fun KSAnnotated.findAnnotation(qualifiedName: String): KSAnnotation? = + annotations.firstOrNull { it.hasQualifiedName(qualifiedName) } + +/** + * Return whether an annotation with the given [qualifiedName] is present on this element, + * optionally considering meta-annotations. + * + * @author Areg Iazychian + */ +internal fun KSAnnotated.hasAnnotation(qualifiedName: String, considerMetaAnnotations: Boolean = false): Boolean { + if (annotations.any { it.hasQualifiedName(qualifiedName) }) { + return true + } + if (!considerMetaAnnotations) { + return false + } + val seen = mutableSetOf() + return annotations.any { it.isMetaAnnotatedWith(qualifiedName, seen) } +} + +/** + * Return the value of the annotation attribute with the given [name], or `null` if the + * attribute is absent or has no value. + * + * @author Areg Iazychian + */ +internal fun KSAnnotation.findValue(name: String): Any? = + arguments.firstOrNull { it.name?.asString() == name }?.value + +/** + * Return the [String] value of the annotation attribute with the given [name], or `null` + * if the attribute is absent or empty. + * + * An empty value is treated as absent to match the Java annotation processor, which only + * sees attributes that have been declared explicitly. + * + * @author Areg Iazychian + */ +internal fun KSAnnotation.findStringValue(name: String): String? = (findValue(name) as? String)?.ifEmpty { null } + +private fun KSAnnotation.hasQualifiedName(qualifiedName: String): Boolean { + if (shortName.asString() != qualifiedName.substringAfterLast('.')) { + return false + } + return annotationType.resolve().declaration.qualifiedName?.asString() == qualifiedName +} + +private fun KSAnnotation.isMetaAnnotatedWith(qualifiedName: String, seen: MutableSet): Boolean { + val declaration = annotationType.resolve().declaration as? KSClassDeclaration ?: return false + val name = declaration.qualifiedName?.asString() ?: return false + if (!seen.add(name)) { + return false + } + return declaration.annotations.any { + it.hasQualifiedName(qualifiedName) || it.isMetaAnnotatedWith(qualifiedName, seen) + } +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataGenerationContext.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataGenerationContext.kt new file mode 100644 index 000000000000..13bb340de4eb --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataGenerationContext.kt @@ -0,0 +1,168 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.DEPRECATED_CONFIGURATION_PROPERTY +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.JAVA_DEPRECATED +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.KOTLIN_DEPRECATED +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation + +/** + * Utilities shared by the components that turn Kotlin declarations into configuration + * metadata. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal class MetadataGenerationContext( + val logger: KSPLogger, + val typeNames: JavaTypeNameResolver = JavaTypeNameResolver(), +) { + + /** + * Return the type of the given [property] as seen from [declaringType], so that the + * type arguments that a superclass declares are resolved against the type that + * extends it. + */ + fun typeOf(property: KSPropertyDeclaration, declaringType: KSClassDeclaration): KSType = + try { + property.asMemberOf(declaringType.asStarProjectedType()) + } + catch (ex: IllegalArgumentException) { + this.logger.info("Unable to resolve the type of '${property.simpleName.asString()}': ${ex.message}") + property.type.resolve() + } + + /** + * Return whether the given [type] is a type that can never be a configuration + * property. + */ + fun isExcluded(type: KSType): Boolean { + val name = (type.declaration as? KSClassDeclaration)?.let { typeNames.resolve(it) } ?: return false + return TYPE_EXCLUDES.contains(name) + } + + /** + * Return whether the given [type] is a [Collection] or a [Map], in which case a getter + * is enough for the property to be bindable. + */ + fun isCollectionOrMap(type: KSType): Boolean { + val declaration = type.declaration as? KSClassDeclaration ?: return false + if (declaration.isCollectionOrMap()) { + return true + } + return declaration.getAllSuperTypes().any { (it.declaration as? KSClassDeclaration)?.isCollectionOrMap() == true } + } + + /** + * Return the KDoc of the given [declaration] as a single line of text, or `null` if it + * has no documentation. + */ + fun documentation(declaration: KSDeclaration?): String? { + val docString = declaration?.docString ?: return null + val text = docString.lineSequence() + .map { it.trimStart().removePrefix("*") } + .joinToString(separator = " ") + .replace(WHITESPACE, " ") + .trim() + return text.ifEmpty { null } + } + + /** + * Return the documentation of the constructor parameter with the given [name], taken + * from the `@property` or `@param` tag of the KDoc of [declaration]. + */ + fun parameterDocumentation(declaration: KSClassDeclaration, name: String): String? { + val docString = declaration.docString ?: return null + val text = docString.lineSequence().map { it.trimStart().removePrefix("*") }.joinToString(separator = "\n") + val tag = Regex("@(?:property|param)\\s+${Regex.escape(name)}\\b(.*?)(?=\\n\\s*@|$)", RegexOption.DOT_MATCHES_ALL) + val value = tag.find(text)?.groupValues?.get(1) ?: return null + return value.replace(WHITESPACE, " ").trim().ifEmpty { null } + } + + /** + * Return whether any of the given [elements] is deprecated. + */ + fun isDeprecated(vararg elements: KSAnnotated?): Boolean = elements.filterNotNull().any(::isDeprecated) + + /** + * Return the deprecation to use for a property, taking the details of + * `@DeprecatedConfigurationProperty` into account when it is present on one of the + * given [elements]. + */ + fun resolveDeprecation(vararg elements: KSAnnotated?): ItemDeprecation { + val annotation = elements.filterNotNull() + .firstNotNullOfOrNull { it.findAnnotation(DEPRECATED_CONFIGURATION_PROPERTY) } + ?: return ItemDeprecation(null, null, null) + return ItemDeprecation( + annotation.findStringValue("reason"), + annotation.findStringValue("replacement"), + annotation.findStringValue("since"), + ) + } + + private fun isDeprecated(element: KSAnnotated): Boolean = + element.hasAnnotation(KOTLIN_DEPRECATED) || element.hasAnnotation(JAVA_DEPRECATED) || + element.hasAnnotation(DEPRECATED_CONFIGURATION_PROPERTY) + + private fun KSClassDeclaration.isCollectionOrMap(): Boolean = + COLLECTION_OR_MAP.contains(qualifiedName?.asString()) + + private companion object { + + private val WHITESPACE = Regex("\\s+") + + private val COLLECTION_OR_MAP = setOf( + "java.util.Collection", + "java.util.Map", + "kotlin.collections.Collection", + "kotlin.collections.Map", + "kotlin.collections.MutableCollection", + "kotlin.collections.MutableMap", + ) + + /** + * Types that are never treated as a nested group or a property. Kept in sync with + * the Java annotation processor. + */ + private val TYPE_EXCLUDES = setOf( + "com.zaxxer.hikari.IConnectionCustomizer", + "groovy.lang.MetaClass", + "groovy.text.markup.MarkupTemplateEngine", + "java.io.Writer", + "java.io.PrintWriter", + "java.lang.ClassLoader", + "java.util.concurrent.ThreadFactory", + "jakarta.jms.XAConnectionFactory", + "javax.sql.DataSource", + "javax.sql.XADataSource", + "org.apache.tomcat.jdbc.pool.PoolConfiguration", + "org.apache.tomcat.jdbc.pool.Validator", + "org.flywaydb.core.api.callback.FlywayCallback", + "org.flywaydb.core.api.resolver.MigrationResolver", + ) + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptor.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptor.kt new file mode 100644 index 000000000000..04d199689e8f --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptor.kt @@ -0,0 +1,266 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueParameter +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.CONFIGURATION_PROPERTIES +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.DEFAULT_VALUE +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.NESTED_CONFIGURATION_PROPERTY +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata + +/** + * Description of a Kotlin declaration that is a candidate for metadata generation. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal sealed class PropertyDescriptor( + val name: String, + val type: KSType, + val declaringType: KSClassDeclaration, +) { + + /** + * Elements that can carry the annotations describing this property, in the order in + * which they should be considered. + */ + protected abstract val annotatedElements: List + + /** + * Elements that can carry the annotations describing the deprecation of this property. + */ + protected open val deprecationElements: List + get() = annotatedElements + + /** + * Return whether this descriptor describes a property rather than a nested group. + */ + abstract fun isProperty(context: MetadataGenerationContext): Boolean + + /** + * Return the default value of this property, or `null` if it has none or if it cannot + * be determined. + */ + abstract fun defaultValue(context: MetadataGenerationContext): Any? + + /** + * Return the description of this property, or `null` if it is not documented. + */ + abstract fun description(context: MetadataGenerationContext): String? + + /** + * Return the getter that a nested group should be attributed to, or `null` if the + * property has none. + */ + protected abstract fun sourceMethod(): String? + + /** + * Return whether this property is a nested group of properties. + */ + fun isNested(context: MetadataGenerationContext): Boolean { + val typeDeclaration = type.declaration as? KSClassDeclaration ?: return false + if (typeDeclaration.classKind == ClassKind.ENUM_CLASS || typeDeclaration.classKind == ClassKind.ENUM_ENTRY) { + return false + } + if (annotatedElements.any { it.hasAnnotation(CONFIGURATION_PROPERTIES) }) { + return false + } + if (annotatedElements.any { it.hasAnnotation(NESTED_CONFIGURATION_PROPERTY) }) { + return true + } + return !isCyclePresent(typeDeclaration, context) && hasSameTopLevelType(typeDeclaration, context) + } + + /** + * Return the metadata for this descriptor, or `null` if it contributes none. + */ + fun toItemMetadata(prefix: String, context: MetadataGenerationContext): ItemMetadata? { + if (isNested(context)) { + return toGroupMetadata(prefix, context) + } + if (isProperty(context)) { + return toPropertyMetadata(prefix, context) + } + return null + } + + private fun toGroupMetadata(prefix: String, context: MetadataGenerationContext): ItemMetadata { + val nestedType = type.declaration as KSClassDeclaration + return ItemMetadata.newGroup( + ConfigurationMetadata.nestedPrefix(prefix, name), + context.typeNames.resolve(nestedType), + context.typeNames.resolve(declaringType), + sourceMethod(), + ) + } + + private fun toPropertyMetadata(prefix: String, context: MetadataGenerationContext): ItemMetadata { + val deprecation = if (context.isDeprecated(*deprecationElements(), declaringType)) { + context.resolveDeprecation(*deprecationElements()) + } + else { + null + } + return ItemMetadata.newProperty( + prefix, + name, + context.typeNames.resolve(type), + context.typeNames.resolve(declaringType), + null, + description(context), + defaultValue(context), + deprecation, + ) + } + + private fun deprecationElements(): Array = deprecationElements.toTypedArray() + + /** + * Return the name of the JVM getter of this property, mirroring the source method + * recorded by the Java annotation processor. + */ + protected fun jvmGetterName(): String { + if (name.length > IS_PREFIX.length && name.startsWith(IS_PREFIX) && !name[IS_PREFIX.length].isLowerCase()) { + return "$name()" + } + return "get" + name.replaceFirstChar(Char::uppercaseChar) + "()" + } + + private fun isCyclePresent(typeDeclaration: KSClassDeclaration, context: MetadataGenerationContext): Boolean { + val target = context.typeNames.resolve(typeDeclaration) + var candidate = declaringType.parentDeclaration + while (candidate is KSClassDeclaration) { + if (context.typeNames.resolve(candidate) == target) { + return true + } + candidate = candidate.parentDeclaration + } + return false + } + + private fun hasSameTopLevelType( + typeDeclaration: KSClassDeclaration, + context: MetadataGenerationContext, + ): Boolean { + val target = context.typeNames.resolve(topLevelType(typeDeclaration)) + var candidate: KSClassDeclaration? = declaringType + while (candidate != null) { + if (context.typeNames.resolve(topLevelType(candidate)) == target) { + return true + } + candidate = candidate.superClassDeclaration() + } + return false + } + + private fun topLevelType(declaration: KSClassDeclaration): KSClassDeclaration { + var candidate = declaration + while (candidate.parentDeclaration is KSClassDeclaration) { + candidate = candidate.parentDeclaration as KSClassDeclaration + } + return candidate + } + + private fun KSClassDeclaration.superClassDeclaration(): KSClassDeclaration? = superTypes + .map { it.resolve().declaration } + .filterIsInstance() + .firstOrNull { it.classKind == ClassKind.CLASS && it.qualifiedName?.asString() != KOTLIN_ANY } + + /** + * A [PropertyDescriptor] for a parameter of the constructor used for binding. + */ + class ConstructorParameter( + name: String, + type: KSType, + declaringType: KSClassDeclaration, + private val parameter: KSValueParameter, + private val property: KSPropertyDeclaration?, + ) : PropertyDescriptor(name, type, declaringType) { + + override val annotatedElements: List = + listOfNotNull(parameter, property?.getter, property, property?.setter) + + override fun isProperty(context: MetadataGenerationContext): Boolean = !isNested(context) + + override fun defaultValue(context: MetadataGenerationContext): Any? { + val annotation = annotatedElements.firstNotNullOfOrNull { it.findAnnotation(DEFAULT_VALUE) } + if (annotation != null) { + return DefaultValues.fromAnnotation(annotation, type, parameter, context) + } + // KSP does not expose parameter default values, see google/ksp#1868 + if (parameter.hasDefault) { + return null + } + return DefaultValues.fromType(type) + } + + override fun description(context: MetadataGenerationContext): String? = context.documentation(property) + ?: parameter.name?.asString()?.let { context.parameterDocumentation(declaringType, it) } + + override fun sourceMethod(): String? = if (property != null) jvmGetterName() else null + + } + + /** + * A [PropertyDescriptor] for a mutable property bound using its getter and setter. + */ + class JavaBean( + name: String, + type: KSType, + declaringType: KSClassDeclaration, + private val property: KSPropertyDeclaration, + private val factoryMethod: KSFunctionDeclaration? = null, + ) : PropertyDescriptor(name, type, declaringType) { + + override val annotatedElements: List = + listOfNotNull(property.getter, property, property.setter) + + override val deprecationElements: List + get() = annotatedElements + listOfNotNull(this.factoryMethod) + + override fun isProperty(context: MetadataGenerationContext): Boolean { + if (context.isExcluded(type)) { + return false + } + return property.isMutable || context.isCollectionOrMap(type) + } + + // KSP does not expose property initializers, see google/ksp#1868 + override fun defaultValue(context: MetadataGenerationContext): Any? = null + + override fun description(context: MetadataGenerationContext): String? = context.documentation(property) + + override fun sourceMethod(): String = jvmGetterName() + + } + + private companion object { + + private const val IS_PREFIX = "is" + + private const val KOTLIN_ANY = "kotlin.Any" + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptorResolver.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptorResolver.kt new file mode 100644 index 000000000000..b325e8550dbc --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/kotlin/org/springframework/boot/configurationprocessor/ksp/PropertyDescriptorResolver.kt @@ -0,0 +1,143 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.getConstructors +import com.google.devtools.ksp.isPublic +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSFunctionDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.AUTOWIRED +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.CONSTRUCTOR_BINDING +import org.springframework.boot.configurationprocessor.ksp.ConfigurationPropertiesAnnotations.NAME + +/** + * Resolves the [PropertyDescriptor] instances of a class annotated with + * `@ConfigurationProperties`. + * + * @author Areg Iazychian + * @since 4.2.0 + */ +internal class PropertyDescriptorResolver(private val context: MetadataGenerationContext) { + + /** + * Return the properties of the given [declaration] that are candidates for metadata + * generation. + */ + fun resolve( + declaration: KSClassDeclaration, + factoryMethod: KSFunctionDeclaration? = null, + ): List { + if (factoryMethod != null) { + return resolveJavaBean(declaration, factoryMethod) + } + val bindConstructor = findBindConstructor(declaration) + return if (bindConstructor != null) { + resolveConstructorBound(declaration, bindConstructor) + } + else { + resolveJavaBean(declaration) + } + } + + private fun findBindConstructor(declaration: KSClassDeclaration): KSFunctionDeclaration? { + val constructors = declaration.getConstructors().toList() + val annotated = constructors.filter { it.hasAnnotation(CONSTRUCTOR_BINDING, considerMetaAnnotations = true) } + if (annotated.isNotEmpty()) { + return annotated.singleOrNull() + } + return deduceBindConstructor(declaration, constructors) + } + + private fun deduceBindConstructor( + declaration: KSClassDeclaration, + constructors: List, + ): KSFunctionDeclaration? { + val candidate = constructors.singleOrNull() ?: return null + if (candidate.parameters.isEmpty() || candidate.hasAnnotation(AUTOWIRED)) { + return null + } + if (declaration.parentDeclaration is KSClassDeclaration && !candidate.isPublic()) { + return null + } + return candidate + } + + private fun resolveConstructorBound( + declaration: KSClassDeclaration, + constructor: KSFunctionDeclaration, + ): List { + val properties = declaration.getAllProperties().associateBy { it.simpleName.asString() } + val descriptors = LinkedHashMap() + constructor.parameters.forEach { parameter -> + val parameterName = parameter.name?.asString() + if (parameterName != null) { + val name = parameter.findAnnotation(NAME)?.findStringValue("value") ?: parameterName + val descriptor = PropertyDescriptor.ConstructorParameter( + name, + parameter.type.resolve(), + declaration, + parameter, + properties[parameterName], + ) + register(descriptors, descriptor, declaration) + } + } + return descriptors.values.toList() + } + + private fun resolveJavaBean( + declaration: KSClassDeclaration, + factoryMethod: KSFunctionDeclaration? = null, + ): List { + val descriptors = LinkedHashMap() + declaration.getAllProperties().filter(::isCandidate).forEach { property -> + val name = property.findAnnotation(NAME)?.findStringValue("value") ?: property.simpleName.asString() + val descriptor = PropertyDescriptor.JavaBean( + name, + this.context.typeOf(property, declaration), + declaration, + property, + factoryMethod, + ) + register(descriptors, descriptor, declaration) + } + return descriptors.values.toList() + } + + private fun isCandidate(property: KSPropertyDeclaration): Boolean = + property.isPublic() && property.getter != null + + private fun register( + descriptors: MutableMap, + descriptor: PropertyDescriptor, + declaration: KSClassDeclaration, + ) { + if (!descriptor.isProperty(this.context) && !descriptor.isNested(this.context)) { + return + } + val existing = descriptors.putIfAbsent(descriptor.name, descriptor) + if (existing != null && existing.type != descriptor.type) { + this.context.logger.error( + "Property name '${descriptor.name}' maps to distinct properties in type " + + context.typeNames.resolve(declaration), + declaration, + ) + } + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider new file mode 100644 index 000000000000..eb878db3cc01 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/main/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider @@ -0,0 +1 @@ +org.springframework.boot.configurationprocessor.ksp.ConfigurationMetadataSymbolProcessorProvider diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/Access.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/Access.kt new file mode 100644 index 000000000000..131d6cb40407 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/Access.kt @@ -0,0 +1,33 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.actuate.endpoint + +/** + * Test copy of Spring Boot's actuator `Access` enum, declared with the same fully + * qualified name so that the processor can be tested without depending on Spring Boot. + * + * @author Areg Iazychian + */ +enum class Access { + + NONE, + + READ_ONLY, + + UNRESTRICTED, + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/annotation/TestEndpointAnnotations.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/annotation/TestEndpointAnnotations.kt new file mode 100644 index 000000000000..d8ca185d2db0 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/actuate/endpoint/annotation/TestEndpointAnnotations.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.actuate.endpoint.annotation + +import org.springframework.boot.actuate.endpoint.Access + +/** + * Test copy of Spring Boot's actuator `@Endpoint`, declared with the same fully qualified + * name so that the processor can be tested without depending on Spring Boot. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.CLASS) +annotation class Endpoint(val id: String = "", val defaultAccess: Access = Access.UNRESTRICTED) + +/** + * Test copy of Spring Boot's actuator `@ReadOperation`. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.FUNCTION) +annotation class ReadOperation diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorTests.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorTests.kt new file mode 100644 index 000000000000..5e2c571a31d1 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/ConfigurationMetadataSymbolProcessorTests.kt @@ -0,0 +1,620 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.Assertions.assertThatIllegalStateException +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata +import java.nio.file.Path +import kotlin.io.path.createDirectories +import kotlin.io.path.writeText + +/** + * Tests for [ConfigurationMetadataSymbolProcessor]. + * + * @author Areg Iazychian + */ +class ConfigurationMetadataSymbolProcessorTests { + + private val compiler = MetadataCompiler() + + @Test + fun `mutable properties are documented`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "SimpleProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("simple") + class SimpleProperties { + + var theName: String? = null + + var counter: Int = 0 + + } + """, + ), + ) + assertThat(metadata.group("simple")?.type).isEqualTo("example.SimpleProperties") + assertThat(metadata.property("simple.the-name")?.type).isEqualTo("java.lang.String") + assertThat(metadata.property("simple.the-name")?.sourceType).isEqualTo("example.SimpleProperties") + assertThat(metadata.property("simple.counter")?.type).isEqualTo("java.lang.Integer") + } + + @Test + fun `read only properties are ignored unless they are collections`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "ReadOnlyProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("read-only") + class ReadOnlyProperties { + + val name: String = "test" + + val items: MutableList = mutableListOf() + + var writable: String? = null + + } + """, + ), + ) + assertThat(metadata.property("read-only.name")).isNull() + assertThat(metadata.property("read-only.items")?.type).isEqualTo("java.util.List") + assertThat(metadata.property("read-only.writable")).isNotNull() + } + + @Test + fun `constructor bound properties use the default value annotation`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "ImmutableProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + import org.springframework.boot.context.properties.bind.DefaultValue + import org.springframework.boot.context.properties.bind.Name + + @ConfigurationProperties("immutable") + data class ImmutableProperties( + val name: String?, + @DefaultValue("8080") val port: Int, + @Name("import") val importName: String?, + val flag: Boolean, + ) + """, + ), + ) + assertThat(metadata.property("immutable.name")?.type).isEqualTo("java.lang.String") + assertThat(metadata.property("immutable.port")?.defaultValue).isEqualTo(8080) + assertThat(metadata.property("immutable.import")).isNotNull() + assertThat(metadata.property("immutable.flag")?.defaultValue).isEqualTo(false) + } + + @Test + fun `parameters with a Kotlin default have no default value`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "DefaultedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("defaulted") + data class DefaultedProperties(val port: Int = 8080) + """, + ), + ) + assertThat(metadata.property("defaulted.port")).isNotNull() + assertThat(metadata.property("defaulted.port")?.defaultValue).isNull() + } + + @Test + fun `nested types are documented as groups`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "NestedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("nested") + class NestedProperties { + + val security: Security = Security() + + class Security { + + var username: String? = null + + } + + } + """, + ), + ) + assertThat(metadata.group("nested.security")?.type).isEqualTo("example.NestedProperties\$Security") + assertThat(metadata.group("nested.security")?.sourceMethod).isEqualTo("getSecurity()") + assertThat(metadata.property("nested.security.username")?.type).isEqualTo("java.lang.String") + } + + @Test + fun `types outside of the declaring type require the nested annotation`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "ExternalNestedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + import org.springframework.boot.context.properties.NestedConfigurationProperty + + class Credentials { + + var username: String? = null + + } + + @ConfigurationProperties("external") + class ExternalNestedProperties { + + var plain: Credentials? = null + + @NestedConfigurationProperty + var nested: Credentials? = null + + } + """, + ), + ) + assertThat(metadata.property("external.plain")?.type).isEqualTo("example.Credentials") + assertThat(metadata.group("external.nested")?.type).isEqualTo("example.Credentials") + assertThat(metadata.property("external.nested.username")?.type).isEqualTo("java.lang.String") + } + + @Test + fun `kotlin types are mapped to their java counterparts`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "TypeProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + import java.time.Duration + + @ConfigurationProperties("types") + class TypeProperties { + + var names: MutableList = mutableListOf() + + var mappings: MutableMap = mutableMapOf() + + var values: Array = emptyArray() + + var numbers: IntArray = intArrayOf() + + var timeout: Duration? = null + + var anything: Any? = null + + } + """, + ), + ) + assertThat(metadata.property("types.names")?.type).isEqualTo("java.util.List") + assertThat(metadata.property("types.mappings")?.type) + .isEqualTo("java.util.Map") + assertThat(metadata.property("types.values")?.type).isEqualTo("java.lang.String[]") + assertThat(metadata.property("types.numbers")?.type).isEqualTo("java.lang.Integer[]") + assertThat(metadata.property("types.timeout")?.type).isEqualTo("java.time.Duration") + assertThat(metadata.property("types.anything")?.type).isEqualTo("java.lang.Object") + } + + @Test + fun `type arguments of a superclass are resolved`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "GenericProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + import java.time.Duration + + abstract class BaseProperties { + + var value: T? = null + + var values: MutableList = mutableListOf() + + } + + @ConfigurationProperties("generic") + class GenericProperties : BaseProperties() + """, + ), + ) + assertThat(metadata.property("generic.value")?.type).isEqualTo("java.time.Duration") + assertThat(metadata.property("generic.values")?.type).isEqualTo("java.util.List") + } + + @Test + fun `kdoc is used as the description`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "DocumentedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("documented") + class DocumentedProperties { + + /** + * Name of the server, spanning + * two lines. + */ + var name: String? = null + + } + """, + ), + ) + assertThat(metadata.property("documented.name")?.description) + .isEqualTo("Name of the server, spanning two lines.") + } + + @Test + fun `kdoc of a constructor parameter is used as the description`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "DocumentedImmutableProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + /** + * Immutable properties. + * + * @property name Name of the server. + * @property port Port of the server. + */ + @ConfigurationProperties("documented-immutable") + data class DocumentedImmutableProperties(val name: String?, val port: Int) + """, + ), + ) + assertThat(metadata.property("documented-immutable.name")?.description).isEqualTo("Name of the server.") + assertThat(metadata.property("documented-immutable.port")?.description).isEqualTo("Port of the server.") + } + + @Test + fun `deprecated properties are documented`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "DeprecatedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + import org.springframework.boot.context.properties.DeprecatedConfigurationProperty + + @ConfigurationProperties("deprecated") + class DeprecatedProperties { + + @get:DeprecatedConfigurationProperty(reason = "Not needed", replacement = "deprecated.replacement") + var legacy: String? = null + + var replacement: String? = null + + } + """, + ), + ) + val deprecation = metadata.property("deprecated.legacy")?.deprecation + assertThat(deprecation).isNotNull() + assertThat(deprecation?.reason).isEqualTo("Not needed") + assertThat(deprecation?.replacement).isEqualTo("deprecated.replacement") + assertThat(metadata.property("deprecated.replacement")?.deprecation).isNull() + } + + @Test + fun `properties of an annotated method are documented`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "MethodProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + class SampleConfiguration { + + @ConfigurationProperties("method") + fun methodProperties(): MethodProperties = MethodProperties() + + } + + class MethodProperties(val ignored: String = "") { + + var name: String? = null + + } + """, + ), + ) + assertThat(metadata.group("method")?.type).isEqualTo("example.MethodProperties") + assertThat(metadata.group("method")?.sourceType).isEqualTo("example.SampleConfiguration") + assertThat(metadata.group("method")?.sourceMethod).isEqualTo("methodProperties()") + assertThat(metadata.property("method.name")?.type).isEqualTo("java.lang.String") + assertThat(metadata.property("method.ignored")).isNull() + } + + @Test + fun `duplicate prefixes are rejected`() { + assertThatIllegalStateException().isThrownBy { + this.compiler.compile( + SourceFile.kotlin( + "DuplicateProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + class DuplicateConfiguration { + + @ConfigurationProperties("duplicate") + fun first(): DuplicateProperties = DuplicateProperties() + + @ConfigurationProperties("duplicate") + fun second(): DuplicateProperties = DuplicateProperties() + + } + + class DuplicateProperties { + + var name: String? = null + + } + """, + ), + ) + }.withMessageContaining("Duplicate @ConfigurationProperties definition for prefix 'duplicate'") + } + + @Test + fun `endpoints are documented`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "SampleEndpoint.kt", + """ + package example + + import org.springframework.boot.actuate.endpoint.Access + import org.springframework.boot.actuate.endpoint.annotation.Endpoint + import org.springframework.boot.actuate.endpoint.annotation.ReadOperation + + @Endpoint(id = "sample") + class SampleEndpoint { + + @ReadOperation + fun read(): String = "sample" + + } + + @Endpoint(id = "restricted", defaultAccess = Access.READ_ONLY) + class RestrictedEndpoint + """, + ), + ) + assertThat(metadata.group("management.endpoint.sample")?.type).isEqualTo("example.SampleEndpoint") + assertThat(metadata.property("management.endpoint.sample.access")?.defaultValue).isEqualTo("unrestricted") + assertThat(metadata.property("management.endpoint.sample.access")?.type) + .isEqualTo("org.springframework.boot.actuate.endpoint.Access") + assertThat(metadata.property("management.endpoint.sample.cache.time-to-live")?.defaultValue).isEqualTo("0ms") + assertThat(metadata.property("management.endpoint.restricted.access")?.defaultValue).isEqualTo("read_only") + assertThat(metadata.property("management.endpoint.restricted.cache.time-to-live")).isNull() + } + + @Test + fun `configuration properties sources are documented in their own file`() { + val metadata = this.compiler.compileSourceMetadata( + "example.Credentials", + SourceFile.kotlin( + "Credentials.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationPropertiesSource + + @ConfigurationPropertiesSource + class Credentials { + + /** + * Name of the user. + */ + var username: String? = null + + } + """, + ), + ) + assertThat(metadata.property("username")?.type).isEqualTo("java.lang.String") + assertThat(metadata.property("username")?.description).isEqualTo("Name of the user.") + assertThat(metadata.property("username")?.sourceType).isEqualTo("example.Credentials") + } + + @Test + fun `additional metadata is merged`(@TempDir location: Path) { + writeAdditionalMetadata( + location, + """ + { + "properties": [ + { + "name": "merged.extra", + "type": "java.lang.String", + "description": "Contributed by additional metadata." + } + ] + } + """, + ) + val metadata = compileMergedSample(location) + assertThat(metadata.property("merged.name")).isNotNull() + assertThat(metadata.property("merged.extra")?.description).isEqualTo("Contributed by additional metadata.") + } + + @Test + fun `ignored properties are removed`(@TempDir location: Path) { + writeAdditionalMetadata( + location, + """ + { + "ignored": { + "properties": [ + { + "name": "merged.name" + } + ] + } + } + """, + ) + val metadata = compileMergedSample(location) + assertThat(metadata.property("merged.name")).isNull() + } + + @Test + fun `no metadata is written when nothing is annotated`() { + val metadata = this.compiler.compile( + SourceFile.kotlin( + "Plain.kt", + """ + package example + + class Plain { + + var name: String? = null + + } + """, + ), + ) + assertThat(metadata.items).isEmpty() + } + + @Test + fun `generated json matches the format of the annotation processor`() { + val json = this.compiler.compileToJson( + SourceFile.kotlin( + "JsonProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("json") + class JsonProperties { + + /** + * Name of the server. + */ + var name: String? = null + + } + """, + ), + ) + assertThat(json).isEqualTo( + """ + { + "groups": [ + { + "name": "json", + "type": "example.JsonProperties", + "sourceType": "example.JsonProperties" + } + ], + "properties": [ + { + "name": "json.name", + "type": "java.lang.String", + "description": "Name of the server.", + "sourceType": "example.JsonProperties" + } + ], + "hints": [], + "ignored": { + "properties": [] + } + } + """.trimIndent(), + ) + } + + private fun writeAdditionalMetadata(location: Path, content: String) { + val metaInf = location.resolve("META-INF").createDirectories() + metaInf.resolve("additional-spring-configuration-metadata.json").writeText(content.trimIndent()) + } + + private fun compileMergedSample(location: Path): ConfigurationMetadata = this.compiler.compile( + mapOf("org.springframework.boot.configurationprocessor.additionalMetadataLocations" to location.toString()), + SourceFile.kotlin( + "MergedProperties.kt", + """ + package example + + import org.springframework.boot.context.properties.ConfigurationProperties + + @ConfigurationProperties("merged") + class MergedProperties { + + var name: String? = null + + } + """, + ), + ) + + private fun ConfigurationMetadata.property(name: String): ItemMetadata? = + item(name, ItemMetadata.ItemType.PROPERTY) + + private fun ConfigurationMetadata.group(name: String): ItemMetadata? = item(name, ItemMetadata.ItemType.GROUP) + + private fun ConfigurationMetadata.item(name: String, type: ItemMetadata.ItemType): ItemMetadata? = + this.items.firstOrNull { it.isOfItemType(type) && it.name == name } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataCompiler.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataCompiler.kt new file mode 100644 index 000000000000..e33d6dc2f36d --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/MetadataCompiler.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.impl.KotlinSymbolProcessing +import com.google.devtools.ksp.processing.KSPJvmConfig +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata +import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.file.Files + +/** + * Runs the [ConfigurationMetadataSymbolProcessor] over Kotlin sources using KSP itself and + * gives access to the metadata that it produced. + * + * @author Areg Iazychian + */ +internal class MetadataCompiler { + + private lateinit var resourceOutputDir: File + + /** + * Compile the given [sources] and return the metadata that the processor produced, + * which is empty when the processor contributed no metadata at all. + */ + fun compile(vararg sources: SourceFile): ConfigurationMetadata = compile(emptyMap(), *sources) + + /** + * Compile the given [sources] and return the metadata that the processor produced for + * the `@ConfigurationPropertiesSource` type with the given [type] name. + */ + fun compileSourceMetadata(type: String, vararg sources: SourceFile): ConfigurationMetadata { + compile(*sources) + return readMetadata(File(this.resourceOutputDir, "META-INF/spring/configuration-metadata/$type.json")) + } + + /** + * Compile the given [sources] with the given processor [options] and return the + * metadata that the processor produced. + */ + fun compile(options: Map, vararg sources: SourceFile): ConfigurationMetadata { + val workingDir = Files.createTempDirectory("symbol-processor").toFile() + workingDir.deleteOnExit() + val config = createConfig(workingDir, writeSources(workingDir, sources), options) + this.resourceOutputDir = config.resourceOutputDir + val logger = RecordingLogger() + val processing = KotlinSymbolProcessing(config, listOf(ConfigurationMetadataSymbolProcessorProvider()), logger) + val exitCode = processing.execute() + check(exitCode == KotlinSymbolProcessing.ExitCode.OK) { + "Processing failed:\n${logger.errors.joinToString(separator = "\n")}" + } + return readMetadata(File(config.resourceOutputDir, METADATA_PATH)) + } + + private fun writeSources(workingDir: File, sources: Array): File { + val sourceRoot = File(workingDir, "src") + sourceRoot.mkdirs() + sources.forEach { File(sourceRoot, it.name).writeText(it.content) } + return sourceRoot + } + + private fun createConfig(workingDir: File, sourceRoot: File, options: Map): KSPJvmConfig = + KSPJvmConfig.Builder().apply { + this.moduleName = "test" + this.sourceRoots = listOf(sourceRoot) + this.projectBaseDir = workingDir + this.outputBaseDir = File(workingDir, "out") + this.cachesDir = File(workingDir, "caches") + this.classOutputDir = File(workingDir, "out/classes") + this.kotlinOutputDir = File(workingDir, "out/kotlin") + this.javaOutputDir = File(workingDir, "out/java") + this.resourceOutputDir = File(workingDir, "out/resources") + this.libraries = classpath() + this.processorOptions = options + this.jdkHome = File(System.getProperty("java.home")) + this.jvmTarget = JVM_TARGET + this.languageVersion = LANGUAGE_VERSION + this.apiVersion = LANGUAGE_VERSION + }.build() + + private fun classpath(): List = System.getProperty("java.class.path") + .split(File.pathSeparator) + .map(::File) + .filter(File::exists) + + private fun readMetadata(file: File): ConfigurationMetadata = + if (file.isFile) file.inputStream().use { JsonMarshaller().read(it) } else ConfigurationMetadata() + + /** + * Return the metadata that the processor produced, rendered as JSON. + */ + fun compileToJson(vararg sources: SourceFile): String = compile(*sources).let { metadata -> + ByteArrayOutputStream().also { JsonMarshaller().write(metadata, it) }.toString(Charsets.UTF_8) + } + + private companion object { + + private const val METADATA_PATH = "META-INF/spring-configuration-metadata.json" + + private const val JVM_TARGET = "17" + + private const val LANGUAGE_VERSION = "2.2" + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/RecordingLogger.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/RecordingLogger.kt new file mode 100644 index 000000000000..5b02566f7357 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/RecordingLogger.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.symbol.KSNode + +/** + * A [KSPLogger] that records the messages that the processor logged. + * + * @author Areg Iazychian + */ +internal class RecordingLogger : KSPLogger { + + val errors = mutableListOf() + + val warnings = mutableListOf() + + override fun error(message: String, symbol: KSNode?) { + this.errors += message + } + + override fun exception(e: Throwable) { + this.errors += e.toString() + } + + override fun info(message: String, symbol: KSNode?) { + } + + override fun logging(message: String, symbol: KSNode?) { + } + + override fun warn(message: String, symbol: KSNode?) { + this.warnings += message + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/SourceFile.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/SourceFile.kt new file mode 100644 index 000000000000..cfaeb3b9c05b --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/configurationprocessor/ksp/SourceFile.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.configurationprocessor.ksp + +/** + * A source file to compile in a test. + * + * @author Areg Iazychian + */ +internal class SourceFile(val name: String, val content: String) { + + companion object { + + /** + * Return a Kotlin source file with the given [name] and [content]. + */ + fun kotlin(name: String, content: String): SourceFile = SourceFile(name, content.trimIndent()) + + } + +} diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/TestConfigurationPropertiesAnnotations.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/TestConfigurationPropertiesAnnotations.kt new file mode 100644 index 000000000000..7e9439086160 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/TestConfigurationPropertiesAnnotations.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.context.properties + +/** + * Test copy of Spring Boot's `@ConfigurationProperties`, declared with the same fully + * qualified name so that the processor can be tested without depending on Spring Boot. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +annotation class ConfigurationProperties(val value: String = "", val prefix: String = "") + +/** + * Test copy of Spring Boot's `@ConfigurationPropertiesSource`. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.CLASS) +annotation class ConfigurationPropertiesSource + +/** + * Test copy of Spring Boot's `@NestedConfigurationProperty`. + * + * @author Areg Iazychian + */ +@Target( + AnnotationTarget.FIELD, + AnnotationTarget.PROPERTY, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.VALUE_PARAMETER, +) +annotation class NestedConfigurationProperty + +/** + * Test copy of Spring Boot's `@DeprecatedConfigurationProperty`. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.PROPERTY_GETTER, AnnotationTarget.FUNCTION) +annotation class DeprecatedConfigurationProperty( + val reason: String = "", + val replacement: String = "", + val since: String = "", +) diff --git a/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/bind/TestBindAnnotations.kt b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/bind/TestBindAnnotations.kt new file mode 100644 index 000000000000..b56befe853b8 --- /dev/null +++ b/configuration-metadata/spring-boot-configuration-symbol-processor/src/test/kotlin/org/springframework/boot/context/properties/bind/TestBindAnnotations.kt @@ -0,0 +1,42 @@ +/* + * Copyright 2012-present the original author or authors. + * + * Licensed 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.springframework.boot.context.properties.bind + +/** + * Test copy of Spring Boot's `@ConstructorBinding`, declared with the same fully qualified + * name so that the processor can be tested without depending on Spring Boot. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.CONSTRUCTOR) +annotation class ConstructorBinding + +/** + * Test copy of Spring Boot's `@DefaultValue`. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +annotation class DefaultValue(vararg val value: String) + +/** + * Test copy of Spring Boot's `@Name`. + * + * @author Areg Iazychian + */ +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) +annotation class Name(val value: String) diff --git a/gradle.properties b/gradle.properties index 7142a8223e76..d14347291af9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,7 @@ jacksonVersion=3.1.5 javaFormatVersion=0.0.47 junitJupiterVersion=6.1.3 kotlinVersion=2.4.10 +kspVersion=2.3.0 mavenVersion=3.9.13 mockitoVersion=5.23.0 nativeBuildToolsVersion=1.1.8 diff --git a/settings.gradle b/settings.gradle index 4a5e437de32f..0744dba4b696 100644 --- a/settings.gradle +++ b/settings.gradle @@ -67,6 +67,7 @@ include "test-support:spring-boot-gradle-test-support" include "configuration-metadata:spring-boot-configuration-metadata" include "configuration-metadata:spring-boot-configuration-metadata-changelog-generator" include "configuration-metadata:spring-boot-configuration-processor" +include "configuration-metadata:spring-boot-configuration-symbol-processor" include "core:spring-boot" include "core:spring-boot-autoconfigure" From 24c963be8463c8dbf5502f92af0465e6f070dfee Mon Sep 17 00:00:00 2001 From: Areg Iazychian Date: Wed, 12 Aug 2026 16:07:24 +0300 Subject: [PATCH 2/3] Manage spring-boot-configuration-symbol-processor in the BOM Add the symbol processor to spring-boot-dependencies so that a project can declare it without having to specify its version. See gh-28046 Signed-off-by: Areg Iazychian --- platform/spring-boot-dependencies/build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/platform/spring-boot-dependencies/build.gradle b/platform/spring-boot-dependencies/build.gradle index 5fe26df15a25..72df484f252d 100644 --- a/platform/spring-boot-dependencies/build.gradle +++ b/platform/spring-boot-dependencies/build.gradle @@ -2159,6 +2159,7 @@ bom { "spring-boot-cloudfoundry", "spring-boot-configuration-metadata", "spring-boot-configuration-processor", + "spring-boot-configuration-symbol-processor", "spring-boot-couchbase", "spring-boot-data-cassandra", "spring-boot-data-cassandra-test", From 86bbb93da6ec12e19b8dd2d38e4bc3a6b32a3289 Mon Sep 17 00:00:00 2001 From: Areg Iazychian Date: Wed, 12 Aug 2026 16:07:24 +0300 Subject: [PATCH 3/3] Document the configuration metadata symbol processor Describe how to apply the symbol processor, the types that it supports, how to contribute additional metadata, and its limitations. Note that a module has to apply either the annotation processor or the symbol processor, as both write the same metadata file. See gh-28046 Signed-off-by: Areg Iazychian --- .../symbol-processor.adoc | 83 +++++++++++++++++++ .../partials/nav-specification.adoc | 1 + 2 files changed, 84 insertions(+) create mode 100644 documentation/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/symbol-processor.adoc diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/symbol-processor.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/symbol-processor.adoc new file mode 100644 index 000000000000..38daedeb575a --- /dev/null +++ b/documentation/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/symbol-processor.adoc @@ -0,0 +1,83 @@ +[[appendix.configuration-metadata.symbol-processor]] += Generating Metadata for Kotlin by Using the Symbol Processor + +Kotlin code can be processed by the Java annotation processor described in xref:specification:configuration-metadata/annotation-processor.adoc[] through kapt. +As kapt is in maintenance mode, the `spring-boot-configuration-symbol-processor` jar provides an alternative that is built on https://kotlinlang.org/docs/ksp-overview.html[Kotlin Symbol Processing] (KSP) and that processes Kotlin sources directly. + +The symbol processor writes the same `META-INF/spring-configuration-metadata.json` file as the annotation processor, so IDEs and other tools consume it without any change. + + + +[[appendix.configuration-metadata.symbol-processor.configuring]] +== Configuring the Symbol Processor + +KSP is applied with its Gradle plugin. +Once the plugin has been applied, declare the symbol processor in the `ksp` configuration, as shown in the following example: + +[source,gradle] +---- +plugins { + id "com.google.devtools.ksp" version "$kspVersion" +} + +dependencies { + ksp "org.springframework.boot:spring-boot-configuration-symbol-processor" +} +---- + + + +[[appendix.configuration-metadata.symbol-processor.mixed-modules]] +== Modules That Contain Java and Kotlin + +Both processors write `META-INF/spring-configuration-metadata.json`, so a module that applies both fails to build with a duplicate entry for that file. +Apply only one of them to a module: the annotation processor when the module declares its configuration properties in Java, and the symbol processor when it declares them in Kotlin. +When properties are declared in both languages, keep the annotation processor and let it process the Kotlin types through kapt. + + + +[[appendix.configuration-metadata.symbol-processor.supported-types]] +== Supported Types + +The symbol processor generates metadata for Kotlin types that are annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation], covering: + +* Constructor binding, including javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] and javadoc:org.springframework.boot.context.properties.bind.Name[format=annotation]. +* JavaBean binding of `var` properties, and of read-only properties whose type is a `Collection` or a `Map`. +* Methods that are annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation], whose return type carries the properties. +* Nested groups, either deduced from a type that is declared in the same top level type or requested with javadoc:org.springframework.boot.context.properties.NestedConfigurationProperty[format=annotation]. +* Descriptions taken from the KDoc of a property, or from the `@property` and `@param` tags of the KDoc of the declaring class for constructor parameters. +* Deprecations declared with `@Deprecated` or javadoc:org.springframework.boot.context.properties.DeprecatedConfigurationProperty[format=annotation]. + +Types that are annotated with an actuator endpoint annotation, such as `@Endpoint`, contribute their `access` property and, when they declare a main read operation, their `cache.time-to-live` property. + +Kotlin types are reported using their JVM names, so a `List` property is described as `java.util.List` and an `Int` property is described as `java.lang.Integer`. + + + +[[appendix.configuration-metadata.symbol-processor.additional-metadata]] +== Adding Additional Metadata + +The metadata of `META-INF/additional-spring-configuration-metadata.json` is merged into the generated file, which also removes the properties that the additional metadata ignores. +As KSP gives a processor no access to the resources of the module, the directories to look into have to be listed with a processor option, as shown in the following example: + +[source,gradle] +---- +ksp { + arg("org.springframework.boot.configurationprocessor.additionalMetadataLocations", "src/main/resources") +} +---- + + + +[[appendix.configuration-metadata.symbol-processor.limitations]] +== Limitations + +The symbol processor does not support the following features of the annotation processor: + +* Default values that are declared with an initializer, such as `var port: Int = 8080`. +KSP does not give a processor access to initializers, so annotate the property with javadoc:org.springframework.boot.context.properties.bind.DefaultValue[format=annotation] to describe such a default. +* Endpoint annotations that are meta-annotated, as opposed to the endpoint annotations of Spring Boot itself. +* javadoc:org.springframework.boot.context.properties.ConfigurationPropertiesSource[format=annotation], and the per-type metadata that the annotation processor writes for it. +* Lombok, which does not apply to Kotlin code. + +Generic type arguments that a superclass declares are not resolved against the type that extends it, so such a property is described using the upper bound of its type variable. diff --git a/documentation/spring-boot-docs/src/docs/antora/modules/specification/partials/nav-specification.adoc b/documentation/spring-boot-docs/src/docs/antora/modules/specification/partials/nav-specification.adoc index 1f48d4c8b99b..83868f784e59 100644 --- a/documentation/spring-boot-docs/src/docs/antora/modules/specification/partials/nav-specification.adoc +++ b/documentation/spring-boot-docs/src/docs/antora/modules/specification/partials/nav-specification.adoc @@ -4,6 +4,7 @@ *** xref:specification:configuration-metadata/format.adoc[] *** xref:specification:configuration-metadata/manual-hints.adoc[] *** xref:specification:configuration-metadata/annotation-processor.adoc[] +*** xref:specification:configuration-metadata/symbol-processor.adoc[] ** xref:specification:executable-jar/index.adoc[] *** xref:specification:executable-jar/nested-jars.adoc[]