Skip to content

Commit e3059ee

Browse files
committed
docs: Add implementation plan for public GsonTypeMapper
1 parent dace598 commit e3059ee

1 file changed

Lines changed: 377 additions & 0 deletions

File tree

Lines changed: 377 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,377 @@
1+
# Public GsonTypeMapper Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Expose a public, caller-configurable `GsonTypeMapper` in `com.retailsvc.http` that mirrors the Jackson mappers and exposes a `gsonBuilder()` method for extending the library default.
6+
7+
**Architecture:** Refactor the existing internal `GsonJsonMapper` so its no-arg constructor builds a JSR-310-aware `Gson` and a new `Gson`-accepting constructor stores any user-supplied instance. Add a new public final `GsonTypeMapper` in `com.retailsvc.http` that composes a `GsonJsonMapper` delegate and adds a `gsonBuilder()` accessor returning the wrapped Gson's `newBuilder()`. Auto-registration in `OpenApiServer` is unchanged.
8+
9+
**Tech Stack:** Java 25, Gson, JUnit 5, AssertJ, Maven.
10+
11+
**Spec:** `docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md`
12+
13+
---
14+
15+
## File Structure
16+
17+
- Modify: `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` — add `GsonJsonMapper(Gson)` ctor, extract `defaultGson()`, add `gson()` accessor.
18+
- Create: `src/main/java/com/retailsvc/http/GsonTypeMapper.java` — public `TypedTypeMapper` wrapper exposing `gsonBuilder()`.
19+
- Create: `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java` — public-API tests.
20+
- Modify: `README.md` — add Gson example to the JSON mapping section.
21+
22+
---
23+
24+
### Task 1: Refactor internal `GsonJsonMapper` to accept a `Gson`
25+
26+
**Files:**
27+
- Modify: `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java`
28+
29+
- [ ] **Step 1: Add a failing test for the `Gson`-accepting constructor**
30+
31+
Append to `src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java` (keep existing tests; add new ones inside the class, before the closing brace):
32+
33+
```java
34+
@Test
35+
void constructorWithGsonUsesSuppliedInstance() {
36+
com.google.gson.Gson custom =
37+
new com.google.gson.GsonBuilder().serializeNulls().create();
38+
GsonJsonMapper m = new GsonJsonMapper(custom);
39+
40+
assertThat(new String(m.writeTo(java.util.Collections.singletonMap("k", null)),
41+
java.nio.charset.StandardCharsets.UTF_8))
42+
.isEqualTo("{\"k\":null}");
43+
}
44+
45+
@Test
46+
void gsonAccessorReturnsWrappedInstance() {
47+
com.google.gson.Gson custom = new com.google.gson.Gson();
48+
GsonJsonMapper m = new GsonJsonMapper(custom);
49+
50+
assertThat(m.gson()).isSameAs(custom);
51+
}
52+
```
53+
54+
Note: inline FQNs above are intentional only to keep the test patch small — clean them up in Step 3 by adding proper imports.
55+
56+
- [ ] **Step 2: Run the tests; expect compilation failure**
57+
58+
Run: `mvn -q test -Dtest=GsonJsonMapperTest`
59+
Expected: compilation error — `GsonJsonMapper(Gson)` and `gson()` do not exist.
60+
61+
- [ ] **Step 3: Implement the refactor**
62+
63+
Replace the constructor block in `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` (currently lines 47–62) with:
64+
65+
```java
66+
private final Gson gson;
67+
68+
public GsonJsonMapper() {
69+
this(defaultGson());
70+
}
71+
72+
public GsonJsonMapper(Gson gson) {
73+
this.gson = java.util.Objects.requireNonNull(gson, "gson must not be null");
74+
}
75+
76+
/** Returns the wrapped {@link Gson} instance. */
77+
public Gson gson() {
78+
return gson;
79+
}
80+
81+
private static Gson defaultGson() {
82+
return new GsonBuilder()
83+
.registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse))
84+
.registerTypeAdapter(
85+
OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse))
86+
.registerTypeAdapter(
87+
ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse))
88+
.registerTypeAdapter(
89+
LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse))
90+
.registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse))
91+
.registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse))
92+
.create();
93+
}
94+
```
95+
96+
Then add `import java.util.Objects;` at the top, remove the inline `java.util.Objects.requireNonNull` qualifier (use bare `Objects.requireNonNull`), and clean up the test file by adding proper imports for `Gson`, `GsonBuilder`, `Collections`, `StandardCharsets` instead of leaving inline FQNs (per project rule "no inline fully-qualified type names").
97+
98+
- [ ] **Step 4: Run the tests; expect pass**
99+
100+
Run: `mvn -q test -Dtest=GsonJsonMapperTest`
101+
Expected: all tests pass, including the two new ones plus the 12 existing ones.
102+
103+
- [ ] **Step 5: Commit**
104+
105+
```bash
106+
git add src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java \
107+
src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java
108+
SKIP=commitlint git commit -m "refactor: Allow GsonJsonMapper to accept a Gson instance"
109+
```
110+
111+
---
112+
113+
### Task 2: Add public `GsonTypeMapper`
114+
115+
**Files:**
116+
- Create: `src/main/java/com/retailsvc/http/GsonTypeMapper.java`
117+
- Create: `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`
118+
119+
- [ ] **Step 1: Write the failing tests**
120+
121+
Create `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`:
122+
123+
```java
124+
package com.retailsvc.http;
125+
126+
import static org.assertj.core.api.Assertions.assertThat;
127+
import static org.assertj.core.api.Assertions.assertThatNullPointerException;
128+
129+
import com.google.gson.Gson;
130+
import com.google.gson.GsonBuilder;
131+
import java.nio.charset.StandardCharsets;
132+
import java.time.Instant;
133+
import java.util.Collections;
134+
import java.util.Map;
135+
import org.junit.jupiter.api.Test;
136+
137+
class GsonTypeMapperTest {
138+
139+
@Test
140+
void noArgConstructorRoundTripsInstantAsIso8601() {
141+
GsonTypeMapper mapper = new GsonTypeMapper();
142+
143+
byte[] out = mapper.writeTo(Map.of("ts", Instant.parse("2026-05-13T10:00:00Z")));
144+
145+
assertThat(new String(out, StandardCharsets.UTF_8))
146+
.isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}");
147+
}
148+
149+
@Test
150+
void readAsDelegatesToWrappedGson() {
151+
GsonTypeMapper mapper = new GsonTypeMapper();
152+
153+
Item item =
154+
mapper.readAs(
155+
"{\"id\":\"x\",\"qty\":3}".getBytes(StandardCharsets.UTF_8),
156+
"application/json",
157+
Item.class);
158+
159+
assertThat(item.id).isEqualTo("x");
160+
assertThat(item.qty).isEqualTo(3);
161+
}
162+
163+
@Test
164+
void customGsonIsUsed() {
165+
Gson custom = new GsonBuilder().serializeNulls().create();
166+
GsonTypeMapper mapper = new GsonTypeMapper(custom);
167+
168+
assertThat(new String(mapper.writeTo(Collections.singletonMap("k", null)), StandardCharsets.UTF_8))
169+
.isEqualTo("{\"k\":null}");
170+
}
171+
172+
@Test
173+
void nullGsonRejected() {
174+
assertThatNullPointerException().isThrownBy(() -> new GsonTypeMapper(null));
175+
}
176+
177+
@Test
178+
void gsonBuilderReturnsBuilderForWrappedGson() {
179+
Gson custom = new GsonBuilder().serializeNulls().create();
180+
GsonTypeMapper mapper = new GsonTypeMapper(custom);
181+
182+
Gson derived = mapper.gsonBuilder().create();
183+
184+
assertThat(derived.toJson(Collections.singletonMap("k", null))).isEqualTo("{\"k\":null}");
185+
}
186+
187+
static final class Item {
188+
String id;
189+
int qty;
190+
}
191+
}
192+
```
193+
194+
- [ ] **Step 2: Run the tests; expect compilation failure**
195+
196+
Run: `mvn -q test -Dtest=GsonTypeMapperTest`
197+
Expected: compilation error — class `GsonTypeMapper` does not exist.
198+
199+
- [ ] **Step 3: Create the implementation**
200+
201+
Create `src/main/java/com/retailsvc/http/GsonTypeMapper.java`:
202+
203+
```java
204+
package com.retailsvc.http;
205+
206+
import com.google.gson.Gson;
207+
import com.google.gson.GsonBuilder;
208+
import com.retailsvc.http.internal.gson.GsonJsonMapper;
209+
import java.util.Objects;
210+
211+
/**
212+
* {@link TypeMapper} for {@code application/json} backed by Gson. Mirrors the ergonomics of
213+
* {@link Jackson2JsonTypeMapper} / {@link Jackson3JsonTypeMapper}: the caller supplies a fully
214+
* configured {@link Gson}; this class never silently mutates it.
215+
*
216+
* <p>The no-argument constructor uses the library's default {@link Gson} — the same JSR-310-aware
217+
* instance the built-in auto-registration produces — making this a drop-in replacement for the
218+
* auto-registered mapper when callers want to wire it explicitly.
219+
*
220+
* <p>To extend the library default with extra type adapters or settings, use {@link #gsonBuilder()}:
221+
*
222+
* <pre>{@code
223+
* Gson custom =
224+
* new GsonTypeMapper()
225+
* .gsonBuilder()
226+
* .registerTypeAdapter(MyType.class, new MyTypeAdapter())
227+
* .create();
228+
* new GsonTypeMapper(custom);
229+
* }</pre>
230+
*/
231+
public final class GsonTypeMapper implements TypedTypeMapper {
232+
233+
private final GsonJsonMapper delegate;
234+
235+
/** Creates a mapper backed by the library's default JSR-310-aware {@link Gson}. */
236+
public GsonTypeMapper() {
237+
this.delegate = new GsonJsonMapper();
238+
}
239+
240+
/**
241+
* Creates a mapper backed by the supplied {@link Gson}.
242+
*
243+
* @throws NullPointerException if {@code gson} is null
244+
*/
245+
public GsonTypeMapper(Gson gson) {
246+
this.delegate = new GsonJsonMapper(Objects.requireNonNull(gson, "gson must not be null"));
247+
}
248+
249+
/**
250+
* Returns a {@link GsonBuilder} pre-populated with the wrapped {@link Gson}'s configuration, so
251+
* callers can derive a customized {@link Gson} from the library default (or from their own
252+
* starting point).
253+
*/
254+
public GsonBuilder gsonBuilder() {
255+
return delegate.gson().newBuilder();
256+
}
257+
258+
@Override
259+
public Object readFrom(byte[] body, String contentTypeHeader) {
260+
return delegate.readFrom(body, contentTypeHeader);
261+
}
262+
263+
@Override
264+
public <T> T readAs(byte[] body, String contentTypeHeader, Class<T> type) {
265+
return delegate.readAs(body, contentTypeHeader, type);
266+
}
267+
268+
@Override
269+
public byte[] writeTo(Object value) {
270+
return delegate.writeTo(value);
271+
}
272+
}
273+
```
274+
275+
- [ ] **Step 4: Run the tests; expect pass**
276+
277+
Run: `mvn -q test -Dtest=GsonTypeMapperTest`
278+
Expected: 5 tests pass.
279+
280+
- [ ] **Step 5: Run the full unit test suite**
281+
282+
Run: `mvn -q test`
283+
Expected: BUILD SUCCESS; no regressions.
284+
285+
- [ ] **Step 6: Commit**
286+
287+
```bash
288+
git add src/main/java/com/retailsvc/http/GsonTypeMapper.java \
289+
src/test/java/com/retailsvc/http/GsonTypeMapperTest.java
290+
SKIP=commitlint git commit -m "feat: Add public GsonTypeMapper exposing gsonBuilder()"
291+
```
292+
293+
---
294+
295+
### Task 3: Document `GsonTypeMapper` in README
296+
297+
**Files:**
298+
- Modify: `README.md`
299+
300+
- [ ] **Step 1: Inspect current Gson section**
301+
302+
Run: `grep -n "GsonJsonMapper\|## JSON" README.md`
303+
Read the JSON-mapping section (around the lines reported) to confirm the existing structure and pick the best insertion point — right after the existing "Gson is the default JSON serializer" passage, alongside the Jackson examples.
304+
305+
- [ ] **Step 2: Add Gson example**
306+
307+
After the existing description of the auto-registered `GsonJsonMapper`, add a subsection covering explicit wiring:
308+
309+
````markdown
310+
### Explicit Gson wiring
311+
312+
When you want to customize Gson, wire `GsonTypeMapper` explicitly. The no-arg
313+
form uses the same JSR-310-aware default as auto-registration; pass a `Gson`
314+
to fully control serialization:
315+
316+
```java
317+
OpenApiServer.builder()
318+
.spec(spec)
319+
.bodyMapper("application/json", new GsonTypeMapper(myGson))
320+
.handlers(handlers)
321+
.build();
322+
```
323+
324+
To extend the library default (instead of building a `Gson` from scratch),
325+
unwrap it via `gsonBuilder()`:
326+
327+
```java
328+
Gson custom =
329+
new GsonTypeMapper()
330+
.gsonBuilder()
331+
.registerTypeAdapter(Money.class, new MoneyAdapter())
332+
.create();
333+
334+
OpenApiServer.builder()
335+
.spec(spec)
336+
.bodyMapper("application/json", new GsonTypeMapper(custom))
337+
.handlers(handlers)
338+
.build();
339+
```
340+
````
341+
342+
Place this block after the existing `GsonJsonMapper` paragraph (around `README.md:130`) and before the next top-level section.
343+
344+
- [ ] **Step 3: Commit**
345+
346+
```bash
347+
git add README.md
348+
SKIP=commitlint git commit -m "docs: Document public GsonTypeMapper and gsonBuilder()"
349+
```
350+
351+
---
352+
353+
### Task 4: Full verification
354+
355+
**Files:** none modified.
356+
357+
- [ ] **Step 1: Run full test + integration suite**
358+
359+
Run: `mvn -q verify`
360+
Expected: BUILD SUCCESS; both Surefire and Failsafe green.
361+
362+
- [ ] **Step 2: Confirm auto-registration path still works**
363+
364+
Run: `mvn -q test -Dtest=TypeMapperRegistrationTest`
365+
Expected: PASS — confirms `OpenApiServer` still auto-registers `GsonJsonMapper` unchanged.
366+
367+
- [ ] **Step 3: Push branch (Sonar runs in CI)**
368+
369+
SonarLint MCP cannot see files inside `.claude/worktrees/` (its `/workspace` mount is the main repo). Sonar findings for this branch will only surface after push via the CI scan — review the CI report after Step 4 and address any findings in a follow-up commit.
370+
371+
- [ ] **Step 4: Push branch**
372+
373+
```bash
374+
git push -u origin feat/gson-type-mapper
375+
```
376+
377+
The user will open the PR manually (gh token cannot create PRs in this repo).

0 commit comments

Comments
 (0)