Chris
Chris

Reputation: 547

Spring MVC and @Validate: Perform validate only on specific condition or if user changes the property

Controller method is expecting a @NotNull @Valid @ModelAttribute Person. Person has a @Valid Address address property.

On PersonController.create(@NotNull @Valid @ModelAttribute Person person, BindingResult bindingResult...) I need to have the person.address validated only if the user sets any of the fields of the address or based on a value of a field of the person instance (e.g. person.hasAddress=true).

The problem is that by default spring creates a new instance of Address which is submitted on createForm submit and fails on validation.

I created a crossproperty validation in Person which requires address not to be null in case hasAddress=true, but can't fix the issue with the validation in the fields of the address.

I tried using @InitBinder("address") / @InitBinder("person.address") in order to set binder.setAutoGrowNestedPaths(false); but I couldn't reach this call. Using the @InitBinder globally would cause other issues with other properties.

I was thinking about groups but that can be used only when you know on dev time if you wan't validation or not. In my case, it will be know on submit, based on any changes on address or the hasAddress field

Any idea?

Upvotes: 2

Views: 7597

Answers (3)

fishbone
fishbone

Reputation: 3249

In had a similar problem (JSR-303 / Spring MVC - validate conditionally using groups)

The main idea of my solution is to dynamically bind the data, i.e. step by step conditionally bind and validate the input data:

  1. I created a new annotation class @BindingGroup. It's similar to the groups parameter of validation constraint annotations. In my solution you use it to specify a group of a field which has no validation constraint.

  2. I created a custom binder called GroupAwareDataBinder. When this binder is called, a group is passed and the binder only binds fields which belong to this group. To set a group for a field you can use the new @BindingGroup annotation. As there could be also situations where normal groups are sufficient, the binder also looks for the groups parameter of validation constraints. For convenience the binder provides a method bindAndValidate().

  3. Specifiy a binding group called BasicCheck and a second binding group AddressCheck and assign them to the respective fields of your Person and Address class.

  4. Now you can perform the binding of your data step by step in your controller method. Here is some pseudo code:

    //create a new binder for a new Person instance
    result = binder.getBindingResult();
    binder.bindAndValidate(data, BasicCheck.class);
    if (person.hasAddress)
       binder.bindAndValidate(data, AddressCheck.class);
    if (!result.hasErrors())
       // do something
    

As you can see, the downside is that you have to perform the binding on your own instead of using nice annotations.

Here is my source code:

BindingGroup:

import java.lang.annotation.*;

@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface BindingGroup
{
   Class<?>[] value() default {};
}

