11

QUESTION: Spring appears to use different deserialization methods for LocalDate depending on whether it appears in a @RequestBody or a request @ReqestParam - is this correct, and if so, is there a way to configure them to be the same throughout an application?

BACKGROUND: In my @RestController, I have two methods - one GET, and one POST. The GET expects a request parameter ("date") that is of type LocalDate; the POST expects a JSON object in which one key ("date") is of type LocalDate. Their signatures are similar to the following:

@RequestMapping(value = "/entity", method = RequestMethod.GET)
public EntityResponse get(
       Principal principal,
       @RequestParam(name = "date", required = false) LocalDate date) 

@RequestMapping(value = "/entity", method = RequestMethod.POST)
public EntityResponse post(
       Principal principal,
       @RequestBody EntityPost entityPost)

public class EntityPost {
       public LocalDate date;
}

I've configured my ObjectMapper as follows:

@Bean
public ObjectMapper objectMapper() {

   ObjectMapper objectMapper = new ObjectMapper();
   objectMapper.enable(SerializationFeature.INDENT_OUTPUT);
   objectMapper.registerModule(new JavaTimeModule());
   objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

   return objectMapper;
}

Which ensures the system accepts LocalDate in the format yyyy-MM-dd and deserializes it as expected - at least when it is part of a @RequestBody. Thus if the following is the request body for the POST

{
"date": 2017-01-01
}

The system deserializes the request body into an EntityPost as expected.

However, that configuration does not apply to the deserialization of the @RequestParam. As a result, this fails:

// fail!
/entity?date=2017-01-01

Instead, the system appears to expect the format MM/dd/yy. As a result, this succeeds:

// success!
/entity?date=01/01/17

I know I can change this on a parameter-by-parameter basis using the @DateTimeFormat annotation. I know that if I change the signature of the GET method as follows, it will accept the first format:

@RequestMapping(value = "/entity", method = RequestMethod.GET)
public EntityResponse get(
       Principal principal,
       @RequestParam(name = "date", required = false) @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate date) 

However, I would prefer if I didn't have to include an annotation for every usage of LocalDate. Is there any way to set this globally, so that the system deserializes every @RequestParam of type LocalDate in the same way?

For reference:

I'm using Spring 4.3.2.RELEASE

I'm using Jackson 2.6.5

