Skip to content

Commit 5e42639

Browse files
wilkinsonaphilwebb
andcommitted
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 <[email protected]>
1 parent 528ced4 commit 5e42639

File tree

5 files changed

+523
-0
lines changed

5 files changed

+523
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.context.properties.source;
18+
19+
import java.util.Collection;
20+
import java.util.LinkedHashMap;
21+
import java.util.LinkedHashSet;
22+
import java.util.Map;
23+
import java.util.Set;
24+
import java.util.function.Consumer;
25+
import java.util.stream.Collectors;
26+
27+
import org.springframework.util.Assert;
28+
29+
/**
30+
* Exception thrown when more than one mutually exclusive configuration property has been
31+
* configured.
32+
*
33+
* @author Andy Wilkinson
34+
* @author Phillip Webb
35+
* @since 2.6.0
36+
*/
37+
@SuppressWarnings("serial")
38+
public class MutuallyExclusiveConfigurationPropertiesException extends RuntimeException {
39+
40+
private final Set<String> configuredNames;
41+
42+
private final Set<String> mutuallyExclusiveNames;
43+
44+
/**
45+
* Creates a new instance for mutually exclusive configuration properties when two or
46+
* more of those properties have been configured.
47+
* @param configuredNames the names of the properties that have been configured
48+
* @param mutuallyExclusiveNames the names of the properties that are mutually
49+
* exclusive
50+
*/
51+
public MutuallyExclusiveConfigurationPropertiesException(Collection<String> configuredNames,
52+
Collection<String> mutuallyExclusiveNames) {
53+
this(asSet(configuredNames), asSet(mutuallyExclusiveNames));
54+
}
55+
56+
private MutuallyExclusiveConfigurationPropertiesException(Set<String> configuredNames,
57+
Set<String> mutuallyExclusiveNames) {
58+
super(buildMessage(mutuallyExclusiveNames, configuredNames));
59+
this.configuredNames = configuredNames;
60+
this.mutuallyExclusiveNames = mutuallyExclusiveNames;
61+
}
62+
63+
/**
64+
* Return the names of the properties that have been configured.
65+
* @return the names of the configured properties
66+
*/
67+
public Set<String> getConfiguredNames() {
68+
return this.configuredNames;
69+
}
70+
71+
/**
72+
* Return the names of the properties that are mutually exclusive.
73+
* @return the names of the mutually exclusive properties
74+
*/
75+
public Set<String> getMutuallyExclusiveNames() {
76+
return this.mutuallyExclusiveNames;
77+
}
78+
79+
private static Set<String> asSet(Collection<String> collection) {
80+
return (collection != null) ? new LinkedHashSet<>(collection) : null;
81+
}
82+
83+
private static String buildMessage(Set<String> mutuallyExclusiveNames, Set<String> configuredNames) {
84+
Assert.isTrue(configuredNames != null && configuredNames.size() > 1,
85+
"ConfiguredNames must contain 2 or more names");
86+
Assert.isTrue(mutuallyExclusiveNames != null && mutuallyExclusiveNames.size() > 1,
87+
"MutuallyExclusiveNames must contain 2 or more names");
88+
return "The configuration properties '" + String.join(", ", mutuallyExclusiveNames)
89+
+ "' are mutually exclusive and '" + String.join(", ", configuredNames)
90+
+ "' have been configured together";
91+
}
92+
93+
/**
94+
* Throw a new {@link MutuallyExclusiveConfigurationPropertiesException} if multiple
95+
* non-null values are defined in a set of entries.
96+
* @param entries a consumer used to populate the entries to check
97+
*/
98+
public static void throwIfMultipleNonNullValuesIn(Consumer<Map<String, Object>> entries) {
99+
Map<String, Object> map = new LinkedHashMap<>();
100+
entries.accept(map);
101+
Set<String> configuredNames = map.entrySet().stream().filter((entry) -> entry.getValue() != null)
102+
.map(Map.Entry::getKey).collect(Collectors.toCollection(LinkedHashSet::new));
103+
if (configuredNames.size() > 1) {
104+
throw new MutuallyExclusiveConfigurationPropertiesException(configuredNames, map.keySet());
105+
}
106+
}
107+
108+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.diagnostics.analyzer;
18+
19+
import java.util.ArrayList;
20+
import java.util.Collection;
21+
import java.util.List;
22+
import java.util.Set;
23+
import java.util.TreeSet;
24+
import java.util.function.Function;
25+
import java.util.stream.Collectors;
26+
import java.util.stream.Stream;
27+
28+
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
29+
import org.springframework.boot.context.properties.source.MutuallyExclusiveConfigurationPropertiesException;
30+
import org.springframework.boot.diagnostics.AbstractFailureAnalyzer;
31+
import org.springframework.boot.diagnostics.FailureAnalysis;
32+
import org.springframework.boot.diagnostics.FailureAnalyzer;
33+
import org.springframework.boot.origin.Origin;
34+
import org.springframework.boot.origin.OriginLookup;
35+
import org.springframework.context.EnvironmentAware;
36+
import org.springframework.core.env.ConfigurableEnvironment;
37+
import org.springframework.core.env.Environment;
38+
import org.springframework.core.env.PropertySource;
39+
40+
/**
41+
* A {@link FailureAnalyzer} that performs analysis of failures caused by an
42+
* {@link MutuallyExclusiveConfigurationPropertiesException}.
43+
*
44+
* @author Andy Wilkinson
45+
*/
46+
class MutuallyExclusiveConfigurationPropertiesFailureAnalyzer
47+
extends AbstractFailureAnalyzer<MutuallyExclusiveConfigurationPropertiesException> implements EnvironmentAware {
48+
49+
private ConfigurableEnvironment environment;
50+
51+
@Override
52+
public void setEnvironment(Environment environment) {
53+
this.environment = (ConfigurableEnvironment) environment;
54+
}
55+
56+
@Override
57+
protected FailureAnalysis analyze(Throwable rootFailure, MutuallyExclusiveConfigurationPropertiesException cause) {
58+
List<Descriptor> descriptors = new ArrayList<>();
59+
for (String name : cause.getConfiguredNames()) {
60+
List<Descriptor> descriptorsForName = getDescriptors(name);
61+
if (descriptorsForName.isEmpty()) {
62+
return null;
63+
}
64+
descriptors.addAll(descriptorsForName);
65+
}
66+
StringBuilder description = new StringBuilder();
67+
appendDetails(description, cause, descriptors);
68+
return new FailureAnalysis(description.toString(),
69+
"Update your configuration so that only one of the mutually exclusive properties is configured.",
70+
cause);
71+
}
72+
73+
private List<Descriptor> getDescriptors(String propertyName) {
74+
return getPropertySources().filter((source) -> source.containsProperty(propertyName))
75+
.map((source) -> Descriptor.get(source, propertyName)).collect(Collectors.toList());
76+
}
77+
78+
private Stream<PropertySource<?>> getPropertySources() {
79+
if (this.environment == null) {
80+
return Stream.empty();
81+
}
82+
return this.environment.getPropertySources().stream()
83+
.filter((source) -> !ConfigurationPropertySources.isAttachedConfigurationPropertySource(source));
84+
}
85+
86+
private void appendDetails(StringBuilder message, MutuallyExclusiveConfigurationPropertiesException cause,
87+
List<Descriptor> descriptors) {
88+
descriptors.sort((d1, d2) -> d1.propertyName.compareTo(d2.propertyName));
89+
message.append(String.format("The following configuration properties are mutually exclusive:%n%n"));
90+
sortedStrings(cause.getMutuallyExclusiveNames())
91+
.forEach((name) -> message.append(String.format("\t%s%n", name)));
92+
message.append(String.format("%n"));
93+
message.append(
94+
String.format("However, more than one of those properties has been configured at the same time:%n%n"));
95+
Set<String> configuredDescriptions = sortedStrings(descriptors,
96+
(descriptor) -> String.format("\t%s%s%n", descriptor.propertyName,
97+
(descriptor.origin != null) ? " (originating from '" + descriptor.origin + "')" : ""));
98+
configuredDescriptions.forEach(message::append);
99+
}
100+
101+
private <S> Set<String> sortedStrings(Collection<String> input) {
102+
return sortedStrings(input, Function.identity());
103+
}
104+
105+
private <S> Set<String> sortedStrings(Collection<S> input, Function<S, String> converter) {
106+
TreeSet<String> results = new TreeSet<>();
107+
for (S item : input) {
108+
results.add(converter.apply(item));
109+
}
110+
return results;
111+
}
112+
113+
private static final class Descriptor {
114+
115+
private final String propertyName;
116+
117+
private final Origin origin;
118+
119+
private Descriptor(String propertyName, Origin origin) {
120+
this.propertyName = propertyName;
121+
this.origin = origin;
122+
}
123+
124+
static Descriptor get(PropertySource<?> source, String propertyName) {
125+
Origin origin = OriginLookup.getOrigin(source, propertyName);
126+
return new Descriptor(propertyName, origin);
127+
}
128+
129+
}
130+
131+
}

spring-boot-project/spring-boot/src/main/resources/META-INF/spring.factories

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ org.springframework.boot.diagnostics.analyzer.BindFailureAnalyzer,\
6666
org.springframework.boot.diagnostics.analyzer.BindValidationFailureAnalyzer,\
6767
org.springframework.boot.diagnostics.analyzer.UnboundConfigurationPropertyFailureAnalyzer,\
6868
org.springframework.boot.diagnostics.analyzer.ConnectorStartFailureAnalyzer,\
69+
org.springframework.boot.diagnostics.analyzer.MutuallyExclusiveConfigurationPropertiesFailureAnalyzer,\
6970
org.springframework.boot.diagnostics.analyzer.NoSuchMethodFailureAnalyzer,\
7071
org.springframework.boot.diagnostics.analyzer.NoUniqueBeanDefinitionFailureAnalyzer,\
7172
org.springframework.boot.diagnostics.analyzer.PortInUseFailureAnalyzer,\
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright 2012-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.boot.context.properties.source;
18+
19+
import java.util.Arrays;
20+
import java.util.Collections;
21+
import java.util.List;
22+
23+
import org.junit.jupiter.api.Test;
24+
25+
import static org.assertj.core.api.Assertions.assertThat;
26+
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
27+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
28+
import static org.assertj.core.api.Assertions.assertThatNoException;
29+
30+
/**
31+
* Tests for {@link MutuallyExclusiveConfigurationPropertiesException}.
32+
*
33+
* @author Phillip Webb
34+
*/
35+
class MutuallyExclusiveConfigurationPropertiesExceptionTests {
36+
37+
@Test
38+
void createWhenConfiguredNamesIsNullThrowsException() {
39+
assertThatIllegalArgumentException()
40+
.isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(null, Arrays.asList("a", "b")))
41+
.withMessage("ConfiguredNames must contain 2 or more names");
42+
}
43+
44+
@Test
45+
void createWhenConfiguredNamesContainsOneElementThrowsException() {
46+
assertThatIllegalArgumentException()
47+
.isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Collections.singleton("a"),
48+
Arrays.asList("a", "b")))
49+
.withMessage("ConfiguredNames must contain 2 or more names");
50+
}
51+
52+
@Test
53+
void createWhenMutuallyExclusiveNamesIsNullThrowsException() {
54+
assertThatIllegalArgumentException()
55+
.isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Arrays.asList("a", "b"), null))
56+
.withMessage("MutuallyExclusiveNames must contain 2 or more names");
57+
}
58+
59+
@Test
60+
void createWhenMutuallyExclusiveNamesContainsOneElementThrowsException() {
61+
assertThatIllegalArgumentException()
62+
.isThrownBy(() -> new MutuallyExclusiveConfigurationPropertiesException(Arrays.asList("a", "b"),
63+
Collections.singleton("a")))
64+
.withMessage("MutuallyExclusiveNames must contain 2 or more names");
65+
}
66+
67+
@Test
68+
void createBuildsSensibleMessage() {
69+
List<String> names = Arrays.asList("a", "b");
70+
assertThat(new MutuallyExclusiveConfigurationPropertiesException(names, names))
71+
.hasMessage("The configuration properties 'a, b' are mutually exclusive "
72+
+ "and 'a, b' have been configured together");
73+
}
74+
75+
@Test
76+
void getConfiguredNamesReturnsConfiguredNames() {
77+
List<String> configuredNames = Arrays.asList("a", "b");
78+
List<String> mutuallyExclusiveNames = Arrays.asList("a", "b", "c");
79+
MutuallyExclusiveConfigurationPropertiesException exception = new MutuallyExclusiveConfigurationPropertiesException(
80+
configuredNames, mutuallyExclusiveNames);
81+
assertThat(exception.getConfiguredNames()).hasSameElementsAs(configuredNames);
82+
}
83+
84+
@Test
85+
void getMutuallyExclusiveNamesReturnsMutuallyExclusiveNames() {
86+
List<String> configuredNames = Arrays.asList("a", "b");
87+
List<String> mutuallyExclusiveNames = Arrays.asList("a", "b", "c");
88+
MutuallyExclusiveConfigurationPropertiesException exception = new MutuallyExclusiveConfigurationPropertiesException(
89+
configuredNames, mutuallyExclusiveNames);
90+
assertThat(exception.getMutuallyExclusiveNames()).hasSameElementsAs(mutuallyExclusiveNames);
91+
}
92+
93+
@Test
94+
void throwIfMultipleNonNullValuesInWhenEntriesHasAllNullsDoesNotThrowException() {
95+
assertThatNoException().isThrownBy(
96+
() -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
97+
entries.put("a", null);
98+
entries.put("b", null);
99+
entries.put("c", null);
100+
}));
101+
}
102+
103+
@Test
104+
void throwIfMultipleNonNullValuesInWhenEntriesHasSingleNonNullDoesNotThrowException() {
105+
assertThatNoException().isThrownBy(
106+
() -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
107+
entries.put("a", null);
108+
entries.put("b", "B");
109+
entries.put("c", null);
110+
}));
111+
}
112+
113+
@Test
114+
void throwIfMultipleNonNullValuesInWhenEntriesHasTwoNonNullsThrowsException() {
115+
assertThatExceptionOfType(MutuallyExclusiveConfigurationPropertiesException.class).isThrownBy(
116+
() -> MutuallyExclusiveConfigurationPropertiesException.throwIfMultipleNonNullValuesIn((entries) -> {
117+
entries.put("a", "a");
118+
entries.put("b", "B");
119+
entries.put("c", null);
120+
})).satisfies((ex) -> {
121+
assertThat(ex.getConfiguredNames()).containsExactly("a", "b");
122+
assertThat(ex.getMutuallyExclusiveNames()).containsExactly("a", "b", "c");
123+
});
124+
}
125+
126+
}

0 commit comments

Comments
 (0)