Jackson deserialize elasticsearch long as LocalDateTime with Java 8

ghz 1years ago ⋅ 8591 views

Question

We have a date field being populated with a long in elasticsearch index.

The field mapping is:

@Field(type = FieldType.Date)
@JsonFormat(shape = JsonFormat.Shape.NUMBER_INT)
private LocalDateTime created;

And I use Jackson JavaTimeModule and Jdk8Module with this configuration:

@Bean
public ElasticsearchOperations elasticsearchTemplate() {
   return new ElasticsearchRestTemplate(client(), new CustomEntityMapper());
}

public static class CustomEntityMapper implements EntityMapper {

        private final ObjectMapper objectMapper;

        public CustomEntityMapper() {
            //we use this so that Elasticsearch understands LocalDate and LocalDateTime objects
            objectMapper = new ObjectMapper()
                              .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
                              .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true)
                              .configure(DeserializationFeature.READ_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
                              .configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
                              .configure(SerializationFeature.WRITE_DATE_TIMESTAMPS_AS_NANOSECONDS, false)
                              //MUST be registered BEFORE calling findAndRegisterModules
                              .registerModule(new JavaTimeModule())
                              .registerModule(new Jdk8Module());
            //only autodetect fields and ignore getters and setters for nonexistent fields when serializing/deserializing
            objectMapper.setVisibility(objectMapper.getSerializationConfig().getDefaultVisibilityChecker()
                            .withFieldVisibility(JsonAutoDetect.Visibility.ANY)
                            .withGetterVisibility(JsonAutoDetect.Visibility.NONE)
                            .withSetterVisibility(JsonAutoDetect.Visibility.NONE)
                            .withCreatorVisibility(JsonAutoDetect.Visibility.NONE));
            //load the other available modules as well
            objectMapper.findAndRegisterModules();
        }

        @Override
        public String mapToString(Object object) throws IOException {
            return objectMapper.writeValueAsString(object);
        }

        @Override
        public <T> T mapToObject(String source, Class<T> clazz) throws IOException {
            return objectMapper.readValue(source, clazz);
        }
}

But when I try to parse an entity in the index with a field such as:

"created" : 1563448935000

I get an error:

com.fasterxml.jackson.databind.exc.MismatchedInputException: Unexpected token (VALUE_NUMBER_INT), expected VALUE_STRING: Expected array or string.

I think, it is possible to deserialize a long to a date, but I don't see what I am missing.

If I map it to Long it works of course and same if the value is stored as String and we shape it and format properly in @JsonFormat. But is it possible to have long->LocalDateTime as well?


Answer

To build LocalDateTime from milliseconds from the epoch of 1970-01-01T00:00:00Z we need a time zone. In version [2.9.9](https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson- databind/2.9.9) it throws exception when milliseconds appears:

raw timestamp (1563448935000) not allowed for java.time.LocalDateTime: need additional information such as an offset or time-zone (see class Javadocs)

But we can implement our deserialiser which will try to do this with default time zone. Example implementation could look like below:

class MillisOrLocalDateTimeDeserializer extends LocalDateTimeDeserializer {

    public MillisOrLocalDateTimeDeserializer() {
        super(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
    }

    @Override
    public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
        if (parser.hasToken(JsonToken.VALUE_NUMBER_INT)) {
            long value = parser.getValueAsLong();
            Instant instant = Instant.ofEpochMilli(value);

            return LocalDateTime.ofInstant(instant, ZoneOffset.UTC);
        }

        return super.deserialize(parser, context);
    }

}

ZoneOffset.UTC is used. In your case you can provide yours or use system default. Example usage:

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;

import java.io.IOException;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class JsonApp {

    public static void main(String[] args) throws Exception {
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        // override default
        javaTimeModule.addDeserializer(LocalDateTime.class, new MillisOrLocalDateTimeDeserializer());

        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(javaTimeModule);

        String json = "{\"created\":1563448935000}";
        System.out.println(mapper.readValue(json, Created.class));

    }
}

class Created {

    private LocalDateTime created;

    // getters, setters, toString
}

Above code prints:

Created{created=2019-07-18T11:22:15}

EDIT: Using Jackson 2.9.0, because of this issue the code provided will not be invoked since findAndRegisterModules which is called AFTER registering the customized module will override it. Removing that call will make the full scenario work. If above will not work for your version, you need to debug default implementation and find a reason.