Browse Source
* gh-17530: Polish "Use MessageSource to interpolate bean validation messages" Use MessageSource to interpolate bean validation messages Closes gh-17530pull/27538/head
10 changed files with 456 additions and 12 deletions
@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
|
||||
/* |
||||
* Copyright 2012-2021 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.boot.validation; |
||||
|
||||
import java.util.LinkedHashSet; |
||||
import java.util.Locale; |
||||
import java.util.Set; |
||||
|
||||
import javax.validation.MessageInterpolator; |
||||
|
||||
import org.springframework.context.MessageSource; |
||||
import org.springframework.context.i18n.LocaleContextHolder; |
||||
|
||||
/** |
||||
* Resolves any message parameters via {@link MessageSource} and then interpolates a |
||||
* message using the underlying {@link MessageInterpolator}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
*/ |
||||
class MessageSourceMessageInterpolator implements MessageInterpolator { |
||||
|
||||
private static final char PREFIX = '{'; |
||||
|
||||
private static final char SUFFIX = '}'; |
||||
|
||||
private static final char ESCAPE = '\\'; |
||||
|
||||
private final MessageSource messageSource; |
||||
|
||||
private final MessageInterpolator messageInterpolator; |
||||
|
||||
MessageSourceMessageInterpolator(MessageSource messageSource, MessageInterpolator messageInterpolator) { |
||||
this.messageSource = messageSource; |
||||
this.messageInterpolator = messageInterpolator; |
||||
} |
||||
|
||||
@Override |
||||
public String interpolate(String messageTemplate, Context context) { |
||||
return interpolate(messageTemplate, context, LocaleContextHolder.getLocale()); |
||||
} |
||||
|
||||
@Override |
||||
public String interpolate(String messageTemplate, Context context, Locale locale) { |
||||
String message = replaceParameters(messageTemplate, locale); |
||||
return this.messageInterpolator.interpolate(message, context, locale); |
||||
} |
||||
|
||||
/** |
||||
* Recursively replaces all message parameters. |
||||
* <p> |
||||
* The message parameter prefix <code>{</code> and suffix <code>}</code> can |
||||
* be escaped using {@code \}, e.g. <code>\{escaped\}</code>. |
||||
* @param message the message containing the parameters to be replaced |
||||
* @param locale the locale to use when resolving replacements |
||||
* @return the message with parameters replaced |
||||
*/ |
||||
private String replaceParameters(String message, Locale locale) { |
||||
return replaceParameters(message, locale, new LinkedHashSet<>(4)); |
||||
} |
||||
|
||||
private String replaceParameters(String message, Locale locale, Set<String> visitedParameters) { |
||||
StringBuilder buf = new StringBuilder(message); |
||||
int parentheses = 0; |
||||
int startIndex = -1; |
||||
int endIndex = -1; |
||||
for (int i = 0; i < buf.length(); i++) { |
||||
if (buf.charAt(i) == ESCAPE) { |
||||
i++; |
||||
} |
||||
else if (buf.charAt(i) == PREFIX) { |
||||
if (startIndex == -1) { |
||||
startIndex = i; |
||||
} |
||||
parentheses++; |
||||
} |
||||
else if (buf.charAt(i) == SUFFIX) { |
||||
if (parentheses > 0) { |
||||
parentheses--; |
||||
} |
||||
endIndex = i; |
||||
} |
||||
if (parentheses == 0 && startIndex < endIndex) { |
||||
String parameter = buf.substring(startIndex + 1, endIndex); |
||||
if (!visitedParameters.add(parameter)) { |
||||
throw new IllegalArgumentException("Circular reference '{" + String.join(" -> ", visitedParameters) |
||||
+ " -> " + parameter + "}'"); |
||||
} |
||||
String value = replaceParameter(parameter, locale, visitedParameters); |
||||
if (value != null) { |
||||
buf.replace(startIndex, endIndex + 1, value); |
||||
i = startIndex + value.length() - 1; |
||||
} |
||||
visitedParameters.remove(parameter); |
||||
startIndex = -1; |
||||
endIndex = -1; |
||||
} |
||||
} |
||||
return buf.toString(); |
||||
} |
||||
|
||||
private String replaceParameter(String parameter, Locale locale, Set<String> visitedParameters) { |
||||
parameter = replaceParameters(parameter, locale, visitedParameters); |
||||
String value = this.messageSource.getMessage(parameter, null, null, locale); |
||||
return (value != null) ? replaceParameters(value, locale, visitedParameters) : null; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
/* |
||||
* Copyright 2012-2021 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.boot.validation; |
||||
|
||||
import java.util.ArrayList; |
||||
import java.util.List; |
||||
import java.util.Locale; |
||||
import java.util.Set; |
||||
|
||||
import javax.validation.ConstraintViolation; |
||||
import javax.validation.Validator; |
||||
import javax.validation.constraints.NotNull; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.context.i18n.LocaleContextHolder; |
||||
import org.springframework.context.support.StaticMessageSource; |
||||
import org.springframework.validation.beanvalidation.LocalValidatorFactoryBean; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy; |
||||
|
||||
/** |
||||
* Integration tests for {@link MessageSourceMessageInterpolator}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
*/ |
||||
class MessageSourceMessageInterpolatorIntegrationTests { |
||||
|
||||
private final Validator validator = buildValidator(); |
||||
|
||||
@NotNull |
||||
private String defaultMessage; |
||||
|
||||
@NotNull(message = "{null}") |
||||
private String nullable; |
||||
|
||||
@NotNull(message = "{blank}") |
||||
private String blank; |
||||
|
||||
@NotNull(message = "{unknown}") |
||||
private String unknown; |
||||
|
||||
@NotNull(message = "{recursion}") |
||||
private String recursion; |
||||
|
||||
@NotNull(message = "\\{null}") |
||||
private String escapePrefix; |
||||
|
||||
@NotNull(message = "{null\\}") |
||||
private String escapeSuffix; |
||||
|
||||
@NotNull(message = "\\{null\\}") |
||||
private String escapePrefixSuffix; |
||||
|
||||
@NotNull(message = "\\\\{null}") |
||||
private String escapeEscape; |
||||
|
||||
@Test |
||||
void defaultMessage() { |
||||
assertThat(validate("defaultMessage")).containsExactly("must not be null"); |
||||
} |
||||
|
||||
@Test |
||||
void nullable() { |
||||
assertThat(validate("nullable")).containsExactly("must not be null"); |
||||
} |
||||
|
||||
@Test |
||||
void blank() { |
||||
assertThat(validate("blank")).containsExactly("must not be null or must not be blank"); |
||||
} |
||||
|
||||
@Test |
||||
void recursion() { |
||||
assertThatThrownBy(() -> validate("recursion")) |
||||
.hasStackTraceContaining("Circular reference '{recursion -> middle -> recursion}'"); |
||||
} |
||||
|
||||
@Test |
||||
void unknown() { |
||||
assertThat(validate("unknown")).containsExactly("{unknown}"); |
||||
} |
||||
|
||||
@Test |
||||
void escapePrefix() { |
||||
assertThat(validate("escapePrefix")).containsExactly("\\{null}"); |
||||
} |
||||
|
||||
@Test |
||||
void escapeSuffix() { |
||||
assertThat(validate("escapeSuffix")).containsExactly("{null\\}"); |
||||
} |
||||
|
||||
@Test |
||||
void escapePrefixSuffix() { |
||||
assertThat(validate("escapePrefixSuffix")).containsExactly("{null}"); |
||||
} |
||||
|
||||
@Test |
||||
void escapeEscape() { |
||||
assertThat(validate("escapeEscape")).containsExactly("\\must not be null"); |
||||
} |
||||
|
||||
private List<String> validate(String property) { |
||||
List<String> messages = new ArrayList<>(); |
||||
Set<ConstraintViolation<Object>> constraints = this.validator.validateProperty(this, property); |
||||
for (ConstraintViolation<Object> constraint : constraints) { |
||||
messages.add(constraint.getMessage()); |
||||
} |
||||
return messages; |
||||
} |
||||
|
||||
private static Validator buildValidator() { |
||||
Locale locale = LocaleContextHolder.getLocale(); |
||||
StaticMessageSource messageSource = new StaticMessageSource(); |
||||
messageSource.addMessage("blank", locale, "{null} or {javax.validation.constraints.NotBlank.message}"); |
||||
messageSource.addMessage("null", locale, "{javax.validation.constraints.NotNull.message}"); |
||||
messageSource.addMessage("recursion", locale, "{middle}"); |
||||
messageSource.addMessage("middle", locale, "{recursion}"); |
||||
MessageInterpolatorFactory messageInterpolatorFactory = new MessageInterpolatorFactory(messageSource); |
||||
try (LocalValidatorFactoryBean validatorFactory = new LocalValidatorFactoryBean()) { |
||||
validatorFactory.setMessageInterpolator(messageInterpolatorFactory.getObject()); |
||||
validatorFactory.afterPropertiesSet(); |
||||
return validatorFactory.getValidator(); |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,119 @@
@@ -0,0 +1,119 @@
|
||||
/* |
||||
* Copyright 2012-2021 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.boot.validation; |
||||
|
||||
import java.util.Locale; |
||||
|
||||
import javax.validation.MessageInterpolator; |
||||
import javax.validation.MessageInterpolator.Context; |
||||
|
||||
import org.junit.jupiter.api.Test; |
||||
|
||||
import org.springframework.context.support.StaticMessageSource; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException; |
||||
import static org.mockito.Mockito.mock; |
||||
|
||||
/** |
||||
* Tests for {@link MessageSourceMessageInterpolator}. |
||||
* |
||||
* @author Dmytro Nosan |
||||
* @author Andy Wilkinson |
||||
*/ |
||||
class MessageSourceMessageInterpolatorTests { |
||||
|
||||
private final Context context = mock(Context.class); |
||||
|
||||
private final StaticMessageSource messageSource = new StaticMessageSource(); |
||||
|
||||
private final MessageSourceMessageInterpolator interpolator = new MessageSourceMessageInterpolator( |
||||
this.messageSource, new IdentityMessageInterpolator()); |
||||
|
||||
@Test |
||||
void interpolateShouldReplaceParameters() { |
||||
this.messageSource.addMessage("foo", Locale.getDefault(), "fooValue"); |
||||
this.messageSource.addMessage("bar", Locale.getDefault(), ""); |
||||
assertThat(this.interpolator.interpolate("{foo}{bar}", this.context)).isEqualTo("fooValue"); |
||||
} |
||||
|
||||
@Test |
||||
void interpolateWhenParametersAreUnknownShouldLeaveThemUnchanged() { |
||||
this.messageSource.addMessage("top", Locale.getDefault(), "{child}+{child}"); |
||||
assertThat(this.interpolator.interpolate("{foo}{top}{bar}", this.context)) |
||||
.isEqualTo("{foo}{child}+{child}{bar}"); |
||||
} |
||||
|
||||
@Test |
||||
void interpolateWhenParametersAreNestedShouldFullyReplaceAllParameters() { |
||||
this.messageSource.addMessage("top", Locale.getDefault(), "{child}+{child}"); |
||||
this.messageSource.addMessage("child", Locale.getDefault(), "{{differentiator}.grandchild}"); |
||||
this.messageSource.addMessage("differentiator", Locale.getDefault(), "first"); |
||||
this.messageSource.addMessage("first.grandchild", Locale.getDefault(), "actualValue"); |
||||
assertThat(this.interpolator.interpolate("{top}", this.context)).isEqualTo("actualValue+actualValue"); |
||||
} |
||||
|
||||
@Test |
||||
void interpolateWhenParameterBracesAreUnbalancedShouldLeaveThemUnchanged() { |
||||
this.messageSource.addMessage("top", Locale.getDefault(), "topValue"); |
||||
assertThat(this.interpolator.interpolate("\\{top}", this.context)).isEqualTo("\\{top}"); |
||||
assertThat(this.interpolator.interpolate("{top\\}", this.context)).isEqualTo("{top\\}"); |
||||
assertThat(this.interpolator.interpolate("{{top}", this.context)).isEqualTo("{{top}"); |
||||
assertThat(this.interpolator.interpolate("{top}}", this.context)).isEqualTo("topValue}"); |
||||
} |
||||
|
||||
@Test |
||||
void interpolateWhenBracesAreEscapedShouldIgnore() { |
||||
this.messageSource.addMessage("foo", Locale.getDefault(), "fooValue"); |
||||
this.messageSource.addMessage("bar", Locale.getDefault(), "\\{foo}"); |
||||
this.messageSource.addMessage("bazz\\}", Locale.getDefault(), "bazzValue"); |
||||
assertThat(this.interpolator.interpolate("{foo}", this.context)).isEqualTo("fooValue"); |
||||
assertThat(this.interpolator.interpolate("{foo}\\a", this.context)).isEqualTo("fooValue\\a"); |
||||
assertThat(this.interpolator.interpolate("\\\\{foo}", this.context)).isEqualTo("\\\\fooValue"); |
||||
assertThat(this.interpolator.interpolate("\\\\\\{foo}", this.context)).isEqualTo("\\\\\\{foo}"); |
||||
assertThat(this.interpolator.interpolate("\\{foo}", this.context)).isEqualTo("\\{foo}"); |
||||
assertThat(this.interpolator.interpolate("{foo\\}", this.context)).isEqualTo("{foo\\}"); |
||||
assertThat(this.interpolator.interpolate("\\{foo\\}", this.context)).isEqualTo("\\{foo\\}"); |
||||
assertThat(this.interpolator.interpolate("{foo}\\", this.context)).isEqualTo("fooValue\\"); |
||||
assertThat(this.interpolator.interpolate("{bar}", this.context)).isEqualTo("\\{foo}"); |
||||
assertThat(this.interpolator.interpolate("{bazz\\}}", this.context)).isEqualTo("bazzValue"); |
||||
} |
||||
|
||||
@Test |
||||
void interpolateWhenParametersContainACycleShouldThrow() { |
||||
this.messageSource.addMessage("a", Locale.getDefault(), "{b}"); |
||||
this.messageSource.addMessage("b", Locale.getDefault(), "{c}"); |
||||
this.messageSource.addMessage("c", Locale.getDefault(), "{a}"); |
||||
assertThatIllegalArgumentException().isThrownBy(() -> this.interpolator.interpolate("{a}", this.context)) |
||||
.withMessage("Circular reference '{a -> b -> c -> a}'"); |
||||
} |
||||
|
||||
private static final class IdentityMessageInterpolator implements MessageInterpolator { |
||||
|
||||
@Override |
||||
public String interpolate(String messageTemplate, Context context) { |
||||
return messageTemplate; |
||||
} |
||||
|
||||
@Override |
||||
public String interpolate(String messageTemplate, Context context, Locale locale) { |
||||
return messageTemplate; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue