'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 1:[1]

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.

Solution 2:[2]

To implement logic (a) both not null or (b) both are nulls you need to implement custom validation. Examples are here:

https://blog.clairvoyantsoft.com/spring-boot-creating-a-custom-annotation-for-validation-edafbf9a97a4

https://www.baeldung.com/spring-mvc-custom-validator

Generally, you create a new annotation, it's just a stub, and then you create a validator which implements ConstraintValidator where you provide your logic and then you put your new annotation to your POJO.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 FailedShack
Solution 2 Vladimir.V.Bvn