Search code examples
javajsonserializationjacksondeserialization

Serialize/de-serialize java duration to JSON with custom hh:mm format with Jackson


Description

I'm new to Java AND Jackson and I try to save a java.time.duration to a JSON in a nice and readable hh:mm (hours:minutes) format for storing and retrieving.

In my project I use:

  • Jackson com.fasterxml.jackson.core:jackson-databind:2.14.1.
  • Jackson com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.14.1 for the support of the newer Java 8 time/date classes.

Minimum working example:

Consider following example class:

public class Book {

    private Duration timeToComplete;

    public Book(Duration durationToComplete) {
        this.timeToComplete = durationToComplete;
    }

    // default constructor + getter & setter
}

If I try to serialize a book instance into JSON like in the following code section

public class JavaToJson throws JsonProcessingException {

    public static void main(String[] args) {
        
        // create the instance of Book, duration 01h:11min
        LocalTime startTime = LocalTime.of(13,30);
        LocalTime endTime = LocalTime.of(14,41);
        Book firstBook = new Book(Duration.between(startTime, endTime));

        // create the mapper, add the java8 time support module and enable pretty parsing
        ObjectMapper objectMapper = JsonMapper.builder()
                .addModule(new JavaTimeModule())
                .build()
                .enable(SerializationFeature.INDENT_OUTPUT);

        // serialize and print to console
        System.out.println(objectMapper.writeValueAsString(firstBook));
    }

}

it gives me the duration in seconds instead of 01:11.

{
  "timeToComplete" : 4740.000000000
}

How would I change the JSON output into a hh:mm format?

What I tried until now

I thought about adding a custom Serializer/Deserializer (potentially a DurationSerializer?) during instantiation of the ObjectMapper but it seems I can't make the formatting work...

ObjectMapper objectMapper = JsonMapper.builder()
                .addModule(new JavaTimeModule())

                // add the custom serializer for the duration
                .addModule(new SimpleModule().addSerializer(new DurationSerializer(){
                    
                    @Override
                    protected DurationSerializer withFormat(Boolean useTimestamp, DateTimeFormatter dtf, JsonFormat.Shape shape) {
                    // here I try to change the formatting
                    DateTimeFormatter dtf = DateTimeFormatter.ofPattern("HH:mm");
                        return super.withFormat(useTimestamp, dtf, shape);
                    }
                }))
                .build()
                .enable(SerializationFeature.INDENT_OUTPUT);

All it does is change it to this strange textual representation of the Duration:

{
  "timeToComplete" : "PT1H11M"
}

So it seems I'm not completely off but the formatting is still not there. Maybe someone can help with the serializing/de-serializing?

Thanks a lot


