Hello

Description: I'm investigating a possible bug with the Spring webDataBinder. Based on my understanding, this issue is reproducible on Spring Boot 3.2.x and 3.1.x, but not on Spring Boot 3.0.x. More specifically, the problem appears in Spring Web 6.1.x.

Sample Project: https://github.com/hugo-ma-alves/webbinder-bug

Related Issues: This issue might be related to a previous one: #32919.

Expected behaviour

When invoking the following endpoint: http://localhost:8080/?%24page=50&%24size=1&name=example, it is expected that the SearchCriteria argument of the controller is populated with all the parameters sent in the URL, including $page and $size.

The controller should echo what it receives. The expected output is: example|50|1.

To map the $page and $size parameters, I created a binder (PaginationParametersBinder) that binds these parameters to the correct POJO property.

@InitBinder
public void bindCustomAttributeNames(WebDataBinder binder, WebRequest request) {
    Object objectToInspect = binder.getTarget();
    if (objectToInspect != null) {
        Class<?> classToInspect = objectToInspect.getClass();
        Map<String, String> paramAndInternalNameMap = buildRequestParamMapping(classToInspect);

        if (!paramAndInternalNameMap.isEmpty()) {
            TreeMap<String, String[]> requestParameterNamesAndValues = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            requestParameterNamesAndValues.putAll(request.getParameterMap());
            bindParameters(binder, paramAndInternalNameMap, requestParameterNamesAndValues);
        }
    }
}

Difference of behaviour between web 6.0.x and 6.1.x

On spring web 6.0.x the binder.getTarget(); returns a instance of my target pojo. With this instance I can then invoke the binder.bind(propertyValues); and the values are correctly binded. However, on web 6.1.x the binder.getTarget() returns null. I refactored the code a bit to not rely on the target but just on the target class:

@InitBinder
public void bindCustomAttributeNames(WebDataBinder binder, WebRequest request) {
    Object objectToInspect = binder.getTarget();
// if (objectToInspect != null) {
        Class<?> classToInspect = binder.getTargetType().getRawClass();
        Map<String, String> paramAndInternalNameMap = buildRequestParamMapping(classToInspect);

        if (!paramAndInternalNameMap.isEmpty()) {
            TreeMap<String, String[]> requestParameterNamesAndValues = new TreeMap<>(String.CASE_INSENSITIVE_ORDER);
            requestParameterNamesAndValues.putAll(request.getParameterMap());
            bindParameters(binder, paramAndInternalNameMap, requestParameterNamesAndValues);
        }
//  }
}

But this change will later cause the following error when invoking the .bind()

Caused by: java.lang.IllegalStateException: Cannot access properties on null bean instance 'searchCriteria'
    at org.springframework.validation.BeanPropertyBindingResult.createBeanWrapper(BeanPropertyBindingResult.java:111)
    at org.springframework.validation.BeanPropertyBindingResult.getPropertyAccessor(BeanPropertyBindingResult.java:97)
    at org.springframework.validation.DataBinder.getPropertyAccessor(DataBinder.java:378)
    at org.springframework.validation.DataBinder.applyPropertyValues(DataBinder.java:1220)
    at org.springframework.validation.DataBinder.doBind(DataBinder.java:1112)
    at org.springframework.web.bind.WebDataBinder.doBind(WebDataBinder.java:235)
    at org.springframework.validation.DataBinder.bind(DataBinder.java:1087)
    at com.example.webbinderbug.PaginationParametersBinder.lambda$bindParameters$2(PaginationParametersBinder.java:41)
    at java.base/java.util.HashMap.forEach(HashMap.java:1429)
    at com.example.webbinderbug.PaginationParametersBinder.bindParameters(PaginationParametersBinder.java:35)
    at com.example.webbinderbug.PaginationParametersBinder.bindCustomAttributeNames(PaginationParametersBinder.java:28)
    at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
    at java.base/java.lang.reflect.Method.invoke(Method.java:580)
    at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:255)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188)
    at org.springframework.web.method.annotation.InitBinderDataBinderFactory.initBinder(InitBinderDataBinderFactory.java:68)
    at org.springframework.web.bind.support.DefaultDataBinderFactory.createBinderInternal(DefaultDataBinderFactory.java:105)
    at org.springframework.web.bind.support.DefaultDataBinderFactory.createBinder(DefaultDataBinderFactory.java:87)
    at org.springframework.web.method.annotation.ModelAttributeMethodProcessor.resolveArgument(ModelAttributeMethodProcessor.java:146)
    at org.springframework.web.method.support.HandlerMethodArgumentResolverComposite.resolveArgument(HandlerMethodArgumentResolverComposite.java:122)
    at org.springframework.web.method.support.InvocableHandlerMethod.getMethodArgumentValues(InvocableHandlerMethod.java:224)
    at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:178)
    at org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:926)
    at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:831)
    at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)
    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)
    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)
    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)

Steps to Reproduce using a unit test

  1. Run the UserControllerTest test in the attached project.
  2. The test should pass.
  3. Change the version of spring-boot-starter-parent to 3.3.2 and rerun the test. This time, the test will fail because the $page and $size parameters are not bound to the POJO.

Can you please help to understand if this is a bug or a change of behaviour introduced by spring web 6.1.x?

Thank you

Comment From: rstoyanchev

Thanks for the sample project.

In 6.0.x, the argument resolver for @ModelAttribute created the target command object, but this needed to be better encapsulated and more re-usable, so in 6.1 with #26721 that capability moved to DataBinder, which now encapsulates support for creating and populating objects via constructor args. This is the reason for the difference, and beyond a mere refactoring, additional improvements have been made since to support constructor args with flexible names, nested constructor binding, collections, and so on.

From what I can see, your goal is to support request params with characters that do not map to Java variable names. This is now supported with constructor binding and the @BindParam annotation, see the docs also on that. I checked to make sure the test passes after I removed PaginationParametersBinder and changed SearchCriteria to:

public class SearchCriteria {

    private final String name;

    @BindParam("$page")
    private final int page;

    @BindParam("$size")
    private final int size;

    public SearchCriteria(String name, int page, int size) {
        this.name = name;
        this.page = page;
        this.size = size;
    }

    public String getName() {
        return name;
    }

    public int getPage() {
        return page;
    }

    public int getSize() {
        return size;
    }

}

The same also works as a record:

public record SearchCriteria (
        String name, @BindParam("$page") int page, @BindParam("$size") int size) {

}

In summary, the difference you have pointed out is expected, and @InitBinder methods generally are invoked before the target object is initialized, and are primarily for initialization of the DataBinder itself. I understand that there was a shortcoming with special parameter names that required a workaround, but we now support flexible parameter names.

Comment From: hugo-ma-alves

Thanks a lot for the context and the workaround.

Just tried to use the constructor args on my project and it works perfectly. No need to use the custom binder anymore.

Since there is no real bug I'm closing this issue.