From 41e6f404c4bc5b197e06689a181c9f673d5bfffe Mon Sep 17 00:00:00 2001 From: Henri Sara Date: Tue, 13 Dec 2011 09:36:13 +0200 Subject: #8093 Support JSR-303 Bean Validation --- build/ivy/ivy.xml | 3 + .../vaadin/data/validator/BeanValidationForm.java | 120 ++++++++ .../data/validator/BeanValidationValidator.java | 318 +++++++++++++++++++++ .../com/vaadin/tests/data/bean/BeanToValidate.java | 56 ++++ .../server/validation/TestBeanValidation.java | 59 ++++ 5 files changed, 556 insertions(+) create mode 100644 src/com/vaadin/data/validator/BeanValidationForm.java create mode 100644 src/com/vaadin/data/validator/BeanValidationValidator.java create mode 100644 tests/server-side/com/vaadin/tests/data/bean/BeanToValidate.java create mode 100644 tests/server-side/com/vaadin/tests/server/validation/TestBeanValidation.java diff --git a/build/ivy/ivy.xml b/build/ivy/ivy.xml index 9dd69e1abe..4fcb44887b 100644 --- a/build/ivy/ivy.xml +++ b/build/ivy/ivy.xml @@ -38,6 +38,9 @@ + + + \ No newline at end of file diff --git a/src/com/vaadin/data/validator/BeanValidationForm.java b/src/com/vaadin/data/validator/BeanValidationForm.java new file mode 100644 index 0000000000..ca94fd33f6 --- /dev/null +++ b/src/com/vaadin/data/validator/BeanValidationForm.java @@ -0,0 +1,120 @@ +package com.vaadin.data.validator; + +import java.util.Collection; +import java.util.Locale; + +import javax.validation.constraints.NotNull; + +import com.vaadin.data.Item; +import com.vaadin.data.util.BeanItem; +import com.vaadin.ui.Field; +import com.vaadin.ui.Form; + +/** + * Vaadin {@link Form} using the JSR-303 (javax.validation) declarative bean + * validation on its fields. + * + * Most validation is performed using {@link BeanValidationValidator}. In + * addition, fields are automatically marked as required when necessary based on + * the {@link NotNull} annotations. + * + * If the item is a {@link BeanItem}, the exact type of the bean is used in + * setting up validators. Otherwise, validation will only be performed based on + * beanClass, and behavior is undefined for subclasses that have fields not + * present in the superclass. + * + * Note that a JSR-303 implementation (e.g. Hibernate Validator or agimatec + * validation) must be present on the project classpath when using bean + * validation. + * + * @since 7.0 + * + * @author Petri Hakala + * @author Henri Sara + */ +public class BeanValidationForm extends Form { + + private static final long serialVersionUID = 1L; + + private Class beanClass; + private Locale locale; + + /** + * Creates a form that performs validation on beans of the type given by + * beanClass. + * + * Full validation of sub-types of beanClass is performed if the item used + * is a {@link BeanItem}. Otherwise, the class given to this constructor + * determines which properties are validated. + * + * @param beanClass + * base class of beans for the form + */ + public BeanValidationForm(Class beanClass) { + if (beanClass == null) { + throw new IllegalArgumentException("Bean class cannot be null"); + } + this.beanClass = beanClass; + } + + @Override + public void addField(Object propertyId, Field field) { + Item item = getItemDataSource(); + Class beanClass = this.beanClass; + if (item instanceof BeanItem) { + beanClass = (Class) ((BeanItem) item).getBean() + .getClass(); + } + BeanValidationValidator validator = BeanValidationValidator + .addValidator(field, propertyId, beanClass); + if (locale != null) { + validator.setLocale(locale); + } + super.addField(propertyId, field); + } + + /** + * Sets the item data source for the form. If the new data source is a + * {@link BeanItem}, its bean must be assignable to the bean class of the + * form. + * + * {@inheritDoc} + */ + @Override + public void setItemDataSource(Item newDataSource) { + if ((newDataSource instanceof BeanItem) + && !beanClass.isAssignableFrom(((BeanItem) newDataSource) + .getBean().getClass())) { + throw new IllegalArgumentException("Bean must be of type " + + beanClass.getName()); + } + super.setItemDataSource(newDataSource); + } + + /** + * Sets the item data source for the form. If the new data source is a + * {@link BeanItem}, its bean must be assignable to the bean class of the + * form. + * + * {@inheritDoc} + */ + @Override + public void setItemDataSource(Item newDataSource, Collection propertyIds) { + if ((newDataSource instanceof BeanItem) + && !beanClass.isAssignableFrom(((BeanItem) newDataSource) + .getBean().getClass())) { + throw new IllegalArgumentException("Bean must be of type " + + beanClass.getName()); + } + super.setItemDataSource(newDataSource, propertyIds); + } + + /** + * Returns the base class of beans supported by this form. + * + * @return bean class + */ + public Class getBeanClass() { + return beanClass; + } +} \ No newline at end of file diff --git a/src/com/vaadin/data/validator/BeanValidationValidator.java b/src/com/vaadin/data/validator/BeanValidationValidator.java new file mode 100644 index 0000000000..379dcf547c --- /dev/null +++ b/src/com/vaadin/data/validator/BeanValidationValidator.java @@ -0,0 +1,318 @@ +package com.vaadin.data.validator; + +import java.io.IOException; +import java.io.ObjectInputStream; +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import javax.validation.ConstraintViolation; +import javax.validation.MessageInterpolator.Context; +import javax.validation.Validation; +import javax.validation.ValidatorFactory; +import javax.validation.constraints.NotNull; +import javax.validation.metadata.BeanDescriptor; +import javax.validation.metadata.ConstraintDescriptor; +import javax.validation.metadata.PropertyDescriptor; + +import com.vaadin.data.Property; +import com.vaadin.data.Validator; +import com.vaadin.data.util.MethodProperty; +import com.vaadin.ui.Field; + +/** + * Vaadin {@link Validator} using the JSR-303 (javax.validation) + * annotation-based bean validation. + * + * The annotations of the fields of the beans are used to determine the + * validation to perform. + * + * Note that a JSR-303 implementation (e.g. Hibernate Validator or agimatec + * validation) must be present on the project classpath when using bean + * validation. + * + * @since 7.0 + * + * @author Petri Hakala + * @author Henri Sara + */ +public class BeanValidationValidator implements Validator { + + private static final long serialVersionUID = 1L; + private static ValidatorFactory factory = Validation + .buildDefaultValidatorFactory(); + + private transient javax.validation.Validator validator; + private String propertyName; + private Class beanClass; + private MethodProperty method; + private Locale locale; + + /** + * Creates a Vaadin {@link Validator} utilizing JSR-303 bean validation. + * + * @param beanClass + * bean class based on which the validation should be performed + * @param propertyName + * property to validate + */ + public BeanValidationValidator(Class beanClass, String propertyName) { + this.beanClass = beanClass; + this.propertyName = propertyName; + validator = factory.getValidator(); + try { + method = new MethodProperty(beanClass.newInstance(), propertyName); + } catch (Exception e) { + throw new IllegalArgumentException("Class '" + beanClass + + "' must contain default constructor"); + } + locale = Locale.getDefault(); + } + + /** + * Apply a bean validation validator to a field based on a bean class and + * the identifier of the property the field displays. The field is also + * marked as required if the bean field has the {@link NotNull} annotation. + *

