diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesException.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesException.java new file mode 100644 index 0000000000..97f96cf4b1 --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesException.java @@ -0,0 +1,108 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.properties.source; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.springframework.util.Assert; + +/** + * Exception thrown when more than one mutually exclusive configuration property has been + * configured. + * + * @author Andy Wilkinson + * @author Phillip Webb + * @since 2.6.0 + */ +@SuppressWarnings("serial") +public class MutuallyExclusiveConfigurationPropertiesException extends RuntimeException { + + private final Set configuredNames; + + private final Set mutuallyExclusiveNames; + + /** + * Creates a new instance for mutually exclusive configuration properties when two or + * more of those properties have been configured. + * @param configuredNames the names of the properties that have been configured + * @param mutuallyExclusiveNames the names of the properties that are mutually + * exclusive + */ + public MutuallyExclusiveConfigurationPropertiesException(Collection configuredNames, + Collection mutuallyExclusiveNames) { + this(asSet(configuredNames), asSet(mutuallyExclusiveNames)); + } + + private MutuallyExclusiveConfigurationPropertiesException(Set configuredNames, + Set mutuallyExclusiveNames) { + super(buildMessage(mutuallyExclusiveNames, configuredNames)); + this.configuredNames = configuredNames; + this.mutuallyExclusiveNames = mutuallyExclusiveNames; + } + + /** + * Return the names of the properties that have been configured. + * @return the names of the configured properties + */ + public Set getConfiguredNames() { + return this.configuredNames; + } + + /** + * Return the names of the properties that are mutually exclusive. + * @return the names of the mutually exclusive properties + */ + public Set getMutuallyExclusiveNames() { + return this.mutuallyExclusiveNames; + } + + private static Set asSet(Collection collection) { + return (collection != null) ? new LinkedHashSet<>(collection) : null; + } + + private static String buildMessage(Set mutuallyExclusiveNames, Set configuredNames) { + Assert.isTrue(configuredNames != null && configuredNames.size() > 1, + "ConfiguredNames must contain 2 or more names"); + Assert.isTrue(mutuallyExclusiveNames != null && mutuallyExclusiveNames.size() > 1, + "MutuallyExclusiveNames must contain 2 or more names"); + return "The configuration properties '" + String.join(", ", mutuallyExclusiveNames) + + "' are mutually exclusive and '" + String.join(", ", configuredNames) + + "' have been configured together"; + } + + /** + * Throw a new {@link MutuallyExclusiveConfigurationPropertiesException} if multiple + * non-null values are defined in a set of entries. + * @param entries a consumer used to populate the entries to check + */ + public static void throwIfMultipleNonNullValuesIn(Consumer> entries) { + Map map = new LinkedHashMap<>(); + entries.accept(map); + Set configuredNames = map.entrySet().stream().filter((entry) -> entry.getValue() != null) + .map(Map.Entry::getKey).collect(Collectors.toCollection(LinkedHashSet::new)); + if (configuredNames.size() > 1) { + throw new MutuallyExclusiveConfigurationPropertiesException(configuredNames, map.keySet()); + } + } + +} diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java new file mode 100644 index 0000000000..429aa21f5f --- /dev/null +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzer.java @@ -0,0 +1,131 @@ +/* + * Copyright 2012-2021 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.diagnostics.analyzer; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.boot.context.properties.source.ConfigurationPropertySources; +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.diagnostics.AbstractFailureAnalyzer; +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.boot.diagnostics.FailureAnalyzer; +import org.springframework.boot.origin.Origin; +import org.springframework.boot.origin.OriginLookup; +import org.springframework.context.EnvironmentAware; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.Environment; +import org.springframework.core.env.PropertySource; + +/** + * A {@link FailureAnalyzer} that performs analysis of failures caused by an + * {@link MutuallyExclusiveConfigurationPropertiesException}. + * + * @author Andy Wilkinson + */ +class MutuallyExclusiveConfigurationPropertiesFailureAnalyzer + extends AbstractFailureAnalyzer implements EnvironmentAware { + + private ConfigurableEnvironment environment; + + @Override + public void setEnvironment(Environment environment) { + this.environment = (ConfigurableEnvironment) environment; + } + + @Override + protected FailureAnalysis analyze(Throwable rootFailure, MutuallyExclusiveConfigurationPropertiesException cause) { + List descriptors = new ArrayList<>(); + for (String name : cause.getConfiguredNames()) { + List descriptorsForName = getDescriptors(name); + if (descriptorsForName.isEmpty()) { + return null; + } + descriptors.addAll(descriptorsForName); + } + StringBuilder description = new StringBuilder(); + appendDetails(description, cause, descriptors); + return new FailureAnalysis(description.toString(), + "Update your configuration so that only one of the mutually exclusive properties is configured.", + cause); + } + + private List getDescriptors(String propertyName) { + return getPropertySources().filter((source) -> source.containsProperty(propertyName)) + .map((source) -> Descriptor.get(source, propertyName)).collect(Collectors.toList()); + } + + private Stream> getPropertySources() { + if (this.environment == null) { + return Stream.empty(); + } + return this.environment.getPropertySources().stream() + .filter((source) -> !ConfigurationPropertySources.isAttachedConfigurationPropertySource(source)); + } + + private void appendDetails(StringBuilder message, MutuallyExclusiveConfigurationPropertiesException cause, + List descriptors) { + descriptors.sort((d1, d2) -> d1.propertyName.compareTo(d2.propertyName)); + message.append(String.format("The following configuration properties are mutually exclusive:%n%n")); + sortedStrings(cause.getMutuallyExclusiveNames()) + .forEach((name) -> message.append(String.format("\t%s%n", name))); + message.append(String.format("%n")); + message.append( + String.format("However, more than one of those properties has been configured at the same time:%n%n")); + Set configuredDescriptions = sortedStrings(descriptors, + (descriptor) -> String.format("\t%s%s%n", descriptor.propertyName, + (descriptor.origin != null) ? " (originating from '" + descriptor.origin + "')" : "")); + configuredDescriptions.forEach(message::append); + } + + private Set sortedStrings(Collection input) { + return sortedStrings(input, Function.identity()); + } + + private Set sortedStrings(Collection input, Function converter) { + TreeSet results = new TreeSet<>(); + for (S item : input) { + results.add(converter.apply(item)); + } + return results; + } + + private static final class Descriptor { + + private final String propertyName; + + private final Origin origin; + + private Descriptor(String propertyName, Origin origin) { + this.propertyName = propertyName; + this.origin = origin; + } + + static Descriptor get(PropertySource source, String propertyName) { + Origin origin = OriginLookup.getOrigin(source, propertyName); + return new Descriptor(propertyName, origin); + } + + } + +} diff --git a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories index c688a6996c..995c173efb 100644 --- a/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories @@ -66,6 +66,7 @@ org.springframework.boot.diagnostics.analyzer.BindFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.BindValidationFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.UnboundConfigurationPropertyFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.ConnectorStartFailureAnalyzer,\ +org.springframework.boot.diagnostics.analyzer.MutuallyExclusiveConfigurationPropertiesFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.NoSuchMethodFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.NoUniqueBeanDefinitionFailureAnalyzer,\ org.springframework.boot.diagnostics.analyzer.PortInUseFailureAnalyzer,\ diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesExceptionTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesExceptionTests.java new file mode 100644 index 0000000000..6a0676de19 --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/source/MutuallyExclusiveConfigurationPropertiesExceptionTests.java @@ -0,0 +1,126 @@ +/* + * Copyright 2012-2021 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.context.properties.source; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; +import static org.assertj.core.api.Assertions.assertThatNoException; + +/** + * Tests for {@link MutuallyExclusiveConfigurationPropertiesException}. + * + * @author Phillip Webb + */ +class MutuallyExclusiveConfigurationPropertiesExceptionTests { + + @Test + void createWhenConfiguredNamesIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(null, Arrays.asList("a", "b"))) + .withMessage("ConfiguredNames must contain 2 or more names"); + } + + @Test + void createWhenConfiguredNamesContainsOneElementThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Collections.singleton("a"), + Arrays.asList("a", "b"))) + .withMessage("ConfiguredNames must contain 2 or more names"); + } + + @Test + void createWhenMutuallyExclusiveNamesIsNullThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Arrays.asList("a", "b"), null)) + .withMessage("MutuallyExclusiveNames must contain 2 or more names"); + } + + @Test + void createWhenMutuallyExclusiveNamesContainsOneElementThrowsException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Arrays.asList("a", "b"), + Collections.singleton("a"))) + .withMessage("MutuallyExclusiveNames must contain 2 or more names"); + } + + @Test + void createBuildsSensibleMessage() { + List names = Arrays.asList("a", "b"); + assertThat(new MutuallyExclusiveConfigurationPropertiesException(names, names)) + .hasMessage("The configuration properties 'a, b' are mutually exclusive " + + "and 'a, b' have been configured together"); + } + + @Test + void getConfiguredNamesReturnsConfiguredNames() { + List configuredNames = Arrays.asList("a", "b"); + List mutuallyExclusiveNames = Arrays.asList("a", "b", "c"); + MutuallyExclusiveConfigurationPropertiesException exception = new MutuallyExclusiveConfigurationPropertiesException( + configuredNames, mutuallyExclusiveNames); + assertThat(exception.getConfiguredNames()).hasSameElementsAs(configuredNames); + } + + @Test + void getMutuallyExclusiveNamesReturnsMutuallyExclusiveNames() { + List configuredNames = Arrays.asList("a", "b"); + List mutuallyExclusiveNames = Arrays.asList("a", "b", "c"); + MutuallyExclusiveConfigurationPropertiesException exception = new MutuallyExclusiveConfigurationPropertiesException( + configuredNames, mutuallyExclusiveNames); + assertThat(exception.getMutuallyExclusiveNames()).hasSameElementsAs(mutuallyExclusiveNames); + } + + @Test + void throwIfMultipleNonNullValuesInWhenEntriesHasAllNullsDoesNotThrowException() { + assertThatNoException().isThrownBy( + () -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("a", null); + entries.put("b", null); + entries.put("c", null); + })); + } + + @Test + void throwIfMultipleNonNullValuesInWhenEntriesHasSingleNonNullDoesNotThrowException() { + assertThatNoException().isThrownBy( + () -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("a", null); + entries.put("b", "B"); + entries.put("c", null); + })); + } + + @Test + void throwIfMultipleNonNullValuesInWhenEntriesHasTwoNonNullsThrowsException() { + assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy( + () -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> { + entries.put("a", "a"); + entries.put("b", "B"); + entries.put("c", null); + })).satisfies((ex) -> { + assertThat(ex.getConfiguredNames()).containsExactly("a", "b"); + assertThat(ex.getMutuallyExclusiveNames()).containsExactly("a", "b", "c"); + }); + } + +} diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzerTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzerTests.java new file mode 100644 index 0000000000..04e373a18c --- /dev/null +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/diagnostics/analyzer/MutuallyExclusiveConfigurationPropertiesFailureAnalyzerTests.java @@ -0,0 +1,157 @@ +/* + * Copyright 2012-2021 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.diagnostics.analyzer; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException; +import org.springframework.boot.diagnostics.FailureAnalysis; +import org.springframework.boot.origin.Origin; +import org.springframework.boot.origin.OriginLookup; +import org.springframework.core.env.EnumerablePropertySource; +import org.springframework.core.env.MapPropertySource; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link MutuallyExclusiveConfigurationPropertiesFailureAnalyzer}. + * + * @author Andy Wilkinson + */ +class MutuallyExclusiveConfigurationPropertiesFailureAnalyzerTests { + + private final MockEnvironment environment = new MockEnvironment(); + + @Test + void analyzeWhenEnvironmentIsNullShouldReturnNull() { + MutuallyExclusiveConfigurationPropertiesException failure = new MutuallyExclusiveConfigurationPropertiesException( + new HashSet<>(Arrays.asList("com.example.a", "com.example.b")), + new HashSet<>(Arrays.asList("com.example.a", "com.example.b"))); + FailureAnalysis failureAnalysis = new MutuallyExclusiveConfigurationPropertiesFailureAnalyzer() + .analyze(failure); + assertThat(failureAnalysis).isNull(); + } + + @Test + void analyzeWhenNotAllPropertiesAreInTheEnvironmentShouldReturnNull() { + MapPropertySource source = new MapPropertySource("test", Collections.singletonMap("com.example.a", "alpha")); + this.environment.getPropertySources().addFirst(OriginCapablePropertySource.get(source)); + MutuallyExclusiveConfigurationPropertiesException failure = new MutuallyExclusiveConfigurationPropertiesException( + new HashSet<>(Arrays.asList("com.example.a", "com.example.b")), + new HashSet<>(Arrays.asList("com.example.a", "com.example.b"))); + FailureAnalysis analysis = performAnalysis(failure); + assertThat(analysis).isNull(); + } + + @Test + void analyzeWhenAllConfiguredPropertiesAreInTheEnvironmentShouldReturnAnalysis() { + Map properties = new HashMap<>(); + properties.put("com.example.a", "alpha"); + properties.put("com.example.b", "bravo"); + MapPropertySource source = new MapPropertySource("test", properties); + this.environment.getPropertySources().addFirst(OriginCapablePropertySource.get(source)); + MutuallyExclusiveConfigurationPropertiesException failure = new MutuallyExclusiveConfigurationPropertiesException( + new HashSet<>(Arrays.asList("com.example.a", "com.example.b")), + new HashSet<>(Arrays.asList("com.example.a", "com.example.b"))); + FailureAnalysis analysis = performAnalysis(failure); + assertThat(analysis.getAction()).isEqualTo( + "Update your configuration so that only one of the mutually exclusive properties is configured."); + assertThat(analysis.getDescription()).contains(String.format( + "The following configuration properties are mutually exclusive:%n%n\tcom.example.a%n\tcom.example.b%n")) + .contains(String + .format("However, more than one of those properties has been configured at the same time:%n%n" + + "\tcom.example.a (originating from 'TestOrigin test')%n" + + "\tcom.example.b (originating from 'TestOrigin test')%n")); + } + + @Test + void analyzeWhenPropertyIsInMultiplePropertySourcesShouldListEachSourceInAnalysis() { + Map properties = new LinkedHashMap<>(); + properties.put("com.example.a", "alpha"); + properties.put("com.example.b", "bravo"); + this.environment.getPropertySources() + .addFirst(OriginCapablePropertySource.get(new MapPropertySource("test-one", properties))); + this.environment.getPropertySources() + .addLast(OriginCapablePropertySource.get(new MapPropertySource("test-two", properties))); + MutuallyExclusiveConfigurationPropertiesException failure = new MutuallyExclusiveConfigurationPropertiesException( + new HashSet<>(Arrays.asList("com.example.a", "com.example.b")), + new HashSet<>(Arrays.asList("com.example.a", "com.example.b"))); + FailureAnalysis analysis = performAnalysis(failure); + assertThat(analysis.getAction()).isEqualTo( + "Update your configuration so that only one of the mutually exclusive properties is configured."); + assertThat(analysis.getDescription()).contains(String.format( + "The following configuration properties are mutually exclusive:%n%n\tcom.example.a%n\tcom.example.b%n")) + .contains(String + .format("However, more than one of those properties has been configured at the same time:%n%n" + + "\tcom.example.a (originating from 'TestOrigin test-one')%n" + + "\tcom.example.a (originating from 'TestOrigin test-two')%n" + + "\tcom.example.b (originating from 'TestOrigin test-one')%n" + + "\tcom.example.b (originating from 'TestOrigin test-two')%n")); + } + + private FailureAnalysis performAnalysis(MutuallyExclusiveConfigurationPropertiesException failure) { + MutuallyExclusiveConfigurationPropertiesFailureAnalyzer analyzer = new MutuallyExclusiveConfigurationPropertiesFailureAnalyzer(); + analyzer.setEnvironment(this.environment); + return analyzer.analyze(failure); + } + + static class OriginCapablePropertySource extends EnumerablePropertySource implements OriginLookup { + + private final EnumerablePropertySource propertySource; + + OriginCapablePropertySource(EnumerablePropertySource propertySource) { + super(propertySource.getName(), propertySource.getSource()); + this.propertySource = propertySource; + } + + @Override + public Object getProperty(String name) { + return this.propertySource.getProperty(name); + } + + @Override + public String[] getPropertyNames() { + return this.propertySource.getPropertyNames(); + } + + @Override + public Origin getOrigin(String name) { + return new Origin() { + + @Override + public String toString() { + return "TestOrigin " + getName(); + } + + }; + } + + static OriginCapablePropertySource get(EnumerablePropertySource propertySource) { + return new OriginCapablePropertySource<>(propertySource); + } + + } + +}