5

Mapping URL request parameters with Spring MVC to an object is fairly straightforward if you're using camelCase parameters in your request, but when presented with hyphen delimited values, how do you map these to an object?

Example for reference:

Controller:

@RestController
public class MyController {

    @RequestMapping(value = "/search", method = RequestMethod.GET)
    public ResponseEntity<String> search(RequestParams requestParams) {
        return new ResponseEntity<>("my-val-1: " + requestParams.getMyVal1() + " my-val-2: " + requestParams.getMyVal2(), HttpStatus.OK);
    }

}

Object to hold parameters:

public class RequestParams {

    private String myVal1;
    private String myVal2;

    public RequestParams() {}

    public String getMyVal1() {
        return myVal1;
    }

    public void setMyVal1(String myVal1) {
        this.myVal1 = myVal1;
    }

    public String getMyVal2() {
        return myVal2;
    }

    public void setMyVal2(String myVal2) {
        this.myVal2 = myVal2;
    }
}

A request made like this works fine:

GET http://localhost:8080/search?myVal1=foo&myVal2=bar

But, what I want is for a request with hyphens to map to the object, like so:

GET http://localhost:8080/search?my-val-1=foo&my-val-2=bar

What do I need to configure in Spring to map url request parameters with hyphens to fields in an object? Bear in mind that we may have many parameters, so using a @RequestParam annotation for each field is not ideal.

A. Weatherwax
  • 680
  • 3
  • 9
  • 20

2 Answers2

5

I extended ServletRequestDataBinder and ServletModelAttributeMethodProcessor to solve the problem.

Consider that your domain object may already be annotated with @JsonProperty or @XmlElement for serialization. This example assumes this is the case. But you could also create your own custom annotation for this purpose e.g. @MyParamMapping.

An example of your annotated domain class is:

public class RequestParams {

    @XmlElement(name = "my-val-1" )
    @JsonProperty(value = "my-val-1")
    private String myVal1;

    @XmlElement(name = "my-val-2")
    @JsonProperty(value = "my-val-2")
    private String myVal2;

    public RequestParams() {
    }

    public String getMyVal1() {
        return myVal1;
    }

    public void setMyVal1(String myVal1) {
        this.myVal1 = myVal1;
    }

    public String getMyVal2() {
        return myVal2;
    }

    public void setMyVal2(String myVal2) {
        this.myVal2 = myVal2;
    }
}

You will need a SerletModelAttributeMethodProcessor to analyze the target class, generate a mapping, invoke your ServletRequestDataBinder.

    public class KebabCaseProcessor extends ServletModelAttributeMethodProcessor {

    public KebabCaseProcessor(boolean annotationNotRequired) {
        super(annotationNotRequired);
    }

    @Autowired
    private RequestMappingHandlerAdapter requestMappingHandlerAdapter;

    private final Map<Class<?>, Map<String, String>> replaceMap = new ConcurrentHashMap<Class<?>, Map<String, String>>();

    @Override
    protected void bindRequestParameters(WebDataBinder binder, NativeWebRequest nativeWebRequest) {
        Object target = binder.getTarget();
        Class<?> targetClass = target.getClass();
        if (!replaceMap.containsKey(targetClass)) {
            Map<String, String> mapping = analyzeClass(targetClass);
            replaceMap.put(targetClass, mapping);
        }
        Map<String, String> mapping = replaceMap.get(targetClass);
        ServletRequestDataBinder kebabCaseDataBinder = new KebabCaseRequestDataBinder(target, binder.getObjectName(), mapping);
        requestMappingHandlerAdapter.getWebBindingInitializer().initBinder(kebabCaseDataBinder, nativeWebRequest);
        super.bindRequestParameters(kebabCaseDataBinder, nativeWebRequest);
    }

    private static Map<String, String> analyzeClass(Class<?> targetClass) {
        Field[] fields = targetClass.getDeclaredFields();
        Map<String, String> renameMap = new HashMap<String, String>();
        for (Field field : fields) {
            XmlElement xmlElementAnnotation = field.getAnnotation(XmlElement.class);
            JsonProperty jsonPropertyAnnotation = field.getAnnotation(JsonProperty.class);
            if (xmlElementAnnotation != null && !xmlElementAnnotation.name().isEmpty()) {
                renameMap.put(xmlElementAnnotation.name(), field.getName());
            } else if (jsonPropertyAnnotation != null && !jsonPropertyAnnotation.value().isEmpty()) {
                renameMap.put(jsonPropertyAnnotation.value(), field.getName());
            }
        }
        if (renameMap.isEmpty())
            return Collections.emptyMap();
        return renameMap;
    }
}

