javajsondatetimejacksonoffsetdatetime

Why OffsetDateTime serializations/deserialization results have difference?


I have follows object:

@Validated
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@EqualsAndHashCode
@ToString
@Schema(description = "Request")
public final class Request implements Serializable {
    private static final long serialVersionUID = 1L;

    @JsonProperty("date")
    @Schema(description = "Date")
    private OffsetDateTime date;
}

And i send this object as rest-controller's response:

@RestController
public class RequestController {

    @RequestMapping(
        value = "/requests",
        produces = {"application/json;charset=UTF-8"}, 
        consumes = {"application/json"},
        method = RequestMethod.POST)
    public ResponseEntity<Request> get() {
        LocalDate date = LocalDate.of(2021, Month.OCTOBER, 22);
        OffsetDateTime dateTime = date.atTime(OffsetTime.MAX);
        Request request = new Request(dateTime);
        return ResponseEntity.ok(request);
    }
}

Yet i have configuration:

@Configuration
public class WebConfiguration implements ServletContextInitializer, WebMvcConfigurer {

    private final List<FilterRegistration> filterRegistrations;
    private final ApplicationContext applicationContext;

    public WebConfiguration(List<RestApplicationInstaller> restApplicationInstallers,
                            List<MonitoringRestApplicationInstaller> monitoringRestApplicationInstallers,
                            List<FilterRegistration> filterRegistrations,
                            ApplicationContext applicationContext) {
        this.filterRegistrations = filterRegistrations;
        this.applicationContext = applicationContext;
    }

    @Override
    public void onStartup(ServletContext servletContext) {
        VersionServletInstaller.installServlets(servletContext, getRegisterAsyncService(servletContext));
        filterRegistrations.forEach(filterRegistration -> filterRegistration.onApplicationEvent(new ContextRefreshedEvent(applicationContext)));
    }

    private RegisterAsyncService getRegisterAsyncService(final ServletContext servletContext) {
        final WebApplicationContext ctx = getWebApplicationContext(servletContext);
        final RegisterAsyncService registerAsyncService = Objects.requireNonNull(ctx).getBean(RegisterAsyncService.class);
        registerAsyncService.exec();
        return registerAsyncService;
    }

    @Bean
    public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer(CustomAnnotationIntrospector customAnnotationIntrospector) {
        return builder -> builder.serializationInclusion(NON_NULL)
            .annotationIntrospector(customAnnotationIntrospector);
    }
}

Ok. So... I get the date field in response as:

2021-10-21T23:59:59.999999999-18:00

When i test my controller, i try to get response, deserialize it to Request object and check matching:

