Skip to content

Commit 4a06f2d

Browse files
authored
Support for OffsetDateTime and ZonedDateTime in jackson-jr-extension-javatime (#201)
1 parent b0debfb commit 4a06f2d

File tree

14 files changed

+558
-48
lines changed

14 files changed

+558
-48
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Project is composed of multiple Maven sub-modules, each corresponding to a jar:
4040
* [jr-retrofit2](../../tree/master/jr-retrofit2) contains `jackson-jr` - based handlers for [Retrofit 2](https://square.github.io/retrofit/) library
4141
* Depends on `jackson-jr` and `Retrofit` API jars, and indirectly on `jackson-core`
4242
* [jr-annotation-support](../../tree/master/jr-annotation-support) contains extension with support for a subset of core [Jackson annotations](../../../jackson-annotations)
43-
* [jr-extension-javatime](../../tree/master/jr-extension-javatime) contains extension with support for a subset of Java 8 Date/Time types (e.g. `java.time.LocalDateTime`)
43+
* [jr-extension-javatime](../../tree/master/jr-extension-javatime) contains extension with support for a subset of Java 8 Date/Time types (e.g. `java.time.LocalDateTime`, `java.time.OffsetDateTime` (2.20), `java.time.ZonedDateTime` (2.20))
4444
* jr-all creates an "uber-jar" that contains individual modules along with all their dependencies:
4545
* `jr-objects` classes as-is, without relocating
4646
* `jr-stree` classes as-is, without relocating

jr-extension-javatime/README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This module extends the functionality of jackson-jr by adding support for (a sub
44

55
### Status
66

7-
Added in Jackson 2.17.
7+
Added in Jackson 2.17; extended in 2.20.
88

99
### Usage
1010
To be able to use supported annotations, you need to register extension like so:
@@ -48,7 +48,10 @@ public class MyClass {
4848
### Date Classes currently supported by `JacksonJrJavaTimeExtension`
4949

5050
- `java.time.LocalDateTime`
51+
- `java.time.OffsetDateTime` (2.20+)
52+
- `java.time.ZonedDateTime` (2.20+)
5153

5254
### Plans for Future
5355

54-
- Add support for other Java 8 Date/Time types
56+
- Add support for more Java 8 Date/Time types
57+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.fasterxml.jackson.jr.extension.javatime;
2+
3+
import java.io.IOException;
4+
import java.time.OffsetDateTime;
5+
import java.time.ZonedDateTime;
6+
import java.time.temporal.TemporalAccessor;
7+
import java.time.temporal.TemporalQuery;
8+
import java.util.Objects;
9+
10+
import com.fasterxml.jackson.core.JsonParser;
11+
import com.fasterxml.jackson.core.JsonToken;
12+
import com.fasterxml.jackson.jr.ob.JSONObjectException;
13+
import com.fasterxml.jackson.jr.ob.api.ValueReader;
14+
import com.fasterxml.jackson.jr.ob.impl.JSONReader;
15+
16+
/**
17+
* {@link ValueReader} designed to easily handle {@link TemporalAccessor} descendants such as
18+
* {@link OffsetDateTime} and {@link ZonedDateTime}. Their string representation is expected to
19+
* be in ISO 8601 format.
20+
* @since 2.20
21+
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601 on Wikipedia</a>
22+
*/
23+
public class DefaultDateTimeValueReader<T extends TemporalAccessor> extends ValueReader {
24+
private final TemporalQuery<T> _query;
25+
26+
/**
27+
* Constructor that includes a temporal query that is to be used during formatting.
28+
* @param targetType Target type
29+
* @param query Temporal query for parsing
30+
*/
31+
public DefaultDateTimeValueReader(Class<T> targetType, TemporalQuery<T> query) {
32+
super(targetType);
33+
_query = Objects.requireNonNull(query);
34+
}
35+
36+
@Override
37+
public Object read(JSONReader reader, JsonParser p) throws IOException {
38+
if (p.hasToken(JsonToken.VALUE_NULL)) {
39+
return null;
40+
}
41+
if (p.hasToken(JsonToken.VALUE_STRING)) {
42+
return JavaTimeReaderWriterProvider.OFFSET_FORMATTER.parse(p.getText(), _query);
43+
}
44+
45+
throw JSONObjectException.from(p,
46+
"Can not create a "+_valueType.getName()+" instance out of "+_tokenDesc(p));
47+
}
48+
49+
/**
50+
* Check if an ISO 8601 string contains a time offset or not.
51+
* @param text ISO 8601 string
52+
* @return Return <code>true</code> when an offset is missing, otherwise: <code>false</code>
53+
*/
54+
protected boolean offsetMissing(String text) {
55+
return !text.matches(".*(Z|[+-]\\d{2}(:?\\d{2})?)$");
56+
}
57+
}

jr-extension-javatime/src/main/java/com/fasterxml/jackson/jr/extension/javatime/JacksonJrJavaTimeExtension.java

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
public class JacksonJrJavaTimeExtension extends JacksonJrExtension {
77
final static JavaTimeReaderWriterProvider DEFAULT_RW_PROVIDER = new JavaTimeReaderWriterProvider();
88

9-
private JavaTimeReaderWriterProvider readerWriterProvider = DEFAULT_RW_PROVIDER;
9+
private JavaTimeReaderWriterProvider _readerWriterProvider = DEFAULT_RW_PROVIDER;
1010

1111
@Override
1212
protected void register(ExtensionContext ctxt) {
13-
ctxt.insertProvider(readerWriterProvider);
13+
ctxt.insertProvider(_readerWriterProvider);
1414
}
1515

1616
public JacksonJrJavaTimeExtension with(JavaTimeReaderWriterProvider p) {
17-
readerWriterProvider = p;
17+
_readerWriterProvider = p;
1818
return this;
1919
}
2020
}
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,117 @@
11
package com.fasterxml.jackson.jr.extension.javatime;
22

3+
import java.time.LocalDateTime;
4+
import java.time.OffsetDateTime;
5+
import java.time.ZoneId;
6+
import java.time.ZonedDateTime;
7+
import java.time.format.DateTimeFormatter;
8+
import java.time.format.DateTimeFormatterBuilder;
9+
import java.time.temporal.ChronoField;
10+
311
import com.fasterxml.jackson.jr.ob.api.ReaderWriterProvider;
412
import com.fasterxml.jackson.jr.ob.api.ValueReader;
513
import com.fasterxml.jackson.jr.ob.api.ValueWriter;
614
import com.fasterxml.jackson.jr.ob.impl.JSONReader;
715
import com.fasterxml.jackson.jr.ob.impl.JSONWriter;
816

9-
import java.time.LocalDateTime;
10-
import java.time.format.DateTimeFormatter;
11-
1217
/**
1318
* Provider for {@link ValueReader}s and {@link ValueWriter}s for Date/Time
1419
* types supported by Java Time Extension.
1520
*/
1621
public class JavaTimeReaderWriterProvider extends ReaderWriterProvider
1722
{
18-
private DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
19-
20-
public JavaTimeReaderWriterProvider() { }
23+
private ZoneId _localZoneId;
24+
25+
protected static final DateTimeFormatter OFFSET_FORMATTER = _createFormatter(true);
26+
protected static final DateTimeFormatter LOCAL_FORMATTER = _createFormatter(false);
27+
28+
public JavaTimeReaderWriterProvider() {
29+
withLocalTimeZone(null);
30+
}
2131

2232
@Override
2333
public ValueReader findValueReader(JSONReader readContext, Class<?> type) {
24-
return LocalDateTime.class.isAssignableFrom(type) ? new LocalDateTimeValueReader(dateTimeFormatter) : null;
34+
if (LocalDateTime.class.isAssignableFrom(type)) {
35+
return new LocalDateTimeValueReader(_localZoneId);
36+
}
37+
if (OffsetDateTime.class.isAssignableFrom(type)) {
38+
return new OffsetDateTimeValueReader(_localZoneId);
39+
}
40+
if (ZonedDateTime.class.isAssignableFrom(type)) {
41+
return new ZonedDateTimeValueReader(_localZoneId);
42+
}
43+
return null;
2544
}
2645

2746
@Override
2847
public ValueWriter findValueWriter(JSONWriter writeContext, Class<?> type) {
29-
return LocalDateTime.class.isAssignableFrom(type) ? new LocalDateTimeValueWriter(dateTimeFormatter) : null;
48+
if (LocalDateTime.class.isAssignableFrom(type)) {
49+
return new LocalDateTimeValueWriter();
50+
}
51+
if (OffsetDateTime.class.isAssignableFrom(type)) {
52+
return new OffsetDateTimeValueWriter();
53+
}
54+
if (ZonedDateTime.class.isAssignableFrom(type)) {
55+
return new ZonedDateTimeValueWriter();
56+
}
57+
return null;
3058
}
31-
59+
3260
/**
33-
* Method for reconfiguring {@link DateTimeFormatter} used for reading/writing
34-
* following Date/Time value types:
35-
*<ul>
36-
* <li>{@code java.time.LocalDateTime}
37-
* </li>
38-
*</ul>
39-
*
40-
* @param formatter
41-
*
42-
* @return This provider instance for call chaining
61+
* Setter to configure a time zone that is to be applied when a zoned ISO 8601 date time needs
62+
* to be converted to a {@link LocalDateTime}. Can be set to <code>null</code> to apply the
63+
* UTC default.
64+
* @param localZoneId Time zone to apply, or <code>null</code>
65+
* @since 2.20
66+
* @return Reference for chaining
4367
*/
44-
public JavaTimeReaderWriterProvider withDateTimeFormatter(DateTimeFormatter formatter) {
45-
dateTimeFormatter = formatter;
68+
public JavaTimeReaderWriterProvider withLocalTimeZone(ZoneId localZoneId) {
69+
_localZoneId = localZoneId == null ? ZoneId.of("Z") : localZoneId;
4670
return this;
4771
}
72+
73+
/**
74+
* Convenience method to quickly set the system default time zone as the preferred one. This
75+
* is equivalent to calling: <code>withLocalTimeZone(ZoneId.systemDefault())</code>.
76+
* @since 2.20
77+
* @return Reference for chaining
78+
*/
79+
public JavaTimeReaderWriterProvider withSystemDefaultTimeZone() {
80+
return withLocalTimeZone(ZoneId.systemDefault());
81+
}
82+
83+
/**
84+
* Create a forgiving date time formatter that allows different interpretations of ISO 8601
85+
* strings to be parsed.
86+
*
87+
* @param includeUtcDefault Set to <code>true</code> to set UTC to be the default offset
88+
* for non-local date times that do not have an offset. Set to
89+
* <code>false</code> when handling local date times.
90+
* @since 2.20
91+
* @return Formatter
92+
*/
93+
private static DateTimeFormatter _createFormatter(boolean includeUtcDefault) {
94+
final DateTimeFormatterBuilder builder = new DateTimeFormatterBuilder()
95+
.parseLenient()
96+
.appendPattern("yyyy-MM-dd'T'HH:mm:ss")
97+
.optionalStart()
98+
.appendFraction(ChronoField.MILLI_OF_SECOND, 1, 9, true)
99+
.optionalEnd()
100+
.optionalStart()
101+
.appendOffsetId()
102+
.optionalStart()
103+
.appendLiteral('[')
104+
.appendZoneRegionId()
105+
.appendLiteral(']')
106+
.optionalEnd()
107+
.optionalEnd();
108+
109+
if (includeUtcDefault) {
110+
// Without this, parsing a ZonedDateTime or OffsetDateTime will cause an exception if
111+
// no offset was specified. So we default the offset to UTC to be safe.
112+
builder.parseDefaulting(ChronoField.OFFSET_SECONDS, 0);
113+
}
114+
115+
return builder.toFormatter();
116+
}
48117
}
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,59 @@
11
package com.fasterxml.jackson.jr.extension.javatime;
22

3+
import java.io.IOException;
4+
import java.time.LocalDateTime;
5+
import java.time.ZoneId;
6+
import java.time.ZonedDateTime;
7+
import java.time.temporal.TemporalAccessor;
8+
import java.util.Objects;
9+
310
import com.fasterxml.jackson.core.JsonParser;
11+
import com.fasterxml.jackson.core.JsonToken;
12+
import com.fasterxml.jackson.jr.ob.JSONObjectException;
413
import com.fasterxml.jackson.jr.ob.api.ValueReader;
514
import com.fasterxml.jackson.jr.ob.impl.JSONReader;
615

7-
import java.io.IOException;
8-
import java.time.LocalDateTime;
9-
import java.time.format.DateTimeFormatter;
10-
16+
/**
17+
* {@link ValueReader} designed specifically to handle {@link LocalDateTime} instances. This
18+
* requires a slightly different approach than other date time types because we want to be as
19+
* forgiving as possible and be able to also interpret ISO 8601 dates that include an offset
20+
* or zone ID.
21+
* @since 2.20
22+
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601 on Wikipedia</a>
23+
*/
1124
public class LocalDateTimeValueReader extends ValueReader {
12-
private final DateTimeFormatter formatter;
13-
14-
public LocalDateTimeValueReader(DateTimeFormatter formatter) {
25+
private final ZoneId _localZoneId;
26+
27+
/**
28+
* Constructor that accepts a zone ID that should be used to when a ISO 8601 string that
29+
* includes an offset needs to be converted to a local date time.
30+
* @param localZoneId Destination zone ID
31+
*/
32+
public LocalDateTimeValueReader(ZoneId localZoneId) {
1533
super(LocalDateTime.class);
16-
this.formatter = formatter;
34+
_localZoneId = Objects.requireNonNull(localZoneId);
1735
}
1836

1937
@Override
2038
public Object read(JSONReader reader, JsonParser p) throws IOException {
21-
return LocalDateTime.parse(p.getText(), formatter);
39+
if (p.hasToken(JsonToken.VALUE_NULL)) {
40+
return null;
41+
}
42+
if (p.hasToken(JsonToken.VALUE_STRING)) {
43+
final TemporalAccessor ta = JavaTimeReaderWriterProvider.LOCAL_FORMATTER.parseBest(p.getText(),
44+
ZonedDateTime::from, LocalDateTime::from);
45+
46+
if (ta instanceof ZonedDateTime) {
47+
// Convert a date time that unexpectedly includes a time offset or zone ID, to a
48+
// local date time
49+
return ((ZonedDateTime)ta).withZoneSameInstant(_localZoneId).toLocalDateTime();
50+
}
51+
if (ta instanceof LocalDateTime) {
52+
return ta;
53+
}
54+
}
55+
56+
throw JSONObjectException.from(p,
57+
"Can not create a "+_valueType.getName()+" instance out of "+_tokenDesc(p));
2258
}
2359
}

jr-extension-javatime/src/main/java/com/fasterxml/jackson/jr/extension/javatime/LocalDateTimeValueWriter.java

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
package com.fasterxml.jackson.jr.extension.javatime;
22

3-
import com.fasterxml.jackson.core.JsonGenerator;
4-
import com.fasterxml.jackson.jr.ob.api.ValueWriter;
5-
import com.fasterxml.jackson.jr.ob.impl.JSONWriter;
6-
73
import java.io.IOException;
84
import java.time.LocalDateTime;
95
import java.time.format.DateTimeFormatter;
106

11-
public class LocalDateTimeValueWriter implements ValueWriter {
12-
private final DateTimeFormatter formatter;
13-
14-
public LocalDateTimeValueWriter(DateTimeFormatter formatter) {
15-
this.formatter = formatter;
16-
}
7+
import com.fasterxml.jackson.core.JsonGenerator;
8+
import com.fasterxml.jackson.jr.ob.api.ValueWriter;
9+
import com.fasterxml.jackson.jr.ob.impl.JSONWriter;
1710

11+
/**
12+
* {@link ValueWriter} that converts a {@link LocalDateTime} to an ISO 8601 string without
13+
* an offset or zone ID.
14+
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601 on Wikipedia</a>
15+
*/
16+
public class LocalDateTimeValueWriter implements ValueWriter {
1817
@Override
1918
public void writeValue(JSONWriter context, JsonGenerator g, Object value) throws IOException {
20-
String localDateTimeString = ((LocalDateTime) value).format(formatter);
19+
final String localDateTimeString = ((LocalDateTime) value).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
2120
context.writeValue(localDateTimeString);
2221
}
2322

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package com.fasterxml.jackson.jr.extension.javatime;
2+
3+
import java.io.IOException;
4+
import java.time.OffsetDateTime;
5+
import java.time.ZoneId;
6+
import java.time.ZonedDateTime;
7+
import java.util.Objects;
8+
9+
import com.fasterxml.jackson.core.JsonParser;
10+
import com.fasterxml.jackson.jr.ob.api.ValueReader;
11+
import com.fasterxml.jackson.jr.ob.impl.JSONReader;
12+
13+
/**
14+
* {@link ValueReader} that converts an ISO 8601 string to a {@link ZonedDateTime} instance.
15+
* @see <a href="https://en.wikipedia.org/wiki/ISO_8601">ISO 8601 on Wikipedia</a>
16+
* @since 2.20
17+
*/
18+
public class OffsetDateTimeValueReader extends DefaultDateTimeValueReader<OffsetDateTime> {
19+
private final ZoneId _localZoneId;
20+
21+
/**
22+
* Constructor that accepts a zone ID as a parameter. The zone ID is configured when no zone
23+
* or offset was configured in the ISO 8601 string.
24+
* @param localZoneId Local zone ID
25+
*/
26+
public OffsetDateTimeValueReader(ZoneId localZoneId) {
27+
super(OffsetDateTime.class, OffsetDateTime::from);
28+
_localZoneId = Objects.requireNonNull(localZoneId);
29+
}
30+
31+
@Override
32+
public Object read(JSONReader reader, JsonParser p) throws IOException {
33+
final OffsetDateTime odt = (OffsetDateTime)super.read(reader, p);
34+
35+
// If the offset was missing, our formatter defaults to UTC. However, we need to set it
36+
// to our preferred local zone to get ISO 8601 compliant behavior. In case our preferred
37+
// zone already is UTC, no harm is done here.
38+
if (odt != null && offsetMissing(p.getText())) {
39+
return odt.withOffsetSameLocal(_localZoneId.getRules().getOffset(odt.toInstant()));
40+
}
41+
return odt;
42+
}
43+
}

0 commit comments

Comments
 (0)