In my case I work with portlets. I think one can easily adapt the binder for servlets:

  import org.springframework.beans.BeanWrapper;
  import org.springframework.beans.MutablePropertyValues;
  import org.springframework.beans.PropertyAccessorUtils;
  import org.springframework.beans.PropertyValue;
  import org.springframework.validation.BindException;
  import org.springframework.web.bind.WebDataBinder;
  import org.springframework.web.portlet.bind.PortletRequestBindingException;
  import org.springframework.web.portlet.bind.PortletRequestParameterPropertyValues;

  import javax.portlet.PortletRequest;
  import javax.validation.Constraint;
  import java.beans.PropertyDescriptor;
  import java.lang.annotation.Annotation;
  import java.lang.reflect.InvocationTargetException;
  import java.lang.reflect.Method;
  import java.security.AccessController;
  import java.security.PrivilegedActionException;
  import java.security.PrivilegedExceptionAction;

  /**
   * binds only fields which belong to a specific group. Fields annotated with either the
   * {BindingGroup} annotation or with validation-constraints having the "groups"-
   * parameter set.
   * Allows conditional or wizard-like step by step binding.
   *
   * @author Uli Hecht ([email protected])
   */
  public class GroupAwarePortletRequestDataBinder extends WebDataBinder
  {
     /**
      * Create a new PortletRequestDataBinder instance, with default object name.
      * @param target the target object to bind onto (or {@code null}
      * if the binder is just used to convert a plain parameter value)
      * @see #DEFAULT_OBJECT_NAME
      */
     public GroupAwarePortletRequestDataBinder(Object target) {
        super(target);
     }

     /**
      * Create a new PortletRequestDataBinder instance.
      * @param target the target object to bind onto (or {@code null}
      * if the binder is just used to convert a plain parameter value)
      * @param objectName the name of the target object
      */
     public GroupAwarePortletRequestDataBinder(Object target, String objectName) {
        super(target, objectName);
     }

     public void bind(PortletRequest request, Class<?> group) throws Exception
     {
        MutablePropertyValues mpvs = new PortletRequestParameterPropertyValues(request);
        MutablePropertyValues targetMpvs = new MutablePropertyValues();
        BeanWrapper bw = (BeanWrapper) this.getPropertyAccessor();
        for (PropertyValue pv : mpvs.getPropertyValues())
        {
           if (bw.isReadableProperty(PropertyAccessorUtils.getPropertyName(pv.getName())))
           {
              PropertyDescriptor pd = bw.getPropertyDescriptor(pv.getName());

              for (final Annotation annot : pd.getReadMethod().getAnnotations())
              {
                 Class<?>[] targetGroups = {};
                 if (BindingGroup.class.isInstance(annot))
                 {
                    targetGroups = ((BindingGroup) annot).value();
                 }
                 else if (annot.annotationType().getAnnotation(Constraint.class) != null)
                 {
                    try
                    {
                       final Method groupsMethod = annot.getClass().getMethod("groups");
                       groupsMethod.setAccessible(true);
                       try {
                          targetGroups = (Class<?>[]) AccessController.doPrivileged(new PrivilegedExceptionAction<Object>()
                          {
                             @Override
                             public Object run() throws Exception
                             {
                                return groupsMethod.invoke(annot, (Object[]) null);
                             }
                          });
                       }
                       catch (PrivilegedActionException pae) {
                          throw pae.getException();
                       }
                    }
                    catch (NoSuchMethodException ignored) {}
                    catch (InvocationTargetException ignored) {}
                    catch (IllegalAccessException ignored) {}
                 }
                 for (Class<?> targetGroup : targetGroups)
                 {
                    if (group.equals(targetGroup))
                    {
                       targetMpvs.addPropertyValue(mpvs.getPropertyValue(pv.getName()));
                    }
                 }
              }
           }
        }
        super.bind(targetMpvs);
     }

     public void bindAndValidate(PortletRequest request, Class<?> group) throws Exception
     {
        bind(request, group);
        validate(group);
     }

     /**
      * Treats errors as fatal.
      * <p>Use this method only if it's an error if the input isn't valid.
      * This might be appropriate if all input is from dropdowns, for example.
      * @throws org.springframework.web.portlet.bind.PortletRequestBindingException subclass of PortletException on any binding problem
      */
     public void closeNoCatch() throws PortletRequestBindingException
     {
        if (getBindingResult().hasErrors()) {
           throw new PortletRequestBindingException(
                 "Errors binding onto object '" + getBindingResult().getObjectName() + "'",
                 new BindException(getBindingResult()));
        }
     }
  }

Here is an example how your controller method should begin. There are some extra steps required which are normally done by Spring if normal binding mechanism was used.

  @ActionMapping
  public void onRequest(ActionRequest request, ActionResponse response, ModelMap modelMap) throws Exception
  {
     Person person = new Person();
     GroupAwarePortletRequestDataBinder dataBinder =
           new GroupAwarePortletRequestDataBinder(person, "person");
     webBindingInitializer.initBinder(dataBinder, new PortletWebRequest(request, response));
     initBinder(dataBinder);
     BindingResult result = dataBinder.getBindingResult();
     modelMap.clear();
     modelMap.addAttribute("person", Person);
     modelMap.putAll(result.getModel());

     // now you are ready to use bindAndValidate()
   }

Some examples for fields of your Person class:

@NotNull(groups = BasicCheck.class)
public String getName() { return name; }

@BindingGroup(BasicCheck.class)
public String phoneNumber() { return phoneNumber; }

@Valid
public Address getAddress() { return address; }

The Address class:

@BindingGroup(BasicCheck.class)
public Integer getZipCode() { return zipCode; }

Writing this answer was lots of work so I hope it helps you.

Upvotes: 4

Chris
Chris

Reputation: 547

  1. Removed the @Valid from the address field
  2. Did the validation manually: inside the create method of the controller: validateAddressIfNeeded(person, bindingResult)

    private void validateAddressIfNeeded(Person person, BindingResult bindingResult) {
        if (person.hasAddress()) {
            bindingResult.pushNestedPath("address");
            validator.validate(person.getAddress(), bindingResult);
            bindingResult.popNestedPath();
        }
    }
    

Upvotes: 1

Serge Ballesta
Serge Ballesta

Reputation: 148890

This answer is for the @InitBinder part. Javadoc says that the value of the annotation is the name of the [model attribute] that this init-binder method is supposed to apply to.

I think you should explicitely set a name to the model attribute and same name to the @InitBinder

@InitBinder("person_with_address")
public void ...
...
public String create(@NotNull @Valid @ModelAttribute("person_with_address") Person person, BindingResult bindingResult...)

That way you should be able to use a dedicated binding for that controller method.

Upvotes: 0

Related Questions