Solution

  • hh:mm format is not supported by Jackson since Java does not recognise it by default. We need to customise serialisation/deserialisation mechanism and provide custom implementation.

    Take a look at:

    Using some examples from linked articles I have created custom serialiser and deserialiser. They do not handle all possible cases but should do the trick for your requirements:

    import com.fasterxml.jackson.annotation.JsonFormat;
    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.core.JsonParser;
    import com.fasterxml.jackson.core.JsonProcessingException;
    import com.fasterxml.jackson.databind.BeanProperty;
    import com.fasterxml.jackson.databind.DeserializationContext;
    import com.fasterxml.jackson.databind.JsonDeserializer;
    import com.fasterxml.jackson.databind.JsonMappingException;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.ObjectMapper;
    import com.fasterxml.jackson.databind.SerializationFeature;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import com.fasterxml.jackson.databind.json.JsonMapper;
    import com.fasterxml.jackson.databind.module.SimpleModule;
    import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
    import com.fasterxml.jackson.datatype.jsr310.deser.DurationDeserializer;
    import com.fasterxml.jackson.datatype.jsr310.ser.DurationSerializer;
    import lombok.AllArgsConstructor;
    import lombok.Data;
    import lombok.NoArgsConstructor;
    import org.apache.commons.lang3.StringUtils;
    import org.apache.commons.lang3.time.DurationFormatUtils;
    
    import java.io.IOException;
    import java.time.Duration;
    import java.time.LocalTime;
    import java.util.Objects;
    import java.util.concurrent.TimeUnit;
    
    public class DurationApp {
        public static void main(String[] args) throws JsonProcessingException {
            LocalTime startTime = LocalTime.of(13, 30);
            LocalTime endTime = LocalTime.of(14, 41);
    
            Book firstBook = new Book(Duration.between(startTime, endTime));
    
            // create the mapper, add the java8 time support module and enable pretty parsing
            ObjectMapper objectMapper = JsonMapper.builder()
                    .addModule(new JavaTimeModule())
                    .addModule(new SimpleModule()
                            .addSerializer(Duration.class, new ApacheDurationSerializer())
                            .addDeserializer(Duration.class, new ApacheDurationDeserializer()))
                    .build()
                    .enable(SerializationFeature.INDENT_OUTPUT);
    
            String json = objectMapper.writeValueAsString(firstBook);
            System.out.println(json);
            Book deserialisedBook = objectMapper.readValue(json, Book.class);
            System.out.println(deserialisedBook);
        }
    }
    
    @Data
    @NoArgsConstructor
    @AllArgsConstructor
    class Book {
        @JsonFormat(pattern = "HH:mm")
        private Duration duration;
    }
    
    
    class ApacheDurationSerializer extends DurationSerializer {
    
        private final String apachePattern;
    
        public ApacheDurationSerializer() {
            this(null);
        }
    
        ApacheDurationSerializer(String apachePattern) {
            this.apachePattern = apachePattern;
        }
    
        @Override
        public void serialize(Duration duration, JsonGenerator generator, SerializerProvider provider) throws IOException {
            if (Objects.nonNull(apachePattern) && Objects.nonNull(duration)) {
                String value = DurationFormatUtils.formatDuration(duration.toMillis(), apachePattern);
    
                generator.writeString(value);
            } else {
                super.serialize(duration, generator, provider);
            }
        }
    
        @Override
        public JsonSerializer<?> createContextual(SerializerProvider prov, BeanProperty property) throws JsonMappingException {
            JsonFormat.Value format = findFormatOverrides(prov, property, handledType());
            if (format != null && format.hasPattern() && isApacheDurationPattern(format.getPattern())) {
                return new ApacheDurationSerializer(format.getPattern());
            }
    
            return super.createContextual(prov, property);
        }
    
        private boolean isApacheDurationPattern(String pattern) {
            try {
                DurationFormatUtils.formatDuration(Duration.ofDays(1).toMillis(), pattern);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    }
    
    class ApacheDurationDeserializer extends DurationDeserializer {
        private final String apachePattern;
        private final int numberOfColonsInPattern;
    
        public ApacheDurationDeserializer() {
            this(null);
        }
    
        ApacheDurationDeserializer(String apachePattern) {
            this.apachePattern = apachePattern;
            this.numberOfColonsInPattern = countColons(apachePattern);
        }
    
        @Override
        public Duration deserialize(JsonParser parser, DeserializationContext context) throws IOException {
            if (Objects.nonNull(apachePattern)) {
                String value = parser.getText();
                if (this.numberOfColonsInPattern != countColons(value)) {
                    throw new JsonMappingException(parser, String.format("Pattern '%s' does not match value '%s'!", apachePattern, value));
                }
                if (numberOfColonsInPattern == 0) {
                    return Duration.ofSeconds(Long.parseLong(value.trim()));
                }
                String[] parts = value.trim().split(":");
                return switch (parts.length) {
                    case 1 -> Duration.ofSeconds(Long.parseLong(value.trim()));
                    case 2 -> Duration.ofSeconds(TimeUnit.HOURS.toSeconds(Long.parseLong(parts[0]))
                            + TimeUnit.MINUTES.toSeconds(Long.parseLong(parts[1])));
                    case 3 -> Duration.ofSeconds(TimeUnit.HOURS.toSeconds(Long.parseLong(parts[0]))
                            + TimeUnit.MINUTES.toSeconds(Long.parseLong(parts[1]))
                            + Long.parseLong(parts[2]));
                    default ->
                            throw new JsonMappingException(parser, String.format("Pattern '%s' does not match value '%s'!", apachePattern, value));
                };
            } else {
                return super.deserialize(parser, context);
            }
        }
    
        @Override
        public Duration deserialize(JsonParser p, DeserializationContext ctxt, Duration intoValue) throws IOException {
            return super.deserialize(p, ctxt, intoValue);
        }
    
        @Override
        public JsonDeserializer<?> createContextual(DeserializationContext ctxt, BeanProperty property) throws JsonMappingException {
            JsonFormat.Value format = findFormatOverrides(ctxt, property, handledType());
            if (format != null && format.hasPattern() && isApacheDurationPattern(format.getPattern())) {
                return new ApacheDurationDeserializer(format.getPattern());
            }
    
            return super.createContextual(ctxt, property);
        }
    
        private boolean isApacheDurationPattern(String pattern) {
            try {
                DurationFormatUtils.formatDuration(Duration.ofDays(1).toMillis(), pattern);
                return true;
            } catch (Exception e) {
                return false;
            }
        }
    
        private static int countColons(String apachePattern) {
            return StringUtils.countMatches(apachePattern, ':');
        }
    }
    

    Above code prints:

    {
      "duration" : "01:11"
    }
    Book(duration=PT1H11M)