Add new property placeholder implementation

This commit provides a rewrite of the parser for properties containing
potentially placeholders.

Assuming a source where `firstName` = `John` and `lastName` = `Smith`,
the "${firstName}-${lastName}" property is evaluated as "John-Smith".

Compared with the existing implementation in PropertyPlaceholderHelper,
the new implementation offers the following extra features:

1. Placeholder can be escaped using a configurable escape character.
When a placeholder is escaped it is rendered as is. This does apply to
any nested placeholder that wouldn't be escaped. For instance,
"\${firstName}" is evaluated as "${firstName}".
2. The default separator can also be escaped the same way. When the
separator is escaped, the left and right parts are not considered as
the key and the default value respectively. Rather the two parts
combined, including the separator (but not the escape separator) are
used for resolution. For instance, ${java\:comp/env/test} is looking
for a "java:comp/env/test" property.
3. Placeholders are resolved lazily. Previously, all nested placeholders
were resolved before considering if a separator was present. This
implementation only attempts the resolution of the default value if the
key does not provide a value.
4. Failure to resolve a placeholder are more rich, with a dedicated
PlaceholderResolutionException that contains the resolution chain.

See gh-9628
See gh-26268
This commit is contained in:
Stéphane Nicoll
2023-12-29 17:40:25 +01:00
parent 0fbfecd392
commit 00e05e603d
3 changed files with 958 additions and 0 deletions

View File

