From 56920024315fbf78a2cd120261f9ea46debe689a Mon Sep 17 00:00:00 2001 From: Thomas Cederholm Date: Mon, 18 May 2026 14:30:09 +0200 Subject: [PATCH] feat: Add Jackson3JsonTypeMapper and rename Jackson v2 adapter Renames JacksonJsonTypeMapper to Jackson2JsonTypeMapper and introduces Jackson3JsonTypeMapper backed by tools.jackson.databind (Jackson 3.1.3). Both adapters ship side-by-side as optional dependencies; consumers pick whichever matches their classpath. Jackson 3 made I/O exceptions unchecked, so the v3 adapter no longer wraps them in UncheckedIOException. --- README.md | 30 ++++++++-- pom.xml | 6 ++ ...apper.java => Jackson2JsonTypeMapper.java} | 6 +- .../http/Jackson3JsonTypeMapper.java | 58 +++++++++++++++++++ ...t.java => Jackson2JsonTypeMapperTest.java} | 6 +- .../http/Jackson3JsonTypeMapperTest.java | 50 ++++++++++++++++ .../java/com/retailsvc/http/RequestTest.java | 2 +- 7 files changed, 147 insertions(+), 11 deletions(-) rename src/main/java/com/retailsvc/http/{JacksonJsonTypeMapper.java => Jackson2JsonTypeMapper.java} (88%) create mode 100644 src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java rename src/test/java/com/retailsvc/http/{JacksonJsonTypeMapperTest.java => Jackson2JsonTypeMapperTest.java} (87%) create mode 100644 src/test/java/com/retailsvc/http/Jackson3JsonTypeMapperTest.java diff --git a/README.md b/README.md index f1e0c80..38ab413 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ It is designed to be simple to use while providing the essential features needed - An OpenAPI 3.1.x specification (`openapi.json` or `openapi.yaml`). - For `application/json` request/response bodies, either: - Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or - - Jackson via the built-in `JacksonJsonTypeMapper(ObjectMapper)` adapter (caller supplies a configured `ObjectMapper`), or + - Jackson via the built-in adapters — `Jackson2JsonTypeMapper(ObjectMapper)` for Jackson 2.x (`com.fasterxml.jackson.*`) or `Jackson3JsonTypeMapper(ObjectMapper)` for Jackson 3.x (`tools.jackson.*`). Caller supplies a configured `ObjectMapper`; the two adapters use disjoint package roots and can coexist on the same classpath. - any other `TypeMapper` you register via `Builder.jsonMapper(mapper)` (shortcut for `bodyMapper("application/json", mapper)`). - Built-in mappers for `application/x-www-form-urlencoded` and `text/plain` need no configuration. Any other media type (`application/xml`, `application/cbor`, etc.) requires registering its own `TypeMapper`. @@ -123,20 +123,42 @@ 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. -For Jackson, the library ships a `JacksonJsonTypeMapper` adapter that wraps an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call): +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 +// Jackson 2.x (group: com.fasterxml.jackson.core) +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -``` java ObjectMapper objectMapper = new ObjectMapper() .registerModule(new JavaTimeModule()) .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); var server = OpenApiServer.builder() .spec(spec) - .jsonMapper(new JacksonJsonTypeMapper(objectMapper)) + .jsonMapper(new Jackson2JsonTypeMapper(objectMapper)) + .handlers(handlers) + .build(); +``` + +```java +// Jackson 3.x (group: tools.jackson.core) +import tools.jackson.databind.ObjectMapper; + +ObjectMapper objectMapper = ObjectMapper.builder() + // ... configure modules, features, etc. + .build(); + +var server = OpenApiServer.builder() + .spec(spec) + .jsonMapper(new Jackson3JsonTypeMapper(objectMapper)) .handlers(handlers) .build(); ``` +Jackson 3 made all I/O exceptions unchecked (`tools.jackson.core.JacksonException extends RuntimeException`), so `Jackson3JsonTypeMapper` propagates read/write failures as-is. `Jackson2JsonTypeMapper` wraps Jackson 2's checked `IOException` in `UncheckedIOException`. + The same shape applies to any custom mapper — implement `TypeMapper` (and optionally `TypedTypeMapper` if you can deserialise directly into a target type, so handlers can call `request.asPojo(MyDto.class)`). If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`. diff --git a/pom.xml b/pom.xml index b69351b..7ced393 100644 --- a/pom.xml +++ b/pom.xml @@ -56,6 +56,12 @@ 2.6 true + + tools.jackson.core + jackson-databind + 3.1.3 + true + org.slf4j slf4j-api diff --git a/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java b/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java similarity index 88% rename from src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java rename to src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java index dd95abb..f1184ef 100644 --- a/src/main/java/com/retailsvc/http/JacksonJsonTypeMapper.java +++ b/src/main/java/com/retailsvc/http/Jackson2JsonTypeMapper.java @@ -18,7 +18,7 @@ *
{@code
  * OpenApiServer.builder()
  *     .spec(spec)
- *     .bodyMapper("application/json", new JacksonJsonTypeMapper(myObjectMapper))
+ *     .bodyMapper("application/json", new Jackson2JsonTypeMapper(myObjectMapper))
  *     .handlers(handlers)
  *     .build();
  * }
