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

Time:01-09

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

CodePudding user response:

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)
  • Related