@DirtiesContext
@SpringBootTest(
    classes = {WebConfiguration.class, JacksonAutoConfiguration.class},
    webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@ExtendWith(SpringExtension.class)
@EnableWebMvc
class RequestControllerTest {

    private static final CharacterEncodingFilter 
    CHARACTER_ENCODING_FILTER = new CharacterEncodingFilter();

    static {
        CHARACTER_ENCODING_FILTER.setEncoding(DEFAULT_ENCODING);
        CHARACTER_ENCODING_FILTER.setForceEncoding(true);
    }

    protected MockMvc mockMvc;

    @Autowired
    protected ObjectMapper objectMapper;

    @Autowired
    private WebApplicationContext context;

    @PostConstruct
    private void postConstruct() {
        this.mockMvc =
            MockMvcBuilders
                .webAppContextSetup(this.context)
                .addFilters(CHARACTER_ENCODING_FILTER)
                .build();
    }

    @Test
    void requestByIdTest() throws Exception {
        mockMvc.perform(
            MockMvcRequestBuilders.post("/requests")
                .characterEncoding(CHARACTER_ENCODING_FILTER)
                .contentType(MediaType.APPLICATION_JSON))
            .andDo(print())
            .andExpect(status().isOk())
            .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
            .andExpect(
                result -> Assertions.assertEquals(mapToObject(result.getResponse().getContentAsString(Charset.forName(CHARACTER_ENCODING_FILTER)), Request.class), getExpectedRequest()));
    }

    private WebComplianceRequest getExpectedRequest() {
        LocalDate date = LocalDate.of(2021, Month.OCTOBER, 22);
        OffsetDateTime dateTime = date.atTime(OffsetTime.MAX);
        Request request = new Request(dateTime);
    }

    private <T> T mapToObject(String json, Class<T> targetClass) {
        try {
            return getReaderForClass(targetClass).readValue(json);
        } catch (IOException e) {
            throw new RuntimeExsception(e);
        }
    }

    private <T> ObjectReader getReaderForClass(Class<T> targetClass) {
        return objectMapper.readerFor(targetClass);
    }
}

But i get a exception, because date field in expected object and in got object are differ:

Date in response: 2021-10-22T17:59:59.999999999Z
Expected date:    2021-10-21T23:59:59.999999999-18:00

Why did this happen?

Why does the Z appear instead of time zone? Why is the date changed from 2021-10-21 to 2021-10-22? And how would i can fix it?

I do not get any exception, I get matching failed because dates differ when I match response and expected objects. I just deserialize object with standard ObjectMapper and check objects matching with equals().


Solution

  • What we know so far

    1. You are creating an OffsetDateTime from a LocalDate adding the maximum offset available (which happens to be -18:00 hours)
    2. this OffsetDateTime gets correctly serialized to a JSON value of 2021-10-21T23:59:59.999999999-18:00
    3. when deserialized, the value (as String) is 2021-10-22T17:59:59.999999999Z

    The critical part is not included so far: What happens between 2. and 3.?
    Please consider updating your question with everything you know about it.

    What we can derive

    The values that appear incongruent are basically the same moment in time (Instant), but represented at an offset of -18:00 at serialization and represented in UTC (+00:00 or simply Z). Due to a difference of 18 hours between those moments and due to the fact you created an OffsetDateTime with OffsetTime.MAX (which is 23:59:59.999999999-18:00, the maximum time of day at an offset of -18:00).

    That's why the result you get after deserialization is not wrong, but its representation may not be the desired one.

    My guess is that an Instant is used at the sub-steps between 2. and 3. and the deserialization simply provides date and time in UTC only.

    I wouldn't pass any time with a maximum offset to any API if it is not explicitly required. Is it in your situation? Consider adding information about that, too.

    What we can do to make the time Strings equal

    You can use a different possibility of creating the OffsetDateTime from the LocalDate, that is using the maximum time of day without an offset explicitly at UTC:

    OffsetDateTime dateTime = OffsetDateTime.of(date, LocalTime.MAX, ZoneOffset.UTC);
    

    This would serialize to 2021-10-21T23:59:59.999999999Z, you could also represent it as 2021-10-21T23:59:59.999999999+00:00 or similar (I would stick to Z) and deserialization should return the same value.


    In case you receive a String representation in UTC and you don't have any influence on it, you will have to parse it and change the representation by applying the minimum offset (-18:00), maybe like this:

    String atMinOffset = OffsetDateTime.parse("2021-10-22T17:59:59.999999999Z")
                                       .withOffsetSameInstant(ZoneOffset.MIN)
                                       .format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
    System.out.println(atMinOffset);
    

    Output:

    2021-10-21T23:59:59.999999999-18:00
    

    In case you get an OffsetDateTime as a response and just want to check if it is the same point in time, consider this:

    public static void main(String[] args) throws IOException {
        OffsetDateTime utcOdt = OffsetDateTime.parse("2021-10-22T17:59:59.999999999Z");
        OffsetDateTime minOffsetOdt = OffsetDateTime.parse("2021-10-21T23:59:59.999999999-18:00");
        
        System.out.println("OffsetDateTimes equal? --> " + utcOdt.equals(minOffsetOdt));
        System.out.println("Instants equal? --> " + utcOdt.toInstant().equals(minOffsetOdt.toInstant()));
    }
    

    It's output is

    OffsetDateTimes equal? --> false
    Instants equal? --> true
    

    Why?
    An OffsetDateTime is a representation of a moment in time while an Instant actually is that moment in time.
    That means you should compare the real moment in time instead of context-based representations of it.