+ * No actual Vaadin validator is added in case no or only {@link NotNull} + * validation is used (required is practically same as NotNull validation). + * + * @param field + * the {@link Field} component to which to add a validator + * @param propertyId + * the property ID of the field of the bean that this field + * displays + * @param beanClass + * the class of the bean with the bean validation annotations + * @return the created validator + */ + public static BeanValidationValidator addValidator(Field field, + Object propertyId, Class beanClass) { + BeanValidationValidator validator = new BeanValidationValidator( + beanClass, String.valueOf(propertyId)); + PropertyDescriptor constraintsForProperty = validator.validator + .getConstraintsForClass(beanClass).getConstraintsForProperty( + propertyId.toString()); + if (constraintsForProperty != null) { + int nonNotNullValidators = constraintsForProperty + .getConstraintDescriptors().size(); + if (validator.isRequired()) { + field.setRequired(true); + field.setRequiredError(validator.getRequiredMessage()); + nonNotNullValidators--; + } + if (nonNotNullValidators > 0) { + field.addValidator(validator); + } + } + return validator; + } + + public boolean isValid(Object value) { + try { + validate(value); + return true; + } catch (Exception e) { + return false; + } + } + + /** + * Checks if the property has been marked as required (has the + * {@link NotNull} annotation. + * + * @return true if the field is marked as not null + */ + public boolean isRequired() { + PropertyDescriptor desc = validator.getConstraintsForClass(beanClass) + .getConstraintsForProperty(propertyName); + if (desc != null) { + Iterator> it = desc + .getConstraintDescriptors().iterator(); + while (it.hasNext()) { + final ConstraintDescriptor d = it.next(); + Annotation a = d.getAnnotation(); + if (a instanceof NotNull) { + return true; + } + } + } + return false; + } + + @SuppressWarnings("unchecked") + public String getRequiredMessage() { + return getErrorMessage(null, NotNull.class); + } + + public void validate(final Object value) throws InvalidValueException { + Object convertedValue = value; + try { + convertedValue = convertValue(value); + } catch (Exception e) { + String msg = getErrorMessage(value); + if (msg != null) { + throw new InvalidValueException(msg); + } else { + // there should be always some constraints if conversion is + // needed + // for example if String -> Integer then Digits annotation + throw new InvalidValueException("Conversion exception"); + } + } + Set violations = validator.validateValue(beanClass, propertyName, + convertedValue); + if (violations.size() > 0) { + final Object finalValue = convertedValue; + List exceptions = new ArrayList(); + for (Object v : violations) { + final ConstraintViolation violation = (ConstraintViolation) v; + String msg = factory.getMessageInterpolator().interpolate( + violation.getMessageTemplate(), new Context() { + + public ConstraintDescriptor getConstraintDescriptor() { + return violation.getConstraintDescriptor(); + } + + public Object getValidatedValue() { + return finalValue; + } + + }, locale); + exceptions.add(msg); + } + StringBuilder b = new StringBuilder(); + for (int i = 0; i < exceptions.size(); i++) { + if (i != 0) { + b.append("
"); + } + b.append(exceptions.get(i)); + } + throw new InvalidValueException(b.toString()); + } + } + + /** + * Convert the value the way {@link MethodProperty} does: if the bean field + * is assignable from the value, return the value directly. Otherwise, try + * to find a constructor for bean field type that takes a String and call it + * with value.toString() . + * + * @param value + * the value to convert + * @return converted value, assignable to the field of the bean + * @throws {@link ConversionException} if no suitable conversion found or + * the target type constructor from string threw an exception + */ + private Object convertValue(Object value) + throws Property.ConversionException { + // Try to assign the compatible value directly + if (value == null + || method.getType().isAssignableFrom(value.getClass())) { + return value; + } else { + try { + // Gets the string constructor + final Constructor constr = method.getType().getConstructor( + new Class[] { String.class }); + + return constr.newInstance(new Object[] { value.toString() }); + + } catch (final java.lang.Exception e) { + throw new Property.ConversionException(e); + } + } + } + + private String getErrorMessage(final Object value, + Class... an) { + BeanDescriptor beanDesc = validator.getConstraintsForClass(beanClass); + PropertyDescriptor desc = beanDesc + .getConstraintsForProperty(propertyName); + if (desc == null) { + // validate() reports a conversion error in this case + return null; + } + Iterator> it = desc.getConstraintDescriptors() + .iterator(); + List exceptions = new ArrayList(); + while (it.hasNext()) { + final ConstraintDescriptor d = it.next(); + Annotation a = d.getAnnotation(); + boolean skip = false; + if (an != null && an.length > 0) { + skip = true; + for (Class t : an) { + if (t == a.annotationType()) { + skip = false; + break; + } + } + } + if (!skip) { + String messageTemplate = null; + try { + Method m = a.getClass().getMethod("message"); + messageTemplate = (String) m.invoke(a); + } catch (Exception ex) { + throw new InvalidValueException( + "Annotation must have message attribute"); + } + String msg = factory.getMessageInterpolator().interpolate( + messageTemplate, new Context() { + + public Object getValidatedValue() { + return value; + } + + public ConstraintDescriptor getConstraintDescriptor() { + return d; + } + }, locale); + exceptions.add(msg); + } + } + if (exceptions.size() > 0) { + StringBuilder b = new StringBuilder(); + for (int i = 0; i < exceptions.size(); i++) { + if (i != 0) { + b.append("
"); + } + b.append(exceptions.get(i)); + } + return b.toString(); + } + return null; + } + + /** + * Sets the locale used for validation error messages. + * + * Revalidation is not automatically triggered by setting the locale. + * + * @param locale + */ + public void setLocale(Locale locale) { + this.locale = locale; + } + + /** + * Gets the locale used for validation error messages. + * + * @return + */ + public Locale getLocale() { + return locale; + } + + private void readObject(ObjectInputStream in) throws IOException, + ClassNotFoundException { + in.defaultReadObject(); + validator = factory.getValidator(); + } +} \ No newline at end of file diff --git a/tests/server-side/com/vaadin/tests/data/bean/BeanToValidate.java b/tests/server-side/com/vaadin/tests/data/bean/BeanToValidate.java new file mode 100644 index 0000000000..be8e40a118 --- /dev/null +++ b/tests/server-side/com/vaadin/tests/data/bean/BeanToValidate.java @@ -0,0 +1,56 @@ +package com.vaadin.tests.data.bean; + +import javax.validation.constraints.Digits; +import javax.validation.constraints.Max; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; +import javax.validation.constraints.Size; + +public class BeanToValidate { + @NotNull + @Size(min = 3, max = 16) + private String firstname; + + @NotNull(message = "Last name must not be empty") + private String lastname; + + @Min(value = 18, message = "Must be 18 or above") + @Max(150) + private int age; + + @Digits(integer = 3, fraction = 2) + private String decimals; + + public String getFirstname() { + return firstname; + } + + public void setFirstname(String firstname) { + this.firstname = firstname; + } + + public String getLastname() { + return lastname; + } + + public void setLastname(String lastname) { + this.lastname = lastname; + } + + public int getAge() { + return age; + } + + public void setAge(int age) { + this.age = age; + } + + public String getDecimals() { + return decimals; + } + + public void setDecimals(String decimals) { + this.decimals = decimals; + } + +} diff --git a/tests/server-side/com/vaadin/tests/server/validation/TestBeanValidation.java b/tests/server-side/com/vaadin/tests/server/validation/TestBeanValidation.java new file mode 100644 index 0000000000..9111ab9a4b --- /dev/null +++ b/tests/server-side/com/vaadin/tests/server/validation/TestBeanValidation.java @@ -0,0 +1,59 @@ +package com.vaadin.tests.server.validation; + +import org.junit.Test; + +import com.vaadin.data.Validator.InvalidValueException; +import com.vaadin.data.validator.BeanValidationValidator; +import com.vaadin.tests.data.bean.BeanToValidate; + +public class TestBeanValidation { + @Test(expected = InvalidValueException.class) + public void testBeanValidationNull() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "firstname"); + validator.validate(null); + } + + @Test(expected = InvalidValueException.class) + public void testBeanValidationStringTooShort() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "firstname"); + validator.validate("aa"); + } + + @Test + public void testBeanValidationStringOk() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "firstname"); + validator.validate("aaa"); + } + + @Test(expected = InvalidValueException.class) + public void testBeanValidationIntegerTooSmall() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "age"); + validator.validate(17); + } + + @Test + public void testBeanValidationIntegerOk() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "age"); + validator.validate(18); + } + + @Test(expected = InvalidValueException.class) + public void testBeanValidationTooManyDigits() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "decimals"); + validator.validate("1234.567"); + } + + @Test + public void testBeanValidationDigitsOk() { + BeanValidationValidator validator = new BeanValidationValidator( + BeanToValidate.class, "decimals"); + validator.validate("123.45"); + } + +} -- cgit v1.2.3