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,503 @@
/*
* 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.ArrayList;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.lang.Nullable;
import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
/**
* Parser for Strings that have placeholder values in them. In its simplest form,
* a placeholder takes the form of {@code ${name}}, where {@code name} is the key
* that can be resolved using a {@link PlaceholderResolver PlaceholderResolver},
* <code>${</code> the prefix, and <code>}</code> the suffix.
*
* <p>A placeholder can also have a default value if its key does not represent a
* known property. The default value is separated from the key using a
* {@code separator}. For instance {@code ${name:John}} resolves to {@code John} if
* the placeholder resolver does not provide a value for the {@code name}
* property.
*
* <p>Placeholders can also have a more complex structure, and the resolution of
* a given key can involve the resolution of nested placeholders. Default values
* can also have placeholders.
*
* <p>For situations where the syntax of a valid placeholder match a String that
* must be rendered as is, the placeholder can be escaped using an {@code escape}
* character. For instance {@code \${name}} resolves as {@code ${name}}.
*
* <p>The prefix, suffix, separator, and escape characters are configurable. Only
* the prefix and suffix are mandatory and the support of default values or
* escaping are conditional on providing a non-null value for them.
*
* <p>This parser makes sure to resolves placeholders as lazily as possible.
*
* @author Stephane Nicoll
* @since 6.2
*/
final class PlaceholderParser {
private static final Log logger = LogFactory.getLog(PlaceholderParser.class);
private static final Map<String, String> wellKnownSimplePrefixes = Map.of(
"}", "{", "]", "[", ")", "(");
private final String prefix;
private final String suffix;
private final String simplePrefix;
@Nullable
private final String separator;
private final boolean ignoreUnresolvablePlaceholders;
@Nullable
private final Character escape;
/**
* Create an instance using the specified input for the parser.
*
* @param prefix the prefix that denotes the start of a placeholder
* @param suffix the suffix that denotes the end of a placeholder
* @param ignoreUnresolvablePlaceholders whether unresolvable placeholders
* should be ignored ({@code true}) or cause an exception ({@code false})
* @param separator the separating character between the placeholder
* variable and the associated default value, if any
* @param escape the character to use at the beginning of a placeholder
* to escape it and render it as is
*/
PlaceholderParser(String prefix, String suffix, boolean ignoreUnresolvablePlaceholders,
@Nullable String separator, @Nullable Character escape) {
this.prefix = prefix;
this.suffix = suffix;
String simplePrefixForSuffix = wellKnownSimplePrefixes.get(this.suffix);
if (simplePrefixForSuffix != null && this.prefix.endsWith(simplePrefixForSuffix)) {
this.simplePrefix = simplePrefixForSuffix;
}
else {
this.simplePrefix = this.prefix;
}
this.separator = separator;
this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
this.escape = escape;
}
/**
* Replaces all placeholders of format {@code ${name}} with the value returned
* from the supplied {@link PlaceholderResolver}.
* @param value the value containing the placeholders to be replaced
* @param placeholderResolver the {@code PlaceholderResolver} to use for replacement
* @return the supplied value with placeholders replaced inline
*/
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
Assert.notNull(value, "'value' must not be null");
ParsedValue parsedValue = parse(value);
PartResolutionContext resolutionContext = new PartResolutionContext(placeholderResolver,
this.prefix, this.suffix, this.ignoreUnresolvablePlaceholders,
candidate -> parse(candidate, false));
return parsedValue.resolve(resolutionContext);
}
/**
* Parse the specified value.
* @param value the value containing the placeholders to be replaced
* @return the different parts that have been identified
*/
ParsedValue parse(String value) {
List<Part> parts = parse(value, false);
return new ParsedValue(value, parts);
}
private List<Part> parse(String value, boolean inPlaceholder) {
LinkedList<Part> parts = new LinkedList<>();
int startIndex = nextStartPrefix(value, 0);
if (startIndex == -1) {
Part part = inPlaceholder ? createSimplePlaceholderPart(value)
: new TextPart(value);
parts.add(part);
return parts;
}
int position = 0;
while (startIndex != -1) {
int endIndex = nextValidEndPrefix(value, startIndex);
if (endIndex == -1) { // Not a valid placeholder, consume the prefix and continue
addText(value, position, startIndex + this.prefix.length(), parts);
position = startIndex + this.prefix.length();
startIndex = nextStartPrefix(value, position);
}
else if (isEscaped(value, startIndex)) { // Not a valid index, accumulate and skip the escape character
addText(value, position, startIndex - 1, parts);
addText(value, startIndex, startIndex + this.prefix.length(), parts);
position = startIndex + this.prefix.length();
startIndex = nextStartPrefix(value, position);
}
else { // Found valid placeholder, recursive parsing
addText(value, position, startIndex, parts);
String placeholder = value.substring(startIndex + this.prefix.length(), endIndex);
List<Part> placeholderParts = parse(placeholder, true);
parts.addAll(placeholderParts);
startIndex = nextStartPrefix(value, endIndex + this.suffix.length());
position = endIndex + this.suffix.length();
}
}
// Add rest of text if necessary
addText(value, position, value.length(), parts);
return inPlaceholder ? List.of(createNestedPlaceholderPart(value, parts)) : parts;
}
private SimplePlaceholderPart createSimplePlaceholderPart(String text) {
String[] keyAndDefault = splitKeyAndDefault(text);
return (keyAndDefault != null) ? new SimplePlaceholderPart(text, keyAndDefault[0], keyAndDefault[1])
: new SimplePlaceholderPart(text, text, null);
}
private NestedPlaceholderPart createNestedPlaceholderPart(String text, List<Part> parts) {
if (this.separator == null) {
return new NestedPlaceholderPart(text, parts, null);
}
List<Part> keyParts = new ArrayList<>();
List<Part> defaultParts = new ArrayList<>();
for (int i = 0; i < parts.size(); i++) {
Part part = parts.get(i);
if (!(part instanceof TextPart)) {
keyParts.add(part);
}
else {
String candidate = part.text();
String[] keyAndDefault = splitKeyAndDefault(candidate);
if (keyAndDefault != null) {
keyParts.add(new TextPart(keyAndDefault[0]));
if (keyAndDefault[1] != null) {
defaultParts.add(new TextPart(keyAndDefault[1]));
}
defaultParts.addAll(parts.subList(i + 1, parts.size()));
return new NestedPlaceholderPart(text, keyParts, defaultParts);
}
else {
keyParts.add(part);
}
}
}
// No separator found
return new NestedPlaceholderPart(text, parts, null);
}
@Nullable
private String[] splitKeyAndDefault(String value) {
if (this.separator == null || !value.contains(this.separator)) {
return null;
}
int position = 0;
int index = value.indexOf(this.separator, position);
StringBuilder buffer = new StringBuilder();
while (index != -1) {
if (isEscaped(value, index)) {
// Accumulate, without the escape character.
buffer.append(value, position, index - 1);
buffer.append(value, index, index + this.separator.length());
position = index + this.separator.length();
index = value.indexOf(this.separator, position);
}
else {
buffer.append(value, position, index);
String key = buffer.toString();
String fallback = value.substring(index + this.separator.length());
return new String[] { key, fallback };
}
}
buffer.append(value, position, value.length());
return new String[] { buffer.toString(), null };
}
private static void addText(String value, int start, int end, LinkedList<Part> parts) {
if (start > end) {
return;
}
String text = value.substring(start, end);
if (!text.isEmpty()) {
if (!parts.isEmpty()) {
Part current = parts.removeLast();
if (current instanceof TextPart textPart) {
parts.add(new TextPart(textPart.text + text));
}
else {
parts.add(current);
parts.add(new TextPart(text));
}
}
else {
parts.add(new TextPart(text));
}
}
}
private int nextStartPrefix(String value, int index) {
return value.indexOf(this.prefix, index);
}
private int nextValidEndPrefix(String value, int startIndex) {
int index = startIndex + this.prefix.length();
int withinNestedPlaceholder = 0;
while (index < value.length()) {
if (StringUtils.substringMatch(value, index, this.suffix)) {
if (withinNestedPlaceholder > 0) {
withinNestedPlaceholder--;
index = index + this.suffix.length();
}
else {
return index;
}
}
else if (StringUtils.substringMatch(value, index, this.simplePrefix)) {
withinNestedPlaceholder++;
index = index + this.simplePrefix.length();
}
else {
index++;
}
}
return -1;
}
private boolean isEscaped(String value, int index) {
return (this.escape != null && index > 0 && value.charAt(index - 1) == this.escape);
}
/**
* Provide the necessary to handle and resolve underlying placeholders.
*/
static class PartResolutionContext implements PlaceholderResolver {
private final String prefix;
private final String suffix;
private final boolean ignoreUnresolvablePlaceholders;
private final Function<String, List<Part>> parser;
private final PlaceholderResolver resolver;
@Nullable
private Set<String> visitedPlaceholders;
PartResolutionContext(PlaceholderResolver resolver, String prefix, String suffix,
boolean ignoreUnresolvablePlaceholders, Function<String, List<Part>> parser) {
this.prefix = prefix;
this.suffix = suffix;
this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
this.parser = parser;
this.resolver = resolver;
}
@Override
public String resolvePlaceholder(String placeholderName) {
String value = this.resolver.resolvePlaceholder(placeholderName);
if (value != null && logger.isTraceEnabled()) {
logger.trace("Resolved placeholder '" + placeholderName + "'");
}
return value;
}
public String handleUnresolvablePlaceholder(String key, String text) {
if (this.ignoreUnresolvablePlaceholders) {
return toPlaceholderText(key);
}
String originalValue = (!key.equals(text) ? toPlaceholderText(text) : null);
throw new PlaceholderResolutionException(
"Could not resolve placeholder '%s'".formatted(key), key, originalValue);
}
private String toPlaceholderText(String text) {
return this.prefix + text + this.suffix;
}
public List<Part> parse(String text) {
return this.parser.apply(text);
}
public void flagPlaceholderAsVisited(String placeholder) {
if (this.visitedPlaceholders == null) {
this.visitedPlaceholders = new HashSet<>(4);
}
if (!this.visitedPlaceholders.add(placeholder)) {
throw new PlaceholderResolutionException(
"Circular placeholder reference '%s'".formatted(placeholder), placeholder, null);
}
}
public void removePlaceholder(String placeholder) {
this.visitedPlaceholders.remove(placeholder);
}
}
/**
* A part is a section of a String containing placeholders to replace.
*/
interface Part {
/**
* Resolve this part using the specified {@link PartResolutionContext}.
* @param resolutionContext the context to use
* @return the resolved part
*/
String resolve(PartResolutionContext resolutionContext);
/**
* Provide a textual representation of this part.
* @return the raw text that this part defines
*/
String text();
/**
* Return a String that appends the resolution of the specified parts.
* @param parts the parts to resolve
* @param resolutionContext the context to use for the resolution
* @return a concatenation of the supplied parts with placeholders replaced inline
*/
static String resolveAll(Iterable<Part> parts, PartResolutionContext resolutionContext) {
StringBuilder sb = new StringBuilder();
for (Part part : parts) {
sb.append(part.resolve(resolutionContext));
}
return sb.toString();
}
}
/**
* A representation of the parsing of an input string.
* @param text the raw input string
* @param parts the parts that appear in the string, in order
*/
record ParsedValue(String text, List<Part> parts) {
public String resolve(PartResolutionContext resolutionContext) {
try {
return Part.resolveAll(this.parts, resolutionContext);
}
catch (PlaceholderResolutionException ex) {
throw ex.withValue(this.text);
}
}
}
/**
* A {@link Part} implementation that does not contain a valid placeholder.
* @param text the raw (and resolved) text
*/
record TextPart(String text) implements Part {
@Override
public String resolve(PartResolutionContext resolutionContext) {
return this.text;
}
}
/**
* A {@link Part} implementation that represents a single placeholder with
* a hard-coded fallback.
* @param text the raw text
* @param key the key of the placeholder
* @param fallback the fallback to use, if any
*/
record SimplePlaceholderPart(String text, String key, @Nullable String fallback) implements Part {
@Override
public String resolve(PartResolutionContext resolutionContext) {
String resolvedValue = resolveToText(resolutionContext, this.key);
if (resolvedValue != null) {
return resolvedValue;
}
else if (this.fallback != null) {
return this.fallback;
}
return resolutionContext.handleUnresolvablePlaceholder(this.key, this.text);
}
@Nullable
private String resolveToText(PartResolutionContext resolutionContext, String text) {
String resolvedValue = resolutionContext.resolvePlaceholder(text);
if (resolvedValue != null) {
resolutionContext.flagPlaceholderAsVisited(text);
// Let's check if we need to recursively resolve that value
List<Part> nestedParts = resolutionContext.parse(resolvedValue);
String value = toText(nestedParts);
if (!isTextOnly(nestedParts)) {
value = new ParsedValue(resolvedValue, nestedParts).resolve(resolutionContext);
}
resolutionContext.removePlaceholder(text);
return value;
}
// Not found
return null;
}
private boolean isTextOnly(List<Part> parts) {
return parts.stream().allMatch(part -> part instanceof TextPart);
}
private String toText(List<Part> parts) {
StringBuilder sb = new StringBuilder();
parts.forEach(part -> sb.append(part.text()));
return sb.toString();
}
}
/**
* A {@link Part} implementation that represents a single placeholder
* containing nested placeholders.
* @param text the raw text of the root placeholder
* @param keyParts the parts of the key
* @param defaultParts the parts of the fallback, if any
*/
record NestedPlaceholderPart(String text, List<Part> keyParts, @Nullable List<Part> defaultParts) implements Part {
@Override
public String resolve(PartResolutionContext resolutionContext) {
String resolvedKey = Part.resolveAll(this.keyParts, resolutionContext);
String value = resolutionContext.resolvePlaceholder(resolvedKey);
if (value != null) {
return value;
}
else if (this.defaultParts != null) {
return Part.resolveAll(this.defaultParts, resolutionContext);
}
return resolutionContext.handleUnresolvablePlaceholder(resolvedKey, this.text);
}
}
}

