From 7199f3c3a189697c3de452d02d184c3afbbede46 Mon Sep 17 00:00:00 2001 From: John Blum Date: Tue, 2 Oct 2018 16:10:16 -0700 Subject: [PATCH] Add Spring @Conditional annotation (SpringBootCondition) to enable/disable Spring bean configuration based on missing properties. --- .../ConditionalOnMissingProperty.java | 80 +++++++++ .../condition/OnMissingPropertyCondition.java | 162 ++++++++++++++++++ ...onalOnMissingPropertyIntegrationTests.java | 113 ++++++++++++ 3 files changed, 355 insertions(+) create mode 100644 spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingProperty.java create mode 100644 spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/OnMissingPropertyCondition.java create mode 100644 spring-geode-autoconfigure/src/test/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingPropertyIntegrationTests.java diff --git a/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingProperty.java b/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingProperty.java new file mode 100644 index 00000000..e4e52c22 --- /dev/null +++ b/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingProperty.java @@ -0,0 +1,80 @@ +/* + * Copyright 2018 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 + * + * http://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.geode.boot.autoconfigure.condition; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.context.annotation.Conditional; +import org.springframework.core.annotation.AliasFor; + +/** + * The {@link ConditionalOnMissingProperty} annotation is a Spring {@link Conditional} used to conditionally enable + * or disable functionality based on the absence of any declared properties. + * + * @author John Blum + * @see org.springframework.context.annotation.Conditional + * @since 1.0.0 + */ +@Target({ ElementType.TYPE, ElementType.METHOD }) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Conditional(OnMissingPropertyCondition.class) +@SuppressWarnings("unused") +public @interface ConditionalOnMissingProperty { + + /** + * The {@link String names} of the properties to test. + * + * If a {@link String prefix} has been defined, it is applied to compute the full key of each property. + * + * For instance, if the {@link String prefix} is {@code app.config} and one value is {@code my-value}, + * the full key would be {@code app.config.my-value}. + * + * Use dashed notation to specify each property, that is all lower case with a "-" to separate words + * (e.g. {@code my-long-property}). + * + * @return the {@link String names} of the properties to test. + * @see #prefix() + */ + @AliasFor("value") + String[] name() default {}; + + /** + * A {@link String prefix} that should be applied to each property. + * + * The {@link String prefix} automatically ends with a dot if not specified. A valid {@link String prefix} + * is defined by one or more words separated with dots (e.g. {@code "acme.system.feature"}). + * + * @return the property {@link String prefix}. + * @see #name() + */ + String prefix() default ""; + + /** + * Alias for {@link #name()}. + * + * @return the {@link String names} of the properties to test. + * @see #name() + */ + @AliasFor("name") + String[] value() default {}; + +} diff --git a/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/OnMissingPropertyCondition.java b/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/OnMissingPropertyCondition.java new file mode 100644 index 00000000..1623e30c --- /dev/null +++ b/spring-geode-autoconfigure/src/main/java/org/springframework/geode/boot/autoconfigure/condition/OnMissingPropertyCondition.java @@ -0,0 +1,162 @@ +/* + * Copyright 2018 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 + * + * http://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.geode.boot.autoconfigure.condition; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; + +import org.apache.shiro.util.Assert; +import org.apache.shiro.util.StringUtils; +import org.springframework.boot.autoconfigure.condition.ConditionMessage; +import org.springframework.boot.autoconfigure.condition.ConditionOutcome; +import org.springframework.boot.autoconfigure.condition.SpringBootCondition; +import org.springframework.context.annotation.Condition; +import org.springframework.context.annotation.ConditionContext; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.env.PropertyResolver; +import org.springframework.core.type.AnnotatedTypeMetadata; +import org.springframework.util.MultiValueMap; + +/** + * The {@link OnMissingPropertyCondition} class is a {@link SpringBootCondition}, Spring {@link Condition} type + * asserting whether the specified, declared properties are missing. + * + * @author John Blum + * @see org.springframework.boot.autoconfigure.condition.ConditionOutcome + * @see org.springframework.boot.autoconfigure.condition.SpringBootCondition + * @see org.springframework.context.annotation.Condition + * @see org.springframework.context.annotation.ConditionContext + * @see org.springframework.core.annotation.AnnotationAttributes + * @see org.springframework.core.env.PropertyResolver + * @see org.springframework.core.type.AnnotatedTypeMetadata + * @see org.springframework.geode.boot.autoconfigure.condition.ConditionalOnMissingProperty + * @since 1.0.0 + */ +public class OnMissingPropertyCondition extends SpringBootCondition { + + @Override + public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { + + String annotationName = ConditionalOnMissingProperty.class.getName(); + + Collection annotationAttributesCollection = + toAnnotationAttributesFromMultiValueMap(metadata.getAllAnnotationAttributes(annotationName)); + + PropertyResolver propertyResolver = getPropertyResolver(context); + + Collection allMatchingProperties = new ArrayList<>(); + + annotationAttributesCollection.forEach(annotationAttributes -> { + + List propertyNames = collectPropertyNames(annotationAttributes); + + allMatchingProperties.addAll(findMatchingProperties(propertyResolver, propertyNames)); + }); + + return determineConditionOutcome(allMatchingProperties); + } + + @SuppressWarnings("unchecked") + private > T toAnnotationAttributesFromMultiValueMap( + MultiValueMap map) { + + List annotationAttributesList = new ArrayList<>(); + + map.forEach((key, value) -> { + for (int index = 0, size = value.size(); index < size; index++) { + + AnnotationAttributes annotationAttributes = + resolveAnnotationAttributes(annotationAttributesList, index); + + annotationAttributes.put(key, value.get(index)); + } + }); + + return (T) annotationAttributesList; + } + + private AnnotationAttributes resolveAnnotationAttributes(List annotationAttributesList, + int index) { + + if (index < annotationAttributesList.size()) { + return annotationAttributesList.get(index); + } + else { + AnnotationAttributes newAnnotationAttributes = new AnnotationAttributes(); + annotationAttributesList.add(newAnnotationAttributes); + return newAnnotationAttributes; + } + } + + private PropertyResolver getPropertyResolver(ConditionContext context) { + return context.getEnvironment(); + } + + private List collectPropertyNames(AnnotationAttributes annotationAttributes) { + + String prefix = getPrefix(annotationAttributes); + + String[] names = getNames(annotationAttributes); + + return Arrays.stream(names).map(name -> prefix + name).collect(Collectors.toList()); + } + + private String[] getNames(AnnotationAttributes annotationAttributes) { + + String[] names = annotationAttributes.getStringArray("name"); + String[] values = annotationAttributes.getStringArray("value"); + + Assert.isTrue(names.length > 0 || values.length > 0, + String.format("The name or value attribute of @%s is required", + ConditionalOnMissingProperty.class.getSimpleName())); + + // TODO remove; not needed when using @AliasFor. + /* + Assert.isTrue(names.length * values.length == 0, + String.format("The name and value attributes of @%s are exclusive", + ConditionalOnMissingProperty.class.getSimpleName())); + */ + + return names.length > 0 ? names : values; + } + + private String getPrefix(AnnotationAttributes annotationAttributes) { + + String prefix = annotationAttributes.getString("prefix"); + + return StringUtils.hasText(prefix) ? prefix.trim().endsWith(".") ? prefix.trim() : prefix.trim() + "." : ""; + } + + private Collection findMatchingProperties(PropertyResolver propertyResolver, List propertyNames) { + return propertyNames.stream().filter(propertyResolver::containsProperty).collect(Collectors.toSet()); + } + + private ConditionOutcome determineConditionOutcome(Collection matchingProperties) { + + if (!matchingProperties.isEmpty()) { + + return ConditionOutcome.noMatch(ConditionMessage.forCondition(ConditionalOnMissingProperty.class) + .found("property already defined", "properties already defined") + .items(matchingProperties)); + } + + return ConditionOutcome.match(); + } +} diff --git a/spring-geode-autoconfigure/src/test/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingPropertyIntegrationTests.java b/spring-geode-autoconfigure/src/test/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingPropertyIntegrationTests.java new file mode 100644 index 00000000..d3618b40 --- /dev/null +++ b/spring-geode-autoconfigure/src/test/java/org/springframework/geode/boot/autoconfigure/condition/ConditionalOnMissingPropertyIntegrationTests.java @@ -0,0 +1,113 @@ +/* + * Copyright 2018 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 + * + * http://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.geode.boot.autoconfigure.condition; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import java.util.function.Function; + +import org.junit.Test; +import org.springframework.boot.builder.SpringApplicationBuilder; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.gemfire.tests.integration.SpringBootApplicationIntegrationTestsSupport; + +/** + * The ConditionalOnMissingPropertyIntegrationTests class... + * + * @author John Blum + * @since 1.0.0 + */ +@SuppressWarnings("unused") +public class ConditionalOnMissingPropertyIntegrationTests extends SpringBootApplicationIntegrationTestsSupport { + + private Function springApplicationBuilderFunction = + Function.identity(); + + private Function allMatchingPropertiesFunction = builder -> { + builder.properties(Collections.singletonMap("example.app.config.propOne", "")); + builder.properties(Collections.singletonMap("example.app.config.propTwo", "test")); + return builder; + }; + + private Function nonMatchingPropertyFunction = builder -> { + builder.properties(Collections.singletonMap("example.app.cfg.propertyOne", "")); + return builder; + }; + + private Function singleMatchingPropertyFunction = builder -> { + builder.properties(Collections.singletonMap("example.app.config.propTwo", "test")); + return builder; + }; + + @Override + protected SpringApplicationBuilder processBeforeBuild(SpringApplicationBuilder springApplicationBuilder) { + return this.springApplicationBuilderFunction.apply(springApplicationBuilder); + } + + private void assertBeanDefinitions(ApplicationContext applicationContext, boolean conditionalBeanExists) { + + assertThat(applicationContext.containsBean("conditionalBean")).isEqualTo(conditionalBeanExists); + assertThat(applicationContext.containsBean("unconditionalBean")).isTrue(); + } + + @Test + public void allBeansExist() { + assertBeanDefinitions(newApplicationContext(TestConfiguration.class), true); + } + + @Test + public void allBeansExistWithNonMatchingProperties() { + + this.springApplicationBuilderFunction = this.nonMatchingPropertyFunction; + + assertBeanDefinitions(newApplicationContext(TestConfiguration.class), true); + } + + @Test + public void unconditionalBeanExistsWithAllMatchingProperties() { + + this.springApplicationBuilderFunction = this.allMatchingPropertiesFunction; + + assertBeanDefinitions(newApplicationContext(TestConfiguration.class), false); + } + + @Test + public void unconditionalBeanExistsWithSingleMatchingProperty() { + + this.springApplicationBuilderFunction = this.singleMatchingPropertyFunction; + + assertBeanDefinitions(newApplicationContext(TestConfiguration.class), false); + } + + @Configuration + static class TestConfiguration { + + @Bean + @ConditionalOnMissingProperty(prefix = "example.app.config", name = { "propOne", "propTwo" }) + Object conditionalBean() { + return "conditional"; + } + + @Bean + Object unconditionalBean() { + return "unconditional"; + } + } +}