Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down Expand Up @@ -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`.
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@
<version>2.6</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>tools.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>3.1.3</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
* <pre>{@code
* OpenApiServer.builder()
* .spec(spec)
* .bodyMapper("application/json", new JacksonJsonTypeMapper(myObjectMapper))
* .bodyMapper("application/json", new Jackson2JsonTypeMapper(myObjectMapper))
* .handlers(handlers)
* .build();
* }</pre>
Expand All @@ -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");
}

Expand Down
58 changes: 58 additions & 0 deletions src/main/java/com/retailsvc/http/Jackson3JsonTypeMapper.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>Implements {@link TypedTypeMapper}, so handlers can ask for a typed view of the body via
* {@link Request#asPojo(Class)}.
*
* <p>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.
*
* <p>Jackson is an <em>optional</em> 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.
*
* <p>Typical wiring:
*
* <pre>{@code
* OpenApiServer.builder()
* .spec(spec)
* .bodyMapper("application/json", new Jackson3JsonTypeMapper(myObjectMapper))
* .handlers(handlers)
* .build();
* }</pre>
*
* <p>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> T readAs(byte[] body, String contentTypeHeader, Class<T> type) {
return mapper.readValue(body, type);
}

@Override
public byte[] writeTo(Object value) {
return mapper.writeValueAsBytes(value);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -43,7 +43,7 @@ void wrapsReadFailureAsUncheckedIOException() {

@Test
void rejectsNullObjectMapper() {
assertThatThrownBy(() -> new JacksonJsonTypeMapper(null))
assertThatThrownBy(() -> new Jackson2JsonTypeMapper(null))
.isInstanceOf(NullPointerException.class)
.hasMessageContaining("mapper");
}
Expand Down
50 changes: 50 additions & 0 deletions src/test/java/com/retailsvc/http/Jackson3JsonTypeMapperTest.java
Original file line number Diff line number Diff line change
@@ -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<String, Object> m = (Map<String, Object>) 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");
}
}
2 changes: 1 addition & 1 deletion src/test/java/com/retailsvc/http/RequestTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading