Affects: All versions; spring-webmvc
This is a feature request for a web data binder that respects (what I call) "binding groups" which are similar to (JSR 303) validation groups. Before invocation, the caller can specify the active binding groups. The binder shall only bind the fields that belong to the specified group.
I'm writing this feature request, because it's now the fourth time that we need it. Here are our use cases:
- It solves security issues: Instead of DTOs (data transfer objects) we could use the binder to exclude entity fields that are not meant to be bound, i.e. changed by the user:
public class Order {
public int productId;
public int amount;
// Not meant to be changed by user, but user could easily add an input field by manipulating the HTML form
public Date createdOn;
}
- Wizard-like forms. There is one object with all fields. But the fields are bound step-by-step. E.g. on the first wizard page the fields of the further pages are not required.
- Conditional binding: E.g. a form that allows to select between two products. Depending on the product there are other fields. If those fields are filled with invalid data (e.g. string in an integer field) and afterwards the user switches the product there are unnecessary binding errors. Here is an example:
- User selects product A
- User fills integer field
amount
with invalid value"foo"
- User switches to product B (which has no field
amount
) - Now there is an exception on binding although the field
amount
is not needed at all
I already implemented this and it's in use. The binder can be used with JSR 303 validation groups and additionally provides a new @BindingGroup
annotation for fields that don't have a validation constraint.
This is what the entity could look like:
public class Order {
// With @BindingGroup
@BindingGroup({OrderStep1.class})
public int productId;
// With JSR303-annotation
@NotNull(groups = OrderStep2.class)
public int amount;
// Not meant to be changed by user, but user could easily add a new input field
// => has no binding group and therefore won't be bound
public Date createdOn;
}
The binder must be called manually in the controller:
public class MyController {
private final WebBindingInitializer webBindingInitializer;
public MyController(WebBindingInitializer webBindingInitializer) {
this.webBindingInitializer = webBindingInitializer;
}
@RequestMapping(/** ... */)
public string onRequest(HttpServletRequest request) {
MutablePropertyValues mpvs = new ServletRequestParameterPropertyValues(request);
Order order = new Order();
GroupAwareWebRequestDataBinder dataBinder = new GroupAwareWebRequestDataBinder(order, "order");
webBindingInitializer.initBinder(dataBinder);
int step = getCurrentWizardStep();
if (step == 1) {
dataBinder.bindAndValidate(mpvs, OrderStep1.class);
} else if (step == 2) {
dataBinder.bindAndValidate(mpvs, OrderStep2.class);
} // ...
}
// ...
}
This is the binder. Notice that I originally wrote this for Portlet-MVC. I adapted it for Web-MVC and I hope everything is correct.
/**
* 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.
*/
public class GroupAwareWebRequestDataBinder extends WebDataBinder
{
/**
* Create a new GroupAwareWebRequestDataBinder 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 GroupAwareWebRequestDataBinder(Object target) {
super(target);
}
/**
* Create a new GroupAwareWebRequestDataBinder 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 GroupAwareWebRequestDataBinder(Object target, String objectName) {
super(target, objectName);
}
public void bind(WebRequest request, Class<?> group) throws Exception {
bind(new MutablePropertyValues(request.getParameterMap()), group);
}
public void bind(MutablePropertyValues mpvs, Class<?> group) throws Exception {
this.checkFieldDefaults(mpvs);
this.checkFieldMarkers(mpvs);
MutablePropertyValues targetMpvs = new MutablePropertyValues();
BeanWrapper bw = (BeanWrapper) this.getPropertyAccessor();
for (PropertyValue pv : mpvs.getPropertyValues()) {
if (bw.isReadableProperty(pv.getName())) {
PropertyDescriptor pd = bw.getPropertyDescriptor(PropertyAccessorUtils.getPropertyName(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(WebRequest request, Class<?> group) throws Exception{
bind(request, group);
validate(group);
}
public void bindAndValidate(MutablePropertyValues mpvs, Class<?> group) throws Exception{
bind(mpvs, 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.
*/
public void closeNoCatch() throws BindException{
if (getBindingResult().hasErrors()) {
throw new BindException(getBindingResult());
}
}
}
The @BindingGroup
annotation:
import java.lang.annotation.*;
@Target({ElementType.ANNOTATION_TYPE, ElementType.FIELD, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface BindingGroup
{
Class<?>[] value() default {};
}
Comment From: rstoyanchev
Thank you for the idea, but we have no plans to introduce such a mechanism that relies on field annotations. It is related to other proposals for data binding based on field annotations such as #23618 and more like it.
Note that as of 6.1 we provide enhanced support for Constructor Binding including nested paths and customizable request parameter names via @BindParam
, which helps with security issues that arise with setter binding.