martes 27 de marzo de 2007

Generic Validator for Spring MVC & DWR

Last month I blogged about how to leverage Spring Validators to check client information delivered in AJAX calls (you should have read it before proceeding here). I got the original idea after reading a very good article at java.net. The result, in my opinion, was very useful but it presented two problems
  • It forces the developer to create the Validator using a predefined schema
  • The Validator had to implement a new interface (ClassAwareValidator)
As a matter of fact they were more annoyances than real problems. Working lately with Validators I faced something really interesting and that deserved a specific solution, how to manage hundreds of domain objects with just one Validator? Probably the model of a common project is made of lots of entities (in JPA terms) and most of them use the same validation concepts, there is really no point developing a custom form and validator for each of them. Most of the work should be able to be packed in a generic solution.

With that idea in my mind and, by the way, with the intention of also solving the points above I started working a little more with the code already available. The goals were:
  • Define a generic Validator that could check any object
  • But not all objects should be checked by it
  • The Validator could be used to check just a field or the whole object
  • The Validator should be usable in AJAX (with DWR)
  • The Validator should work side by side with other Validators in the system
  • The Validator should support Spring MVC forms
The first thing I realized was that I would need some annotations. A generic Validator cannot work if it can't determine what fields should it test and what checks should it perform. The most elegant solution is to annotate the fields in the target classes, this way all other services behave as expected in its absence. I started defining the following:

@Target(ElementType.FIELD)
public @interface StringConstraint {
   public boolean required() default true;
   public String regexp() default "";
   public int maxLength() default 0;
}

@Target(ElementType.FIELD)
public @interface IntegerConstraint {
   public boolean required() default true;
   public int minValue() default 0;
   public int maxValue() default 0;
}

Those should be enough to start. Later some other ones could be created to test dates or decimals, for example. The code is very clear but to explain it a little more:
  • The string annotation can be applied to any field and it will check that the field is not null, it matches a regular expression and it does not span over a number of characters.
  • The integer annotation defines a minimum and maximum values for the field
With them a Validator can be constructed. The first step is determining what objects it will support:

protected Class getValidationAnnotation(Field field) {
   Class validAnnotation = null;
   if (field != null)
      for (Annotation annotation: field.getAnnotations())
         if (validAnnotation == null)
            if (validAnnotations.contains(annotation.annotationType()))
               validAnnotation = annotation.annotationType();
   return validAnnotation;
}

public boolean supports(Class aClass) {
   boolean supports = false;
   if ((aClass != null) & (validAnnotations != null)) {
      for (Field field : aClass.getDeclaredFields())
         if (!supports) supports |= (getValidationAnnotation(field) != null);
   }
   return supports;
}

The Validator is loaded with the annotations that needs to check (with an init method). To test an object it reads all the fields and checks if at least one of them is annotated with one of the annotations considered valid. If the class is supported it just needs to check each annotated field

public void validate(Object object, Errors errors) {
   if ((object == null) | (errors == null))
      throw new IllegalArgumentException();
   if (!this.supports(object.getClass()))
      throw new IllegalArgumentException();
   for (Field field : object.getClass().getDeclaredFields())
      validateField(field, object, errors);
}

protected void validateFieldAsString(Field field, Object object, Errors errors) {
   StringConstraint req = field.getAnnotation(StringConstraint.class);
   if (req.required() && validateNull(field, object))
      errors.rejectValue(field.getName(), errorCodes[0]);
   field.setAccessible(true);
   stringField = field.get(object).toString();
   if ((stringField != null) & (req.regexp().length() > 0)) {
      Pattern pattern = Pattern.compile(req.regexp());
      Matcher matcher = pattern.matcher(stringField);
      if (!matcher.matches())
         errors.rejectValue(field.getName(), errorCodes[1], req.regexp());
   }
   if ((stringField != null) & (req.maxLength() > 0)) {
      if (stringField.length() > req.maxLength())
         errors.rejectValue(
                  field.getName(),
                  errorCodes[3],
                  Integer.toString(req.maxLength()));
   }
}

Of course, a validateFieldXXX is needed for each type of annotation. I've just posted the String one as an example. There's one point left and it is modifying the validation service so it can use this new Validator (and by the way fix the little annoyances described above)

protected Validator getValidator(Class clazz) {
   Validator validValidator = null;
   if (clazz != null) {
      for (Validator validator : this.validators)
         if ((validValidator == null) && (validator.supports(clazz)))
            validValidator = validator;
   }
   return validValidator;
}

public String validateField(String fieldName, String fieldValue) throws Exception {
   String message = null;
   if ((fieldName == null) || (fieldName.indexOf(".") < 0))
      throw new DWRBindException(fieldName);
   String className = fieldName.substring(0, fieldName.lastIndexOf("."));
   Class clazz = this.getClassFromName(className);
   Validator validator = this.getValidator(clazz);
   if (validator != null) {
      String field = fieldName.substring(fieldName.lastIndexOf(".") + 1);
      String capitalizedField = StringUtils.capitalize(field);
      Object formBackingObject = clazz.newInstance();
      Field f = clazz.getDeclaredField(field);
      Object value = this.getValueObject(f.getType(), fieldValue);
      f.setAccessible(true);
      f.set(formBackingObject, value);
      Errors errors = validateObject(formBackingObject);
      FieldError error = errors.getFieldError(field);
      if (error != null) message = error.getCode();
   }
   return message;
}

public Errors validateObject(Object object) throws Exception {
   Errors errors = null;
   if (object != null) {
      Validator validator = this.getValidator(object.getClass());
      if (validator != null) {
         errors = new DirectFieldBindingResult(object, "command");
         Class[] validationArgs = new Class[] { Object.class, Errors.class };
         String methodName = "validate";
         Method method = validator.getClass().getMethod(methodName, validationArgs);
         if (method != null)
            method.invoke(validator, new Object[] { object, errors });
      }
   }
   return errors;
}

The full source code has been submitted to the Spring Annotations project and should be available there (with time). Until then I've compiled a test WAR available here. The implementation sources are available here (the testing classes source code is shown here if you want to peek).

4 comentarios:

GrèGe dijo...

Hi Jose, tHx 4 your incredibly fine work ! I've not found source code in the war u posted... Would it B possible 4 U ...

I'm really anthousiastic about your work : I'm workin' on Spring, DWR and Alfresco too... and all your notes do interest me. I discover annotations. Seems 2 B very powerful... Your "paper" about logging was intersting. I'll get it a try asap.

Jose Noheda dijo...

So true...thank you! I will upload the sources tomorrow (Monday) when I have access to my laptop. Sorry for the delay.

Fuel dijo...

Can u upload ur source to help other ppl to understand ur wonderful contribution? Thanks!

Densis dijo...

cheap tramadol
cheap celebrex
cheap meridia
cheap fioricet
cheap valium
cheap zoloft
cheap cialis
cheap paxil
Diflucan Valtrex
cheap propecia