Skip to content

Commit b59eef2

Browse files
committed
fix: Emit fixed microsecond precision for JSR-310 temporals
Java's OffsetDateTime/Instant/ZonedDateTime/LocalDateTime/LocalTime toString() omits seconds and fractional digits when zero, producing inconsistent serialised forms (e.g. "20:00Z" vs "20:00:00.123456Z"). Encode through a DateTimeFormatter that always writes six-digit (microsecond) fractional precision. Decoding remains permissive via each type's ISO parser.
1 parent bce9b71 commit b59eef2

2 files changed

Lines changed: 47 additions & 12 deletions

File tree

src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@
2020
import java.time.LocalDateTime;
2121
import java.time.LocalTime;
2222
import java.time.OffsetDateTime;
23+
import java.time.ZoneOffset;
2324
import java.time.ZonedDateTime;
25+
import java.time.format.DateTimeFormatter;
26+
import java.time.format.DateTimeFormatterBuilder;
27+
import java.time.temporal.ChronoField;
2428
import java.util.ArrayList;
2529
import java.util.LinkedHashMap;
2630
import java.util.List;
@@ -40,8 +44,9 @@
4044
* {@code Instant}, etc.).
4145
*
4246
* <p>JSR-310 types ({@code Instant}, {@code OffsetDateTime}, {@code ZonedDateTime}, {@code
43-
* LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are round-tripped as their ISO-8601 string
44-
* form.
47+
* LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are round-tripped as ISO-8601 strings.
48+
* Types with a sub-second component are serialised with fixed six-digit (microsecond) fractional
49+
* precision; parsing accepts any ISO-8601 fractional precision (or none).
4550
*/
4651
public final class GsonJsonMapper implements TypedTypeMapper {
4752

@@ -60,17 +65,47 @@ public Gson gson() {
6065
return gson;
6166
}
6267

68+
private static final DateTimeFormatter LOCAL_TIME_MICROS =
69+
new DateTimeFormatterBuilder()
70+
.appendPattern("HH:mm:ss")
71+
.appendFraction(ChronoField.NANO_OF_SECOND, 6, 6, true)
72+
.toFormatter();
73+
74+
private static final DateTimeFormatter LOCAL_DATE_TIME_MICROS =
75+
new DateTimeFormatterBuilder()
76+
.append(DateTimeFormatter.ISO_LOCAL_DATE)
77+
.appendLiteral('T')
78+
.append(LOCAL_TIME_MICROS)
79+
.toFormatter();
80+
81+
private static final DateTimeFormatter OFFSET_DATE_TIME_MICROS =
82+
new DateTimeFormatterBuilder().append(LOCAL_DATE_TIME_MICROS).appendOffsetId().toFormatter();
83+
84+
private static final DateTimeFormatter ZONED_DATE_TIME_MICROS =
85+
new DateTimeFormatterBuilder()
86+
.append(OFFSET_DATE_TIME_MICROS)
87+
.optionalStart()
88+
.appendLiteral('[')
89+
.parseCaseSensitive()
90+
.appendZoneRegionId()
91+
.appendLiteral(']')
92+
.optionalEnd()
93+
.toFormatter();
94+
95+
private static final DateTimeFormatter INSTANT_MICROS =
96+
OFFSET_DATE_TIME_MICROS.withZone(ZoneOffset.UTC);
97+
6398
private static Gson defaultGson() {
6499
return new GsonBuilder()
65-
.registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse))
100+
.registerTypeAdapter(Instant.class, iso(INSTANT_MICROS::format, Instant::parse))
66101
.registerTypeAdapter(
67-
OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse))
102+
OffsetDateTime.class, iso(OFFSET_DATE_TIME_MICROS::format, OffsetDateTime::parse))
68103
.registerTypeAdapter(
69-
ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse))
104+
ZonedDateTime.class, iso(ZONED_DATE_TIME_MICROS::format, ZonedDateTime::parse))
70105
.registerTypeAdapter(
71-
LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse))
106+
LocalDateTime.class, iso(LOCAL_DATE_TIME_MICROS::format, LocalDateTime::parse))
72107
.registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse))
73-
.registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse))
108+
.registerTypeAdapter(LocalTime.class, iso(LOCAL_TIME_MICROS::format, LocalTime::parse))
74109
.create();
75110
}
76111

src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,21 +61,21 @@ void writesMapAndList() {
6161
void writesInstantAsIso8601() {
6262
Instant t = Instant.parse("2026-05-13T10:00:00Z");
6363
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
64-
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}");
64+
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00.000000Z\"}");
6565
}
6666

6767
@Test
6868
void writesOffsetDateTimeAsIso8601() {
6969
OffsetDateTime t = OffsetDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC);
7070
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
71-
.isEqualTo("{\"ts\":\"2026-05-13T10:00Z\"}");
71+
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00.000000Z\"}");
7272
}
7373

7474
@Test
7575
void writesZonedDateTimeAsIso8601() {
7676
ZonedDateTime t = ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC);
7777
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
78-
.contains("2026-05-13T10:00Z");
78+
.contains("2026-05-13T10:00:00.000000Z");
7979
}
8080

8181
@Test
@@ -84,7 +84,7 @@ void writesLocalDateTimeAsIso8601() {
8484
new String(
8585
mapper.writeTo(Map.of("ts", LocalDateTime.of(2026, 5, 13, 10, 0))),
8686
StandardCharsets.UTF_8))
87-
.isEqualTo("{\"ts\":\"2026-05-13T10:00\"}");
87+
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00.000000\"}");
8888
}
8989

9090
@Test
@@ -98,7 +98,7 @@ void writesLocalDateAsIso8601() {
9898
@Test
9999
void writesLocalTimeAsIso8601() {
100100
assertThat(new String(mapper.writeTo(Map.of("t", LocalTime.of(10, 0))), StandardCharsets.UTF_8))
101-
.isEqualTo("{\"t\":\"10:00\"}");
101+
.isEqualTo("{\"t\":\"10:00:00.000000\"}");
102102
}
103103

104104
@Test

0 commit comments

Comments
 (0)