Add support for escaping prefix and separator in property placeholders

Closes gh-9628
Closes gh-26268
This commit is contained in:
Stéphane Nicoll
2024-02-15 15:28:36 +01:00
27 changed files with 1114 additions and 188 deletions

View File

@@ -101,8 +101,8 @@ NOTE: When configuring a `PropertySourcesPlaceholderConfigurer` using JavaConfig
Using the above configuration ensures Spring initialization failure if any `${}`
placeholder could not be resolved. It is also possible to use methods like
`setPlaceholderPrefix`, `setPlaceholderSuffix`, or `setValueSeparator` to customize
placeholders.
`setPlaceholderPrefix`, `setPlaceholderSuffix`, `setValueSeparator`, or
`setEscapeCharacter` to customize placeholders.
NOTE: Spring Boot configures by default a `PropertySourcesPlaceholderConfigurer` bean that
will get properties from `application.properties` and `application.yml` files.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -100,6 +100,8 @@ public abstract class PlaceholderConfigurerSupport extends PropertyResourceConfi
/** Default value separator: {@value}. */
public static final String DEFAULT_VALUE_SEPARATOR = ":";
/** Default escape character: {@value}. */
public static final Character DEFAULT_ESCAPE_CHARACTER = '\\';
/** Defaults to {@value #DEFAULT_PLACEHOLDER_PREFIX}. */
protected String placeholderPrefix = DEFAULT_PLACEHOLDER_PREFIX;
@@ -111,6 +113,10 @@ public abstract class PlaceholderConfigurerSupport extends PropertyResourceConfi
@Nullable
protected String valueSeparator = DEFAULT_VALUE_SEPARATOR;
/** Defaults to {@value #DEFAULT_ESCAPE_CHARACTER}. */
@Nullable
protected Character escapeCharacter = DEFAULT_ESCAPE_CHARACTER;
protected boolean trimValues = false;
@Nullable
@@ -151,6 +157,17 @@ public abstract class PlaceholderConfigurerSupport extends PropertyResourceConfi
this.valueSeparator = valueSeparator;
}
/**
* Specify the escape character to use to ignore placeholder prefix
* or value separator, or {@code null} if no escaping should take
* place.
* <p>Default is {@value #DEFAULT_ESCAPE_CHARACTER}.
* @since 6.2
*/
public void setEscapeCharacter(@Nullable Character escsEscapeCharacter) {
this.escapeCharacter = escsEscapeCharacter;
}
/**
* Specify whether to trim resolved values before applying them,
* removing superfluous whitespace from the beginning and end.

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -234,7 +234,8 @@ public class PropertyPlaceholderConfigurer extends PlaceholderConfigurerSupport
public PlaceholderResolvingStringValueResolver(Properties props) {
this.helper = new PropertyPlaceholderHelper(
placeholderPrefix, placeholderSuffix, valueSeparator, ignoreUnresolvablePlaceholders);
placeholderPrefix, placeholderSuffix, valueSeparator,
ignoreUnresolvablePlaceholders, escapeCharacter);
this.resolver = new PropertyPlaceholderConfigurerResolver(props);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -193,6 +193,7 @@ public class PropertySourcesPlaceholderConfigurer extends PlaceholderConfigurerS
propertyResolver.setPlaceholderPrefix(this.placeholderPrefix);
propertyResolver.setPlaceholderSuffix(this.placeholderSuffix);
propertyResolver.setValueSeparator(this.valueSeparator);
propertyResolver.setEscapeCharacter(this.escapeCharacter);
StringValueResolver valueResolver = strVal -> {
String resolved = (this.ignoreUnresolvablePlaceholders ?

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -40,6 +40,7 @@ import org.springframework.core.env.MutablePropertySources;
import org.springframework.core.io.support.EncodedResource;
import org.springframework.core.io.support.PropertiesLoaderUtils;
import org.springframework.core.io.support.PropertySourceFactory;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -132,7 +133,7 @@ class PropertySourceAnnotationTests {
void withUnresolvablePlaceholder() {
assertThatExceptionOfType(BeanDefinitionStoreException.class)
.isThrownBy(() -> new AnnotationConfigApplicationContext(ConfigWithUnresolvablePlaceholder.class))
.withCauseInstanceOf(IllegalArgumentException.class);
.withCauseInstanceOf(PlaceholderResolutionException.class);
}
@Test

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -28,6 +28,7 @@ import org.springframework.context.support.ClassPathXmlApplicationContext;
import org.springframework.context.support.GenericXmlApplicationContext;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -136,7 +137,7 @@ class ContextNamespaceHandlerTests {
assertThatExceptionOfType(FatalBeanException.class).isThrownBy(() ->
new ClassPathXmlApplicationContext("contextNamespaceHandlerTests-location-placeholder.xml", getClass()))
.havingRootCause()
.isInstanceOf(IllegalArgumentException.class)
.isInstanceOf(PlaceholderResolutionException.class)
.withMessage("Could not resolve placeholder 'foo' in value \"${foo}\"");
}

View File

@@ -37,6 +37,7 @@ import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.core.testfixture.env.MockPropertySource;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -170,7 +171,7 @@ class PropertySourcesPlaceholderConfigurerTests {
assertThatExceptionOfType(BeanDefinitionStoreException.class)
.isThrownBy(() -> ppc.postProcessBeanFactory(bf))
.havingCause()
.isExactlyInstanceOf(IllegalArgumentException.class)
.isExactlyInstanceOf(PlaceholderResolutionException.class)
.withMessage("Could not resolve placeholder 'my.name' in value \"${my.name}\"");
}
@@ -201,8 +202,8 @@ class PropertySourcesPlaceholderConfigurerTests {
assertThatExceptionOfType(BeanCreationException.class)
.isThrownBy(context::refresh)
.havingCause()
.isExactlyInstanceOf(IllegalArgumentException.class)
.withMessage("Could not resolve placeholder 'enigma' in value \"${enigma}\"");
.isExactlyInstanceOf(PlaceholderResolutionException.class)
.withMessage("Could not resolve placeholder 'enigma' in value \"${enigma}\" <-- \"${my.key}\"");
}
@Test

View File

@@ -102,6 +102,7 @@ dependencies {
testImplementation("jakarta.xml.bind:jakarta.xml.bind-api")
testImplementation("org.jetbrains.kotlinx:kotlinx-serialization-json")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor")
testImplementation("org.mockito:mockito-core")
testImplementation("org.skyscreamer:jsonassert")
testImplementation("org.xmlunit:xmlunit-assertj")
testImplementation("org.xmlunit:xmlunit-matchers")

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -521,6 +521,11 @@ public abstract class AbstractEnvironment implements ConfigurableEnvironment {
this.propertyResolver.setValueSeparator(valueSeparator);
}
@Override
public void setEscapeCharacter(@Nullable Character escapeCharacter) {
this.propertyResolver.setEscapeCharacter(escapeCharacter);
}
@Override
public void setIgnoreUnresolvableNestedPlaceholders(boolean ignoreUnresolvableNestedPlaceholders) {
this.propertyResolver.setIgnoreUnresolvableNestedPlaceholders(ignoreUnresolvableNestedPlaceholders);

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* 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.
@@ -61,6 +61,9 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
@Nullable
private String valueSeparator = SystemPropertyUtils.VALUE_SEPARATOR;
@Nullable
private Character escapeCharacter = SystemPropertyUtils.ESCAPE_CHARACTER;
private final Set<String> requiredProperties = new LinkedHashSet<>();
@@ -121,6 +124,19 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
this.valueSeparator = valueSeparator;
}
/**
* Specify the escape character to use to ignore placeholder prefix
* or value separator, or {@code null} if no escaping should take
* place.
* <p>The default is "\".
* @since 6.2
* @see org.springframework.util.SystemPropertyUtils#ESCAPE_CHARACTER
*/
@Override
public void setEscapeCharacter(@Nullable Character escapeCharacter) {
this.escapeCharacter = escapeCharacter;
}
/**
* Set whether to throw an exception when encountering an unresolvable placeholder
* nested within the value of a given property. A {@code false} value indicates strict
@@ -232,7 +248,7 @@ public abstract class AbstractPropertyResolver implements ConfigurablePropertyRe
private PropertyPlaceholderHelper createPlaceholderHelper(boolean ignoreUnresolvablePlaceholders) {
return new PropertyPlaceholderHelper(this.placeholderPrefix, this.placeholderSuffix,
this.valueSeparator, ignoreUnresolvablePlaceholders);
this.valueSeparator, ignoreUnresolvablePlaceholders, this.escapeCharacter);
}
private String doResolvePlaceholders(String text, PropertyPlaceholderHelper helper) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2016 the original author or authors.
* 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.
@@ -74,6 +74,14 @@ public interface ConfigurablePropertyResolver extends PropertyResolver {
*/
void setValueSeparator(@Nullable String valueSeparator);
/**
* Specify the escape character to use to ignore placeholder prefix
* or value separator, or {@code null} if no escaping should take
* place.
* @since 6.2
*/
void setEscapeCharacter(@Nullable Character escapeCharacter);
/**
* Set whether to throw an exception when encountering an unresolvable placeholder
* nested within the value of a given property. A {@code false} value indicates strict

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -36,6 +36,7 @@ import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.PlaceholderResolutionException;
import org.springframework.util.ReflectionUtils;
/**
@@ -93,8 +94,8 @@ public class PropertySourceProcessor {
}
}
catch (RuntimeException | IOException ex) {
// Placeholders not resolvable (IllegalArgumentException) or resource not found when trying to open it
if (ignoreResourceNotFound && (ex instanceof IllegalArgumentException || isIgnorableException(ex) ||
// Placeholders not resolvable or resource not found when trying to open it
if (ignoreResourceNotFound && (ex instanceof PlaceholderResolutionException || isIgnorableException(ex) ||
isIgnorableException(ex.getCause()))) {
if (logger.isInfoEnabled()) {
logger.info("Properties location [" + location + "] not resolvable: " + ex.getMessage());

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

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2021 the original author or authors.
* 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.
@@ -16,14 +16,7 @@
package org.springframework.util;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.lang.Nullable;
@@ -37,31 +30,12 @@ import org.springframework.lang.Nullable;
*
* @author Juergen Hoeller
* @author Rob Harrop
* @author Stephane Nicoll
* @since 3.0
*/
public class PropertyPlaceholderHelper {
private static final Log logger = LogFactory.getLog(PropertyPlaceholderHelper.class);
private static final Map<String, String> wellKnownSimplePrefixes = new HashMap<>(4);
static {
wellKnownSimplePrefixes.put("}", "{");
wellKnownSimplePrefixes.put("]", "[");
wellKnownSimplePrefixes.put(")", "(");
}
private final String placeholderPrefix;
private final String placeholderSuffix;
private final String simplePrefix;
@Nullable
private final String valueSeparator;
private final boolean ignoreUnresolvablePlaceholders;
private final PlaceholderParser parser;
/**
@@ -71,7 +45,7 @@ public class PropertyPlaceholderHelper {
* @param placeholderSuffix the suffix that denotes the end of a placeholder
*/
public PropertyPlaceholderHelper(String placeholderPrefix, String placeholderSuffix) {
this(placeholderPrefix, placeholderSuffix, null, true);
this(placeholderPrefix, placeholderSuffix, null, true, null);
}
/**
@@ -82,23 +56,35 @@ public class PropertyPlaceholderHelper {
* and the associated default value, if any
* @param ignoreUnresolvablePlaceholders indicates whether unresolvable placeholders should
* be ignored ({@code true}) or cause an exception ({@code false})
* @deprecated in favor of {@link PropertyPlaceholderHelper#PropertyPlaceholderHelper(String, String, String, boolean, Character)}
*/
@Deprecated(since = "6.2", forRemoval = true)
public PropertyPlaceholderHelper(String placeholderPrefix, String placeholderSuffix,
@Nullable String valueSeparator, boolean ignoreUnresolvablePlaceholders) {
this(placeholderPrefix, placeholderSuffix, valueSeparator, ignoreUnresolvablePlaceholders, null);
}
/**
* Creates a new {@code PropertyPlaceholderHelper} that uses the supplied prefix and suffix.
* @param placeholderPrefix the prefix that denotes the start of a placeholder
* @param placeholderSuffix the suffix that denotes the end of a placeholder
* @param valueSeparator the separating character between the placeholder variable
* and the associated default value, if any
* @param ignoreUnresolvablePlaceholders indicates whether unresolvable placeholders should
* be ignored ({@code true}) or cause an exception ({@code false})
* @param escapeCharacter the escape character to use to ignore placeholder prefix
* or value separator, if any
* @since 6.2
*/
public PropertyPlaceholderHelper(String placeholderPrefix, String placeholderSuffix,
@Nullable String valueSeparator, boolean ignoreUnresolvablePlaceholders,
@Nullable Character escapeCharacter) {
Assert.notNull(placeholderPrefix, "'placeholderPrefix' must not be null");
Assert.notNull(placeholderSuffix, "'placeholderSuffix' must not be null");
this.placeholderPrefix = placeholderPrefix;
this.placeholderSuffix = placeholderSuffix;
String simplePrefixForSuffix = wellKnownSimplePrefixes.get(this.placeholderSuffix);
if (simplePrefixForSuffix != null && this.placeholderPrefix.endsWith(simplePrefixForSuffix)) {
this.simplePrefix = simplePrefixForSuffix;
}
else {
this.simplePrefix = this.placeholderPrefix;
}
this.valueSeparator = valueSeparator;
this.ignoreUnresolvablePlaceholders = ignoreUnresolvablePlaceholders;
this.parser = new PlaceholderParser(placeholderPrefix, placeholderSuffix,
ignoreUnresolvablePlaceholders, valueSeparator, escapeCharacter);
}
@@ -123,94 +109,11 @@ public class PropertyPlaceholderHelper {
*/
public String replacePlaceholders(String value, PlaceholderResolver placeholderResolver) {
Assert.notNull(value, "'value' must not be null");
return parseStringValue(value, placeholderResolver, null);
return parseStringValue(value, placeholderResolver);
}
protected String parseStringValue(
String value, PlaceholderResolver placeholderResolver, @Nullable Set<String> visitedPlaceholders) {
int startIndex = value.indexOf(this.placeholderPrefix);
if (startIndex == -1) {
return value;
}
StringBuilder result = new StringBuilder(value);
while (startIndex != -1) {
int endIndex = findPlaceholderEndIndex(result, startIndex);
if (endIndex != -1) {
String placeholder = result.substring(startIndex + this.placeholderPrefix.length(), endIndex);
String originalPlaceholder = placeholder;
if (visitedPlaceholders == null) {
visitedPlaceholders = new HashSet<>(4);
}
if (!visitedPlaceholders.add(originalPlaceholder)) {
throw new IllegalArgumentException(
"Circular placeholder reference '" + originalPlaceholder + "' in property definitions");
}
// Recursive invocation, parsing placeholders contained in the placeholder key.
placeholder = parseStringValue(placeholder, placeholderResolver, visitedPlaceholders);
// Now obtain the value for the fully resolved key...
String propVal = placeholderResolver.resolvePlaceholder(placeholder);
if (propVal == null && this.valueSeparator != null) {
int separatorIndex = placeholder.indexOf(this.valueSeparator);
if (separatorIndex != -1) {
String actualPlaceholder = placeholder.substring(0, separatorIndex);
String defaultValue = placeholder.substring(separatorIndex + this.valueSeparator.length());
propVal = placeholderResolver.resolvePlaceholder(actualPlaceholder);
if (propVal == null) {
propVal = defaultValue;
}
}
}
if (propVal != null) {
// Recursive invocation, parsing placeholders contained in the
// previously resolved placeholder value.
propVal = parseStringValue(propVal, placeholderResolver, visitedPlaceholders);
result.replace(startIndex, endIndex + this.placeholderSuffix.length(), propVal);
if (logger.isTraceEnabled()) {
logger.trace("Resolved placeholder '" + placeholder + "'");
}
startIndex = result.indexOf(this.placeholderPrefix, startIndex + propVal.length());
}
else if (this.ignoreUnresolvablePlaceholders) {
// Proceed with unprocessed value.
startIndex = result.indexOf(this.placeholderPrefix, endIndex + this.placeholderSuffix.length());
}
else {
throw new IllegalArgumentException("Could not resolve placeholder '" +
placeholder + "'" + " in value \"" + value + "\"");
}
visitedPlaceholders.remove(originalPlaceholder);
}
else {
startIndex = -1;
}
}
return result.toString();
}
private int findPlaceholderEndIndex(CharSequence buf, int startIndex) {
int index = startIndex + this.placeholderPrefix.length();
int withinNestedPlaceholder = 0;
while (index < buf.length()) {
if (StringUtils.substringMatch(buf, index, this.placeholderSuffix)) {
if (withinNestedPlaceholder > 0) {
withinNestedPlaceholder--;
index = index + this.placeholderSuffix.length();
}
else {
return index;
}
}
else if (StringUtils.substringMatch(buf, index, this.simplePrefix)) {
withinNestedPlaceholder++;
index = index + this.simplePrefix.length();
}
else {
index++;
}
}
return -1;
protected String parseStringValue(String value, PlaceholderResolver placeholderResolver) {
return this.parser.replacePlaceholders(value, placeholderResolver);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -44,12 +44,17 @@ public abstract class SystemPropertyUtils {
/** Value separator for system property placeholders: {@value}. */
public static final String VALUE_SEPARATOR = ":";
/** Default escape character: {@value}. */
public static final Character ESCAPE_CHARACTER = '\\';
private static final PropertyPlaceholderHelper strictHelper =
new PropertyPlaceholderHelper(PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX, VALUE_SEPARATOR, false);
new PropertyPlaceholderHelper(PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX, VALUE_SEPARATOR,
false, ESCAPE_CHARACTER);
private static final PropertyPlaceholderHelper nonStrictHelper =
new PropertyPlaceholderHelper(PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX, VALUE_SEPARATOR, true);
new PropertyPlaceholderHelper(PLACEHOLDER_PREFIX, PLACEHOLDER_SUFFIX, VALUE_SEPARATOR,
true, ESCAPE_CHARACTER);
/**

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2019 the original author or authors.
* 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.
@@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.convert.ConverterNotFoundException;
import org.springframework.core.testfixture.env.MockPropertySource;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -227,7 +228,7 @@ class PropertySourcesPropertyResolverTests {
MutablePropertySources propertySources = new MutablePropertySources();
propertySources.addFirst(new MockPropertySource().withProperty("key", "value"));
PropertyResolver resolver = new PropertySourcesPropertyResolver(propertySources);
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
resolver.resolveRequiredPlaceholders("Replace this ${key} plus ${unknown}"));
}
@@ -290,11 +291,11 @@ class PropertySourcesPropertyResolverTests {
assertThat(pr.getProperty("p2")).isEqualTo("v2");
assertThat(pr.getProperty("p3")).isEqualTo("v1:v2");
assertThat(pr.getProperty("p4")).isEqualTo("v1:v2");
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
pr.getProperty("p5"))
.withMessageContaining("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\"");
assertThat(pr.getProperty("p6")).isEqualTo("v1:v2:def");
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
pr.getProperty("pL"))
.withMessageContaining("Circular");
}
@@ -315,7 +316,7 @@ class PropertySourcesPropertyResolverTests {
// placeholders nested within the value of "p4" are unresolvable and cause an
// exception by default
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
pr.getProperty("p4"))
.withMessageContaining("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\"");
@@ -327,7 +328,7 @@ class PropertySourcesPropertyResolverTests {
// resolve[Nested]Placeholders methods behave as usual regardless the value of
// ignoreUnresolvableNestedPlaceholders
assertThat(pr.resolvePlaceholders("${p1}:${p2}:${bogus}")).isEqualTo("v1:v2:${bogus}");
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
pr.resolveRequiredPlaceholders("${p1}:${p2}:${bogus}"))
.withMessageContaining("Could not resolve placeholder 'bogus' in value \"${p1}:${p2}:${bogus}\"");
}

View File

@@ -23,8 +23,10 @@ import org.junit.jupiter.api.Test;
import org.springframework.core.SpringProperties;
import org.springframework.core.testfixture.env.MockPropertySource;
import org.springframework.util.PlaceholderResolutionException;
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.springframework.core.env.AbstractEnvironment.ACTIVE_PROFILES_PROPERTY_NAME;
import static org.springframework.core.env.AbstractEnvironment.DEFAULT_PROFILES_PROPERTY_NAME;
@@ -207,9 +209,9 @@ class StandardEnvironmentTests {
void defaultProfileWithCircularPlaceholder() {
try {
System.setProperty(DEFAULT_PROFILES_PROPERTY_NAME, "${spring.profiles.default}");
assertThatIllegalArgumentException()
assertThatExceptionOfType(PlaceholderResolutionException.class)
.isThrownBy(environment::getDefaultProfiles)
.withMessage("Circular placeholder reference 'spring.profiles.default' in property definitions");
.withMessageContaining("Circular placeholder reference 'spring.profiles.default'");
}
finally {
System.clearProperty(DEFAULT_PROFILES_PROPERTY_NAME);

View File

@@ -21,8 +21,10 @@ import java.beans.PropertyEditor;
import org.junit.jupiter.api.Test;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
/**
@@ -96,7 +98,7 @@ class ResourceEditorTests {
PropertyEditor editor = new ResourceEditor(new DefaultResourceLoader(), new StandardEnvironment(), false);
System.setProperty("test.prop", "foo");
try {
assertThatIllegalArgumentException().isThrownBy(() -> {
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() -> {
editor.setAsText("${test.prop}-${bar}");
editor.getValue();
});

View File

@@ -32,10 +32,12 @@ import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.ResourceLoader;
import org.springframework.util.ClassUtils;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.assertj.core.api.Assertions.assertThatNoException;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link PropertySourceProcessor}.
@@ -73,8 +75,8 @@ class PropertySourceProcessorTests {
class FailOnErrorTests {
@Test
void processorFailsOnIllegalArgumentException() {
assertProcessorFailsOnError(IllegalArgumentExceptionPropertySourceFactory.class, IllegalArgumentException.class);
void processorFailsOnPlaceholderResolutionException() {
assertProcessorFailsOnError(PlaceholderResolutionExceptionPropertySourceFactory.class, PlaceholderResolutionException.class);
}
@Test
@@ -98,7 +100,7 @@ class PropertySourceProcessorTests {
@Test
void processorIgnoresIllegalArgumentException() {
assertProcessorIgnoresFailure(IllegalArgumentExceptionPropertySourceFactory.class);
assertProcessorIgnoresFailure(PlaceholderResolutionExceptionPropertySourceFactory.class);
}
@Test
@@ -134,11 +136,11 @@ class PropertySourceProcessorTests {
}
private static class IllegalArgumentExceptionPropertySourceFactory implements PropertySourceFactory {
private static class PlaceholderResolutionExceptionPropertySourceFactory implements PropertySourceFactory {
@Override
public PropertySource<?> createPropertySource(String name, EncodedResource resource) {
throw new IllegalArgumentException("bogus");
throw mock(PlaceholderResolutionException.class);
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -24,9 +24,10 @@ import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.FileUrlResource;
import org.springframework.core.io.Resource;
import org.springframework.util.PlaceholderResolutionException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* Tests for {@link ResourceArrayPropertyEditor}.
@@ -81,7 +82,7 @@ class ResourceArrayPropertyEditorTests {
false);
System.setProperty("test.prop", "foo");
try {
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
editor.setAsText("${test.prop}-${bar}"));
}
finally {

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

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -19,7 +19,6 @@ package org.springframework.util;
import java.util.Properties;
import java.util.stream.Stream;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
@@ -29,11 +28,11 @@ import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.util.PropertyPlaceholderHelper.PlaceholderResolver;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;
/**
* Tests for {@link PropertyPlaceholderHelper}.
@@ -116,16 +115,15 @@ class PropertyPlaceholderHelperTests {
Properties props = new Properties();
props.setProperty("foo", "bar");
PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", null, false);
assertThatIllegalArgumentException().isThrownBy(() ->
PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", null, false, null);
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
helper.replacePlaceholders(text, props));
}
@Nested
class DefaultValueTests {
private final PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":", true);
private final PropertyPlaceholderHelper helper = new PropertyPlaceholderHelper("${", "}", ":", true, null);
@ParameterizedTest(name = "{0} -> {1}")
@MethodSource("defaultValues")
@@ -137,12 +135,11 @@ class PropertyPlaceholderHelperTests {
}
@Test
@Disabled("gh-26268")
void defaultValueIsNotEvaluatedEarly() {
PlaceholderResolver resolver = mockPlaceholderResolver("one", "1");
assertThat(this.helper.replacePlaceholders("This is ${one:or${two}}",resolver)).isEqualTo("This is 1");
assertThat(this.helper.replacePlaceholders("This is ${one:or${two}}", resolver)).isEqualTo("This is 1");
verify(resolver).resolvePlaceholder("one");
verifyNoMoreInteractions(resolver);
verify(resolver, never()).resolvePlaceholder("two");
}
static Stream<Arguments> defaultValues() {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -21,7 +21,7 @@ import java.util.Map;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
/**
* @author Rob Harrop
@@ -97,7 +97,7 @@ class SystemPropertyUtilsTests {
@Test
void replaceWithNoDefault() {
assertThatIllegalArgumentException().isThrownBy(() ->
assertThatExceptionOfType(PlaceholderResolutionException.class).isThrownBy(() ->
SystemPropertyUtils.resolvePlaceholders("${test.prop}"));
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -68,7 +68,7 @@ public class SendToMethodReturnValueHandler implements HandlerMethodReturnValueH
private String defaultUserDestinationPrefix = "/queue";
private final PropertyPlaceholderHelper placeholderHelper = new PropertyPlaceholderHelper("{", "}", null, false);
private final PropertyPlaceholderHelper placeholderHelper = new PropertyPlaceholderHelper("{", "}", null, false, null);
@Nullable
private MessageHeaderInitializer headerInitializer;

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2023 the original author or authors.
* 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.
@@ -586,7 +586,7 @@ public class StandaloneMockMvcBuilder extends AbstractMockMvcBuilder<StandaloneM
private final PlaceholderResolver resolver;
public StaticStringValueResolver(Map<String, String> values) {
this.helper = new PropertyPlaceholderHelper("${", "}", ":", false);
this.helper = new PropertyPlaceholderHelper("${", "}", ":", false, null);
this.resolver = values::get;
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2002-2020 the original author or authors.
* 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.
@@ -39,11 +39,13 @@ public abstract class ServletContextPropertyUtils {
private static final PropertyPlaceholderHelper strictHelper =
new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX,
SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, false);
SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR,
false, SystemPropertyUtils.ESCAPE_CHARACTER);
private static final PropertyPlaceholderHelper nonStrictHelper =
new PropertyPlaceholderHelper(SystemPropertyUtils.PLACEHOLDER_PREFIX,
SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR, true);
SystemPropertyUtils.PLACEHOLDER_SUFFIX, SystemPropertyUtils.VALUE_SEPARATOR,
true, SystemPropertyUtils.ESCAPE_CHARACTER);
/**