2

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

  • Jackson by default tries to serialize dates, times, etc. as timestamps, even if you have registered the `JavaTimeModule` or added other similar configuration. You need to explicitly disable that. In this case, you need to disable SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS, but I suggest to also disable SerializationFeature.WRITE_DATES_AS_TIMESTAMPS. – Rob Spoor Jan 07 '23 at 12:17
  • @citizen_code what result do you expect when duration is negative or exceeds 24 hours? – Andrey B. Panfilov Jan 07 '23 at 12:22
  • Good points. @RobSpoor disabeling the TIMESTAMPS feature just left me with the default textual representation `"PT1H11M"`. @Andrey as negative durations make no sense in this context only positive durations should be allowed. Durations above 1 day should be still represented as hh:mm, but I guess that isn't how DateTimeFormatter is able to function?! – citizen_code Jan 07 '23 at 17:21

1 Answers1

2

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)
Michał Ziober
  • 37,175
  • 18
  • 99
  • 146
  • 1
    Thanks, that looks beautiful. I'm super new to Java and Jackson, can you confirm my understanding that this solution catches two possibilities 1) Default duration formatting if no Jackson Annotation is used or falsely formatted apacheDurationPattern is provided in the Annotation `@JsonFormat(pattern = "falsePattern")` 2) `ApacheDurationSerializer(String apachePattern)` gets its String from the `@JsonFormat` Annotation. Is there also a way for this solution without using Jackson Annotations? – citizen_code Jan 07 '23 at 17:06
  • 1
    @citizen_code, you understand it properly. I wanted to keep default implementation. You can just hardcode pattern in serialiser and deserialiser and skip annotation. It just allows to provide different patterns for different fields. If in your case it always be the same pattern you can simplify the code. – Michał Ziober Jan 08 '23 at 00:14
  • 1
    Thanks for the confirmation and help! – citizen_code Jan 09 '23 at 09:27