Add exception and analyzer for mutually exclusive config props

Add `MutuallyExclusiveConfigurationPropertiesException` and a related
failure analyzer so that a nice message can be displayed if more than
one mutually exclusive property is defined.

Closes gh-28121

Co-authored-by: Phillip Webb <pwebb@vmware.com>
This commit is contained in:
Andy Wilkinson 2021-09-23 16:35:20 +01:00 committed by Phillip Webb
parent 528ced4f0d
commit 5e426394db
5 changed files with 523 additions and 0 deletions

View File

@ -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<String> configuredNames;
private final Set<String> 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<String> configuredNames,
Collection<String> mutuallyExclusiveNames) {
this(asSet(configuredNames), asSet(mutuallyExclusiveNames));
}
private MutuallyExclusiveConfigurationPropertiesException(Set<String> configuredNames,
Set<String> 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<String> getConfiguredNames() {
return this.configuredNames;
}
/**
* Return the names of the properties that are mutually exclusive.
* @return the names of the mutually exclusive properties
*/
public Set<String> getMutuallyExclusiveNames() {
return this.mutuallyExclusiveNames;
}
private static Set<String> asSet(Collection<String> collection) {
return (collection != null) ? new LinkedHashSet<>(collection) : null;
}
private static String buildMessage(Set<String> mutuallyExclusiveNames, Set<String> 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<Map<String, Object>> entries) {
Map<String, Object> map = new LinkedHashMap<>();
entries.accept(map);
Set<String> 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());
}
}
}

View File

@ -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<MutuallyExclusiveConfigurationPropertiesException> implements EnvironmentAware {
private ConfigurableEnvironment environment;
@Override
public void setEnvironment(Environment environment) {
this.environment = (ConfigurableEnvironment) environment;
}
@Override
protected FailureAnalysis analyze(Throwable rootFailure, MutuallyExclusiveConfigurationPropertiesException cause) {
List<Descriptor> descriptors = new ArrayList<>();
for (String name : cause.getConfiguredNames()) {
List<Descriptor> 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<Descriptor> getDescriptors(String propertyName) {
return getPropertySources().filter((source) -> source.containsProperty(propertyName))
.map((source) -> Descriptor.get(source, propertyName)).collect(Collectors.toList());
}
private Stream<PropertySource<?>> 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<Descriptor> 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<String> configuredDescriptions = sortedStrings(descriptors,
(descriptor) -> String.format("\t%s%s%n", descriptor.propertyName,
(descriptor.origin != null) ? " (originating from '" + descriptor.origin + "')" : ""));
configuredDescriptions.forEach(message::append);
}
private <S> Set<String> sortedStrings(Collection<String> input) {
return sortedStrings(input, Function.identity());
}
private <S> Set<String> sortedStrings(Collection<S> input, Function<S, String> converter) {
TreeSet<String> 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);
}
}
}

View File

@ -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,\

View File

@ -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<String> 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<String> configuredNames = Arrays.asList("a", "b");
List<String> mutuallyExclusiveNames = Arrays.asList("a", "b", "c");
MutuallyExclusiveConfigurationPropertiesException exception = new MutuallyExclusiveConfigurationPropertiesException(
configuredNames, mutuallyExclusiveNames);
assertThat(exception.getConfiguredNames()).hasSameElementsAs(configuredNames);
}
@Test
void getMutuallyExclusiveNamesReturnsMutuallyExclusiveNames() {
List<String> configuredNames = Arrays.asList("a", "b");
List<String> 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");
});
}
}

View File

@ -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<String, Object> 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<String, Object> 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<T> extends EnumerablePropertySource<T> implements OriginLookup<String> {
private final EnumerablePropertySource<T> propertySource;
OriginCapablePropertySource(EnumerablePropertySource<T> 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 <T> OriginCapablePropertySource<T> get(EnumerablePropertySource<T> propertySource) {
return new OriginCapablePropertySource<>(propertySource);
}
}
}