spring-boot

Spring 3 using object mapper to convert value(deserialize) from List<Map<String,Object> to Some DTO throws error for Instant field


I have a code which was working fine with Spring boot 2 but when i moved it to spring 3, I get an error message saying

java.lang.IllegalArgumentException: Cannot deserialize value of type `java.time.Instant` from Object value (token `JsonToken.START_OBJECT`) at [Source: UNKNOWN; byte offset: #UNKNOWN] (through reference chain: java.util.ArrayList[0]->com.oxane.argon.kpi.KpiFieldDTO["lastModifiedDate"])

I have a query which was quite complex and uses many joins and CTE after that I fetch limited data from a POJO table. So I use NamedParameterJdbcTemplate to fetch list of result after that I convert List<Map<String,Object>> to my DTO using object mapper, which was working fine before spring 2 now I receive the above error. Data for Instant field is stored as Datetimeoffset in DB because I haven't added any line for backward compatibility.

My DTO looks like this

public class DTO {

private String loanId;

private String obligor;

private LocalDate fsDateLatest;

private LocalDate lastUpdate;

private Instant createdDate;

private Instant lastModifiedDate;

private Integer investmentType;

private LocalDate maturityDate;

private String reportingCurrency;

private BigDecimal debtWeightedSpread1L;

private Integer lastModifiedBy;

private String lastModifiedByEmail;
//getters and setters and constructors
}

Code to convert data

List<Map<String, Object>> kpiByObligorAndFs;
final List<DTO> findAllByObligorAndFsDateLessThanEqual = objectMapper.convertValue(kpiByObligorAndFs, new TypeReference<List<DTO>>() {
    });

One of the Map from list

{loanId=ID000470, obligor=XYZ, fsDateLatest=2023-12-31, lastUpdate=2024-03-10, lastModifiedDate=2024-05-09 15:46:30.199154 +00:00, createdDate=2024-03-27 04:43:23.046667 +00:00, investmentType=160, maturityDate=2028-05-26, reportingCurrency=USD, debtWeightedSpread1L=0.0598180100, lastModifiedBy=1303}

Solution

  • So after debugging for many hours found the solution. ObjectMapper is unable to deserialize a datetimeoffset value to Instant in Spring Boot 3, it's likely due to the format of the datetimeoffset string not being directly compatible with Jackson's default deserialization for Instant.

    MS SQL is using their custom class microsoft.sql.DateTimeOffset for Instant with no proper deserialization from DateTimeOffset at-least while using custom object mapper. JPA on its own does this perfectly but not when you use object mapper.

    So, when object mapper tries to convert DateTimeOffset to Instant it first calls the Serailizer of DateTimeOffset. Which by default gives serializes to this string value

    {"minutesOffset":0,"timestamp":"2020-03-12T14:16:55.300+00:00","offsetDateTime":"2020-03-12T14:16:55.3Z"}
    

    hence you get the start object error, if you do jsonParser.getTextValue() you will get "{" and if you use jsonParser.readValueAsTree() you will get above Json.

    So, since it calls DateTimeOffset Serializer first, I need to override it.

    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import microsoft.sql.DateTimeOffset;
    
    public class DateTimeOffsetSerializer extends JsonSerializer<DateTimeOffset> {
    
       @Override
       public void serialize(DateTimeOffset value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
           gen.writeString(value.getTimestamp().toInstant().toString());
       }
    }
    

    But now issue is, in your pojo you have Instant field, so you can not use @JsonSerialize annotation. You have to register object mapper and add this serializer as default for whole application.

    Code for ObjectMapper bean...

    @Bean(name = "objectMapper")
    public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
        final JavaTimeModule javaTimeModule = new JavaTimeModule();
        SimpleModule module = new SimpleModule();
        module.addSerializer(DateTimeOffset.class, new DateTimeOffsetSerializer());
        return builder.createXmlMapper(false).build().setSerializationInclusion(Include.NON_NULL)
                .configure(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature(), true)
                .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false).registerModule(javaTimeModule)
                .registerModule(module);
    }
    

    I'm currently testing if this objectmapper going to create any issue in older cases. Will post in case of any bug.