Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Spring deserializes a LocalDate in a @RequestBody differently from one in a @RequestParam - why, and can they be the same?

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

like image 883
drew Avatar asked Apr 28 '17 15:04

drew


People also ask

Can we use @RequestBody and @RequestParam together?

Can we use RequestBody and RequestParam together? nothing. So it fails with 400 because the request can't be correctly handled by the handler method.

What is difference between @RequestBody and @ResponseBody?

By using @RequestBody annotation you will get your values mapped with the model you created in your system for handling any specific call. While by using @ResponseBody you can send anything back to the place from where the request was generated. Both things will be mapped easily without writing any custom parser etc.

How does Spring RequestBody work?

Simply put, the @RequestBody annotation maps the HttpRequest body to a transfer or domain object, enabling automatic deserialization of the inbound HttpRequest body onto a Java object. Spring automatically deserializes the JSON into a Java type, assuming an appropriate one is specified.

Does spring @RequestBody support the GET method?

HTTP's GET method does not include a request body as part of the spec. Spring MVC respects the HTTP specs. Specifically, servers are allowed to discard the body.


2 Answers

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();
}
like image 113
Dormouse Avatar answered Sep 24 '22 09:09

Dormouse


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()));
    }
}
like image 40
drew Avatar answered Sep 22 '22 09:09

drew