|
| 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