View File

@ -0,0 +1,100 @@
/*
* 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.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.lang.Nullable;
/**
* Thrown when the resolution of placeholder failed. This exception provides
* the placeholder as well as the hierarchy of values that led to the issue.
*
* @author Stephane Nicoll
* @since 6.2
*/
@SuppressWarnings("serial")
public class PlaceholderResolutionException extends RuntimeException {
private final String reason;
private final String placeholder;
private final List<String> values;
/**
* Create an exception using the specified reason for its message.
* @param reason the reason for the exception, should contain the placeholder
* @param placeholder the placeholder
* @param value the original expression that led to the issue if available
*/
PlaceholderResolutionException(String reason, String placeholder, @Nullable String value) {
this(reason, placeholder, (value != null ? List.of(value) : Collections.emptyList()));
}
private PlaceholderResolutionException(String reason, String placeholder, List<String> values) {
super(buildMessage(reason, values));
this.reason = reason;
this.placeholder = placeholder;
this.values = values;
}
private static String buildMessage(String reason, List<String> values) {
StringBuilder sb = new StringBuilder();
sb.append(reason);
if (!CollectionUtils.isEmpty(values)) {
String valuesChain = values.stream().map(value -> "\"" + value + "\"")
.collect(Collectors.joining(" <-- "));
sb.append(" in value %s".formatted(valuesChain));
}
return sb.toString();
}
/**
* Return a {@link PlaceholderResolutionException} that provides
* an additional parent value.
* @param value the parent value to add
* @return a new exception with the parent value added
*/
PlaceholderResolutionException withValue(String value) {
List<String> allValues = new ArrayList<>(this.values);
allValues.add(value);
return new PlaceholderResolutionException(this.reason, this.placeholder, allValues);
}
/**
* Return the placeholder that could not be resolved.
* @return the unresolvable placeholder
*/
public String getPlaceholder() {
return this.placeholder;
}
/**
* Return a contextualized list of the resolution attempts that led to this
* exception, where the first element is the value that generated this
* exception.
* @return the stack of values that led to this exception
*/
public List<String> getValues() {
return this.values;
}
}

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);
}
}