Skip to content

Commit 1f0de23

Browse files
committed
feat: GsonJsonMapper with integer-preserving and JSR-310 adapters
1 parent e01ef10 commit 1f0de23

2 files changed

Lines changed: 226 additions & 0 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package com.retailsvc.http.internal.gson;
2+
3+
import com.google.gson.Gson;
4+
import com.google.gson.GsonBuilder;
5+
import com.google.gson.JsonArray;
6+
import com.google.gson.JsonElement;
7+
import com.google.gson.JsonNull;
8+
import com.google.gson.JsonObject;
9+
import com.google.gson.JsonParser;
10+
import com.google.gson.JsonPrimitive;
11+
import com.google.gson.TypeAdapter;
12+
import com.google.gson.stream.JsonReader;
13+
import com.google.gson.stream.JsonWriter;
14+
import com.retailsvc.http.TypeMapper;
15+
import java.io.IOException;
16+
import java.nio.charset.StandardCharsets;
17+
import java.time.Instant;
18+
import java.time.LocalDate;
19+
import java.time.LocalDateTime;
20+
import java.time.LocalTime;
21+
import java.time.OffsetDateTime;
22+
import java.time.ZonedDateTime;
23+
import java.util.ArrayList;
24+
import java.util.LinkedHashMap;
25+
import java.util.List;
26+
import java.util.Map;
27+
28+
/**
29+
* Built-in {@link TypeMapper} for {@code application/json} backed by Gson. Auto-registered by
30+
* {@link com.retailsvc.http.OpenApiServer.Builder} when Gson is on the classpath and no
31+
* user-supplied JSON mapper has been registered.
32+
*
33+
* <p>JSON numbers without a decimal point or exponent are returned as {@code Long}; fractional
34+
* numbers are returned as {@code Double}. JSR-310 types ({@code Instant}, {@code OffsetDateTime},
35+
* {@code ZonedDateTime}, {@code LocalDateTime}, {@code LocalDate}, {@code LocalTime}) are written
36+
* as their ISO-8601 string form.
37+
*/
38+
public final class GsonJsonMapper implements TypeMapper {
39+
40+
private final Gson gson;
41+
42+
public GsonJsonMapper() {
43+
this.gson =
44+
new GsonBuilder()
45+
.registerTypeAdapter(Instant.class, isoStringWriter(Instant::toString))
46+
.registerTypeAdapter(OffsetDateTime.class, isoStringWriter(OffsetDateTime::toString))
47+
.registerTypeAdapter(ZonedDateTime.class, isoStringWriter(ZonedDateTime::toString))
48+
.registerTypeAdapter(LocalDateTime.class, isoStringWriter(LocalDateTime::toString))
49+
.registerTypeAdapter(LocalDate.class, isoStringWriter(LocalDate::toString))
50+
.registerTypeAdapter(LocalTime.class, isoStringWriter(LocalTime::toString))
51+
.create();
52+
}
53+
54+
@Override
55+
public Object readFrom(byte[] body, String contentTypeHeader) {
56+
JsonElement element = JsonParser.parseString(new String(body, StandardCharsets.UTF_8));
57+
return toJavaObject(element);
58+
}
59+
60+
@Override
61+
public byte[] writeTo(Object value) {
62+
return gson.toJson(value).getBytes(StandardCharsets.UTF_8);
63+
}
64+
65+
/**
66+
* Recursively converts a {@link JsonElement} tree to plain Java objects, preserving integers as
67+
* {@code Long} and fractional numbers as {@code Double}.
68+
*/
69+
private static Object toJavaObject(JsonElement element) {
70+
if (element == null || element instanceof JsonNull) {
71+
return null;
72+
} else if (element instanceof JsonObject obj) {
73+
Map<String, Object> map = new LinkedHashMap<>();
74+
for (Map.Entry<String, JsonElement> entry : obj.entrySet()) {
75+
map.put(entry.getKey(), toJavaObject(entry.getValue()));
76+
}
77+
return map;
78+
} else if (element instanceof JsonArray arr) {
79+
List<Object> list = new ArrayList<>(arr.size());
80+
for (JsonElement item : arr) {
81+
list.add(toJavaObject(item));
82+
}
83+
return list;
84+
} else if (element instanceof JsonPrimitive prim) {
85+
if (prim.isBoolean()) {
86+
return prim.getAsBoolean();
87+
} else if (prim.isString()) {
88+
return prim.getAsString();
89+
} else {
90+
// Number
91+
String raw = prim.getAsString();
92+
if (raw.indexOf('.') < 0 && raw.indexOf('e') < 0 && raw.indexOf('E') < 0) {
93+
try {
94+
return Long.parseLong(raw);
95+
} catch (NumberFormatException _) {
96+
// falls through to Double for out-of-range integers
97+
}
98+
}
99+
return Double.parseDouble(raw);
100+
}
101+
}
102+
throw new IllegalStateException("Unexpected JsonElement type: " + element.getClass());
103+
}
104+
105+
private static <T> TypeAdapter<T> isoStringWriter(java.util.function.Function<T, String> toIso) {
106+
return new TypeAdapter<T>() {
107+
@Override
108+
public void write(JsonWriter out, T value) throws IOException {
109+
if (value == null) {
110+
out.nullValue();
111+
} else {
112+
out.value(toIso.apply(value));
113+
}
114+
}
115+
116+
@Override
117+
public T read(JsonReader in) {
118+
throw new UnsupportedOperationException(
119+
"GsonJsonMapper does not parse JSR-310 types; values arrive as String");
120+
}
121+
};
122+
}
123+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.retailsvc.http.internal.gson;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import java.nio.charset.StandardCharsets;
6+
import java.time.Instant;
7+
import java.time.LocalDate;
8+
import java.time.LocalDateTime;
9+
import java.time.LocalTime;
10+
import java.time.OffsetDateTime;
11+
import java.time.ZoneOffset;
12+
import java.time.ZonedDateTime;
13+
import java.util.List;
14+
import java.util.Map;
15+
import org.junit.jupiter.api.Test;
16+
17+
class GsonJsonMapperTest {
18+
19+
private final GsonJsonMapper mapper = new GsonJsonMapper();
20+
21+
@Test
22+
void readPreservesIntegersAsLong() {
23+
@SuppressWarnings("unchecked")
24+
Map<String, Object> parsed =
25+
(Map<String, Object>) mapper.readFrom(bytes("{\"n\":42}"), "application/json");
26+
assertThat(parsed.get("n")).isEqualTo(42L).isInstanceOf(Long.class);
27+
}
28+
29+
@Test
30+
void readKeepsFractionalAsDouble() {
31+
@SuppressWarnings("unchecked")
32+
Map<String, Object> parsed =
33+
(Map<String, Object>) mapper.readFrom(bytes("{\"n\":1.5}"), "application/json");
34+
assertThat(parsed.get("n")).isEqualTo(1.5).isInstanceOf(Double.class);
35+
}
36+
37+
@Test
38+
void readBasicTypes() {
39+
@SuppressWarnings("unchecked")
40+
Map<String, Object> parsed =
41+
(Map<String, Object>)
42+
mapper.readFrom(
43+
bytes("{\"s\":\"hi\",\"b\":true,\"n\":null,\"a\":[1,2]}"), "application/json");
44+
assertThat(parsed.get("s")).isEqualTo("hi");
45+
assertThat(parsed.get("b")).isEqualTo(Boolean.TRUE);
46+
assertThat(parsed.get("n")).isNull();
47+
assertThat(parsed.get("a")).isEqualTo(List.of(1L, 2L));
48+
}
49+
50+
@Test
51+
void writesMapAndList() {
52+
byte[] out = mapper.writeTo(Map.of("k", List.of(1L, 2L)));
53+
assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":[1,2]}");
54+
}
55+
56+
@Test
57+
void writesInstantAsIso8601() {
58+
Instant t = Instant.parse("2026-05-13T10:00:00Z");
59+
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
60+
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}");
61+
}
62+
63+
@Test
64+
void writesOffsetDateTimeAsIso8601() {
65+
OffsetDateTime t = OffsetDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC);
66+
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
67+
.isEqualTo("{\"ts\":\"2026-05-13T10:00Z\"}");
68+
}
69+
70+
@Test
71+
void writesZonedDateTimeAsIso8601() {
72+
ZonedDateTime t = ZonedDateTime.of(2026, 5, 13, 10, 0, 0, 0, ZoneOffset.UTC);
73+
assertThat(new String(mapper.writeTo(Map.of("ts", t)), StandardCharsets.UTF_8))
74+
.contains("2026-05-13T10:00Z");
75+
}
76+
77+
@Test
78+
void writesLocalDateTimeAsIso8601() {
79+
assertThat(
80+
new String(
81+
mapper.writeTo(Map.of("ts", LocalDateTime.of(2026, 5, 13, 10, 0))),
82+
StandardCharsets.UTF_8))
83+
.isEqualTo("{\"ts\":\"2026-05-13T10:00\"}");
84+
}
85+
86+
@Test
87+
void writesLocalDateAsIso8601() {
88+
assertThat(
89+
new String(
90+
mapper.writeTo(Map.of("d", LocalDate.of(2026, 5, 13))), StandardCharsets.UTF_8))
91+
.isEqualTo("{\"d\":\"2026-05-13\"}");
92+
}
93+
94+
@Test
95+
void writesLocalTimeAsIso8601() {
96+
assertThat(new String(mapper.writeTo(Map.of("t", LocalTime.of(10, 0))), StandardCharsets.UTF_8))
97+
.isEqualTo("{\"t\":\"10:00\"}");
98+
}
99+
100+
private static byte[] bytes(String s) {
101+
return s.getBytes(StandardCharsets.UTF_8);
102+
}
103+
}

0 commit comments

Comments
 (0)