From b3f35baed3da4269487e5921f6adb6521fa2c965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Nicoll?= Date: Mon, 5 May 2025 10:42:47 +0200 Subject: [PATCH] Add metadata support for types in arbitrary modules Previously, if a ConfigurationProperties had a nested type or was extending from a type located outside the compilation unit, no metadata discovered on the source code was available (documentation and explicit default value, if any). This typically happens when such a type resides in another module. This commit introduces `@ConfigurationPropertiesSource` as a way to annotate such type and have metadata generated for them in their own module. Type-metadata is generated as one file per type and is reused transparently whenever that type is used. As for module metadata, an additional file can be crafted manually and will be merged when the metadata for the type is generated. The following is an example structure with two types where one has an additional metadata: META-iNF/ spring/ configuration-properties/ additional/ com.example.SourceOne.json com.example.SourceOne.json com.example.SourceTwo.json Those files are used only by the annotation processor and are not meant to be public API. See gh-18366 --- .../annotation-processor.adoc | 29 +- .../pages/configuration-metadata/index.adoc | 2 +- .../configuration-metadata/manual-hints.adoc | 2 + .../source/Host.java | 52 +++ .../source/Host.kt | 19 + ...figurationMetadataAnnotationProcessor.java | 82 +++- ...ConfigurationPropertiesSourceResolver.java | 185 ++++++++ ...onstructorParameterPropertyDescriptor.java | 10 +- .../JavaBeanPropertyDescriptor.java | 10 +- .../LombokPropertyDescriptor.java | 9 +- .../MetadataCollector.java | 71 +-- .../MetadataCollectors.java | 93 ++++ .../MetadataGenerationEnvironment.java | 37 +- .../configurationprocessor/MetadataStore.java | 131 +++++- .../PropertyDescriptor.java | 32 +- .../PropertyDescriptorResolver.java | 21 +- .../RecordParameterPropertyDescriptor.java | 10 +- .../metadata/ItemHint.java | 10 + ...ationMetadataAnnotationProcessorTests.java | 419 ++++++++++++++++++ .../MetadataCollectorTests.java | 162 +++++++ .../MetadataGenerationEnvironmentFactory.java | 1 + .../MetadataStoreTests.java | 15 +- .../metadata/ItemHintTests.java | 69 +++ .../test/CompiledMetadataReader.java | 9 +- ...figurationMetadataAnnotationProcessor.java | 8 + .../ConfigurationPropertiesSource.java | 36 ++ .../source/BaseSource.java | 46 ++ .../source/ConcreteProperties.java | 38 ++ .../source/ConcreteSource.java | 35 ++ .../source/ConcreteSourceAnnotated.java | 32 ++ .../source/ConventionSource.java | 44 ++ .../source/ConventionSourceAnnotated.java | 32 ++ .../source/ImmutableSource.java | 37 ++ .../source/ImmutableSourceAnnotated.java | 32 ++ .../source/LombokSource.java | 36 ++ .../source/LombokSourceAnnotated.java | 31 ++ .../source/ParentWithHintProperties.java | 37 ++ .../source/RecordSource.java | 24 + .../source/RecordSourceAnnotated.java | 25 ++ .../source/SimpleSource.java | 55 +++ .../source/SimpleSourceAnnotated.java | 32 ++ .../generation/AbstractPropertiesSource.java | 50 +++ ...ConfigurationPropertySourcesContainer.java | 73 +++ .../generation/ImmutablePropertiesSource.java | 40 ++ .../generation/LombokPropertiesSource.java | 39 ++ .../generation/NestedPropertiesSource.java | 60 +++ .../generation/RecordPropertiesSources.java | 31 ++ .../generation/SimplePropertiesSource.java | 50 +++ ...configurationsample.source.BaseSource.json | 15 + ...urationsample.source.ConventionSource.json | 14 + ...gurationsample.source.ImmutableSource.json | 40 ++ ...nfigurationsample.source.LombokSource.json | 40 ++ ...nfigurationsample.source.RecordSource.json | 40 ++ ...nfigurationsample.source.SimpleSource.json | 40 ++ .../ConfigurationPropertiesSource.java | 95 ++++ 55 files changed, 2548 insertions(+), 139 deletions(-) create mode 100644 spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/appendix/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.java create mode 100644 spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.kt create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationPropertiesSourceResolver.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollectors.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataCollectorTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/metadata/ItemHintTests.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/ConfigurationPropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/BaseSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ConcreteProperties.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ConcreteSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ConcreteSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ConventionSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ConventionSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ImmutableSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ImmutableSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/LombokSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/LombokSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/ParentWithHintProperties.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/RecordSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/RecordSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/SimpleSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/SimpleSourceAnnotated.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/AbstractPropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/ConfigurationPropertySourcesContainer.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/ImmutablePropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/LombokPropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/NestedPropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/RecordPropertiesSources.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationsample/source/generation/SimplePropertiesSource.java create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.BaseSource.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.ConventionSource.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.ImmutableSource.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.LombokSource.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.RecordSource.json create mode 100644 spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/resources/META-INF/spring/configuration-metadata/org.springframework.boot.configurationsample.source.SimpleSource.json create mode 100644 spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/ConfigurationPropertiesSource.java diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc index 941d73f9ab..1bd89fb429 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/annotation-processor.adoc @@ -84,9 +84,9 @@ With Gradle, declare the dependencies in the `annotationProcessor` configuration [[appendix.configuration-metadata.annotation-processor.automatic-metadata-generation]] == Automatic Metadata Generation -The processor picks up both classes and methods that are annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation]. +The processor picks up both classes and methods that are annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation]. It also picks classes that are annotated with javadoc:org.springframework.boot.context.properties.ConfigurationPropertiesSource[format=annotation] -NOTE: Custom annotations that are meta-annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation] are not supported. +NOTE: Custom annotations that are meta-annotated with either of those annotations are not supported. If the class has a single parameterized constructor, one property is created per constructor parameter, unless the constructor is annotated with javadoc:org.springframework.beans.factory.annotation.Autowired[format=annotation]. If the class has a constructor explicitly annotated with javadoc:org.springframework.boot.context.properties.bind.ConstructorBinding[format=annotation], one property is created per constructor parameter for that constructor. @@ -100,9 +100,10 @@ include-code::MyServerProperties[] This exposes three properties where `my.server.name` has no default and `my.server.ip` and `my.server.port` defaults to `"127.0.0.1"` and `9797` respectively. The Javadoc on fields is used to populate the `description` attribute. For instance, the description of `my.server.ip` is "IP address to listen to.". + The `description` attribute can only be populated when the type is available as source code that is being compiled. It will not be populated when the type is only available as a compiled class from a dependency. -For such cases, xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.adding-additional-metadata[manual metadata] should be provided. +For such cases, you can xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.automatic-metadata-generation.source[source the metadata] or xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.adding-additional-metadata[provide manual entries]. NOTE: You should only use plain text with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation] field Javadoc, since they are not processed before being added to the JSON. @@ -156,6 +157,24 @@ TIP: This has no effect on collections and maps, as those types are automaticall +[[appendix.configuration-metadata.annotation-processor.automatic-metadata-generation.source]] +=== Configuration Properties Source + +If a type located in another module is used in a javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation]-annotated type, some metadata elements cannot be discovered automatically. +Reusing the example above, if `Host` is located in another module, full metadata is not available as the annotation processor does not have access to the source of `Host`. + +To handle this use case, add the annotation processor in the module that contains the `Host` type and annotate it with javadoc:org.springframework.boot.context.properties.ConfigurationPropertiesSource[format=annotation]: + +include-code::Host[] + +This generates the metadata for `Host` in `META-INF/spring/configuration-metadata/com.example.Host.json` and is reused automatically by the annotation processor when it handles such type. + +You can also annotate a parent class located in another module that a javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation]-annotated type extends from. + +TIP: If you need to reuse metadata for a type that you do not control, create a file named with the pattern above and it will be used as long as it is available on the classpath. + + + [[appendix.configuration-metadata.annotation-processor.adding-additional-metadata]] == Adding Additional Metadata @@ -163,10 +182,12 @@ Spring Boot's configuration file handling is quite flexible, and it is often the You may also need to tune some attributes of an existing key or to ignore the key altogether. To support such cases and let you provide custom "hints", the annotation processor automatically merges items from `META-INF/additional-spring-configuration-metadata.json` into the main metadata file. +When generating source metadata for a type, you can also craft custom metadata for that type, for example `com.example.SomeType`, in `META-INF/spring/configuration/metadata/com.example.SomeType.json`. + If you refer to a property that has been detected automatically, the description, default value, and deprecation information are overridden, if specified. If the manual property declaration is not identified in the current module, it is added as a new property. -The format of the `additional-spring-configuration-metadata.json` file is exactly the same as the regular `spring-configuration-metadata.json`. +The format of the additional metadata file is exactly the same as the regular `spring-configuration-metadata.json`. The items contained in the "`ignored.properties`" section are removed from the "`properties`" section of the generated `spring-configuration-metadata.json` file. The additional properties file is optional. diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/index.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/index.adoc index 9adaa90f79..c682127ec6 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/index.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/index.adoc @@ -6,4 +6,4 @@ Spring Boot jars include metadata files that provide details of all supported co The files are designed to let IDE developers offer contextual help and "`code completion`" as users are working with `application.properties` or `application.yaml` files. The majority of the metadata file is generated automatically at compile time by processing all items annotated with javadoc:org.springframework.boot.context.properties.ConfigurationProperties[format=annotation]. -However, it is possible to xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.adding-additional-metadata[write part of the metadata manually] for corner cases or more advanced use cases. +For corner cases or more advanced use cases, it is possible to xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.automatic-metadata-generation.source[source the metadata of external types ] or xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.adding-additional-metadata[write part of the metadata manually]. diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/manual-hints.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/manual-hints.adoc index 937b3b7399..612b6b067b 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/manual-hints.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/specification/pages/configuration-metadata/manual-hints.adoc @@ -42,6 +42,8 @@ In order to offer additional content assistance for the keys, you could add the ]} ---- +NOTE: Hints can also be added for xref:configuration-metadata/annotation-processor.adoc#appendix.configuration-metadata.annotation-processor.automatic-metadata-generation.source[external types] and are applied whenever that type is used. + TIP: We recommend that you use an javadoc:java.lang.Enum[] for those two values instead. If your IDE supports it, this is by far the most effective approach to auto-completion. diff --git a/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/appendix/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.java b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/appendix/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.java new file mode 100644 index 0000000000..912f5845ac --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/java/org/springframework/boot/docs/appendix/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.java @@ -0,0 +1,52 @@ +/* + * Copyright 2012-2025 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.docs.appendix.configurationmetadata.annotationprocessor.automaticmetadatageneration.source; + +import org.springframework.boot.context.properties.ConfigurationPropertiesSource; + +@ConfigurationPropertiesSource +public class Host { + + /** + * IP address to listen to. + */ + private String ip = "127.0.0.1"; + + /** + * Port to listener to. + */ + private int port = 9797; + + // @fold:on // getters/setters ... + public String getIp() { + return this.ip; + } + + public void setIp(String ip) { + this.ip = ip; + } + + public int getPort() { + return this.port; + } + + public void setPort(int port) { + this.port = port; + } + // @fold:off + +} diff --git a/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.kt b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.kt new file mode 100644 index 0000000000..86d111ba42 --- /dev/null +++ b/spring-boot-project/spring-boot-docs/src/main/kotlin/org/springframework/boot/docs/configurationmetadata/annotationprocessor/automaticmetadatageneration/source/Host.kt @@ -0,0 +1,19 @@ +package org.springframework.boot.docs.configurationmetadata.annotationprocessor.automaticmetadatageneration.source + +import org.springframework.boot.context.properties.ConfigurationPropertiesScan + +@ConfigurationPropertiesScan +class Host { + + /** + * IP address to listen to. + */ + var ip: String = "127.0.0.1" + + /** + * Port to listener to. + */ + var port = 9797 + + +} \ No newline at end of file diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java index 5293a7e8c0..1a39c885f7 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessor.java @@ -16,7 +16,6 @@ package org.springframework.boot.configurationprocessor; -import java.io.FileNotFoundException; import java.io.PrintWriter; import java.io.StringWriter; import java.time.Duration; @@ -28,6 +27,7 @@ import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.function.Supplier; import javax.annotation.processing.AbstractProcessor; import javax.annotation.processing.ProcessingEnvironment; @@ -48,6 +48,7 @@ import javax.tools.Diagnostic.Kind; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.InvalidConfigurationMetadataException; import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; +import org.springframework.boot.configurationprocessor.metadata.ItemHint; import org.springframework.boot.configurationprocessor.metadata.ItemIgnore; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; @@ -64,6 +65,7 @@ import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; * @since 1.2.0 */ @SupportedAnnotationTypes({ ConfigurationMetadataAnnotationProcessor.CONFIGURATION_PROPERTIES_ANNOTATION, + ConfigurationMetadataAnnotationProcessor.CONFIGURATION_PROPERTIES_SOURCE_ANNOTATION, ConfigurationMetadataAnnotationProcessor.AUTO_CONFIGURATION_ANNOTATION, ConfigurationMetadataAnnotationProcessor.CONFIGURATION_ANNOTATION, ConfigurationMetadataAnnotationProcessor.CONTROLLER_ENDPOINT_ANNOTATION, @@ -78,6 +80,8 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor static final String CONFIGURATION_PROPERTIES_ANNOTATION = "org.springframework.boot.context.properties.ConfigurationProperties"; + static final String CONFIGURATION_PROPERTIES_SOURCE_ANNOTATION = "org.springframework.boot.context.properties.ConfigurationPropertiesSource"; + static final String NESTED_CONFIGURATION_PROPERTY_ANNOTATION = "org.springframework.boot.context.properties.NestedConfigurationProperty"; static final String DEPRECATED_CONFIGURATION_PROPERTY_ANNOTATION = "org.springframework.boot.context.properties.DeprecatedConfigurationProperty"; @@ -116,6 +120,8 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor private MetadataStore metadataStore; + private MetadataCollectors metadataCollectors; + private MetadataCollector metadataCollector; private MetadataGenerationEnvironment metadataEnv; @@ -124,6 +130,10 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor return CONFIGURATION_PROPERTIES_ANNOTATION; } + protected String configurationPropertiesSourceAnnotation() { + return CONFIGURATION_PROPERTIES_SOURCE_ANNOTATION; + } + protected String nestedConfigurationPropertyAnnotation() { return NESTED_CONFIGURATION_PROPERTY_ANNOTATION; } @@ -178,23 +188,35 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor @Override public synchronized void init(ProcessingEnvironment env) { super.init(env); - this.metadataStore = new MetadataStore(env); - this.metadataCollector = new MetadataCollector(env, this.metadataStore.readMetadata()); + TypeUtils typeUtils = new TypeUtils(env); + this.metadataStore = new MetadataStore(env, typeUtils); + this.metadataCollectors = new MetadataCollectors(env, typeUtils); + this.metadataCollector = this.metadataCollectors.getModuleMetadataCollector(); this.metadataEnv = new MetadataGenerationEnvironment(env, configurationPropertiesAnnotation(), - nestedConfigurationPropertyAnnotation(), deprecatedConfigurationPropertyAnnotation(), - constructorBindingAnnotation(), autowiredAnnotation(), defaultValueAnnotation(), endpointAnnotations(), - readOperationAnnotation(), optionalParameterAnnotation(), nameAnnotation()); + configurationPropertiesSourceAnnotation(), nestedConfigurationPropertyAnnotation(), + deprecatedConfigurationPropertyAnnotation(), constructorBindingAnnotation(), autowiredAnnotation(), + defaultValueAnnotation(), endpointAnnotations(), readOperationAnnotation(), + optionalParameterAnnotation(), nameAnnotation()); } @Override public boolean process(Set annotations, RoundEnvironment roundEnv) { - this.metadataCollector.processing(roundEnv); + this.metadataCollectors.processing(roundEnv); TypeElement annotationType = this.metadataEnv.getConfigurationPropertiesAnnotationElement(); if (annotationType != null) { // Is @ConfigurationProperties available for (Element element : roundEnv.getElementsAnnotatedWith(annotationType)) { processElement(element); } } + TypeElement sourceAnnotationType = this.metadataEnv.getConfigurationPropertiesSourceAnnotationElement(); + if (sourceAnnotationType != null) { // Is @ConfigurationPropertiesSource available + for (Element element : roundEnv.getElementsAnnotatedWith(sourceAnnotationType)) { + if (element instanceof TypeElement typeElement) { + MetadataCollector metadataCollector = this.metadataCollectors.getMetadataCollector(typeElement); + processSourceElement(metadataCollector, "", typeElement); + } + } + } Set endpointTypes = this.metadataEnv.getEndpointAnnotationElements(); if (!endpointTypes.isEmpty()) { // Are endpoint annotations available for (TypeElement endpointType : endpointTypes) { @@ -203,6 +225,7 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor } if (roundEnv.processingOver()) { try { + writeSourceMetadata(); writeMetadata(); } catch (Exception ex) { @@ -276,6 +299,10 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor seen.push(element); new PropertyDescriptorResolver(this.metadataEnv).resolve(element, source).forEach((descriptor) -> { this.metadataCollector.add(descriptor.resolveItemMetadata(prefix, this.metadataEnv)); + ItemHint itemHint = descriptor.resolveItemHint(prefix, this.metadataEnv); + if (itemHint != null) { + this.metadataCollector.add(itemHint); + } if (descriptor.isNested(this.metadataEnv)) { TypeElement nestedTypeElement = (TypeElement) this.metadataEnv.getTypeUtils() .asElement(descriptor.getType()); @@ -287,6 +314,18 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor } } + private void processSourceElement(MetadataCollector metadataCollector, String prefix, TypeElement element) { + new PropertyDescriptorResolver(this.metadataEnv).resolve(element, null).forEach((descriptor) -> { + metadataCollector.add(descriptor.resolveItemMetadata(prefix, this.metadataEnv)); + if (descriptor.isNested(this.metadataEnv)) { + TypeElement nestedTypeElement = (TypeElement) this.metadataEnv.getTypeUtils() + .asElement(descriptor.getType()); + String nestedPrefix = ConfigurationMetadata.nestedPrefix(prefix, descriptor.getName()); + processSourceElement(metadataCollector, nestedPrefix, nestedTypeElement); + } + }); + } + private void processEndpoint(Element element, List annotations) { try { String annotationName = this.metadataEnv.getTypeUtils().getQualifiedName(annotations.get(0)); @@ -381,9 +420,20 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor return this.metadataEnv.getAnnotationElementStringValue(annotation, "value"); } + protected void writeSourceMetadata() throws Exception { + for (TypeElement sourceType : this.metadataCollectors.getSourceTypes()) { + ConfigurationMetadata metadata = this.metadataCollectors.getMetadataCollector(sourceType).getMetadata(); + metadata = mergeAdditionalMetadata(metadata, () -> this.metadataStore.readAdditionalMetadata(sourceType)); + removeIgnored(metadata); + if (!metadata.getItems().isEmpty()) { + this.metadataStore.writeMetadata(metadata, sourceType); + } + } + } + protected ConfigurationMetadata writeMetadata() throws Exception { ConfigurationMetadata metadata = this.metadataCollector.getMetadata(); - metadata = mergeAdditionalMetadata(metadata); + metadata = mergeAdditionalMetadata(metadata, () -> this.metadataStore.readAdditionalMetadata()); removeIgnored(metadata); if (!metadata.getItems().isEmpty()) { this.metadataStore.writeMetadata(metadata); @@ -398,14 +448,16 @@ public class ConfigurationMetadataAnnotationProcessor extends AbstractProcessor } } - private ConfigurationMetadata mergeAdditionalMetadata(ConfigurationMetadata metadata) { + private ConfigurationMetadata mergeAdditionalMetadata(ConfigurationMetadata metadata, + Supplier additionalMetadataSupplier) { try { - ConfigurationMetadata merged = new ConfigurationMetadata(metadata); - merged.merge(this.metadataStore.readAdditionalMetadata()); - return merged; - } - catch (FileNotFoundException ex) { - // No additional metadata + ConfigurationMetadata additionalMetadata = additionalMetadataSupplier.get(); + if (additionalMetadata != null) { + ConfigurationMetadata merged = new ConfigurationMetadata(metadata); + merged.merge(additionalMetadata); + return merged; + } + return metadata; } catch (InvalidConfigurationMetadataException ex) { log(ex.getKind(), ex.getMessage()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationPropertiesSourceResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationPropertiesSourceResolver.java new file mode 100644 index 0000000000..aa99dafef8 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConfigurationPropertiesSourceResolver.java @@ -0,0 +1,185 @@ +/* + * Copyright 2012-2025 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; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.TypeElement; +import javax.tools.FileObject; +import javax.tools.StandardLocation; + +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; +import org.springframework.boot.configurationprocessor.metadata.ItemHint; +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; +import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller; +import org.springframework.boot.configurationprocessor.support.ConventionUtils; + +/** + * Resolve source configuration metadata for arbitrary types. + * + * @author Stephane Nicoll + */ +class ConfigurationPropertiesSourceResolver { + + private final ProcessingEnvironment processingEnvironment; + + private final TypeUtils typeUtils; + + ConfigurationPropertiesSourceResolver(ProcessingEnvironment processingEnvironment, TypeUtils typeUtils) { + this.typeUtils = typeUtils; + this.processingEnvironment = processingEnvironment; + } + + /** + * Resolve the {@link SourceMetadata} for the specified type. If the type has no + * source metadata, return an {@link SourceMetadata#EMPTY} source. + * @param typeElement the type to discover source metadata from + * @return the source metadata for the specified type + */ + SourceMetadata resolveSource(TypeElement typeElement) { + ConfigurationMetadata configurationMetadata = resolveConfigurationMetadata(typeElement); + return (configurationMetadata != null) + ? new SourceMetadata(configurationMetadata.getItems(), configurationMetadata.getHints()) + : SourceMetadata.EMPTY; + } + + private ConfigurationMetadata resolveConfigurationMetadata(TypeElement type) { + try { + String sourceLocation = MetadataStore.SOURCE_METADATA_PATH.apply(type, this.typeUtils); + FileObject resource = this.processingEnvironment.getFiler() + .getResource(StandardLocation.CLASS_PATH, "", sourceLocation); + return (resource != null) ? new JsonMarshaller().read(resource.openInputStream()) : null; + } + catch (Exception ex) { + return null; + } + } + + /** + * Additional source of metadata. + */ + static final class SourceMetadata { + + /** + * An empty source metadata. + */ + public static final SourceMetadata EMPTY = new SourceMetadata(Collections.emptyList(), Collections.emptyList()); + + private final Map items; + + private final Map hints; + + private SourceMetadata(List items, List hints) { + this.items = items.stream() + .collect(Collectors.toMap((item) -> ConventionUtils.toDashedCase(item.getName()), Function.identity())); + this.hints = hints.stream() + .collect(Collectors.toMap((item) -> ConventionUtils.toDashedCase(item.getName()), Function.identity())); + } + + /** + * Create a {@link PropertyDescriptor} for the given property name. + * @param name the name of a property + * @param propertyDescriptor the descriptor of the property + * @return a property descriptor that applies additional source metadata if + * necessary + */ + PropertyDescriptor createPropertyDescriptor(String name, PropertyDescriptor propertyDescriptor) { + String key = ConventionUtils.toDashedCase(name); + if (this.items.containsKey(key)) { + ItemMetadata itemMetadata = this.items.get(key); + ItemHint itemHint = this.hints.get(key); + return new SourcePropertyDescriptor(propertyDescriptor, itemMetadata, itemHint); + } + return propertyDescriptor; + } + + /** + * Create a {@link PropertyDescriptor} for the given property name. + * @param name the name of a property + * @param regularDescriptor a function to get the descriptor + * @return a property descriptor that applies additional source metadata if + * necessary + */ + PropertyDescriptor createPropertyDescriptor(String name, + Function regularDescriptor) { + return createPropertyDescriptor(name, regularDescriptor.apply(name)); + } + + } + + /** + * A {@link PropertyDescriptor} that applies source metadata. + */ + static class SourcePropertyDescriptor extends PropertyDescriptor { + + private final PropertyDescriptor delegate; + + private final ItemMetadata sourceItemMetadata; + + private final ItemHint sourceItemHint; + + SourcePropertyDescriptor(PropertyDescriptor delegate, ItemMetadata sourceItemMetadata, + ItemHint sourceItemHint) { + super(delegate.getName(), delegate.getType(), delegate.getDeclaringElement(), delegate.getGetter()); + this.delegate = delegate; + this.sourceItemMetadata = sourceItemMetadata; + this.sourceItemHint = sourceItemHint; + } + + @Override + protected ItemHint resolveItemHint(String prefix, MetadataGenerationEnvironment environment) { + return (this.sourceItemHint != null) ? this.sourceItemHint.applyPrefix(prefix) + : super.resolveItemHint(prefix, environment); + } + + @Override + protected boolean isMarkedAsNested(MetadataGenerationEnvironment environment) { + return this.delegate.isMarkedAsNested(environment); + } + + @Override + protected String resolveDescription(MetadataGenerationEnvironment environment) { + String description = this.delegate.resolveDescription(environment); + return (description != null) ? description : this.sourceItemMetadata.getDescription(); + } + + @Override + protected Object resolveDefaultValue(MetadataGenerationEnvironment environment) { + Object defaultValue = this.delegate.resolveDefaultValue(environment); + return (defaultValue != null) ? defaultValue : this.sourceItemMetadata.getDefaultValue(); + } + + @Override + protected ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { + ItemDeprecation itemDeprecation = this.delegate.resolveItemDeprecation(environment); + return (itemDeprecation != null) ? itemDeprecation : this.sourceItemMetadata.getDeprecation(); + } + + @Override + boolean isProperty(MetadataGenerationEnvironment environment) { + return this.delegate.isProperty(environment); + } + + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java index da36d12a31..6b67cd7f17 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/ConstructorParameterPropertyDescriptor.java @@ -16,15 +16,13 @@ package org.springframework.boot.configurationprocessor; -import java.util.Arrays; -import java.util.List; - -import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; + /** * A {@link PropertyDescriptor} for a constructor parameter. * @@ -45,8 +43,8 @@ class ConstructorParameterPropertyDescriptor extends ParameterPropertyDescriptor } @Override - protected List getDeprecatableElements() { - return Arrays.asList(getGetter(), this.setter, this.field); + protected ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { + return resolveItemDeprecation(environment, getGetter(), this.setter, this.field); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/JavaBeanPropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/JavaBeanPropertyDescriptor.java index 88023f5a49..e884dd5eab 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/JavaBeanPropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/JavaBeanPropertyDescriptor.java @@ -16,15 +16,13 @@ package org.springframework.boot.configurationprocessor; -import java.util.Arrays; -import java.util.List; - -import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; + /** * A {@link PropertyDescriptor} for a standard JavaBean property. * @@ -68,8 +66,8 @@ class JavaBeanPropertyDescriptor extends PropertyDescriptor { } @Override - protected List getDeprecatableElements() { - return Arrays.asList(getGetter(), this.setter, this.field, this.factoryMethod); + protected ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { + return resolveItemDeprecation(environment, getGetter(), this.setter, this.field, this.factoryMethod); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/LombokPropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/LombokPropertyDescriptor.java index 83a50b863d..03c7ac7297 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/LombokPropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/LombokPropertyDescriptor.java @@ -16,18 +16,17 @@ package org.springframework.boot.configurationprocessor; -import java.util.Arrays; -import java.util.List; import java.util.Map; import javax.lang.model.element.AnnotationMirror; -import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.Modifier; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; + /** * A {@link PropertyDescriptor} for a Lombok field. * @@ -80,8 +79,8 @@ class LombokPropertyDescriptor extends PropertyDescriptor { } @Override - protected List getDeprecatableElements() { - return Arrays.asList(getGetter(), this.setter, this.field, this.factoryMethod); + protected ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { + return resolveItemDeprecation(environment, getGetter(), this.setter, this.field, this.factoryMethod); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java index 2aa1a55a07..552c6f46e5 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollector.java @@ -16,18 +16,14 @@ package org.springframework.boot.configurationprocessor; -import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Set; import java.util.function.Consumer; - -import javax.annotation.processing.ProcessingEnvironment; -import javax.annotation.processing.RoundEnvironment; -import javax.lang.model.element.Element; -import javax.lang.model.element.TypeElement; +import java.util.function.Predicate; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; +import org.springframework.boot.configurationprocessor.metadata.ItemHint; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; /** @@ -37,48 +33,33 @@ import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; * @author Andy Wilkinson * @author Kris De Volder * @author Moritz Halbritter - * @since 1.2.2 + * @author Stephane Nicoll */ -public class MetadataCollector { +class MetadataCollector { - private final Set metadataItems = new LinkedHashSet<>(); - - private final ProcessingEnvironment processingEnvironment; + private final Predicate mergeRequired; private final ConfigurationMetadata previousMetadata; - private final TypeUtils typeUtils; + private final Set metadataItems = new LinkedHashSet<>(); - private final Set processedSourceTypes = new HashSet<>(); + private final Set metadataHints = new LinkedHashSet<>(); /** * Creates a new {@code MetadataProcessor} instance. - * @param processingEnvironment the processing environment of the build + * @param mergeRequired specify whether an item can be merged * @param previousMetadata any previous metadata or {@code null} */ - public MetadataCollector(ProcessingEnvironment processingEnvironment, ConfigurationMetadata previousMetadata) { - this.processingEnvironment = processingEnvironment; + MetadataCollector(Predicate mergeRequired, ConfigurationMetadata previousMetadata) { + this.mergeRequired = mergeRequired; this.previousMetadata = previousMetadata; - this.typeUtils = new TypeUtils(processingEnvironment); } - public void processing(RoundEnvironment roundEnv) { - for (Element element : roundEnv.getRootElements()) { - markAsProcessed(element); - } - } - - private void markAsProcessed(Element element) { - if (element instanceof TypeElement) { - this.processedSourceTypes.add(this.typeUtils.getQualifiedName(element)); - } - } - - public void add(ItemMetadata metadata) { + void add(ItemMetadata metadata) { this.metadataItems.add(metadata); } - public void add(ItemMetadata metadata, Consumer onConflict) { + void add(ItemMetadata metadata, Consumer onConflict) { ItemMetadata existing = find(metadata.getName()); if (existing != null) { onConflict.accept(existing); @@ -87,7 +68,7 @@ public class MetadataCollector { add(metadata); } - public boolean addIfAbsent(ItemMetadata metadata) { + boolean addIfAbsent(ItemMetadata metadata) { ItemMetadata existing = find(metadata.getName()); if (existing != null) { return false; @@ -96,7 +77,11 @@ public class MetadataCollector { return true; } - public boolean hasSimilarGroup(ItemMetadata metadata) { + void add(ItemHint itemHint) { + this.metadataHints.add(itemHint); + } + + boolean hasSimilarGroup(ItemMetadata metadata) { if (!metadata.isOfItemType(ItemMetadata.ItemType.GROUP)) { throw new IllegalStateException("item " + metadata + " must be a group"); } @@ -109,15 +94,18 @@ public class MetadataCollector { return false; } - public ConfigurationMetadata getMetadata() { + ConfigurationMetadata getMetadata() { ConfigurationMetadata metadata = new ConfigurationMetadata(); for (ItemMetadata item : this.metadataItems) { metadata.add(item); } + for (ItemHint metadataHint : this.metadataHints) { + metadata.add(metadataHint); + } if (this.previousMetadata != null) { List items = this.previousMetadata.getItems(); for (ItemMetadata item : items) { - if (shouldBeMerged(item)) { + if (this.mergeRequired.test(item)) { metadata.addIfMissing(item); } } @@ -132,17 +120,4 @@ public class MetadataCollector { .orElse(null); } - private boolean shouldBeMerged(ItemMetadata itemMetadata) { - String sourceType = itemMetadata.getSourceType(); - return (sourceType != null && !deletedInCurrentBuild(sourceType) && !processedInCurrentBuild(sourceType)); - } - - private boolean deletedInCurrentBuild(String sourceType) { - return this.processingEnvironment.getElementUtils().getTypeElement(sourceType.replace('$', '.')) == null; - } - - private boolean processedInCurrentBuild(String sourceType) { - return this.processedSourceTypes.contains(sourceType); - } - } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollectors.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollectors.java new file mode 100644 index 0000000000..fcaefc437a --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataCollectors.java @@ -0,0 +1,93 @@ +/* + * Copyright 2012-2025 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; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import javax.annotation.processing.ProcessingEnvironment; +import javax.annotation.processing.RoundEnvironment; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; + +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; + +/** + * Container for {@link MetadataCollector}. Usually, either metadata for the whole module + * or metadata for types is generated. This makes sure to record types that have been + * processed and determine if previous metadata should be merged. + * + * @author Stephane Nicoll + */ +class MetadataCollectors { + + private final ProcessingEnvironment processingEnvironment; + + private final TypeUtils typeUtils; + + private final MetadataStore metadataStore; + + private final MetadataCollector metadataCollector; + + private final Set processedSourceTypes = new HashSet<>(); + + private final Map metadataTypeCollectors = new HashMap<>(); + + MetadataCollectors(ProcessingEnvironment processingEnvironment, TypeUtils typeUtils) { + this.processingEnvironment = processingEnvironment; + this.typeUtils = typeUtils; + this.metadataStore = new MetadataStore(this.processingEnvironment, this.typeUtils); + this.metadataCollector = new MetadataCollector(this::shouldBeMerged, this.metadataStore.readMetadata()); + } + + void processing(RoundEnvironment roundEnv) { + for (Element element : roundEnv.getRootElements()) { + if (element instanceof TypeElement) { + this.processedSourceTypes.add(this.typeUtils.getQualifiedName(element)); + } + } + } + + MetadataCollector getModuleMetadataCollector() { + return this.metadataCollector; + } + + MetadataCollector getMetadataCollector(TypeElement element) { + return this.metadataTypeCollectors.computeIfAbsent(element, + (ignored) -> new MetadataCollector(this::shouldBeMerged, this.metadataStore.readMetadata(element))); + } + + Set getSourceTypes() { + return this.metadataTypeCollectors.keySet(); + } + + private boolean shouldBeMerged(ItemMetadata itemMetadata) { + String sourceType = itemMetadata.getSourceType(); + return (sourceType != null && !deletedInCurrentBuild(sourceType) && !processedInCurrentBuild(sourceType)); + } + + private boolean deletedInCurrentBuild(String sourceType) { + return this.processingEnvironment.getElementUtils().getTypeElement(sourceType.replace('$', '.')) == null; + } + + private boolean processedInCurrentBuild(String sourceType) { + return this.processedSourceTypes.contains(sourceType); + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java index 015c61a0f9..6f069820c0 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironment.java @@ -41,6 +41,7 @@ import javax.lang.model.type.TypeKind; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.Elements; +import org.springframework.boot.configurationprocessor.ConfigurationPropertiesSourceResolver.SourceMetadata; import org.springframework.boot.configurationprocessor.fieldvalues.FieldValuesParser; import org.springframework.boot.configurationprocessor.fieldvalues.javac.JavaCompilerFieldValuesParser; import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; @@ -75,12 +76,18 @@ class MetadataGenerationEnvironment { private final FieldValuesParser fieldValuesParser; + private final ConfigurationPropertiesSourceResolver sourceResolver; + private final Map> defaultValues = new HashMap<>(); + private final Map sources = new HashMap<>(); + private final String configurationPropertiesAnnotation; private final String nestedConfigurationPropertyAnnotation; + private final String configurationPropertiesSourceAnnotation; + private final String deprecatedConfigurationPropertyAnnotation; private final String constructorBindingAnnotation; @@ -98,15 +105,17 @@ class MetadataGenerationEnvironment { private final String autowiredAnnotation; MetadataGenerationEnvironment(ProcessingEnvironment environment, String configurationPropertiesAnnotation, - String nestedConfigurationPropertyAnnotation, String deprecatedConfigurationPropertyAnnotation, - String constructorBindingAnnotation, String autowiredAnnotation, String defaultValueAnnotation, - Set endpointAnnotations, String readOperationAnnotation, String optionalParameterAnnotation, - String nameAnnotation) { + String configurationPropertiesSourceAnnotation, String nestedConfigurationPropertyAnnotation, + String deprecatedConfigurationPropertyAnnotation, String constructorBindingAnnotation, + String autowiredAnnotation, String defaultValueAnnotation, Set endpointAnnotations, + String readOperationAnnotation, String optionalParameterAnnotation, String nameAnnotation) { this.typeUtils = new TypeUtils(environment); this.elements = environment.getElementUtils(); this.messager = environment.getMessager(); this.fieldValuesParser = resolveFieldValuesParser(environment); + this.sourceResolver = new ConfigurationPropertiesSourceResolver(environment, this.typeUtils); this.configurationPropertiesAnnotation = configurationPropertiesAnnotation; + this.configurationPropertiesSourceAnnotation = configurationPropertiesSourceAnnotation; this.nestedConfigurationPropertyAnnotation = nestedConfigurationPropertyAnnotation; this.deprecatedConfigurationPropertyAnnotation = deprecatedConfigurationPropertyAnnotation; this.constructorBindingAnnotation = constructorBindingAnnotation; @@ -146,6 +155,22 @@ class MetadataGenerationEnvironment { return this.defaultValues.computeIfAbsent(type, this::resolveFieldValues).get(name); } + /** + * Resolve the {@link SourceMetadata} for the specified property. + * @param field the field of the property (can be {@code null}) + * @param getter the getter of the property (can be {@code null}) + * @return the {@link SourceMetadata} for the specified property + */ + SourceMetadata resolveSourceMetadata(VariableElement field, ExecutableElement getter) { + if (field != null && field.getEnclosingElement() instanceof TypeElement type) { + return this.sources.computeIfAbsent(type, this.sourceResolver::resolveSource); + } + if (getter != null && getter.getEnclosingElement() instanceof TypeElement type) { + return this.sources.computeIfAbsent(type, this.sourceResolver::resolveSource); + } + return SourceMetadata.EMPTY; + } + boolean isExcluded(TypeMirror type) { if (type == null) { return false; @@ -314,6 +339,10 @@ class MetadataGenerationEnvironment { return getAnnotation(element, this.configurationPropertiesAnnotation); } + TypeElement getConfigurationPropertiesSourceAnnotationElement() { + return this.elements.getTypeElement(this.configurationPropertiesSourceAnnotation); + } + AnnotationMirror getNestedConfigurationPropertyAnnotation(Element element) { return getAnnotation(element, this.nestedConfigurationPropertyAnnotation); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataStore.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataStore.java index 31ba670d7c..238e7e7f9b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataStore.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/MetadataStore.java @@ -22,8 +22,10 @@ import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; +import java.util.function.BiFunction; import javax.annotation.processing.ProcessingEnvironment; +import javax.lang.model.element.TypeElement; import javax.tools.Diagnostic; import javax.tools.FileObject; import javax.tools.StandardLocation; @@ -37,46 +39,123 @@ import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller; * * @author Andy Wilkinson * @author Scott Frederick - * @since 1.2.2 */ -public class MetadataStore { +class MetadataStore { static final String METADATA_PATH = "META-INF/spring-configuration-metadata.json"; + static final BiFunction SOURCE_METADATA_PATH = (type, + typeUtils) -> "META-INF/spring/configuration-metadata/%s.json".formatted(typeUtils.getQualifiedName(type)); + private static final String ADDITIONAL_METADATA_PATH = "META-INF/additional-spring-configuration-metadata.json"; + static final BiFunction ADDITIONAL_SOURCE_METADATA_PATH = (type, + typeUtils) -> "META-INF/spring/configuration-metadata/additional/%s.json" + .formatted(typeUtils.getQualifiedName(type)); + private static final String RESOURCES_DIRECTORY = "resources"; private static final String CLASSES_DIRECTORY = "classes"; private final ProcessingEnvironment environment; - public MetadataStore(ProcessingEnvironment environment) { + private final TypeUtils typeUtils; + + MetadataStore(ProcessingEnvironment environment, TypeUtils typeUtils) { this.environment = environment; + this.typeUtils = typeUtils; } - public ConfigurationMetadata readMetadata() { + /** + * Read the existing {@link ConfigurationMetadata} of the current module or + * {@code null} if it is not available yet. + * @return the metadata or {@code null} if none is present + */ + ConfigurationMetadata readMetadata() { + return readMetadata(METADATA_PATH); + } + + /** + * Read the existing {@link ConfigurationMetadata} for the specified type or + * {@code null} if it is not available yet. + * @param typeElement the type to read metadata for + * @return the metadata for the given type or {@code null} + */ + ConfigurationMetadata readMetadata(TypeElement typeElement) { + return readMetadata(SOURCE_METADATA_PATH.apply(typeElement, this.typeUtils)); + } + + private ConfigurationMetadata readMetadata(String location) { try { - return readMetadata(getMetadataResource().openInputStream()); + return readMetadata(location, getMetadataResource(location).openInputStream()); } catch (IOException ex) { return null; } } - public void writeMetadata(ConfigurationMetadata metadata) throws IOException { + /** + * Write the module {@link ConfigurationMetadata} to the filesystem. + * @param metadata the metadata to write + */ + void writeMetadata(ConfigurationMetadata metadata) throws IOException { + writeMetadata(metadata, () -> createMetadataResource(METADATA_PATH)); + } + + /** + * Write the {@link ConfigurationMetadata} for the {@link TypeElement} to the + * filesystem. + * @param metadata the metadata to write + * @param typeElement the type to write metadata for + */ + void writeMetadata(ConfigurationMetadata metadata, TypeElement typeElement) throws IOException { + writeMetadata(metadata, () -> createMetadataResource(SOURCE_METADATA_PATH.apply(typeElement, this.typeUtils))); + } + + /** + * Write the metadata to the {@link FileObject} provided by the given supplier. + * @param metadata the metadata to provide + * @param fileObjectProvider a supplier for the {@link FileObject} to use + */ + private void writeMetadata(ConfigurationMetadata metadata, FileObjectSupplier fileObjectProvider) + throws IOException { if (!metadata.getItems().isEmpty()) { - try (OutputStream outputStream = createMetadataResource().openOutputStream()) { + try (OutputStream outputStream = fileObjectProvider.get().openOutputStream()) { new JsonMarshaller().write(metadata, outputStream); } } } - public ConfigurationMetadata readAdditionalMetadata() throws IOException { - return readMetadata(getAdditionalMetadataStream()); + /** + * Read additional {@link ConfigurationMetadata} for the current module or + * {@code null}. + * @return additional metadata or {@code null} if none is present + */ + ConfigurationMetadata readAdditionalMetadata() { + return readAdditionalMetadata(ADDITIONAL_METADATA_PATH); } - private ConfigurationMetadata readMetadata(InputStream in) { + /** + * Read additional {@link ConfigurationMetadata} for the {@link TypeElement} or + * {@code null}. + * @param typeElement the type to get additional metadata for + * @return additional metadata for the given type or {@code null} if none is present + */ + ConfigurationMetadata readAdditionalMetadata(TypeElement typeElement) { + return readAdditionalMetadata(ADDITIONAL_SOURCE_METADATA_PATH.apply(typeElement, this.typeUtils)); + } + + private ConfigurationMetadata readAdditionalMetadata(String location) { + try { + InputStream in = getAdditionalMetadataStream(location); + return readMetadata(location, in); + } + catch (IOException ex) { + return null; + } + } + + private ConfigurationMetadata readMetadata(String location, InputStream in) { try (in) { return new JsonMarshaller().read(in); } @@ -85,29 +164,28 @@ public class MetadataStore { } catch (Exception ex) { throw new InvalidConfigurationMetadataException( - "Invalid additional meta-data in '" + METADATA_PATH + "': " + ex.getMessage(), - Diagnostic.Kind.ERROR); + "Invalid additional meta-data in '" + location + "': " + ex.getMessage(), Diagnostic.Kind.ERROR); } } - private FileObject getMetadataResource() throws IOException { - return this.environment.getFiler().getResource(StandardLocation.CLASS_OUTPUT, "", METADATA_PATH); + private FileObject getMetadataResource(String location) throws IOException { + return this.environment.getFiler().getResource(StandardLocation.CLASS_OUTPUT, "", location); } - private FileObject createMetadataResource() throws IOException { - return this.environment.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", METADATA_PATH); + private FileObject createMetadataResource(String location) throws IOException { + return this.environment.getFiler().createResource(StandardLocation.CLASS_OUTPUT, "", location); } - private InputStream getAdditionalMetadataStream() throws IOException { + private InputStream getAdditionalMetadataStream(String additionalMetadataLocation) throws IOException { // Most build systems will have copied the file to the class output location FileObject fileObject = this.environment.getFiler() - .getResource(StandardLocation.CLASS_OUTPUT, "", ADDITIONAL_METADATA_PATH); + .getResource(StandardLocation.CLASS_OUTPUT, "", additionalMetadataLocation); InputStream inputStream = getMetadataStream(fileObject); if (inputStream != null) { return inputStream; } try { - File file = locateAdditionalMetadataFile(new File(fileObject.toUri())); + File file = locateAdditionalMetadataFile(new File(fileObject.toUri()), additionalMetadataLocation); return (file.exists() ? new FileInputStream(file) : fileObject.toUri().toURL().openStream()); } catch (Exception ex) { @@ -124,7 +202,7 @@ public class MetadataStore { } } - File locateAdditionalMetadataFile(File standardLocation) throws IOException { + File locateAdditionalMetadataFile(File standardLocation, String additionalMetadataLocation) throws IOException { if (standardLocation.exists()) { return standardLocation; } @@ -132,13 +210,13 @@ public class MetadataStore { .get(ConfigurationMetadataAnnotationProcessor.ADDITIONAL_METADATA_LOCATIONS_OPTION); if (locations != null) { for (String location : locations.split(",")) { - File candidate = new File(location, ADDITIONAL_METADATA_PATH); + File candidate = new File(location, additionalMetadataLocation); if (candidate.isFile()) { return candidate; } } } - return new File(locateGradleResourcesDirectory(standardLocation), ADDITIONAL_METADATA_PATH); + return new File(locateGradleResourcesDirectory(standardLocation), additionalMetadataLocation); } private File locateGradleResourcesDirectory(File standardAdditionalMetadataLocation) throws FileNotFoundException { @@ -152,4 +230,13 @@ public class MetadataStore { return new File(buildDirectoryPath, RESOURCES_DIRECTORY + '/' + classOutputLocation.getName()); } + /** + * Internal callback that can throw an {@link IOException}. + */ + private interface FileObjectSupplier { + + FileObject get() throws IOException; + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java index 1ff2ad0898..8b08c75b8b 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptor.java @@ -16,7 +16,7 @@ package org.springframework.boot.configurationprocessor; -import java.util.List; +import java.util.Arrays; import javax.lang.model.element.Element; import javax.lang.model.element.ElementKind; @@ -26,6 +26,7 @@ import javax.lang.model.type.TypeMirror; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; +import org.springframework.boot.configurationprocessor.metadata.ItemHint; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; /** @@ -106,6 +107,16 @@ abstract class PropertyDescriptor { return null; } + /** + * Resolve the {@link ItemHint} for this property. + * @param prefix the property prefix + * @param environment the metadata generation environment + * @return the item hint or {@code null} + */ + protected ItemHint resolveItemHint(String prefix, MetadataGenerationEnvironment environment) { + return null; + } + /** * Return if this is a nested property. * @param environment the metadata generation environment @@ -185,13 +196,14 @@ abstract class PropertyDescriptor { deprecation); } - private String resolveType(MetadataGenerationEnvironment environment) { - return environment.getTypeUtils().getType(getDeclaringElement(), getType()); + protected final ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment, + Element... elements) { + boolean deprecated = Arrays.stream(elements).anyMatch(environment::isDeprecated); + return deprecated ? environment.resolveItemDeprecation(getGetter()) : null; } - private ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { - boolean deprecated = getDeprecatableElements().stream().anyMatch(environment::isDeprecated); - return deprecated ? environment.resolveItemDeprecation(getGetter()) : null; + private String resolveType(MetadataGenerationEnvironment environment) { + return environment.getTypeUtils().getType(getDeclaringElement(), getType()); } /** @@ -209,11 +221,11 @@ abstract class PropertyDescriptor { protected abstract Object resolveDefaultValue(MetadataGenerationEnvironment environment); /** - * Return all the elements that should be considered when checking for deprecation - * annotations. - * @return the deprecatable elements + * Resolve the {@link ItemDeprecation} for this property. + * @param environment the metadata generation environment + * @return the deprecation or {@code null} */ - protected abstract List getDeprecatableElements(); + protected abstract ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment); /** * Return true if this descriptor is for a property. diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java index 1f1853de87..450545a676 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/PropertyDescriptorResolver.java @@ -32,6 +32,8 @@ import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; import javax.lang.model.util.ElementFilter; +import org.springframework.boot.configurationprocessor.ConfigurationPropertiesSourceResolver.SourceMetadata; + /** * Resolve {@link PropertyDescriptor} instances. * @@ -91,11 +93,13 @@ class PropertyDescriptorResolver { ExecutableElement setter = members.getPublicSetter(name, type); VariableElement field = members.getFields().get(name); RecordComponentElement recordComponent = members.getRecordComponents().get(name); - return (recordComponent != null) + SourceMetadata sourceMetadata = this.environment.resolveSourceMetadata(field, getter); + PropertyDescriptor propertyDescriptor = (recordComponent != null) ? new RecordParameterPropertyDescriptor(name, type, parameter, declaringElement, getter, recordComponent) : new ConstructorParameterPropertyDescriptor(name, type, parameter, declaringElement, getter, setter, field); + return sourceMetadata.createPropertyDescriptor(name, propertyDescriptor); } private String getPropertyName(VariableElement parameter) { @@ -118,16 +122,23 @@ class PropertyDescriptorResolver { VariableElement field = members.getFields().get(name); ExecutableElement getter = findMatchingGetter(members, getters, field); TypeMirror propertyType = getter.getReturnType(); - register(candidates, new JavaBeanPropertyDescriptor(getPropertyName(field, name), propertyType, - declaringElement, getter, members.getPublicSetter(name, propertyType), field, factoryMethod)); + SourceMetadata sourceMetadata = this.environment.resolveSourceMetadata(field, getter); + register(candidates, + sourceMetadata.createPropertyDescriptor(getPropertyName(field, name), + (propertyName) -> new JavaBeanPropertyDescriptor(propertyName, propertyType, + declaringElement, getter, members.getPublicSetter(name, propertyType), field, + factoryMethod))); }); // Then check for Lombok ones members.getFields().forEach((name, field) -> { TypeMirror propertyType = field.asType(); ExecutableElement getter = members.getPublicGetter(name, propertyType); ExecutableElement setter = members.getPublicSetter(name, propertyType); - register(candidates, new LombokPropertyDescriptor(getPropertyName(field, name), propertyType, - declaringElement, getter, setter, field, factoryMethod)); + SourceMetadata sourceMetadata = this.environment.resolveSourceMetadata(field, getter); + register(candidates, + sourceMetadata.createPropertyDescriptor(getPropertyName(field, name), + (propertyName) -> new LombokPropertyDescriptor(propertyName, propertyType, declaringElement, + getter, setter, field, factoryMethod))); }); return candidates.values().stream(); } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/RecordParameterPropertyDescriptor.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/RecordParameterPropertyDescriptor.java index a2ced12c8a..72ab59a0c1 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/RecordParameterPropertyDescriptor.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/RecordParameterPropertyDescriptor.java @@ -16,16 +16,14 @@ package org.springframework.boot.configurationprocessor; -import java.util.Arrays; -import java.util.List; - -import javax.lang.model.element.Element; import javax.lang.model.element.ExecutableElement; import javax.lang.model.element.RecordComponentElement; import javax.lang.model.element.TypeElement; import javax.lang.model.element.VariableElement; import javax.lang.model.type.TypeMirror; +import org.springframework.boot.configurationprocessor.metadata.ItemDeprecation; + /** * A {@link PropertyDescriptor} for a record parameter. * @@ -44,8 +42,8 @@ class RecordParameterPropertyDescriptor extends ParameterPropertyDescriptor { } @Override - protected List getDeprecatableElements() { - return Arrays.asList(getGetter()); + protected ItemDeprecation resolveItemDeprecation(MetadataGenerationEnvironment environment) { + return resolveItemDeprecation(environment, getGetter()); } @Override diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemHint.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemHint.java index c44e22b89d..f2d7597a90 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemHint.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/main/java/org/springframework/boot/configurationprocessor/metadata/ItemHint.java @@ -72,6 +72,16 @@ public class ItemHint implements Comparable { return Collections.unmodifiableList(this.providers); } + /** + * Return an {@link ItemHint} with the given prefix applied. + * @param prefix the prefix to apply + * @return a new {@link ItemHint} with the same of this instance whose property name + * has the prefix applied to it + */ + public ItemHint applyPrefix(String prefix) { + return new ItemHint(ConventionUtils.toDashedCase(prefix) + "." + this.name, this.values, this.providers); + } + @Override public int compareTo(ItemHint other) { return getName().compareTo(other.getName()); diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java index d659e13de7..1d53efea6e 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/ConfigurationMetadataAnnotationProcessorTests.java @@ -18,13 +18,19 @@ package org.springframework.boot.configurationprocessor; import java.time.temporal.ChronoField; import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.function.Consumer; +import java.util.function.Function; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; import org.springframework.boot.configurationprocessor.metadata.ItemIgnore; import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; import org.springframework.boot.configurationprocessor.metadata.Metadata; +import org.springframework.boot.configurationprocessor.test.CompiledMetadataReader; +import org.springframework.boot.configurationprocessor.test.TestConfigurationMetadataAnnotationProcessor; import org.springframework.boot.configurationsample.deprecation.Dbcp2Configuration; import org.springframework.boot.configurationsample.method.NestedPropertiesMethod; import org.springframework.boot.configurationsample.record.ExampleRecord; @@ -47,6 +53,30 @@ import org.springframework.boot.configurationsample.simple.SimpleCollectionPrope import org.springframework.boot.configurationsample.simple.SimplePrefixValueProperties; import org.springframework.boot.configurationsample.simple.SimpleProperties; import org.springframework.boot.configurationsample.simple.SimpleTypeProperties; +import org.springframework.boot.configurationsample.source.ConcreteProperties; +import org.springframework.boot.configurationsample.source.ConcreteSource; +import org.springframework.boot.configurationsample.source.ConcreteSourceAnnotated; +import org.springframework.boot.configurationsample.source.ConventionSource; +import org.springframework.boot.configurationsample.source.ConventionSourceAnnotated; +import org.springframework.boot.configurationsample.source.ImmutableSource; +import org.springframework.boot.configurationsample.source.ImmutableSourceAnnotated; +import org.springframework.boot.configurationsample.source.LombokSource; +import org.springframework.boot.configurationsample.source.LombokSourceAnnotated; +import org.springframework.boot.configurationsample.source.ParentWithHintProperties; +import org.springframework.boot.configurationsample.source.RecordSource; +import org.springframework.boot.configurationsample.source.RecordSourceAnnotated; +import org.springframework.boot.configurationsample.source.SimpleSource; +import org.springframework.boot.configurationsample.source.SimpleSourceAnnotated; +import org.springframework.boot.configurationsample.source.generation.AbstractPropertiesSource; +import org.springframework.boot.configurationsample.source.generation.ConfigurationPropertySourcesContainer; +import org.springframework.boot.configurationsample.source.generation.ConfigurationPropertySourcesContainer.First; +import org.springframework.boot.configurationsample.source.generation.ConfigurationPropertySourcesContainer.Second; +import org.springframework.boot.configurationsample.source.generation.ConfigurationPropertySourcesContainer.Third; +import org.springframework.boot.configurationsample.source.generation.ImmutablePropertiesSource; +import org.springframework.boot.configurationsample.source.generation.LombokPropertiesSource; +import org.springframework.boot.configurationsample.source.generation.NestedPropertiesSource; +import org.springframework.boot.configurationsample.source.generation.RecordPropertiesSources; +import org.springframework.boot.configurationsample.source.generation.SimplePropertiesSource; import org.springframework.boot.configurationsample.specific.AnnotatedGetter; import org.springframework.boot.configurationsample.specific.BoxingPojo; import org.springframework.boot.configurationsample.specific.BuilderPojo; @@ -69,6 +99,10 @@ import org.springframework.boot.configurationsample.specific.InvalidDoubleRegist import org.springframework.boot.configurationsample.specific.SimplePojo; import org.springframework.boot.configurationsample.specific.StaticAccessor; import org.springframework.core.test.tools.CompilationException; +import org.springframework.core.test.tools.Compiled; +import org.springframework.core.test.tools.ResourceFile; +import org.springframework.core.test.tools.SourceFile; +import org.springframework.core.test.tools.TestCompiler; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatExceptionOfType; @@ -92,6 +126,7 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene assertThat(new ConfigurationMetadataAnnotationProcessor().getSupportedAnnotationTypes()) .containsExactlyInAnyOrder("org.springframework.boot.autoconfigure.AutoConfiguration", "org.springframework.boot.context.properties.ConfigurationProperties", + "org.springframework.boot.context.properties.ConfigurationPropertiesSource", "org.springframework.context.annotation.Configuration", "org.springframework.boot.actuate.endpoint.annotation.Endpoint", "org.springframework.boot.actuate.endpoint.jmx.annotation.JmxEndpoint", @@ -592,4 +627,388 @@ class ConfigurationMetadataAnnotationProcessorTests extends AbstractMetadataGene assertThat(metadata.getIgnored()).containsExactly(ItemIgnore.forProperty("ignored.prop3")); } + @Nested + class SourceTests { + + @Test + void javaBeansSourceIsMergedWithNestedConfigurationProperty() { + ConfigurationMetadata metadata = compile(SimpleSourceAnnotated.class, SimpleSource.class); + assertThat(metadata).has(Metadata.withGroup("example.nested", SimpleSource.class)) + .has(Metadata.withProperty("example.nested.name", String.class).withDescription("Name description.")) + .has(Metadata.withProperty("example.nested.description", String.class) + .withDescription("Description description.") + .withDefaultValue("Hello World")) + .has(Metadata.withProperty("example.nested.type", String.class) + .withDescription("A property with a fixed set of values.") + .withDefaultValue("single")); + } + + @Test + void lombokSourceIsMergedWithNestedConfigurationProperty() { + ConfigurationMetadata metadata = compile(LombokSourceAnnotated.class, LombokSource.class); + assertThat(metadata).has(Metadata.withGroup("example.nested", LombokSource.class)) + .has(Metadata.withProperty("example.nested.name", String.class).withDescription("Name description.")) + .has(Metadata.withProperty("example.nested.description", String.class) + .withDescription("Description description.") + .withDefaultValue("Hello World")) + .has(Metadata.withProperty("example.nested.type", String.class) + .withDescription("A property with a fixed set of values.") + .withDefaultValue("single")); + } + + @Test + void immutableSourceIsMergedWithNestedConfigurationProperty() { + ConfigurationMetadata metadata = compile(ImmutableSourceAnnotated.class, ImmutableSource.class); + assertThat(metadata).has(Metadata.withGroup("example.nested", ImmutableSource.class)) + .has(Metadata.withProperty("example.nested.name", String.class).withDescription("Name description.")) + .has(Metadata.withProperty("example.nested.description", String.class) + .withDescription("Description description.") + .withDefaultValue("Hello World")) + .has(Metadata.withProperty("example.nested.type", String.class) + .withDescription("A property with a fixed set of values.") + .withDefaultValue("single")); + } + + @Test + void recordSourceIsMergedWithNestedConfigurationProperty() { + ConfigurationMetadata metadata = compile(RecordSourceAnnotated.class, RecordSource.class); + assertThat(metadata).has(Metadata.withGroup("example.nested", RecordSource.class)) + .has(Metadata.withProperty("example.nested.name", String.class).withDescription("Name description.")) + .has(Metadata.withProperty("example.nested.description", String.class) + .withDescription("Description description.") + .withDefaultValue("Hello World")) + .has(Metadata.withProperty("example.nested.type", String.class) + .withDescription("A property with a fixed set of values.") + .withDefaultValue("single")); + } + + @Test + void sourceIsMergedWithConfigurationProperties() { + ConfigurationMetadata metadata = compile(ParentWithHintProperties.class); + assertThat(metadata).has(Metadata.withGroup("example", ParentWithHintProperties.class)) + .has(Metadata.withProperty("example.name", String.class).withDescription("Name description.")) + .has(Metadata.withProperty("example.description", String.class) + .withDescription("Description description.") + .withDefaultValue("Hello World")) + .has(Metadata.withProperty("example.type", String.class) + .withDescription("A property with a fixed set of values.") + .withDefaultValue("single")) + .has(Metadata.withProperty("example.enabled", Boolean.class) + .withDescription("Whether this is enabled.") + .withDefaultValue(false)); + } + + @Test + void sourceHintIsMergedWithNestedConfigurationProperty() { + ConfigurationMetadata metadata = compile(SimpleSourceAnnotated.class); + assertThat(metadata).has(Metadata.withHint("example.nested.type") + .withValue(0, "auto", "Detect the type automatically.") + .withValue(1, "single", "Single type.") + .withValue(2, "multi", "Multi type.")); + } + + @Test + void sourceHintIsMergedWithConfigurationProperties() { + ConfigurationMetadata metadata = compile(ParentWithHintProperties.class); + assertThat(metadata).has(Metadata.withHint("example.type") + .withValue(0, "auto", "Detect the type automatically.") + .withValue(1, "single", "Single type.") + .withValue(2, "multi", "Multi type.")); + } + + @Test + void sourceWithNonCanonicalMetadataIsDiscovered() { + ConfigurationMetadata metadata = compile(ConventionSourceAnnotated.class); + assertThat(metadata).has(Metadata.withGroup("example.nested", ConventionSource.class)) + .has(Metadata.withProperty("example.nested.first-name", String.class).withDescription("Camel case.")) + .has(Metadata.withProperty("example.nested.last-name", String.class) + .withDescription("Canonical format.")); + assertThat(metadata.getItems()).hasSize(4); + } + + @Test + void sourceFromParentClasIsDiscoveredForConcreteSource() { + ConfigurationMetadata metadata = compile(ConcreteSourceAnnotated.class, ConcreteSource.class); + assertThat(metadata).has(Metadata.withGroup("example", ConcreteSourceAnnotated.class)) + .has(Metadata.withGroup("example.nested", ConcreteSource.class)) + .has(Metadata.withProperty("example.nested.enabled", Boolean.class) + .withDescription("Whether the feature is enabled.")) + .has(Metadata.withProperty("example.nested.username", String.class) + .withDescription("User name.") + .withDefaultValue("user")) + .has(Metadata.withProperty("example.nested.password", String.class).withDescription("Password.")); + assertThat(metadata.getItems()).hasSize(5); + } + + @Test + void sourceFromParentClasIsDiscoveredForConfigurationProperties() { + ConfigurationMetadata metadata = compile(ConcreteProperties.class); + assertThat(metadata).has(Metadata.withGroup("example", ConcreteProperties.class)) + .has(Metadata.withProperty("example.enabled", Boolean.class) + .withDescription("Whether the feature is enabled.")) + .has(Metadata.withProperty("example.username", String.class) + .withDescription("User name.") + .withDefaultValue("user")) + .has(Metadata.withProperty("example.password", String.class).withDescription("Password.")); + assertThat(metadata.getItems()).hasSize(4); + } + + } + + @Nested + class SourceGenerationTests { + + @Test + void simplePropertiesSource() { + compile(withTestClasses(SimplePropertiesSource.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(SimplePropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).isEmpty(); + }); + } + + @Test + void simplePropertiesSourceWithAdditionalMetadataIsMerged() { + String additionalMetadata = """ + { + "properties": [ + { + "name": "custom", + "type": "java.lang.Integer", + "description": "Custom property description." + } + ] + }"""; + compile(withTestClasses(SimplePropertiesSource.class) + .andThen(withAdditionalMetadata(SimplePropertiesSource.class, additionalMetadata)), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(SimplePropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")) + .has(Metadata.withProperty("custom") + .ofType(Integer.class) + .withDescription("Custom property description.")); + assertThat(metadata.getItems()).hasSize(3); + }); + } + + @Test + void simplePropertiesSourceWithAdditionalMetadataHintIsMerged() { + String additionalMetadata = """ + { + "hints": [ + { + "name": "name", + "values": [ + { "value": "boot", "description": "Spring Boot." }, + { "value": "framework", "description": "Spring Framework." } + ] + } + ] + }"""; + compile(withTestClasses(SimplePropertiesSource.class) + .andThen(withAdditionalMetadata(SimplePropertiesSource.class, additionalMetadata)), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(SimplePropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")) + .has(Metadata.withHint("name") + .withValue(0, "boot", "Spring Boot.") + .withValue(1, "framework", "Spring Framework.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).hasSize(1); + }); + } + + @Test + void simplePropertiesSourceWithAdditionalMetadataCanBeOverridden() { + String additionalMetadata = """ + { + "properties": [ + { + "name": "name", + "description": "Custom description." + } + ] + }"""; + compile(withTestClasses(SimplePropertiesSource.class) + .andThen(withAdditionalMetadata(SimplePropertiesSource.class, additionalMetadata)), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(SimplePropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Custom description.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + }); + } + + @Test + void lombokPropertiesSource() { + compile(withTestClasses(LombokPropertiesSource.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(LombokPropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).isEmpty(); + }); + } + + @Test + void immutablePropertiesSource() { + compile(withTestClasses(ImmutablePropertiesSource.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(ImmutablePropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).isEmpty(); + }); + } + + @Test + void recordPropertiesSource() { + compile(withTestClasses(RecordPropertiesSources.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(RecordPropertiesSources.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).isEmpty(); + }); + } + + @Test + void abstractPropertiesSource() { + compile(withTestClasses(AbstractPropertiesSource.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(AbstractPropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name") + .ofType(String.class) + .withDefaultValue("boot") + .withDescription("Description of this simple property.")) + .has(Metadata.withProperty("enabled") + .ofType(Boolean.class) + .withDefaultValue(false) + .withDescription("Whether it is enabled.")); + assertThat(metadata.getItems()).hasSize(2); + assertThat(metadata.getHints()).isEmpty(); + }); + } + + @Test + void nonRootConfigurationPropertiesSources() { + compile(withTestClasses(ConfigurationPropertySourcesContainer.class), (compiled) -> { + assertThat(CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(ConfigurationPropertySourcesContainer.class))) + .isNull(); + ConfigurationMetadata firstMetadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(First.class)); + assertThat(firstMetadata).isNotNull() + .has(Metadata.withProperty("name").ofType(String.class).withDescription("A name.")); + assertThat(firstMetadata.getItems()).hasSize(1); + assertThat(firstMetadata.getHints()).isEmpty(); + ConfigurationMetadata secondMetadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(Second.class)); + assertThat(secondMetadata).isNotNull() + .has(Metadata.withProperty("visible") + .ofType(Boolean.class) + .withDefaultValue(true) + .withDescription("Whether this is visible.")); + assertThat(secondMetadata.getItems()).hasSize(1); + assertThat(secondMetadata.getHints()).isEmpty(); + assertThat(CompiledMetadataReader.getMetadata(compiled, getSourceMetadataLocation(Third.class))) + .isNull(); + + }); + } + + @Test + void nestedPropertiesSource() { + compile(withTestClasses(NestedPropertiesSource.class), (compiled) -> { + ConfigurationMetadata metadata = CompiledMetadataReader.getMetadata(compiled, + getSourceMetadataLocation(NestedPropertiesSource.class)); + assertThat(metadata).isNotNull() + .has(Metadata.withProperty("name").ofType(String.class).withDescription("A name.")) + .has(Metadata.withGroup("nested").ofType(NestedPropertiesSource.Nested.class)) + .has(Metadata.withProperty("nested.name").ofType(String.class).withDescription("Another name.")); + assertThat(metadata.getItems()).hasSize(3); + }); + } + + private String getSourceMetadataLocation(Class type) { + return "META-INF/spring/configuration-metadata/%s.json".formatted(type.getName()); + } + + private void compile(Function configuration, Consumer compiled) { + TestCompiler testCompiler = TestCompiler.forSystem(); + configuration.apply(testCompiler) + .withProcessors(new TestConfigurationMetadataAnnotationProcessor()) + .compile(compiled); + } + + private Function withTestClasses(Class... testClasses) { + return (compiler) -> compiler + .withSources(Arrays.stream(testClasses).map(SourceFile::forTestClass).toList()); + } + + private Function withAdditionalMetadata(Class type, String content) { + String location = "META-INF/spring/configuration-metadata/additional/%s.json".formatted(type.getName()); + return (compiler) -> compiler.withResources(ResourceFile.of(location, content)); + } + + } + } diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataCollectorTests.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataCollectorTests.java new file mode 100644 index 0000000000..3597504038 --- /dev/null +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataCollectorTests.java @@ -0,0 +1,162 @@ +/* + * Copyright 2012-2025 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; + +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Predicate; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.configurationprocessor.metadata.ConfigurationMetadata; +import org.springframework.boot.configurationprocessor.metadata.ItemHint; +import org.springframework.boot.configurationprocessor.metadata.ItemHint.ValueHint; +import org.springframework.boot.configurationprocessor.metadata.ItemMetadata; +import org.springframework.boot.configurationprocessor.metadata.JsonMarshaller; +import org.springframework.boot.configurationprocessor.metadata.Metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link MetadataCollector}. + * + * @author Stephane Nicoll + */ +class MetadataCollectorTests { + + private static final Predicate NO_MERGE = (metadata) -> false; + + private static final ConfigurationMetadata SINGLE_ITEM_METADATA = readMetadata(""" + { + "properties": [ + { "name": "name", "type": "java.lang.String" } + ] + } + """); + + @Test + void addSingleItemMetadata() { + MetadataCollector collector = createSimpleCollector(); + collector.add(SINGLE_ITEM_METADATA.getItems().get(0)); + assertThat(collector.getMetadata()).has(Metadata.withProperty("name", String.class)); + } + + @Test + void addIfAbsentAddsPropertyIfItDoesNotExist() { + MetadataCollector collector = createSimpleCollector(); + collector.addIfAbsent(SINGLE_ITEM_METADATA.getItems().get(0)); + ConfigurationMetadata metadata = collector.getMetadata(); + assertThat(metadata).has(Metadata.withProperty("name", String.class)); + assertThat(metadata.getItems()).hasSize(1); + } + + @Test + void addIfAbsentIgnoresExistingProperty() { + MetadataCollector collector = createSimpleCollector(); + collector.addIfAbsent(SINGLE_ITEM_METADATA.getItems().get(0)); + collector.addIfAbsent(SINGLE_ITEM_METADATA.getItems().get(0)); + collector.addIfAbsent(SINGLE_ITEM_METADATA.getItems().get(0)); + ConfigurationMetadata metadata = collector.getMetadata(); + assertThat(metadata).has(Metadata.withProperty("name", String.class)); + assertThat(metadata.getItems()).hasSize(1); + } + + @Test + @SuppressWarnings("unchecked") + void addNewMetadataDoesNotInvokeConflictResolution() { + MetadataCollector collector = createSimpleCollector(); + Consumer conflictResolution = mock(Consumer.class); + collector.add(SINGLE_ITEM_METADATA.getItems().get(0), conflictResolution); + then(conflictResolution).shouldHaveNoInteractions(); + } + + @SuppressWarnings("unchecked") + @Test + void addMetadataWithExistingInstanceInvokesConflictResolution() { + MetadataCollector collector = createSimpleCollector(); + ItemMetadata metadata = SINGLE_ITEM_METADATA.getItems().get(0); + collector.add(metadata); + Consumer conflictResolution = mock(Consumer.class); + collector.add(metadata, conflictResolution); + then(conflictResolution).should().accept(metadata); + } + + @Test + void addSingleItemHint() { + MetadataCollector collector = createSimpleCollector(); + collector.add(SINGLE_ITEM_METADATA.getItems().get(0)); + ValueHint firstValueHint = new ValueHint("one", "First."); + ValueHint secondValueHint = new ValueHint("two", "Second."); + ItemHint itemHint = new ItemHint("name", List.of(firstValueHint, secondValueHint), Collections.emptyList()); + collector.add(itemHint); + assertThat(collector.getMetadata()) + .has(Metadata.withHint("name").withValue(0, "one", "First.").withValue(1, "two", "Second.")); + } + + @Test + @SuppressWarnings("unchecked") + void getMetadataDoesNotInvokeMergeFunctionIfPreviousMetadataIsNull() { + Predicate mergedRequired = mock(Predicate.class); + MetadataCollector collector = new MetadataCollector(mergedRequired, null); + collector.add(SINGLE_ITEM_METADATA.getItems().get(0)); + collector.getMetadata(); + then(mergedRequired).shouldHaveNoInteractions(); + } + + @Test + @SuppressWarnings("unchecked") + void getMetadataAddPreviousItemIfMergeFunctionReturnsTrue() { + Predicate mergedRequired = mock(Predicate.class); + ItemMetadata itemMetadata = SINGLE_ITEM_METADATA.getItems().get(0); + given(mergedRequired.test(itemMetadata)).willReturn(true); + MetadataCollector collector = new MetadataCollector(mergedRequired, SINGLE_ITEM_METADATA); + assertThat(collector.getMetadata()).has(Metadata.withProperty("name", String.class)); + then(mergedRequired).should().test(itemMetadata); + } + + @Test + @SuppressWarnings("unchecked") + void getMetadataDoesNotAddPreviousItemIfMergeFunctionReturnsFalse() { + Predicate mergedRequired = mock(Predicate.class); + ItemMetadata itemMetadata = SINGLE_ITEM_METADATA.getItems().get(0); + given(mergedRequired.test(itemMetadata)).willReturn(false); + MetadataCollector collector = new MetadataCollector(mergedRequired, SINGLE_ITEM_METADATA); + assertThat(collector.getMetadata().getItems()).isEmpty(); + then(mergedRequired).should().test(itemMetadata); + } + + private MetadataCollector createSimpleCollector() { + return new MetadataCollector(NO_MERGE, null); + } + + private static ConfigurationMetadata readMetadata(String json) { + try { + JsonMarshaller marshaller = new JsonMarshaller(); + return marshaller.read(new ByteArrayInputStream(json.getBytes(StandardCharsets.UTF_8))); + } + catch (Exception ex) { + throw new IllegalStateException("Invalid JSON: " + json, ex); + } + } + +} diff --git a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironmentFactory.java b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironmentFactory.java index 8a171d271e..b3ed727e70 100644 --- a/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironmentFactory.java +++ b/spring-boot-project/spring-boot-tools/spring-boot-configuration-processor/src/test/java/org/springframework/boot/configurationprocessor/MetadataGenerationEnvironmentFactory.java @@ -42,6 +42,7 @@ class MetadataGenerationEnvironmentFactory implements Function + * This annotation has no effect on the actual binding process, but serves as a hint to + * the {@code spring-boot-configuration-processor} to generate full metadata for the type. + *