@@ -0,0 +1,355 @@
/*
* Copyright 2002-2024 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.util;
import java.util.Properties;
import java.util.stream.Stream;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.util.PlaceholderParser.ParsedValue;
import org.springframework.util.PlaceholderParser.TextPart;
import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link PlaceholderParser}.
*
* @author Stephane Nicoll
*/
class PlaceholderParserTests {
@Nested // Tests with only the basic placeholder feature enabled
class OnlyPlaceholderTests {
private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, null, null);
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("placeholders")
void placeholderIsReplaced(String text, String expected) {
Properties properties = new Properties();
properties.setProperty("firstName", "John");
properties.setProperty("nested0", "first");
properties.setProperty("nested1", "Name");
assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
}
static Stream<Arguments> placeholders() {
return Stream.of(
Arguments.of("${firstName}", "John"),
Arguments.of("$${firstName}", "$John"),
Arguments.of("}${firstName}", "}John"),
Arguments.of("${firstName}$", "John$"),
Arguments.of("${firstName}}", "John}"),
Arguments.of("${firstName} ${firstName}", "John John"),
Arguments.of("First name: ${firstName}", "First name: John"),
Arguments.of("${firstName} is the first name", "John is the first name"),
Arguments.of("${first${nested1}}", "John"),
Arguments.of("${${nested0}${nested1}}", "John")
);
}
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("nestedPlaceholders")
void nestedPlaceholdersAreReplaced(String text, String expected) {
Properties properties = new Properties();
properties.setProperty("p1", "v1");
properties.setProperty("p2", "v2");
properties.setProperty("p3", "${p1}:${p2}"); // nested placeholders
properties.setProperty("p4", "${p3}"); // deeply nested placeholders
properties.setProperty("p5", "${p1}:${p2}:${bogus}"); // unresolvable placeholder
assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
}
static Stream<Arguments> nestedPlaceholders() {
return Stream.of(
Arguments.of("${p1}:${p2}", "v1:v2"),
Arguments.of("${p3}", "v1:v2"),
Arguments.of("${p4}", "v1:v2"),
Arguments.of("${p5}", "v1:v2:${bogus}"),
Arguments.of("${p0${p0}}", "${p0${p0}}")
);
}
@Test
void parseWithSinglePlaceholder() {
PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
assertThat(this.parser.replacePlaceholders("${firstName}", resolver))
.isEqualTo("John");
verify(resolver).resolvePlaceholder("firstName");
verifyNoMoreInteractions(resolver);
}
@Test
void parseWithPlaceholderAndPrefixText() {
PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
assertThat(this.parser.replacePlaceholders("This is ${firstName}", resolver))
.isEqualTo("This is John");
verify(resolver).resolvePlaceholder("firstName");
verifyNoMoreInteractions(resolver);
}
@Test
void parseWithMultiplePlaceholdersAndText() {
PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John", "lastName", "Smith");
assertThat(this.parser.replacePlaceholders("User: ${firstName} - ${lastName}.", resolver))
.isEqualTo("User: John - Smith.");
verify(resolver).resolvePlaceholder("firstName");
verify(resolver).resolvePlaceholder("lastName");
verifyNoMoreInteractions(resolver);
}
@Test
void parseWithNestedPlaceholderInKey() {
PlaceholderResolver resolver = mockPlaceholderResolver(
"nested", "Name", "firstName", "John");
assertThat(this.parser.replacePlaceholders("${first${nested}}", resolver))
.isEqualTo("John");
verifyPlaceholderResolutions(resolver, "nested", "firstName");
}
@Test
void parseWithMultipleNestedPlaceholdersInKey() {
PlaceholderResolver resolver = mockPlaceholderResolver(
"nested0", "first", "nested1", "Name", "firstName", "John");
assertThat(this.parser.replacePlaceholders("${${nested0}${nested1}}", resolver))
.isEqualTo("John");
verifyPlaceholderResolutions(resolver, "nested0", "nested1", "firstName");
}
@Test
void placeholdersWithSeparatorAreHandledAsIs() {
PlaceholderResolver resolver = mockPlaceholderResolver("my:test", "value");
assertThat(this.parser.replacePlaceholders("${my:test}", resolver)).isEqualTo("value");
verifyPlaceholderResolutions(resolver, "my:test");
}
@Test
void placeholdersWithoutEscapeCharAreNotEscaped() {
PlaceholderResolver resolver = mockPlaceholderResolver("test", "value");
assertThat(this.parser.replacePlaceholders("\\${test}", resolver)).isEqualTo("\\value");
verifyPlaceholderResolutions(resolver, "test");
}
@Test
void textWithInvalidPlaceholderIsMerged() {
String text = "test${of${with${and${";
ParsedValue parsedValue = this.parser.parse(text);
assertThat(parsedValue.parts()).singleElement().isInstanceOfSatisfying(
TextPart.class, textPart -> assertThat(textPart.text()).isEqualTo(text));
}
}
@Nested // Tests with the use of a separator
class DefaultValueTests {
private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, ":", null);
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("placeholders")
void placeholderIsReplaced(String text, String expected) {
Properties properties = new Properties();
properties.setProperty("firstName", "John");
properties.setProperty("nested0", "first");
properties.setProperty("nested1", "Name");
assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
}
static Stream<Arguments> placeholders() {
return Stream.of(
Arguments.of("${invalid:John}", "John"),
Arguments.of("${first${invalid:Name}}", "John"),
Arguments.of("${invalid:${firstName}}", "John"),
Arguments.of("${invalid:${${nested0}${nested1}}}", "John"),
Arguments.of("${invalid:$${firstName}}", "$John"),
Arguments.of("${invalid: }${firstName}", " John"),
Arguments.of("${invalid:}", ""),
Arguments.of("${:}", "")
);
}
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("nestedPlaceholders")
void nestedPlaceholdersAreReplaced(String text, String expected) {
Properties properties = new Properties();
properties.setProperty("p1", "v1");
properties.setProperty("p2", "v2");
properties.setProperty("p3", "${p1}:${p2}"); // nested placeholders
properties.setProperty("p4", "${p3}"); // deeply nested placeholders
properties.setProperty("p5", "${p1}:${p2}:${bogus}"); // unresolvable placeholder
properties.setProperty("p6", "${p1}:${p2}:${bogus:def}"); // unresolvable w/ default
assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
}
static Stream<Arguments> nestedPlaceholders() {
return Stream.of(
Arguments.of("${p6}", "v1:v2:def"),
Arguments.of("${invalid:${p1}:${p2}}", "v1:v2"),
Arguments.of("${invalid:${p3}}", "v1:v2"),
Arguments.of("${invalid:${p4}}", "v1:v2"),
Arguments.of("${invalid:${p5}}", "v1:v2:${bogus}"),
Arguments.of("${invalid:${p6}}", "v1:v2:def")
);
}
@Test
void parseWithHardcodedFallback() {
PlaceholderResolver resolver = mockPlaceholderResolver();
assertThat(this.parser.replacePlaceholders("${firstName:Steve}", resolver))
.isEqualTo("Steve");
verifyPlaceholderResolutions(resolver, "firstName");
}
@Test
void parseWithNestedPlaceholderInKeyUsingFallback() {
PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
assertThat(this.parser.replacePlaceholders("${first${invalid:Name}}", resolver))
.isEqualTo("John");
verifyPlaceholderResolutions(resolver, "invalid", "firstName");
}
@Test
void parseWithFallbackUsingPlaceholder() {
PlaceholderResolver resolver = mockPlaceholderResolver("firstName", "John");
assertThat(this.parser.replacePlaceholders("${invalid:${firstName}}", resolver))
.isEqualTo("John");
verifyPlaceholderResolutions(resolver, "invalid", "firstName");
}
}
@Nested // Tests with the use of the escape character
class EscapedTests {
private final PlaceholderParser parser = new PlaceholderParser("${", "}", true, ":", '\\');
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("escapedPlaceholders")
void escapedPlaceholderIsNotReplaced(String text, String expected) {
PlaceholderResolver resolver = mockPlaceholderResolver(
"firstName", "John", "nested0", "first", "nested1", "Name",
"${test}", "John",
"p1", "v1", "p2", "\\${p1:default}", "p3", "${p2}",
"p4", "adc${p0:\\${p1}}",
"p5", "adc${\\${p0}:${p1}}",
"p6", "adc${p0:def\\${p1}}",
"p7", "adc\\${");
assertThat(this.parser.replacePlaceholders(text, resolver)).isEqualTo(expected);
}
static Stream<Arguments> escapedPlaceholders() {
return Stream.of(
Arguments.of("\\${firstName}", "${firstName}"),
Arguments.of("First name: \\${firstName}", "First name: ${firstName}"),
Arguments.of("$\\${firstName}", "$${firstName}"),
Arguments.of("\\}${firstName}", "\\}John"),
Arguments.of("${\\${test}}", "John"),
Arguments.of("${p2}", "${p1:default}"),
Arguments.of("${p3}", "${p1:default}"),
Arguments.of("${p4}", "adc${p1}"),
Arguments.of("${p5}", "adcv1"),
Arguments.of("${p6}", "adcdef${p1}"),
Arguments.of("${p7}", "adc\\${"));
}
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("escapedSeparators")
void escapedSeparatorIsNotReplaced(String text, String expected) {
Properties properties = new Properties();
properties.setProperty("first:Name", "John");
properties.setProperty("nested0", "first");
properties.setProperty("nested1", "Name");
assertThat(this.parser.replacePlaceholders(text, properties::getProperty)).isEqualTo(expected);
}
static Stream<Arguments> escapedSeparators() {
return Stream.of(
Arguments.of("${first\\:Name}", "John"),
Arguments.of("${last\\:Name}", "${last:Name}")
);
}
}
@Nested
class ExceptionTests {
private final PlaceholderParser parser = new PlaceholderParser("${", "}", false, ":", null);
@Test
void textWithCircularReference() {
PlaceholderResolver resolver = mockPlaceholderResolver("pL", "${pR}", "pR", "${pL}");
assertThatThrownBy(() -> this.parser.replacePlaceholders("${pL}", resolver))
.isInstanceOf(PlaceholderResolutionException.class)
.hasMessage("Circular placeholder reference 'pL' in value \"${pL}\" <-- \"${pR}\" <-- \"${pL}\"");
}
@Test
void unresolvablePlaceholderIsReported() {
PlaceholderResolver resolver = mockPlaceholderResolver();
assertThatExceptionOfType(PlaceholderResolutionException.class)
.isThrownBy(() -> this.parser.replacePlaceholders("${bogus}", resolver))
.withMessage("Could not resolve placeholder 'bogus' in value \"${bogus}\"")
.withNoCause();
}
@Test
void unresolvablePlaceholderInNestedPlaceholderIsReportedWithChain() {
PlaceholderResolver resolver = mockPlaceholderResolver("p1", "v1", "p2", "v2",
"p3", "${p1}:${p2}:${bogus}");
assertThatExceptionOfType(PlaceholderResolutionException.class)
.isThrownBy(() -> this.parser.replacePlaceholders("${p3}", resolver))
.withMessage("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\" <-- \"${p3}\"")
.withNoCause();
}
}
PlaceholderResolver mockPlaceholderResolver(String... pairs) {
if (pairs.length % 2 == 1) {
throw new IllegalArgumentException("size must be even, it is a set of key=value pairs");
}
PlaceholderResolver resolver = mock(PlaceholderResolver.class);
for (int i = 0; i < pairs.length; i += 2) {
String key = pairs[i];
String value = pairs[i + 1];
given(resolver.resolvePlaceholder(key)).willReturn(value);
}
return resolver;
}
void verifyPlaceholderResolutions(PlaceholderResolver mock, String... placeholders) {
for (String placeholder : placeholders) {
verify(mock).resolvePlaceholder(placeholder);
}
verifyNoMoreInteractions(mock);
}
}