Search code examples
javaspringspring-bootpojo

Spring Boot: Optional mapping of request parameters to POJO


I'm trying to map request parameters of a controller method into a POJO object, but only if any of its fields are present. However, I can't seem to find a way to achieve this. I have the following POJO:

public class TimeWindowModel {

    @NotNull
    public Date from;

    @NotNull
    public Date to;

}

If none of the fields are specified, I'd like to get an empty Optional, otherwise I'd get an Optional with a validated instance of the POJO. Spring supports mapping request parameter into POJO objects by leaving them unannotated in the handler:

@GetMapping("/shop/{shopId}/slot")
public Slice<Slot> getSlots(@RequestAttribute("staff") Staff staff,
                            @PathVariable("shopId") Long shopId, @Valid TimeWindowModel timeWindow) {
    // controller code
}

With this, Spring will map request parameters "from" and "to" to an instance of TimeWindowModel. However, I want to make this mapping optional. For POST requests you can use @RequestBody @Valid Optional<T>, which will give you an Optional<T> containing an instance of T, but only if a request body was provided, otherwise it will be empty. This makes @Valid work as expected.

When not annotated, Optional<T> doesn't appear to do anything. You always get an Optional<T> with an instance of the POJO. This is problematic when combined with @Valid because it will complain that "from" and "to" are not set.

The goal is to get either (a) an instance of the POJO where both "from" and "to" are not null or (b) nothing at all. If only one of them is specified, then @Valid should fail and report that the other is missing.


Solution

  • I came up with a solution with a custom HandlerMethodArgumentResolver, Jackson and Jackson Databind.

    The annotation:

    @Target(ElementType.PARAMETER)
    @Retention(RetentionPolicy.RUNTIME)
    public @interface RequestParamBind {
    }
    

    The resolver:

    public class RequestParamBindResolver implements HandlerMethodArgumentResolver {
    
        private final ObjectMapper mapper;
    
        public RequestParamBindResolver(ObjectMapper mapper) {
            this.mapper = mapper.copy();
            this.mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
        }
    
        @Override
        public boolean supportsParameter(MethodParameter parameter) {
            return parameter.getParameterAnnotation(RequestParamBind.class) != null;
        }
    
        @Override
        public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mav, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
            // take the first instance of each request parameter
            Map<String, String> requestParameters = webRequest.getParameterMap()
                    .entrySet().stream()
                    .collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue()[0]));
    
            // perform the actual resolution
            Object resolved = doResolveArgument(parameter, requestParameters);
    
            // *sigh*
            // see: https://stackoverflow.com/questions/18091936/spring-mvc-valid-validation-with-custom-handlermethodargumentresolver
            if (parameter.hasParameterAnnotation(Valid.class)) {
                String parameterName = Conventions.getVariableNameForParameter(parameter);
                WebDataBinder binder = binderFactory.createBinder(webRequest, resolved, parameterName);
                // DataBinder constructor unwraps Optional, so the target could be null
                if (binder.getTarget() != null) {
                    binder.validate();
                    BindingResult bindingResult = binder.getBindingResult();
                    if (bindingResult.getErrorCount() > 0)
                        throw new MethodArgumentNotValidException(parameter, bindingResult);
                }
            }
    
            return resolved;
        }
    
        private Object doResolveArgument(MethodParameter parameter, Map<String, String> requestParameters) {
            Class<?> clazz = parameter.getParameterType();
            if (clazz != Optional.class)
                return mapper.convertValue(requestParameters, clazz);
    
            // special case for Optional<T>
            Type type = parameter.getGenericParameterType();
            Class<?> optionalType = (Class<?>)((ParameterizedType)type).getActualTypeArguments()[0];
            Object obj = mapper.convertValue(requestParameters, optionalType);
    
            // convert back to a map to find if any fields were set
            // TODO: how can we tell null from not set?
            if (mapper.convertValue(obj, new TypeReference<Map<String, String>>() {})
                    .values().stream().anyMatch(Objects::nonNull))
                return Optional.of(obj);
    
            return Optional.empty();
        }
    }
    

    Then, we register it:

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        //...
    
        @Override
        public void addArgumentResolvers(
          List<HandlerMethodArgumentResolver> argumentResolvers) {
            argumentResolvers.add(new RequestParamBindResolver(new ObjectMapper()));
        }
    }
    

    Finally, we can use it like so:

    @GetMapping("/shop/{shopId}/slot")
    public Slice<Slot> getSlots(@RequestAttribute("staff") Staff staff,
                                @PathVariable("shopId") Long shopId,
                                @RequestParamBind @Valid Optional<TimeWindowModel> timeWindow) {
        // controller code
    }
    

    Which works exactly as you'd expect.

    I'm sure it's possible to accomplish this by using Spring's own DataBind classes in the resolver. However, Jackson Databind seemed like the most straight-forward solution. That said, it's not able to distinguish between fields that are set to null and fields that just not set. This is not really an issue for my use-case, but it's something that should be noted.