diff --git a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc index 2e4ca3b32dc4..608b29076754 100644 --- a/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc +++ b/spring-boot-project/spring-boot-docs/src/docs/antora/modules/reference/pages/features/external-config.adoc @@ -912,7 +912,7 @@ TIP: We recommend that, when possible, properties are stored in lower-case kebab ==== Binding Maps When binding to `Map` properties you may need to use a special bracket notation so that the original `key` value is preserved. -If the key is not surrounded by `[]`, any characters that are not alpha-numeric, `-` or `.` are removed. +A binding failure occurs if the key contains any characters that are not alpha-numeric, `-` or `.` but not surrounded by `[]`. For example, consider binding the following properties to a `Map`: @@ -927,8 +927,8 @@ my: NOTE: For YAML files, the brackets need to be surrounded by quotes for the keys to be parsed properly. -The properties above will bind to a `Map` with `/key1`, `/key2` and `key3` as the keys in the map. -The slash has been removed from `key3` because it was not surrounded by square brackets. +The properties above is failed to bind to a `Map` with `/key1`, `/key2` and `/key3` as the keys in the map, +because `/key3` was not surrounded by square brackets. When binding to scalar values, keys with `.` in them do not need to be surrounded by `[]`. Scalar values include enums and all types in the `java.lang` package except for `Object`. diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java index 573339edf929..c3999f393f08 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/bind/MapBinder.java @@ -31,12 +31,14 @@ import org.springframework.boot.context.properties.source.IterableConfigurationPropertySource; import org.springframework.core.CollectionFactory; import org.springframework.core.ResolvableType; +import org.springframework.core.env.PropertySource; /** * {@link AggregateBinder} for Maps. * * @author Phillip Webb * @author Madhura Bhave + * @author Yanming Zhou */ class MapBinder extends AggregateBinder> { @@ -166,6 +168,23 @@ private class EntryBinder { void bindEntries(ConfigurationPropertySource source, Map map) { if (source instanceof IterableConfigurationPropertySource iterableSource) { for (ConfigurationPropertyName name : iterableSource) { + if (name.isLastElementNonUniform()) { + String sourceName = name.getSource(); + int index = sourceName.lastIndexOf('.'); + String key = sourceName.substring(index + 1); + if (!key.equals(name.getLastElement(Form.ORIGINAL)) + && !key.equalsIgnoreCase(name.getLastElement(Form.UNIFORM))) { + boolean forYaml = false; + if (source.getUnderlyingSource() instanceof PropertySource ps) { + String propertySourceName = ps.getName(); + forYaml = propertySourceName.contains(".yaml") || propertySourceName.contains(".yml"); + } + String validKey = (forYaml ? "\"[" : "[") + key + (forYaml ? "]\"" : "]"); + throw new IllegalArgumentException( + "Please rewrite key '" + key + "' to '" + validKey + "'"); + + } + } Bindable valueBindable = getValueBindable(name); ConfigurationPropertyName entryName = getEntryName(source, name); Object key = getContext().getConverter().convert(getKeyName(entryName), this.keyType); diff --git a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java index 91052105ff3a..0b73ebf2ea39 100644 --- a/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java +++ b/spring-boot-project/spring-boot/src/main/java/org/springframework/boot/context/properties/source/ConfigurationPropertyName.java @@ -46,6 +46,7 @@ * * @author Phillip Webb * @author Madhura Bhave + * @author Yanming Zhou * @since 2.0.0 * @see #of(CharSequence) * @see ConfigurationPropertySource @@ -121,6 +122,23 @@ public boolean isNumericIndex(int elementIndex) { return this.elements.getType(elementIndex) == ElementType.NUMERICALLY_INDEXED; } + /** + * Return whether the last element is non-uniform. + * @return whether the last element is non-uniform + */ + public boolean isLastElementNonUniform() { + int size = getNumberOfElements(); + return size != 0 && this.elements.getType(size - 1) == ElementType.NON_UNIFORM; + } + + /** + * Return the source. + * @return the source + */ + public String getSource() { + return this.elements.source.toString(); + } + /** * Return the last element in the name in the given form. * @param form the form to return diff --git a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java index 9ba175996d98..c75490075c4c 100644 --- a/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java +++ b/spring-boot-project/spring-boot/src/test/java/org/springframework/boot/context/properties/bind/MapBinderTests.java @@ -64,6 +64,7 @@ * * @author Phillip Webb * @author Madhura Bhave + * @author Yanming Zhou */ class MapBinderTests { @@ -651,6 +652,42 @@ void bindToEnumMapShouldBind() { assertThat(result).hasSize(1).containsEntry(ExampleEnum.FOO_BAR, "value"); } + @Test + void rejectNonUniformKey() { + MapConfigurationPropertySource source = new MapConfigurationPropertySource( + Collections.singletonMap("props./foobar", "value")); + this.sources.add(source); + Binder binder = new Binder(this.sources, null, null, null); + Bindable> bindable = Bindable + .of(ResolvableType.forClassWithGenerics(Map.class, String.class, String.class)); + assertThatExceptionOfType(BindException.class).isThrownBy(() -> binder.bind("props", bindable)) + .withCauseInstanceOf(IllegalArgumentException.class); + } + + @Test + void doNotRejectUnderscoreNonUniformKey() { + MapConfigurationPropertySource source = new MapConfigurationPropertySource( + Collections.singletonMap("props.foo_bar", "value")); + this.sources.add(source); + Binder binder = new Binder(this.sources, null, null, null); + Bindable> bindable = Bindable + .of(ResolvableType.forClassWithGenerics(Map.class, String.class, String.class)); + Map result = binder.bind("props", bindable).get(); + assertThat(result).hasSize(1).containsEntry("foo_bar", "value"); + } + + @Test + void doNotRejectCaseInsensitiveNonUniformKey() { + MapConfigurationPropertySource source = new MapConfigurationPropertySource( + Collections.singletonMap("props.FOOBAR", "value")); + this.sources.add(source); + Binder binder = new Binder(this.sources, null, null, null); + Bindable> bindable = Bindable + .of(ResolvableType.forClassWithGenerics(Map.class, String.class, String.class)); + Map result = binder.bind("props", bindable).get(); + assertThat(result).hasSize(1).containsEntry("FOOBAR", "value"); + } + private Bindable> getMapBindable(Class keyGeneric, ResolvableType valueType) { ResolvableType keyType = ResolvableType.forClass(keyGeneric); return Bindable.of(ResolvableType.forClassWithGenerics(Map.class, keyType, valueType));