1

I've refactored some legacy code within Spring Boot (2.1.2) system and migrated from java.util.Date to java.time based classes (jsr310). The system expects the dates in a ISO8601 formated string, whereas some are complete timestamps with time information (e.g. "2019-01-29T15:29:34+01:00") while others are only dates with offset (e.g. "2019-01-29+01:00"). Here is the DTO (as Kotlin data class):

data class Dto(
        // ...
        @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ssXXX")
        @JsonProperty("processingTimestamp")
        val processingTimestamp: OffsetDateTime,

        // ...
        @JsonFormat(shape = JsonFormat.Shape.STRING,  pattern = "yyyy-MM-ddXXX")
        @JsonProperty("orderDate")
        val orderDate: OffsetDateTime,
        // ...
)

While Jackson perfectly deserializes processingTimestamp, it fails with orderDate:

Caused by: java.time.DateTimeException: Unable to obtain OffsetDateTime from TemporalAccessor: {OffsetSeconds=32400},ISO resolved to 2018-10-23 of type java.time.format.Parsed
    at java.time.OffsetDateTime.from(OffsetDateTime.java:370) ~[na:1.8.0_152]
    at com.fasterxml.jackson.datatype.jsr310.deser.InstantDeserializer.deserialize(InstantDeserializer.java:207) ~[jackson-datatype-jsr310-2.9.8.jar:2.9.8]

This makes sense to me, since OffsetDateTime cannot find any time information necessary to construct the instant. If I change to val orderDate: LocalDate Jackson can successfully deserialize, but then the offset information is gone (which I need to convert to Instant later).

Question

My current workaround is to use OffsetDateTime, in combination with a custom deserializer (see below). But I'm wondering, if there is a better solution for this?

Also, I'd wish for a more appropriate data type like OffsetDate, but I cannot find it in java.time.

PS

I was asking myself if "2019-01-29+01:00" is a valid for ISO8601. However, since I found that java.time.DateTimeFormatter.ISO_DATE is can correctly parse it and I cannot change the format how the clients send data, I put aside this question.

Workaround

data class Dto(
        // ...
        @JsonFormat(shape = JsonFormat.Shape.STRING,  pattern = "yyyy-MM-ddXXX")
        @JsonProperty("catchDate")
        @JsonDeserialize(using = OffsetDateDeserializer::class)
        val orderDate: OffsetDateTime,
        // ...
)

class OffsetDateDeserializer(
        private val formatter: DateTimeFormatter = DateTimeFormatter.ISO_DATE
) : JSR310DateTimeDeserializerBase<OffsetDateTime>(OffsetDateTime::class.java, formatter) {


    override fun deserialize(parser: JsonParser, context: DeserializationContext): OffsetDateTime? {
        if (parser.hasToken(JsonToken.VALUE_STRING)) {
            val string = parser.text.trim()
            if (string.isEmpty()) {
                return null
            }

            val parsed: TemporalAccessor = formatter.parse(string)
            val offset = if(parsed.isSupported(ChronoField.OFFSET_SECONDS)) ZoneOffset.from(parsed) else ZoneOffset.UTC
            val localDate = LocalDate.from(parsed)
            return OffsetDateTime.of(localDate.atStartOfDay(), offset)
        }
        throw context.wrongTokenException(parser, _valueClass, parser.currentToken, "date with offset must be contained in string")
    }

    override fun withDateFormat(otherFormatter: DateTimeFormatter?): JsonDeserializer<OffsetDateTime> = OffsetDateDeserializer(formatter)
}
rene
  • 1,618
  • 21
  • 26
  • 2
    An `OffsetDate` class was considered for JSR-310, but it was pushed back as having minimal use cases. Using `OffsetDateTime` is your best option in the JDK. You can parse the format using `DateTimeFormatterBuilder` and `parseDefaulting`. – JodaStephen Jan 30 '19 at 09:33
  • @JodaStephen thank you so much for the clarification! I take your suggestion and create an answer for my question. – rene Jan 30 '19 at 14:05

1 Answers1

0

As @JodaStephen explained in the comments, OffsetDate was not included in java.time to have a minimal set of classes. So, OffsetDateTime is the best option.

He also suggested to use DateTimeFormatterBuilder and parseDefaulting to create a DateTimeFormatter instance, to directly create OffsetDateTime from the formatters parsing result (TemporalAccessor). AFAIK, I still need to create a custom deserializer to use the formatter. Here is code, which solved my problem:

class OffsetDateDeserializer: JsonDeserializer<OffsetDateTime>() {

    private val formatter = DateTimeFormatterBuilder()
            .append(DateTimeFormatter.ISO_DATE)
            .parseDefaulting(ChronoField.HOUR_OF_DAY, 0)
            .parseDefaulting(ChronoField.MINUTE_OF_HOUR, 0)
            .parseDefaulting(ChronoField.SECOND_OF_MINUTE, 0)
            .parseDefaulting(ChronoField.MILLI_OF_SECOND, 0)
            .parseDefaulting(ChronoField.OFFSET_SECONDS, 0)
            .toFormatter()


    override fun deserialize(parser: JsonParser, context: DeserializationContext): OffsetDateTime? {
        if (parser.hasToken(JsonToken.VALUE_STRING)) {
            val string = parser.text.trim()
            if (string.isEmpty()) {
                return null
            }
            try {
                return OffsetDateTime.from(formatter.parse(string))
            } catch (e: DateTimeException){
                throw context.wrongTokenException(parser, OffsetDateTime::class.java, parser.currentToken, "error while parsing date: ${e.message}")
            }
        }
        throw context.wrongTokenException(parser, OffsetDateTime::class.java, parser.currentToken, "date with offset must be contained in string")
    }
}

rene
  • 1,618
  • 21
  • 26