diff --git a/README.md b/README.md
index a5520f8..00b2abc 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,32 @@ The library ships an internal `GsonJsonMapper` that is auto-registered for `appl
- For `request.asPojo(MyDto.class)`, delegates to Gson — the target type's fields determine the Java types (`int`, `long`, `Instant`, etc.).
- Round-trips JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as their ISO-8601 string form.
+To customize Gson, wire `GsonTypeMapper` explicitly. The no-arg form uses the same JSR-310-aware default as auto-registration; pass a `Gson` to fully control serialization:
+
+```java
+var server = OpenApiServer.builder()
+ .spec(spec)
+ .jsonMapper(new GsonTypeMapper(myGson))
+ .handlers(handlers)
+ .build();
+```
+
+To extend the library default (instead of building a `Gson` from scratch), unwrap it via `gsonBuilder()`:
+
+```java
+Gson custom =
+ new GsonTypeMapper()
+ .gsonBuilder()
+ .registerTypeAdapter(Money.class, new MoneyAdapter())
+ .create();
+
+var server = OpenApiServer.builder()
+ .spec(spec)
+ .jsonMapper(new GsonTypeMapper(custom))
+ .handlers(handlers)
+ .build();
+```
+
For Jackson, the library ships two adapters that wrap an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call). Pick the one that matches your Jackson major:
```java
diff --git a/docs/superpowers/plans/2026-05-20-public-gson-type-mapper.md b/docs/superpowers/plans/2026-05-20-public-gson-type-mapper.md
new file mode 100644
index 0000000..ab8fad9
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-20-public-gson-type-mapper.md
@@ -0,0 +1,377 @@
+# Public GsonTypeMapper Implementation Plan
+
+> **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.
+
+**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.
+
+**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.
+
+**Tech Stack:** Java 25, Gson, JUnit 5, AssertJ, Maven.
+
+**Spec:** `docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md`
+
+---
+
+## File Structure
+
+- Modify: `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` — add `GsonJsonMapper(Gson)` ctor, extract `defaultGson()`, add `gson()` accessor.
+- Create: `src/main/java/com/retailsvc/http/GsonTypeMapper.java` — public `TypedTypeMapper` wrapper exposing `gsonBuilder()`.
+- Create: `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java` — public-API tests.
+- Modify: `README.md` — add Gson example to the JSON mapping section.
+
+---
+
+### Task 1: Refactor internal `GsonJsonMapper` to accept a `Gson`
+
+**Files:**
+- Modify: `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java`
+
+- [ ] **Step 1: Add a failing test for the `Gson`-accepting constructor**
+
+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):
+
+```java
+ @Test
+ void constructorWithGsonUsesSuppliedInstance() {
+ com.google.gson.Gson custom =
+ new com.google.gson.GsonBuilder().serializeNulls().create();
+ GsonJsonMapper m = new GsonJsonMapper(custom);
+
+ assertThat(new String(m.writeTo(java.util.Collections.singletonMap("k", null)),
+ java.nio.charset.StandardCharsets.UTF_8))
+ .isEqualTo("{\"k\":null}");
+ }
+
+ @Test
+ void gsonAccessorReturnsWrappedInstance() {
+ com.google.gson.Gson custom = new com.google.gson.Gson();
+ GsonJsonMapper m = new GsonJsonMapper(custom);
+
+ assertThat(m.gson()).isSameAs(custom);
+ }
+```
+
+Note: inline FQNs above are intentional only to keep the test patch small — clean them up in Step 3 by adding proper imports.
+
+- [ ] **Step 2: Run the tests; expect compilation failure**
+
+Run: `mvn -q test -Dtest=GsonJsonMapperTest`
+Expected: compilation error — `GsonJsonMapper(Gson)` and `gson()` do not exist.
+
+- [ ] **Step 3: Implement the refactor**
+
+Replace the constructor block in `src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java` (currently lines 47–62) with:
+
+```java
+ private final Gson gson;
+
+ public GsonJsonMapper() {
+ this(defaultGson());
+ }
+
+ public GsonJsonMapper(Gson gson) {
+ this.gson = java.util.Objects.requireNonNull(gson, "gson must not be null");
+ }
+
+ /** Returns the wrapped {@link Gson} instance. */
+ public Gson gson() {
+ return gson;
+ }
+
+ private static Gson defaultGson() {
+ return new GsonBuilder()
+ .registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse))
+ .registerTypeAdapter(
+ OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse))
+ .registerTypeAdapter(
+ ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse))
+ .registerTypeAdapter(
+ LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse))
+ .registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse))
+ .registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse))
+ .create();
+ }
+```
+
+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").
+
+- [ ] **Step 4: Run the tests; expect pass**
+
+Run: `mvn -q test -Dtest=GsonJsonMapperTest`
+Expected: all tests pass, including the two new ones plus the 12 existing ones.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java \
+ src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java
+SKIP=commitlint git commit -m "refactor: Allow GsonJsonMapper to accept a Gson instance"
+```
+
+---
+
+### Task 2: Add public `GsonTypeMapper`
+
+**Files:**
+- Create: `src/main/java/com/retailsvc/http/GsonTypeMapper.java`
+- Create: `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`:
+
+```java
+package com.retailsvc.http;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class GsonTypeMapperTest {
+
+ @Test
+ void noArgConstructorRoundTripsInstantAsIso8601() {
+ GsonTypeMapper mapper = new GsonTypeMapper();
+
+ byte[] out = mapper.writeTo(Map.of("ts", Instant.parse("2026-05-13T10:00:00Z")));
+
+ assertThat(new String(out, StandardCharsets.UTF_8))
+ .isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}");
+ }
+
+ @Test
+ void readAsDelegatesToWrappedGson() {
+ GsonTypeMapper mapper = new GsonTypeMapper();
+
+ Item item =
+ mapper.readAs(
+ "{\"id\":\"x\",\"qty\":3}".getBytes(StandardCharsets.UTF_8),
+ "application/json",
+ Item.class);
+
+ assertThat(item.id).isEqualTo("x");
+ assertThat(item.qty).isEqualTo(3);
+ }
+
+ @Test
+ void customGsonIsUsed() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonTypeMapper mapper = new GsonTypeMapper(custom);
+
+ assertThat(new String(mapper.writeTo(Collections.singletonMap("k", null)), StandardCharsets.UTF_8))
+ .isEqualTo("{\"k\":null}");
+ }
+
+ @Test
+ void nullGsonRejected() {
+ assertThatNullPointerException().isThrownBy(() -> new GsonTypeMapper(null));
+ }
+
+ @Test
+ void gsonBuilderReturnsBuilderForWrappedGson() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonTypeMapper mapper = new GsonTypeMapper(custom);
+
+ Gson derived = mapper.gsonBuilder().create();
+
+ assertThat(derived.toJson(Collections.singletonMap("k", null))).isEqualTo("{\"k\":null}");
+ }
+
+ static final class Item {
+ String id;
+ int qty;
+ }
+}
+```
+
+- [ ] **Step 2: Run the tests; expect compilation failure**
+
+Run: `mvn -q test -Dtest=GsonTypeMapperTest`
+Expected: compilation error — class `GsonTypeMapper` does not exist.
+
+- [ ] **Step 3: Create the implementation**
+
+Create `src/main/java/com/retailsvc/http/GsonTypeMapper.java`:
+
+```java
+package com.retailsvc.http;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.retailsvc.http.internal.gson.GsonJsonMapper;
+import java.util.Objects;
+
+/**
+ * {@link TypeMapper} for {@code application/json} backed by Gson. Mirrors the ergonomics of
+ * {@link Jackson2JsonTypeMapper} / {@link Jackson3JsonTypeMapper}: the caller supplies a fully
+ * configured {@link Gson}; this class never silently mutates it.
+ *
+ *
The no-argument constructor uses the library's default {@link Gson} — the same JSR-310-aware
+ * instance the built-in auto-registration produces — making this a drop-in replacement for the
+ * auto-registered mapper when callers want to wire it explicitly.
+ *
+ *
To extend the library default with extra type adapters or settings, use {@link #gsonBuilder()}:
+ *
+ *
{@code
+ * Gson custom =
+ * new GsonTypeMapper()
+ * .gsonBuilder()
+ * .registerTypeAdapter(MyType.class, new MyTypeAdapter())
+ * .create();
+ * new GsonTypeMapper(custom);
+ * }
+ */
+public final class GsonTypeMapper implements TypedTypeMapper {
+
+ private final GsonJsonMapper delegate;
+
+ /** Creates a mapper backed by the library's default JSR-310-aware {@link Gson}. */
+ public GsonTypeMapper() {
+ this.delegate = new GsonJsonMapper();
+ }
+
+ /**
+ * Creates a mapper backed by the supplied {@link Gson}.
+ *
+ * @throws NullPointerException if {@code gson} is null
+ */
+ public GsonTypeMapper(Gson gson) {
+ this.delegate = new GsonJsonMapper(Objects.requireNonNull(gson, "gson must not be null"));
+ }
+
+ /**
+ * Returns a {@link GsonBuilder} pre-populated with the wrapped {@link Gson}'s configuration, so
+ * callers can derive a customized {@link Gson} from the library default (or from their own
+ * starting point).
+ */
+ public GsonBuilder gsonBuilder() {
+ return delegate.gson().newBuilder();
+ }
+
+ @Override
+ public Object readFrom(byte[] body, String contentTypeHeader) {
+ return delegate.readFrom(body, contentTypeHeader);
+ }
+
+ @Override
+ public T readAs(byte[] body, String contentTypeHeader, Class type) {
+ return delegate.readAs(body, contentTypeHeader, type);
+ }
+
+ @Override
+ public byte[] writeTo(Object value) {
+ return delegate.writeTo(value);
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests; expect pass**
+
+Run: `mvn -q test -Dtest=GsonTypeMapperTest`
+Expected: 5 tests pass.
+
+- [ ] **Step 5: Run the full unit test suite**
+
+Run: `mvn -q test`
+Expected: BUILD SUCCESS; no regressions.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/main/java/com/retailsvc/http/GsonTypeMapper.java \
+ src/test/java/com/retailsvc/http/GsonTypeMapperTest.java
+SKIP=commitlint git commit -m "feat: Add public GsonTypeMapper exposing gsonBuilder()"
+```
+
+---
+
+### Task 3: Document `GsonTypeMapper` in README
+
+**Files:**
+- Modify: `README.md`
+
+- [ ] **Step 1: Inspect current Gson section**
+
+Run: `grep -n "GsonJsonMapper\|## JSON" README.md`
+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.
+
+- [ ] **Step 2: Add Gson example**
+
+After the existing description of the auto-registered `GsonJsonMapper`, add a subsection covering explicit wiring:
+
+````markdown
+### Explicit Gson wiring
+
+When you want to customize Gson, wire `GsonTypeMapper` explicitly. The no-arg
+form uses the same JSR-310-aware default as auto-registration; pass a `Gson`
+to fully control serialization:
+
+```java
+OpenApiServer.builder()
+ .spec(spec)
+ .bodyMapper("application/json", new GsonTypeMapper(myGson))
+ .handlers(handlers)
+ .build();
+```
+
+To extend the library default (instead of building a `Gson` from scratch),
+unwrap it via `gsonBuilder()`:
+
+```java
+Gson custom =
+ new GsonTypeMapper()
+ .gsonBuilder()
+ .registerTypeAdapter(Money.class, new MoneyAdapter())
+ .create();
+
+OpenApiServer.builder()
+ .spec(spec)
+ .bodyMapper("application/json", new GsonTypeMapper(custom))
+ .handlers(handlers)
+ .build();
+```
+````
+
+Place this block after the existing `GsonJsonMapper` paragraph (around `README.md:130`) and before the next top-level section.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add README.md
+SKIP=commitlint git commit -m "docs: Document public GsonTypeMapper and gsonBuilder()"
+```
+
+---
+
+### Task 4: Full verification
+
+**Files:** none modified.
+
+- [ ] **Step 1: Run full test + integration suite**
+
+Run: `mvn -q verify`
+Expected: BUILD SUCCESS; both Surefire and Failsafe green.
+
+- [ ] **Step 2: Confirm auto-registration path still works**
+
+Run: `mvn -q test -Dtest=TypeMapperRegistrationTest`
+Expected: PASS — confirms `OpenApiServer` still auto-registers `GsonJsonMapper` unchanged.
+
+- [ ] **Step 3: Push branch (Sonar runs in CI)**
+
+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.
+
+- [ ] **Step 4: Push branch**
+
+```bash
+git push -u origin feat/gson-type-mapper
+```
+
+The user will open the PR manually (gh token cannot create PRs in this repo).
diff --git a/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md b/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md
new file mode 100644
index 0000000..348f421
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-20-public-gson-type-mapper-design.md
@@ -0,0 +1,97 @@
+# Public GsonTypeMapper
+
+## Problem
+
+`Jackson2JsonTypeMapper` and `Jackson3JsonTypeMapper` are public in
+`com.retailsvc.http` and accept a fully-configured `ObjectMapper`, giving
+callers direct control over JSON serialization. The Gson equivalent,
+`GsonJsonMapper`, lives in `com.retailsvc.http.internal.gson`, is
+package-private, has only a no-arg constructor, and hard-codes a Gson
+instance with JSR-310 type adapters. Callers cannot pass their own `Gson`
+or extend the library's defaults without forking the class.
+
+## Goals
+
+- Expose a public, caller-configurable Gson adapter in `com.retailsvc.http`
+ that mirrors the Jackson mappers' ergonomics.
+- Let callers extend the library's default (JSR-310-aware) Gson without
+ re-implementing the type adapters.
+- Preserve the current auto-registration behavior: putting Gson on the
+ classpath continues to wire up the JSR-310-aware mapper with no
+ configuration.
+
+## Non-goals
+
+- Changing the auto-registration mechanism or class-name lookup in
+ `OpenApiServer`.
+- Adding new JSR-310 adapters or changing the existing ISO-8601 format.
+- Deprecating or removing `GsonJsonMapper`; it stays as the internal
+ implementation.
+
+## Design
+
+### Internal refactor: `com.retailsvc.http.internal.gson.GsonJsonMapper`
+
+- Add a constructor `GsonJsonMapper(Gson gson)` that stores the supplied
+ instance.
+- Existing no-arg constructor delegates to `new GsonJsonMapper(defaultGson())`
+ where `defaultGson()` is a private static method building the current
+ JSR-310-aware default.
+- Add a `Gson gson()` accessor so the public wrapper can reach the
+ underlying instance.
+- `readFrom` / `readAs` / `writeTo` behavior is unchanged.
+
+### New public class: `com.retailsvc.http.GsonTypeMapper`
+
+`public final class GsonTypeMapper implements TypedTypeMapper`
+
+Constructors:
+
+- `GsonTypeMapper()` — wraps `new GsonJsonMapper()` (library default, JSR-310
+ aware).
+- `GsonTypeMapper(Gson gson)` — wraps `new GsonJsonMapper(gson)`; throws
+ `NullPointerException` via `Objects.requireNonNull` if `gson` is null.
+
+Methods:
+
+- `public GsonBuilder gsonBuilder()` — returns `delegate.gson().newBuilder()`,
+ a builder pre-populated with the wrapped `Gson`'s configuration. Lets
+ callers extend the library default in one line:
+ `new GsonTypeMapper().gsonBuilder().registerTypeAdapter(...).create()`.
+- `readFrom`, `readAs`, `writeTo` — delegate to the internal mapper.
+
+### `OpenApiServer`
+
+No change. The auto-registration class-name constant still points at
+`com.retailsvc.http.internal.gson.GsonJsonMapper` and the no-arg
+constructor still produces a JSR-310-aware mapper.
+
+### Tests
+
+New `src/test/java/com/retailsvc/http/GsonTypeMapperTest.java`, JUnit 5
+with AssertJ, camelCase method names, static imports, curly braces on
+every block:
+
+- `noArgConstructorRoundTripsJsr310` — verifies `Instant`,
+ `OffsetDateTime`, `LocalDate` survive a write→read cycle as ISO-8601.
+- `customGsonIsUsed` — supplies a `Gson` with a custom type adapter and
+ asserts the adapter wins over the default behavior.
+- `nullGsonRejected` — constructor with `null` throws `NullPointerException`.
+- `gsonBuilderPreservesWrappedConfig` — builds a `GsonTypeMapper` with a
+ custom Gson, calls `gsonBuilder().create()`, and asserts the custom
+ adapter still applies on the new instance.
+
+The existing `GsonJsonMapperTest` stays as-is to cover internal behavior.
+
+### Documentation
+
+Update README "JSON mapping" section to add a Gson example alongside the
+Jackson ones, showing both `new GsonTypeMapper()` and the
+`gsonBuilder()` extension pattern.
+
+## Risks
+
+- Two public ways to obtain a Gson-backed mapper (auto-registration vs
+ explicit `new GsonTypeMapper()`). Mitigation: README clarifies that
+ auto-registration is for the zero-config path and `GsonTypeMapper` is
+ for callers who want to customize.
diff --git a/src/main/java/com/retailsvc/http/GsonTypeMapper.java b/src/main/java/com/retailsvc/http/GsonTypeMapper.java
new file mode 100644
index 0000000..fff3bac
--- /dev/null
+++ b/src/main/java/com/retailsvc/http/GsonTypeMapper.java
@@ -0,0 +1,69 @@
+package com.retailsvc.http;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.retailsvc.http.internal.gson.GsonJsonMapper;
+import java.util.Objects;
+
+/**
+ * {@link TypeMapper} for {@code application/json} backed by Gson. The caller supplies a fully
+ * configured {@link Gson}; this class never silently mutates it.
+ *
+ * The no-argument constructor uses the library's default {@link Gson} — the same JSR-310-aware
+ * instance the built-in auto-registration produces — making this a drop-in replacement for the
+ * auto-registered mapper when callers want to wire it explicitly.
+ *
+ *
To extend the library default with extra type adapters or settings, use {@link
+ * #gsonBuilder()}:
+ *
+ *
{@code
+ * Gson custom =
+ * new GsonTypeMapper()
+ * .gsonBuilder()
+ * .registerTypeAdapter(MyType.class, new MyTypeAdapter())
+ * .create();
+ * new GsonTypeMapper(custom);
+ * }
+ */
+public final class GsonTypeMapper implements TypedTypeMapper {
+
+ private final GsonJsonMapper delegate;
+
+ /** Creates a mapper backed by the library's default JSR-310-aware {@link Gson}. */
+ public GsonTypeMapper() {
+ this.delegate = new GsonJsonMapper();
+ }
+
+ /**
+ * Creates a mapper backed by the supplied {@link Gson}.
+ *
+ * @throws NullPointerException if {@code gson} is null
+ */
+ public GsonTypeMapper(Gson gson) {
+ this.delegate = new GsonJsonMapper(Objects.requireNonNull(gson, "gson must not be null"));
+ }
+
+ /**
+ * Returns a {@link GsonBuilder} pre-populated with the wrapped {@link Gson}'s configuration, so
+ * callers can derive a customized {@link Gson} from the library default (or from their own
+ * starting point).
+ */
+ public GsonBuilder gsonBuilder() {
+ return delegate.gson().newBuilder();
+ }
+
+ @Override
+ public Object readFrom(byte[] body, String contentTypeHeader) {
+ return delegate.readFrom(body, contentTypeHeader);
+ }
+
+ @Override
+ public T readAs(byte[] body, String contentTypeHeader, Class type) {
+ return delegate.readAs(body, contentTypeHeader, type);
+ }
+
+ @Override
+ public byte[] writeTo(Object value) {
+ return delegate.writeTo(value);
+ }
+}
diff --git a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java
index 72101e4..1deabdd 100644
--- a/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java
+++ b/src/main/java/com/retailsvc/http/internal/gson/GsonJsonMapper.java
@@ -25,6 +25,7 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.function.Function;
/**
@@ -47,18 +48,30 @@ public final class GsonJsonMapper implements TypedTypeMapper {
private final Gson gson;
public GsonJsonMapper() {
- this.gson =
- new GsonBuilder()
- .registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse))
- .registerTypeAdapter(
- OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse))
- .registerTypeAdapter(
- ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse))
- .registerTypeAdapter(
- LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse))
- .registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse))
- .registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse))
- .create();
+ this(defaultGson());
+ }
+
+ public GsonJsonMapper(Gson gson) {
+ this.gson = Objects.requireNonNull(gson, "gson must not be null");
+ }
+
+ /** Returns the wrapped {@link Gson} instance. */
+ public Gson gson() {
+ return gson;
+ }
+
+ private static Gson defaultGson() {
+ return new GsonBuilder()
+ .registerTypeAdapter(Instant.class, iso(Instant::toString, Instant::parse))
+ .registerTypeAdapter(
+ OffsetDateTime.class, iso(OffsetDateTime::toString, OffsetDateTime::parse))
+ .registerTypeAdapter(
+ ZonedDateTime.class, iso(ZonedDateTime::toString, ZonedDateTime::parse))
+ .registerTypeAdapter(
+ LocalDateTime.class, iso(LocalDateTime::toString, LocalDateTime::parse))
+ .registerTypeAdapter(LocalDate.class, iso(LocalDate::toString, LocalDate::parse))
+ .registerTypeAdapter(LocalTime.class, iso(LocalTime::toString, LocalTime::parse))
+ .create();
}
@Override
diff --git a/src/test/java/com/retailsvc/http/GsonTypeMapperTest.java b/src/test/java/com/retailsvc/http/GsonTypeMapperTest.java
new file mode 100644
index 0000000..5a6349b
--- /dev/null
+++ b/src/test/java/com/retailsvc/http/GsonTypeMapperTest.java
@@ -0,0 +1,69 @@
+package com.retailsvc.http;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Map;
+import org.junit.jupiter.api.Test;
+
+class GsonTypeMapperTest {
+
+ @Test
+ void noArgConstructorRoundTripsInstantAsIso8601() {
+ GsonTypeMapper mapper = new GsonTypeMapper();
+
+ byte[] out = mapper.writeTo(Map.of("ts", Instant.parse("2026-05-13T10:00:00Z")));
+
+ assertThat(new String(out, StandardCharsets.UTF_8))
+ .isEqualTo("{\"ts\":\"2026-05-13T10:00:00Z\"}");
+ }
+
+ @Test
+ void readAsDelegatesToWrappedGson() {
+ GsonTypeMapper mapper = new GsonTypeMapper();
+
+ Item item =
+ mapper.readAs(
+ "{\"id\":\"x\",\"qty\":3}".getBytes(StandardCharsets.UTF_8),
+ "application/json",
+ Item.class);
+
+ assertThat(item.id).isEqualTo("x");
+ assertThat(item.qty).isEqualTo(3);
+ }
+
+ @Test
+ void customGsonIsUsed() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonTypeMapper mapper = new GsonTypeMapper(custom);
+
+ assertThat(
+ new String(mapper.writeTo(Collections.singletonMap("k", null)), StandardCharsets.UTF_8))
+ .isEqualTo("{\"k\":null}");
+ }
+
+ @Test
+ void nullGsonRejected() {
+ assertThatNullPointerException().isThrownBy(() -> new GsonTypeMapper(null));
+ }
+
+ @Test
+ void gsonBuilderReturnsBuilderForWrappedGson() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonTypeMapper mapper = new GsonTypeMapper(custom);
+
+ Gson derived = mapper.gsonBuilder().create();
+
+ assertThat(derived.toJson(Collections.singletonMap("k", null))).isEqualTo("{\"k\":null}");
+ }
+
+ static final class Item {
+ String id;
+ int qty;
+ }
+}
diff --git a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java
index 8fd1e28..c5c92d1 100644
--- a/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java
+++ b/src/test/java/com/retailsvc/http/internal/gson/GsonJsonMapperTest.java
@@ -2,6 +2,8 @@
import static org.assertj.core.api.Assertions.assertThat;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDate;
@@ -10,6 +12,7 @@
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
@@ -118,6 +121,24 @@ void readAsRoundTripsJsr310Fields() {
assertThat(value.day).isEqualTo(LocalDate.of(2026, 5, 13));
}
+ @Test
+ void constructorWithGsonUsesSuppliedInstance() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonJsonMapper m = new GsonJsonMapper(custom);
+
+ byte[] out = m.writeTo(Collections.singletonMap("k", null));
+
+ assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":null}");
+ }
+
+ @Test
+ void gsonAccessorReturnsWrappedInstance() {
+ Gson custom = new GsonBuilder().serializeNulls().create();
+ GsonJsonMapper m = new GsonJsonMapper(custom);
+
+ assertThat(m.gson()).isSameAs(custom);
+ }
+
static final class Item {
String id;
int qty;