Skip to content

WebDataBinder different behaviour between 6.0.x and 6.1.x - spring boot 3.0x and 3.2.x #33279

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
hugo-ma-alves opened this issue Jul 26, 2024 · 2 comments
Assignees
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) status: invalid An issue that we don't feel is valid

Comments

@hugo-ma-alves
Copy link

hugo-ma-alves commented Jul 26, 2024

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

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged or decided on label Jul 26, 2024
@snicoll snicoll added the in: web Issues in web modules (web, webmvc, webflux, websocket) label Jul 26, 2024
@rstoyanchev rstoyanchev self-assigned this Jul 30, 2024
@rstoyanchev
Copy link
Contributor

rstoyanchev commented Jul 30, 2024

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.

@rstoyanchev rstoyanchev added status: waiting-for-feedback We need additional information before we can continue status: waiting-for-triage An issue we've not yet triaged or decided on and removed status: waiting-for-triage An issue we've not yet triaged or decided on labels Jul 30, 2024
@hugo-ma-alves
Copy link
Author

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.

@rstoyanchev rstoyanchev removed status: waiting-for-feedback We need additional information before we can continue status: waiting-for-triage An issue we've not yet triaged or decided on labels Jul 31, 2024
@bclozel bclozel closed this as not planned Won't fix, can't repro, duplicate, stale Aug 1, 2024
@bclozel bclozel added the status: invalid An issue that we don't feel is valid label Aug 1, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
in: web Issues in web modules (web, webmvc, webflux, websocket) status: invalid An issue that we don't feel is valid
Projects
None yet
Development

No branches or pull requests

5 participants