diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java index c7c9d6b6de63c5f7af54b5728f8d9b1d5a8b602e..643b4360c8a1e3c483351a5b1aaacf312390d1b2 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/Binder.java @@ -79,7 +79,7 @@ public interface Binder { /** * Bind source values in the map to the properties of the model object. - * @param values the source values to bind + * @param sourceValues the source values to bind * @return the results of the binding operation */ BindingResults bind(Map sourceValues); diff --git a/org.springframework.context/src/main/java/org/springframework/ui/binding/support/GenericBinder.java b/org.springframework.context/src/main/java/org/springframework/ui/binding/support/GenericBinder.java index 442f940e6ebaf7a67b5ccaa94b48852a309d7083..2ad25684ac41d05272d84c9f63ee1de64fea840e 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/binding/support/GenericBinder.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/binding/support/GenericBinder.java @@ -28,6 +28,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import org.springframework.context.MessageSource; import org.springframework.context.expression.MapAccessor; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.core.GenericTypeResolver; @@ -58,11 +59,12 @@ import org.springframework.ui.binding.BindingResults; import org.springframework.ui.binding.FormatterRegistry; import org.springframework.ui.format.AnnotationFormatterFactory; import org.springframework.ui.format.Formatter; +import org.springframework.ui.message.MessageBuilder; +import org.springframework.ui.message.ResolvableArgument; import org.springframework.util.Assert; /** * A generic {@link Binder binder} suitable for use in most environments. - * TODO - localization of alert messages using MessageResolver/MessageSource * @author Keith Donald * @since 3.0 * @see #configureBinding(BindingConfiguration) @@ -87,6 +89,8 @@ public class GenericBinder implements Binder { private static Formatter defaultFormatter = new DefaultFormatter(); + private MessageSource messageSource; + /** * Creates a new binder for the model object. * @param model the model object containing properties this binder will bind to @@ -118,6 +122,25 @@ public class GenericBinder implements Binder { this.formatterRegistry = formatterRegistry; } + /** + * Configure the MessageSource that resolves localized {@link BindingResult} alert messages. + * @param messageSource the message source + */ + public void setMessageSource(MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * Configure the TypeConverter that converts values as required by Binding setValue and getValue attempts. + * For a setValue attempt, the TypeConverter will be asked to perform a conversion if the value parsed by the Binding's Formatter is not assignable to the target property type. + * For a getValue attempt, the TypeConverter will be asked to perform a conversion if the property type does not match the type T required by the Binding's Formatter. + * @param typeConverter the type converter used by the binding system, which is based on Spring EL + * @see EvaluationContext#getTypeConverter() + */ + public void setTypeConverter(TypeConverter typeConverter) { + this.typeConverter = typeConverter; + } + public Binding configureBinding(BindingConfiguration configuration) { Binding binding; try { @@ -158,14 +181,17 @@ public class GenericBinder implements Binder { } else { results.add(new NoSuchBindingResult(property, value)); } - } + } return results; } - + // subclassing hooks - + /** * Hook subclasses may use to filter the source values to bind. + * This hook allows the binder to pre-process the source values before binding occurs. +- * For example, a Binder might insert empty or default values for fields that are not present. +- * As another example, a Binder might collapse multiple source values into a single source value. * @param sourceValues the original source values map provided by the caller * @return the filtered source values map that will be used to bind */ @@ -334,7 +360,7 @@ public class GenericBinder implements Binder { try { formatter = getFormatter(); } catch (EvaluationException e) { - // could occur the property was not found or is not readable + // could occur if the property was not found or is not readable // TODO probably should not handle all EL failures, only type conversion & property not found? return new ExpressionEvaluationErrorResult(property.getExpressionString(), formatted, e); } @@ -352,7 +378,7 @@ public class GenericBinder implements Binder { try { formatter = getFormatter(); } catch (EvaluationException e) { - // could occur the property was not found or is not readable + // could occur if the property was not found or is not readable // TODO probably should not handle all EL failures, only type conversion & property not found? return new ExpressionEvaluationErrorResult(property.getExpressionString(), formatted, e); } @@ -461,16 +487,17 @@ public class GenericBinder implements Binder { } } - static class NoSuchBindingResult implements BindingResult { - private String property; + class NoSuchBindingResult implements BindingResult { + private String property; + private Object sourceValue; - + public NoSuchBindingResult(String property, Object sourceValue) { this.property = property; this.sourceValue = sourceValue; } - + public String getProperty() { return property; } @@ -486,7 +513,6 @@ public class GenericBinder implements Binder { public Alert getAlert() { return new AbstractAlert() { public String getElement() { - // TODO append model first? e.g. model.property return getProperty(); } @@ -499,21 +525,30 @@ public class GenericBinder implements Binder { } public String getMessage() { - return "Failed to bind to property '" + property + "'; no binding has been added for the property"; + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code(getCode()); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.defaultMessage("Failed to bind to property '" + property + + "'; no binding has been added for the property"); + return builder.build(); } }; - } + } } - - static class InvalidFormatResult implements BindingResult { + + class InvalidFormatResult implements BindingResult { private String property; - private Object formatted; + private Object sourceValue; + + private ParseException cause; - public InvalidFormatResult(String property, Object formatted, ParseException e) { + public InvalidFormatResult(String property, Object sourceValue, ParseException cause) { this.property = property; - this.formatted = formatted; + this.sourceValue = sourceValue; + this.cause = cause; } public String getProperty() { @@ -521,7 +556,7 @@ public class GenericBinder implements Binder { } public Object getSourceValue() { - return formatted; + return sourceValue; } public boolean isFailure() { @@ -531,7 +566,6 @@ public class GenericBinder implements Binder { public Alert getAlert() { return new AbstractAlert() { public String getElement() { - // TODO append model first? e.g. model.property return getProperty(); } @@ -544,26 +578,31 @@ public class GenericBinder implements Binder { } public String getMessage() { - return "Failed to bind to property '" + property + "'; the user value " - + StylerUtils.style(formatted) + " has an invalid format and could no be parsed"; + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code(getCode()); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.arg("errorOffset", cause.getErrorOffset()); + builder.defaultMessage("Failed to bind to property '" + property + "'; the user value " + + StylerUtils.style(sourceValue) + " has an invalid format and could no be parsed"); + return builder.build(); } }; } } - // TODO the if branching in here is not very clean - static class ExpressionEvaluationErrorResult implements BindingResult { + class ExpressionEvaluationErrorResult implements BindingResult { private String property; - private Object formatted; + private Object sourceValue; - private EvaluationException e; + private EvaluationException cause; - public ExpressionEvaluationErrorResult(String property, Object formatted, EvaluationException e) { + public ExpressionEvaluationErrorResult(String property, Object sourceValue, EvaluationException cause) { this.property = property; - this.formatted = formatted; - this.e = e; + this.sourceValue = sourceValue; + this.cause = cause; } public String getProperty() { @@ -571,7 +610,7 @@ public class GenericBinder implements Binder { } public Object getSourceValue() { - return formatted; + return sourceValue; } public boolean isFailure() { @@ -581,95 +620,97 @@ public class GenericBinder implements Binder { public Alert getAlert() { return new AbstractAlert() { public String getElement() { - // TODO append model first? e.g. model.property return getProperty(); } public String getCode() { - return getFailureCode(); + SpelMessage spelCode = ((SpelEvaluationException) cause).getMessageCode(); + if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { + return "conversionFailed"; + } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { + return "propertyNotFound"; + } else { + return "couldNotSetValue"; + } } - + public Severity getSeverity() { - return getFailureSeverity(); + SpelMessage spelCode = ((SpelEvaluationException) cause).getMessageCode(); + if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { + return Severity.FATAL; + } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { + return Severity.WARNING; + } else { + return Severity.FATAL; + } } public String getMessage() { - return getFailureMessage(); + SpelMessage spelCode = ((SpelEvaluationException) cause).getMessageCode(); + if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { + AccessException accessException = (AccessException) cause.getCause(); + if (accessException.getCause() != null) { + Throwable cause = accessException.getCause(); + if (cause instanceof SpelEvaluationException + && ((SpelEvaluationException) cause).getMessageCode() == SpelMessage.TYPE_CONVERSION_ERROR) { + ConversionFailedException failure = (ConversionFailedException) cause.getCause(); + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code("conversionFailed"); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.defaultMessage("Failed to bind to property '" + property + "'; user value " + + StylerUtils.style(sourceValue) + " could not be converted to property type [" + + failure.getTargetType().getName() + "]"); + return builder.build(); + } + } + } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code(getCode()); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.defaultMessage("Failed to bind to property '" + property + "'; no such property exists on model"); + return builder.build(); + } + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code("couldNotSetValue"); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.defaultMessage("Failed to bind to property '" + property + "'; reason = " + cause.getLocalizedMessage()); + return builder.build(); } + }; } - public String getFailureCode() { - SpelMessage spelCode = ((SpelEvaluationException) e).getMessageCode(); - if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { - return "typeConversionFailure"; - } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { - return "propertyNotFound"; - } else { - // TODO return more specific code based on underlying EvaluationException error code - return "couldNotSetValue"; - } - } - - public Severity getFailureSeverity() { - SpelMessage spelCode = ((SpelEvaluationException) e).getMessageCode(); - if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { - return Severity.FATAL; - } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { - return Severity.WARNING; - } else { - return Severity.FATAL; - } - } - - public String getFailureMessage() { - SpelMessage spelCode = ((SpelEvaluationException) e).getMessageCode(); - if (spelCode == SpelMessage.EXCEPTION_DURING_PROPERTY_WRITE) { - AccessException accessException = (AccessException) e.getCause(); - if (accessException.getCause() != null) { - Throwable cause = accessException.getCause(); - if (cause instanceof SpelEvaluationException - && ((SpelEvaluationException) cause).getMessageCode() == SpelMessage.TYPE_CONVERSION_ERROR) { - ConversionFailedException failure = (ConversionFailedException) cause.getCause(); - return "Failed to bind to property '" + property + "'; user value " - + StylerUtils.style(formatted) + " could not be converted to property type [" - + failure.getTargetType().getName() + "]"; - } - } - } else if (spelCode == SpelMessage.PROPERTY_OR_FIELD_NOT_READABLE) { - return "Failed to bind to property '" + property + "'; no such property exists on model"; - } - return "Failed to bind to property '" + property + "'; reason = " + e.getLocalizedMessage(); - } } - static class SuccessResult implements BindingResult { + class SuccessResult implements BindingResult { private String property; - private Object formatted; + private Object sourceValue; - public SuccessResult(String property, Object formatted) { + public SuccessResult(String property, Object sourceValue) { this.property = property; - this.formatted = formatted; + this.sourceValue = sourceValue; } public String getProperty() { return property; } - + public Object getSourceValue() { - return formatted; + return sourceValue; } - + public boolean isFailure() { return false; } - + public Alert getAlert() { return new AbstractAlert() { public String getElement() { - // TODO append model first? e.g. model.property return getProperty(); } @@ -682,13 +723,18 @@ public class GenericBinder implements Binder { } public String getMessage() { - return "Sucessfully bound user value " + StylerUtils.style(formatted) + "to property '" + property + "'"; + MessageBuilder builder = new MessageBuilder(messageSource); + builder.code("bindSuccess"); + builder.arg("label", new ResolvableArgument(property)); + builder.arg("value", sourceValue); + builder.defaultMessage("Successfully bound user value " + StylerUtils.style(sourceValue) + "to property '" + property + "'"); + return builder.build(); } }; } } - + static abstract class AbstractAlert implements Alert { public String toString() { return getElement() + ":" + getCode() + " - " + getMessage(); diff --git a/org.springframework.context/src/main/java/org/springframework/ui/message/DefaultMessageResolver.java b/org.springframework.context/src/main/java/org/springframework/ui/message/DefaultMessageResolver.java index f71342cb1869b59fe823522dfda12547736898d5..3f5e198e3f79780c3627f485deb9ff0db56ec46d 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/message/DefaultMessageResolver.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/message/DefaultMessageResolver.java @@ -39,7 +39,7 @@ final class DefaultMessageResolver implements MessageResolver, MessageSourceReso private Map args; - private String defaultText; + private String defaultMessage; private ExpressionParser expressionParser; @@ -47,18 +47,25 @@ final class DefaultMessageResolver implements MessageResolver, MessageSourceReso ExpressionParser expressionParser) { this.codes = codes; this.args = args; - this.defaultText = defaultText; + this.defaultMessage = defaultText; this.expressionParser = expressionParser; } // implementing MessageResolver public String resolveMessage(MessageSource messageSource, Locale locale) { + if (messageSource == null) { + if (defaultMessage != null) { + return defaultMessage; + } else { + throw new MessageResolutionException("Unable to resolve message; MessagSource argument is null and no defaultMessage is configured"); + } + } String messageString; try { messageString = messageSource.getMessage(this, locale); } catch (NoSuchMessageException e) { - throw new MessageResolutionException("Unable to resolve message in MessageSource [" + messageSource + "]", e); + throw new MessageResolutionException("Unable to resolve message in" + messageSource, e); } Expression message; try { @@ -88,11 +95,11 @@ final class DefaultMessageResolver implements MessageResolver, MessageSourceReso } public String getDefaultMessage() { - return defaultText; + return defaultMessage; } public String toString() { - return new ToStringCreator(this).append("codes", codes).append("defaultText", defaultText).toString(); + return new ToStringCreator(this).append("codes", codes).append("defaultText", defaultMessage).toString(); } @SuppressWarnings("unchecked") diff --git a/org.springframework.context/src/main/java/org/springframework/ui/message/MessageBuilder.java b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageBuilder.java new file mode 100644 index 0000000000000000000000000000000000000000..895560073a6fb1ef0425e7338e08300344878bba --- /dev/null +++ b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageBuilder.java @@ -0,0 +1,136 @@ +/* + * Copyright 2004-2009 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 + * + * http://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.ui.message; + +import java.util.Locale; + +import org.springframework.context.MessageSource; +import org.springframework.context.i18n.LocaleContextHolder; + +/** + * Builds a localized message for display in a user interface. + * Allows convenient specification of the codes to try to resolve the message. + * Also supports named arguments that can inserted into a message template using eval #{expressions}. + *

+ * Usage example: + *

+ * String message = new MessageBuilder(messageSource).
+ *     code("invalidFormat").
+ *     arg("label", new ResolvableArgument("mathForm.decimalField")).
+ *     arg("format", "#,###.##").
+ *     defaultMessage("The decimal field must be in format #,###.##").
+ *     build();
+ * 
+ * Example messages.properties loaded by the MessageSource: + *
+ * invalidFormat=The #{label} must be in format #{format}.
+ * mathForm.decimalField=Decimal Field
+ * 
+ * @author Keith Donald + * @since 3.0 + * @see #code(String) + * @see #arg(String, Object) + * @see #defaultMessage(String) + * @see #locale(Locale) + */ +public class MessageBuilder { + + private MessageSource messageSource; + + private Locale locale; + + private MessageResolverBuilder builder = new MessageResolverBuilder(); + + /** + * Create a new MessageBuilder that builds messages from message templates defined in the MessageSource + * @param messageSource the message source + */ + public MessageBuilder(MessageSource messageSource) { + this.messageSource = messageSource; + } + + /** + * Add a code that will be tried to lookup the message template used to create the localized message. + * Successive calls to this method add additional codes. + * Codes are tried in the order they are added. + * @param code a message code to try + * @return this, for fluent API usage + */ + public MessageBuilder code(String code) { + builder.code(code); + return this; + } + + /** + * Add an argument to insert into the message. + * Named arguments are inserted by eval #{expressions} denoted within the message template. + * For example, the value of the 'format' argument would be inserted where a corresponding #{format} expression is defined in the message template. + * Successive calls to this method add additional arguments. + * May also add {@link ResolvableArgument resolvable arguments} whose values are resolved against the MessageSource. + * @param name the argument name + * @param value the argument value + * @return this, for fluent API usage + * @see ResolvableArgument + */ + public MessageBuilder arg(String name, Object value) { + builder.arg(name, value); + return this; + } + + /** + * Set the default message. + * If there are no codes to try, this will be used as the message. + * If there are codes to try but none of those resolve to a message, this will be used as the message. + * @param message the default text + * @return this, for fluent API usage + */ + public MessageBuilder defaultMessage(String message) { + builder.defaultMessage(message); + return this; + } + + /** + * Set the message locale. + * If not set, the default locale the Locale of the current request obtained from {@link LocaleContextHolder#getLocale()}. + * @param message the locale + * @return this, for fluent API usage + */ + public MessageBuilder locale(Locale locale) { + this.locale = locale; + return this; + } + + /** + * Builds the resolver for the message. + * Call after recording all builder instructions. + * @return the built message resolver + * @throws IllegalStateException if no codes have been added and there is no default message + */ + public String build() { + return builder.build().resolveMessage(messageSource, getLocale()); + } + + // internal helpers + + private Locale getLocale() { + if (locale != null) { + return locale; + } else { + return LocaleContextHolder.getLocale(); + } + } + +} \ No newline at end of file diff --git a/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolutionException.java b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolutionException.java index 93ed34178cc47dc5a0a82a5e8c04a9a6390c5fb5..a56497733c4a105c4b05088cedb24901787afa85 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolutionException.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolutionException.java @@ -23,6 +23,14 @@ package org.springframework.ui.message; @SuppressWarnings("serial") public class MessageResolutionException extends RuntimeException { + /** + * Creates a new message resolution exception. + * @param message a messaging describing the failure + */ + public MessageResolutionException(String message) { + super(message); + } + /** * Creates a new message resolution exception. * @param message a messaging describing the failure diff --git a/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolver.java b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolver.java index d628c5a8c1d3a400b8a3fc2c738e6040918099f0..c0265d5359dfeec38f0c928ae9486684d7bb8e90 100644 --- a/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolver.java +++ b/org.springframework.context/src/main/java/org/springframework/ui/message/MessageResolver.java @@ -20,8 +20,7 @@ import java.util.Locale; import org.springframework.context.MessageSource; /** - * A factory for a localized message. - * TODO - consider putting this abstraction together with MessageSource; does it need to be in its own package? + * A factory for a localized message resolved from a MessageSource. * @author Keith Donald * @since 3.0 * @see MessageSource diff --git a/org.springframework.context/src/test/java/org/springframework/ui/binding/support/GenericBinderTests.java b/org.springframework.context/src/test/java/org/springframework/ui/binding/support/GenericBinderTests.java index 554f37349c1c2c4d8ba6b210cf4be6dfcdcd6a35..c88e132ace64eefcbf605922c87d8b07cff0ab08 100644 --- a/org.springframework.context/src/test/java/org/springframework/ui/binding/support/GenericBinderTests.java +++ b/org.springframework.context/src/test/java/org/springframework/ui/binding/support/GenericBinderTests.java @@ -86,7 +86,7 @@ public class GenericBinderTests { BindingResults results = binder.bind(values); assertEquals(3, results.size()); assertTrue(results.get(1).isFailure()); - assertEquals("typeConversionFailure", results.get(1).getAlert().getCode()); + assertEquals("conversionFailed", results.get(1).getAlert().getCode()); } @Test @@ -229,7 +229,7 @@ public class GenericBinderTests { assertEquals(0, b.getCollectionValues().length); BindingResult result = b.setValue(new String[] { "BAR", "BOGUS", "BOOP" }); assertTrue(result.isFailure()); - assertEquals("typeConversionFailure", result.getAlert().getCode()); + assertEquals("conversionFailed", result.getAlert().getCode()); } @Test diff --git a/org.springframework.context/src/test/java/org/springframework/ui/message/MessageBuilderTests.java b/org.springframework.context/src/test/java/org/springframework/ui/message/MessageBuilderTests.java new file mode 100644 index 0000000000000000000000000000000000000000..c2d877c07d2712438330650c7336d5ffc6b261de --- /dev/null +++ b/org.springframework.context/src/test/java/org/springframework/ui/message/MessageBuilderTests.java @@ -0,0 +1,21 @@ +package org.springframework.ui.message; + +import static org.junit.Assert.assertEquals; + +import java.util.Locale; + +import org.junit.Test; + +public class MessageBuilderTests { + + @Test + public void buildMessage() { + MockMessageSource messageSource = new MockMessageSource(); + messageSource.addMessage("invalidFormat", Locale.US, "#{label} must be in format #{format}"); + messageSource.addMessage("mathForm.decimalField", Locale.US, "Decimal Field"); + MessageBuilder builder = new MessageBuilder(messageSource); + String message = builder.code("invalidFormat").arg("label", new ResolvableArgument("mathForm.decimalField")) + .arg("format", "#,###.##").defaultMessage("Field must be in format #,###.##").build(); + assertEquals("Decimal Field must be in format #,###.##", message); + } +}