Reputation: 547
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
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:
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.
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()
.
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.
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
Reputation: 547
@Valid
from the address fieldDid 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
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