drew
  • 2,949
  • 3
  • 25
  • 27
  • 1
    I believe you need to define a global `@ControllerAdvice`. See [Setting default DateTimeFormat Annotation in Spring](http://stackoverflow.com/q/40644368/5221149) – Andreas Apr 28 '17 at 16:09
  • 1
    Are you using spring boot? – notionquest Apr 28 '17 at 18:18
  • No, not using spring boot. – drew Apr 28 '17 at 19:03
  • 2
    The body gets parsed by Jackson the param does not, that's why it is different. Usually you can just register a custom converter with the ConversionService which does the conversion you want it to do. - Also the link from @Andreas seems to be what you want. – dav1d Apr 28 '17 at 20:00
  • What system is used to deserialize the request params? – drew Apr 28 '17 at 20:19
  • 1
    *"What system ...?"* Spring Framework. `@RequestParam` processing is all Spring. – Andreas Apr 28 '17 at 23:23
  • Add `@DateTimeFormat(pattern="yyyy-MM-dd")` to your `@RequestParam` annotated argument. Else configure the formatter/converter to (also) accept that date format. – M. Deinum Apr 29 '17 at 09:10
  • @Andreas - what part of the Spring Framework, I mean. Any component in particular? – drew May 01 '17 at 15:29
  • @Andreas - I should say, I now understand that I need to configure WebDataBinder, but beyond that, is there any more information on the Spring Framework subsystem that handles deserialization of request parameters? – drew May 01 '17 at 15:46
  • 1
    It is part of `spring-web-XXX.jar`. So I guess you could call that the "Web" subsystem. It is mainly documented in the "[Web MVC framework](https://docs.spring.io/spring/docs/current/spring-framework-reference/html/mvc.html)" chapter of the "[Spring Framework Reference Documentation](https://docs.spring.io/spring/docs/current/spring-framework-reference/html/index.html)" guide, so perhaps the "Web MVC" subsystem is the answer you're looking for. – Andreas May 01 '17 at 16:15
  • @Andreas - That gives me what I need to know. Thanks! – drew May 01 '17 at 18:42

3 Answers3

7

Per @Andreas in comments, the Spring Framework uses Jackson to deserialize @RequestBody but Spring itself deserializes @RequestParam. This is the source of the difference between the two.

This answer shows how to use @ControllerAdvice and @InitBinder to customize the deserialization of @RequestParam. The code I ultimately used follows:

import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.InitBinder;

import java.beans.PropertyEditorSupport;
import java.text.Format;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.function.Function;

@ControllerAdvice
public class ControllerAdviceInitBinder {

    private static class Editor<T> extends PropertyEditorSupport {

        private final Function<String, T> parser;
        private final Format format;

        public Editor(Function<String, T> parser, Format format) {

            this.parser = parser;
            this.format = format;
        }

        public void setAsText(String text) {

            setValue(this.parser.apply(text));
        }

        public String getAsText() {

            return format.format((T) getValue());
        }
    }

    @InitBinder
    public void initBinder(WebDataBinder webDataBinder) {

        webDataBinder.registerCustomEditor(
                Instant.class,
                new Editor<>(
                        Instant::parse,
                        DateTimeFormatter.ISO_INSTANT.toFormat()));

        webDataBinder.registerCustomEditor(
                LocalDate.class,
                new Editor<>(
                        text -> LocalDate.parse(text, DateTimeFormatter.ISO_LOCAL_DATE),
                        DateTimeFormatter.ISO_LOCAL_DATE.toFormat()));

        webDataBinder.registerCustomEditor(
                LocalDateTime.class,
                new Editor<>(
                        text -> LocalDateTime.parse(text, DateTimeFormatter.ISO_LOCAL_DATE_TIME),
                        DateTimeFormatter.ISO_LOCAL_DATE_TIME.toFormat()));

        webDataBinder.registerCustomEditor(
                LocalTime.class,
                new Editor<>(
                        text -> LocalTime.parse(text, DateTimeFormatter.ISO_LOCAL_TIME),
                        DateTimeFormatter.ISO_LOCAL_TIME.toFormat()));

        webDataBinder.registerCustomEditor(
                OffsetDateTime.class,
                new Editor<>(
                        text -> OffsetDateTime.parse(text, DateTimeFormatter.ISO_OFFSET_DATE_TIME),
                        DateTimeFormatter.ISO_OFFSET_DATE_TIME.toFormat()));

        webDataBinder.registerCustomEditor(
                OffsetTime.class,
                new Editor<>(
                        text -> OffsetTime.parse(text, DateTimeFormatter.ISO_OFFSET_TIME),
                        DateTimeFormatter.ISO_OFFSET_TIME.toFormat()));

        webDataBinder.registerCustomEditor(
                ZonedDateTime.class,
                new Editor<>(
                        text -> ZonedDateTime.parse(text, DateTimeFormatter.ISO_ZONED_DATE_TIME),
                        DateTimeFormatter.ISO_ZONED_DATE_TIME.toFormat()));
    }
}
Community
  • 1
  • 1
drew
  • 2,949
  • 3
  • 25
  • 27
  • This solution does not work with `@RequestParam(required = false)` Optional date`. For a solution that can also deal with these, see this answer: https://stackoverflow.com/a/45453492/5391954 – britter Aug 02 '17 at 06:56
6

Create a Formatter for LocalDate:

public class LocalDateFormatter implements Formatter<LocalDate> {

    @Override
    public LocalDate parse(String text, Locale locale) throws ParseException {
        return LocalDate.parse(text, DateTimeFormatter.ISO_DATE);
    }

    @Override
    public String print(LocalDate object, Locale locale) {
        return DateTimeFormatter.ISO_DATE.format(object);
    }
}

Spring 5+: Register the formatter: Implement WebMvcConfigurer in your @Configuration and override addFormatters:

@Override
public void addFormatters(FormatterRegistry registry) {
    registry.addFormatter(new LocalDateFormatter());
}

Spring Boot: Define a @Primary @Bean to override the default formatter:

@Bean
@Primary
public Formatter<LocalDate> localDateFormatter() {
    return new LocalDateFormatter();
}
Dormouse
  • 1,617
  • 1
  • 23
  • 33
  • 1
    The Spring 5+ solution worked fine for me in Spring Boot (2.1.9) environment but I could not get the "Spring Boot" option to work. Still, this is by far the easiest solution that I found after hours of testing different variations and configurations. – Anders Bergquist Nov 05 '19 at 11:31
  • It should work, as it is provided by [auto configuration](https://docs.spring.io/spring-boot/docs/2.1.9.RELEASE/reference/htmlsingle/#boot-features-spring-mvc-auto-configuration). – Dormouse Nov 05 '19 at 19:51
0

for springboot 2.1.6

 @Bean
public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
    return builder -> {
        DateTimeFormatter localDateFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd");
        builder.serializerByType(LocalDate.class, new LocalDateSerializer(localDateFormatter));
        builder.deserializerByType(LocalDate.class, new LocalDateDeserializer(localDateFormatter));

        DateTimeFormatter localDateTimeFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss");
        builder.serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(localDateTimeFormatter));
        builder.deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(localDateTimeFormatter));
    };
}
chen kqing
  • 29
  • 5