Our Rest API takes JSON input from several external parties. They all use "ISO-ish" formats, but the formatting of the time zone offset is slightly different. These are some of the most common formats we see:
2018-01-01T15:56:31.410Z
2018-01-01T15:56:31.41Z
2018-01-01T15:56:31Z
2018-01-01T15:56:31+00:00
2018-01-01T15:56:31+0000
2018-01-01T15:56:31+00
Our stack is Spring Boot 2.0 with Jackson ObjectMapper. In our data classes we use the type java.time.OffsetDateTime
a lot.
Several developers have tried to achieve a solution that parses all of the above formats, none have been successful. Particularly the fourth variant with a colon (00:00
) seems to be unparseable.
It would be great if the solution works without having to place an annotation on each and every date/time field of our models.
Dear community, do you have a solution?
One alternative is to create a custom deserializer. First you annotate the respective field:
@JsonDeserialize(using = OffsetDateTimeDeserializer.class)
private OffsetDateTime date;
And then you create the deserializer. It uses a java.time.format.DateTimeFormatterBuilder
, using lots of optional sections to deal with all the different types of offsets:
public class OffsetDateTimeDeserializer extends JsonDeserializer<OffsetDateTime> {
private DateTimeFormatter fmt = new DateTimeFormatterBuilder()
// date/time
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// offset (hh:mm - "+00:00" when it's zero)
.optionalStart().appendOffset("+HH:MM", "+00:00").optionalEnd()
// offset (hhmm - "+0000" when it's zero)
.optionalStart().appendOffset("+HHMM", "+0000").optionalEnd()
// offset (hh - "+00" when it's zero)
.optionalStart().appendOffset("+HH", "+00").optionalEnd()
// offset (pattern "X" uses "Z" for zero offset)
.optionalStart().appendPattern("X").optionalEnd()
// create formatter
.toFormatter();
@Override
public OffsetDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
return OffsetDateTime.parse(p.getText(), fmt);
}
}
I also used the built-in constant DateTimeFormatter.ISO_LOCAL_DATE_TIME
because it takes care of the optional fraction of seconds - and the number of fractional digits seems to be variable as well, and this built-in formatter already takes care of those details for you.
I'm using JDK 1.8.0_144 and found a shorter (but not much) solution:
private DateTimeFormatter fmt = new DateTimeFormatterBuilder()
// date/time
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
// offset +00:00 or Z
.optionalStart().appendOffset("+HH:MM", "Z").optionalEnd()
// offset +0000, +00 or Z
.optionalStart().appendOffset("+HHmm", "Z").optionalEnd()
// create formatter
.toFormatter();
Another improvement you can make is change the formatter to be static final
, because this class is immutable and thread-safe.
This is just about a quarter of an answer. I neither have experience with Kotlin nor Jackson, but I have a couple of solutions in Java that I’d like to contribute. I should be glad if you can fit them into a total solution somehow.
String modifiedEx = ex.replaceFirst("(\\d{2})(\\d{2})$", "$1:$2");
System.out.println(OffsetDateTime.parse(modifiedEx));
On my Java 9 (9.0.4) the one-arg OffsetDateTime.parse
parses all of your example strings except the one with offset +0000
without colon. So my hack is to insert that colon and then parse. The above parses all of your strings. It doesn’t work readily in Java 8 (there were some changes from Java 8 to Java 9).
The nicer solution that works in Java 8 too (I have tested):
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.appendPattern("[XXX][XX][X]")
.toFormatter();
System.out.println(OffsetDateTime.parse(ex, formatter));
The patterns XXX
, XX
and X
match +00:00
, +0000
and +00
, respectively. We need to try them in order from the longest to the shortest to make sure that all text is being parsed in all cases.
Thank you very much for all your input!
I chose the deserializer suggested by jeedas combined with the formatter suggested by Ole V.V (because it's shorter).
class DefensiveIsoOffsetDateTimeDeserializer : JsonDeserializer<OffsetDateTime>() {
private val formatter = DateTimeFormatterBuilder()
.append(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
.appendPattern("[XXX][XX][X]")
.toFormatter()
override fun deserialize(p: JsonParser, ctxt: DeserializationContext)
= OffsetDateTime.parse(p.text, formatter)
override fun handledType() = OffsetDateTime::class.java
}
I also added a custom serializer to make sure we use the correct format when producing json:
class OffsetDateTimeSerializer: JsonSerializer<OffsetDateTime>() {
override fun serialize(
value: OffsetDateTime,
gen: JsonGenerator,
serializers: SerializerProvider
) = gen.writeString(value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
override fun handledType() = OffsetDateTime::class.java
}
Putting all the parts together, I added a @Configuraton
class to my spring classpath to make it work without any annotations on the data classes:
@Configuration
open class JacksonConfig {
@Bean
open fun jacksonCustomizer() = Jackson2ObjectMapperBuilderCustomizer {
it.deserializers(DefensiveIsoOffsetDateTimeDeserializer())
it.serializers(OffsetDateTimeSerializer())
}
}