@@ -27,11 +27,11 @@ * must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the * built-in {@code GsonJsonMapper} auto-fallback instead. */ -public final class JacksonJsonTypeMapper implements TypedTypeMapper { +public final class Jackson2JsonTypeMapper implements TypedTypeMapper { private final ObjectMapper mapper; - public JacksonJsonTypeMapper(ObjectMapper mapper) { + public Jackson2JsonTypeMapper(ObjectMapper mapper) { this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); } diff --git a/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java b/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java new file mode 100644 index 0000000..57f232b --- /dev/null +++ b/src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java @@ -0,0 +1,58 @@ +package com.retailsvc.http; + +import java.util.Objects; +import tools.jackson.databind.ObjectMapper; + +/** + * {@link TypeMapper} for {@code application/json} backed by Jackson 3. The caller supplies a + * fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the + * mapper you pass is the mapper you get. + * + *

Implements {@link TypedTypeMapper}, so handlers can ask for a typed view of the body via + * {@link Request#asPojo(Class)}. + * + *

Use this adapter for Jackson 3.x (group {@code tools.jackson.core}). For Jackson 2.x (group + * {@code com.fasterxml.jackson.core}), use {@link Jackson2JsonTypeMapper} instead — the two majors + * use disjoint package roots and can coexist on the same classpath. + * + *

Jackson is an optional Maven dependency of this library; consumers that use Jackson + * must declare {@code tools.jackson.core:jackson-databind} themselves. Consumers that use Gson can + * rely on the built-in {@code GsonJsonMapper} auto-fallback instead. + * + *

Typical wiring: + * + *

{@code
+ * OpenApiServer.builder()
+ *     .spec(spec)
+ *     .bodyMapper("application/json", new Jackson3JsonTypeMapper(myObjectMapper))
+ *     .handlers(handlers)
+ *     .build();
+ * }
+ * + *

Jackson 3 made all I/O exceptions unchecked ({@code tools.jackson.core.JacksonException + * extends RuntimeException}), so this adapter no longer needs to wrap them — read/write failures + * propagate as-is to the caller. + */ +public final class Jackson3JsonTypeMapper implements TypedTypeMapper { + + private final ObjectMapper mapper; + + public Jackson3JsonTypeMapper(ObjectMapper mapper) { + this.mapper = Objects.requireNonNull(mapper, "mapper must not be null"); + } + + @Override + public Object readFrom(byte[] body, String contentTypeHeader) { + return readAs(body, contentTypeHeader, Object.class); + } + + @Override + public T readAs(byte[] body, String contentTypeHeader, Class type) { + return mapper.readValue(body, type); + } + + @Override + public byte[] writeTo(Object value) { + return mapper.writeValueAsBytes(value); + } +} diff --git a/src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java b/src/test/java/com/retailsvc/http/Jackson2JsonTypeMapperTest.java similarity index 87% rename from src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java rename to src/test/java/com/retailsvc/http/Jackson2JsonTypeMapperTest.java index 1c2ae0f..6501b7a 100644 --- a/src/test/java/com/retailsvc/http/JacksonJsonTypeMapperTest.java +++ b/src/test/java/com/retailsvc/http/Jackson2JsonTypeMapperTest.java @@ -10,9 +10,9 @@ import java.util.Map; import org.junit.jupiter.api.Test; -class JacksonJsonTypeMapperTest { +class Jackson2JsonTypeMapperTest { - private final JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); + private final Jackson2JsonTypeMapper mapper = new Jackson2JsonTypeMapper(new ObjectMapper()); @Test void readsJsonObjectAsMap() { @@ -43,7 +43,7 @@ void wrapsReadFailureAsUncheckedIOException() { @Test void rejectsNullObjectMapper() { - assertThatThrownBy(() -> new JacksonJsonTypeMapper(null)) + assertThatThrownBy(() -> new Jackson2JsonTypeMapper(null)) .isInstanceOf(NullPointerException.class) .hasMessageContaining("mapper"); } diff --git a/src/test/java/com/retailsvc/http/Jackson3JsonTypeMapperTest.java b/src/test/java/com/retailsvc/http/Jackson3JsonTypeMapperTest.java new file mode 100644 index 0000000..6cc97d0 --- /dev/null +++ b/src/test/java/com/retailsvc/http/Jackson3JsonTypeMapperTest.java @@ -0,0 +1,50 @@ +package com.retailsvc.http; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +class Jackson3JsonTypeMapperTest { + + private final Jackson3JsonTypeMapper mapper = new Jackson3JsonTypeMapper(new ObjectMapper()); + + @Test + void readsJsonObjectAsMap() { + byte[] body = "{\"n\":42,\"s\":\"hi\",\"a\":[1,2]}".getBytes(StandardCharsets.UTF_8); + + Object parsed = mapper.readFrom(body, "application/json"); + + assertThat(parsed).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map m = (Map) parsed; + assertThat(m).containsEntry("n", 42).containsEntry("s", "hi").containsEntry("a", List.of(1, 2)); + } + + @Test + void writesMapAsJson() { + byte[] out = mapper.writeTo(Map.of("k", "v")); + + assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":\"v\"}"); + } + + @Test + void readFailurePropagatesAsJacksonException() { + byte[] malformed = "not json".getBytes(StandardCharsets.UTF_8); + + assertThatThrownBy(() -> mapper.readFrom(malformed, "application/json")) + .isInstanceOf(JacksonException.class); + } + + @Test + void rejectsNullObjectMapper() { + assertThatThrownBy(() -> new Jackson3JsonTypeMapper(null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("mapper"); + } +} diff --git a/src/test/java/com/retailsvc/http/RequestTest.java b/src/test/java/com/retailsvc/http/RequestTest.java index 98afcb5..5eef1aa 100644 --- a/src/test/java/com/retailsvc/http/RequestTest.java +++ b/src/test/java/com/retailsvc/http/RequestTest.java @@ -59,7 +59,7 @@ void readsBoundContext() throws Exception { @Test void asPojoDeserialisesViaTypedMapper() { - JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper()); + Jackson2JsonTypeMapper mapper = new Jackson2JsonTypeMapper(new ObjectMapper()); byte[] body = "{\"id\":\"x-1\",\"qty\":7}".getBytes(StandardCharsets.UTF_8); Request req = new Request(