This KebabCaseProcessor will use reflection to get a list of mappings for your request object. It will then invoke the KebabCaseDataBinder - passing in the mappings.

@Configuration
public class KebabCaseRequestDataBinder extends ExtendedServletRequestDataBinder {

    private final Map<String, String> renameMapping;

    public KebabCaseRequestDataBinder(Object target, String objectName, Map<String, String> mapping) {
        super(target, objectName);
        this.renameMapping = mapping;
    }

    protected void addBindValues(MutablePropertyValues mpvs, ServletRequest request) {
        super.addBindValues(mpvs, request);
        for (Map.Entry<String, String> entry : renameMapping.entrySet()) {
            String from = entry.getKey();
            String to = entry.getValue();
            if (mpvs.contains(from)) {
                mpvs.add(to, mpvs.getPropertyValue(from).getValue());
            }
        }
    }
}

All that remains now is to add this behavior to your configuration. The following configuration overrides the default configuration that the @EnableWebMVC delivers and adds this behavior to your request processing.

@Configuration
public static class WebContextConfiguration extends WebMvcConfigurationSupport {
    @Override
    protected void addArgumentResolvers(List<HandlerMethodArgumentResolver> argumentResolvers) {
        argumentResolvers.add(kebabCaseProcessor());
    }

    @Bean
    protected KebabCaseProcessor kebabCaseProcessor() {
        return new KebabCaseProcessor(true);
    }
} 

Credit should be given to @Jkee. This solution is derivative of an example he posted here: How to customize parameter names when binding spring mvc command objects.

Community
  • 1
  • 1
Bjorn Loftis
  • 156
  • 10
  • I found that remove requestMappingHandlerAdapter also work – zhaozhi Feb 03 '17 at 08:55
  • It seems this way can only convert parameter to primitive type field, but "s1,s2,s3" can not be converted to List any more. – zhaozhi Feb 10 '17 at 09:31
  • sorry, requestMappingHandlerAdapter is needed. It's used to init binder's conversionService, without it, you can not cast "s1,s2,s3" to a list. But I have problem autowire a requestMappingHandlerAdapter to KebabCaseProcessor. So I fix it by the code: kebabCaseDataBinder.setConversionService(binder.getConversionService()); – zhaozhi Feb 15 '17 at 09:10
  • You should copy errors from kebabCaseDataBinder to external binder. If you do not do that, data conversion error will not be returned. It should be at the end of bindRequestParameters: super.bindRequestParameters(kebabCaseDataBinder, nativeWebRequest); binder.getBindingResult().addAllErrors(kebabCaseDataBinder.getBindingResult()); – Krzysztof Jun 01 '21 at 12:32
1

One way I can think of getting around the hyphens is to use HttpServletRequestWrapper class to wrap the original request.

  1. Parse all the request parameters in this class and convert all hyphenated parameters into camelcase. After this, spring will be able to automatically map those parameters to your POJO classes.

    public class CustomRequestWrapper extends HttpServletRequestWrapper {
    
        private Map<String, String> camelCasedParams = new Hashmap();
        public CustomRequestWrapper(HttpServletRequest req){
            //Get all params from request.
            //Transform each param name from hyphenated to camel case
            //Put them in camelCasedParams; 
        }
    
        public String getParameter(String name){
            return camelCasedParams.get(name);
        }
    
        //Similarly, override other methods related to request parameters
    }
    
  2. Inject this request wrapper from J2EE filter. You can refer to below link for a tutorial on injecting request wrappers using filter.

    http://www.programcreek.com/java-api-examples/javax.servlet.http.HttpServletRequestWrapper

  3. Update your web xml to include filter and its filter mapping.

jmattheis
  • 10,494
  • 11
  • 46
  • 58
Mohit
  • 1,740
  • 1
  • 15
  • 19
  • Thanks for the answer, it solves the problem as asked, but unfortunately, since it's actually modifying the query parameter, it causes problems elsewhere (i.e. some of Spring's HATEOAS library) – A. Weatherwax Dec 04 '15 at 21:49