+ * Typically, this annotation is only required for types located in a different module + * than the {@code @ConfigurationProperties} class that references them. When both types + * are in the same module, the annotation processor can automatically discover full + * metadata as long as the source is available. + *

+ * Use this annotation when metadata for types located outside the module is needed: + *

    + *
  1. Nested types annotated by {@code @NestedConfigurationProperty}
  2. + *
  3. Base classes that a {@code @ConfigurationProperties}-annotated type extends + * from
  4. + *
+ *

+ * In the example below, {@code ServerProperties} is located in module "A" and + * {@code Host} in module "B":


+ * @ConfigurationProperties("example.server")
+ * class ServerProperties {
+ *
+ *     @NestedConfigurationProperty
+ *     private final Host host = new Host();
+ *
+ *     public Host getHost() { ... }
+ *
+ *     // Other properties, getter, setter.
+ *
+ * }
+ *

+ * Properties from {@code Host} are detected as they are based on the type, but + * description and default value are not. To fix this, add the + * {@code spring-boot-configuration-processor} to module "B" if it is not present already + * and update {@code Host} as follows::


+ * @ConfigurationPropertiesSource
+ * class Host {
+ *
+ *     /**
+ *      * URL to use.
+ *      */
+ *     private String url = "https://example.com";
+ *
+ *     // Other properties, getter, setter.
+ *
+ * }
+ *

+ * Similarly the metadata of a base class that a + * {@code @ConfigurationProperties}-annotated type extends from can also be detected. + * Consider the following example:


+ * @ConfigurationProperties("example.client.github")
+ * class GitHubClientProperties extends AbstractClientProperties {
+ *
+ *     // Additional properties, getter, setter.
+ *
+ * }
+ *

+ * As with nested types, adding {@code @ConfigurationPropertiesSource} to + * {@code AbstractClientProperties} and the {@code spring-boot-configuration-processor} to + * its module ensures full metadata generation. + * + * @author Stephane Nicoll + * @since 4.0.0 + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface ConfigurationPropertiesSource { + +}