Skip to content

Commit 6a1874d

Browse files
committed
feat: Add JacksonJsonTypeMapper adapter for ObjectMapper-backed JSON mapping
1 parent d02cf2d commit 6a1874d

5 files changed

Lines changed: 193 additions & 4 deletions

File tree

README.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ It is designed to be simple to use while providing the essential features needed
2424
- An OpenAPI 3.1.x specification (`openapi.json` or `openapi.yaml`).
2525
- For `application/json` request/response bodies, either:
2626
- Gson on the classpath — auto-registered via the built-in `GsonJsonMapper` (integer-preserving, JSR-310 written as ISO-8601), or
27-
- a user-supplied `TypeMapper` registered via `Builder.bodyMapper("application/json", mapper)` (e.g. backed by Jackson).
27+
- Jackson via the built-in `JacksonJsonTypeMapper(ObjectMapper)` adapter (caller supplies a configured `ObjectMapper`), or
28+
- any other `TypeMapper` you register via `Builder.bodyMapper("application/json", mapper)`.
2829
- 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`.
2930

3031

@@ -117,17 +118,23 @@ The library ships an internal `GsonJsonMapper` that is auto-registered for `appl
117118
- Returns JSON integers as `Long` and fractional numbers as `Double`.
118119
- Writes JSR-310 types (`Instant`, `OffsetDateTime`, `ZonedDateTime`, `LocalDateTime`, `LocalDate`, `LocalTime`) as ISO-8601 strings.
119120

120-
For non-ISO date formats, custom naming strategies, or other custom serialization, register your own `TypeMapper`:
121+
For Jackson, the library ships a `JacksonJsonTypeMapper` adapter that wraps an `ObjectMapper` you configure (modules, naming strategy, JSR-310, date formats — all your call):
121122

122123
``` java
124+
ObjectMapper objectMapper = new ObjectMapper()
125+
.registerModule(new JavaTimeModule())
126+
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
127+
123128
var server = OpenApiServer.builder()
124129
.spec(spec)
125-
.bodyMapper("application/json", new MyCustomJsonMapper())
130+
.bodyMapper("application/json", new JacksonJsonTypeMapper(objectMapper))
126131
.handlers(handlers)
127132
.build();
128133
```
129134

130-
If Gson is not on the classpath and no `application/json` mapper is registered, `build()` throws `IllegalStateException`.
135+
The same shape applies to any custom mapper — implement `TypeMapper` and register it.
136+
137+
If neither Gson is on the classpath nor any `application/json` mapper is registered, `build()` throws `IllegalStateException`.
131138

132139
### Body parsers and response writers
133140

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
</dependencyManagement>
3939

4040
<dependencies>
41+
<dependency>
42+
<groupId>com.fasterxml.jackson.core</groupId>
43+
<artifactId>jackson-databind</artifactId>
44+
<version>2.21.3</version>
45+
<optional>true</optional>
46+
</dependency>
4147
<dependency>
4248
<groupId>com.google.code.gson</groupId>
4349
<artifactId>gson</artifactId>
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.retailsvc.http;
2+
3+
import com.fasterxml.jackson.databind.ObjectMapper;
4+
import java.io.IOException;
5+
import java.io.UncheckedIOException;
6+
import java.util.Objects;
7+
8+
/**
9+
* {@link TypeMapper} for {@code application/json} backed by Jackson. The caller supplies a
10+
* fully-configured {@link ObjectMapper}; this class never adds modules or changes settings — the
11+
* mapper you pass is the mapper you get.
12+
*
13+
* <p>Typical wiring:
14+
*
15+
* <pre>{@code
16+
* OpenApiServer.builder()
17+
* .spec(spec)
18+
* .bodyMapper("application/json", new JacksonJsonTypeMapper(myObjectMapper))
19+
* .handlers(handlers)
20+
* .build();
21+
* }</pre>
22+
*
23+
* <p>Jackson is an <em>optional</em> Maven dependency of this library; consumers that use Jackson
24+
* must declare {@code jackson-databind} themselves. Consumers that use Gson can rely on the
25+
* built-in {@code GsonJsonMapper} auto-fallback instead.
26+
*/
27+
public final class JacksonJsonTypeMapper implements TypeMapper {
28+
29+
private final ObjectMapper mapper;
30+
31+
public JacksonJsonTypeMapper(ObjectMapper mapper) {
32+
this.mapper = Objects.requireNonNull(mapper, "mapper must not be null");
33+
}
34+
35+
@Override
36+
public Object readFrom(byte[] body, String contentTypeHeader) {
37+
try {
38+
return mapper.readValue(body, Object.class);
39+
} catch (IOException e) {
40+
throw new UncheckedIOException(e);
41+
}
42+
}
43+
44+
@Override
45+
public byte[] writeTo(Object value) {
46+
try {
47+
return mapper.writeValueAsBytes(value);
48+
} catch (IOException e) {
49+
throw new UncheckedIOException(e);
50+
}
51+
}
52+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package com.retailsvc.http;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.fasterxml.jackson.databind.ObjectMapper;
7+
import java.io.UncheckedIOException;
8+
import java.nio.charset.StandardCharsets;
9+
import java.util.List;
10+
import java.util.Map;
11+
import org.junit.jupiter.api.Test;
12+
13+
class JacksonJsonTypeMapperTest {
14+
15+
private final JacksonJsonTypeMapper mapper = new JacksonJsonTypeMapper(new ObjectMapper());
16+
17+
@Test
18+
void readsJsonObjectAsMap() {
19+
byte[] body = "{\"n\":42,\"s\":\"hi\",\"a\":[1,2]}".getBytes(StandardCharsets.UTF_8);
20+
21+
Object parsed = mapper.readFrom(body, "application/json");
22+
23+
assertThat(parsed).isInstanceOf(Map.class);
24+
@SuppressWarnings("unchecked")
25+
Map<String, Object> m = (Map<String, Object>) parsed;
26+
assertThat(m).containsEntry("n", 42).containsEntry("s", "hi").containsEntry("a", List.of(1, 2));
27+
}
28+
29+
@Test
30+
void writesMapAsJson() {
31+
byte[] out = mapper.writeTo(Map.of("k", "v"));
32+
33+
assertThat(new String(out, StandardCharsets.UTF_8)).isEqualTo("{\"k\":\"v\"}");
34+
}
35+
36+
@Test
37+
void wrapsReadFailureAsUncheckedIOException() {
38+
byte[] malformed = "not json".getBytes(StandardCharsets.UTF_8);
39+
40+
assertThatThrownBy(() -> mapper.readFrom(malformed, "application/json"))
41+
.isInstanceOf(UncheckedIOException.class);
42+
}
43+
44+
@Test
45+
void rejectsNullObjectMapper() {
46+
assertThatThrownBy(() -> new JacksonJsonTypeMapper(null))
47+
.isInstanceOf(NullPointerException.class)
48+
.hasMessageContaining("mapper");
49+
}
50+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
package com.retailsvc.http;
2+
3+
import static java.net.HttpURLConnection.HTTP_ACCEPTED;
4+
import static java.net.HttpURLConnection.HTTP_CREATED;
5+
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
6+
import static java.net.HttpURLConnection.HTTP_NOT_IMPLEMENTED;
7+
import static org.assertj.core.api.Assertions.assertThat;
8+
9+
import java.util.Map;
10+
import org.junit.jupiter.api.Test;
11+
12+
class ResponseTest {
13+
14+
@Test
15+
void acceptedNoBody() {
16+
Response r = Response.accepted();
17+
18+
assertThat(r.status()).isEqualTo(HTTP_ACCEPTED);
19+
assertThat(r.body()).isNull();
20+
assertThat(r.headers()).isEmpty();
21+
}
22+
23+
@Test
24+
void acceptedWithBody() {
25+
Map<String, String> job = Map.of("id", "job-42");
26+
Response r = Response.accepted(job);
27+
28+
assertThat(r.status()).isEqualTo(HTTP_ACCEPTED);
29+
assertThat(r.body()).isEqualTo(job);
30+
}
31+
32+
@Test
33+
void createdWithBody() {
34+
Map<String, String> resource = Map.of("id", "x-1");
35+
Response r = Response.created(resource);
36+
37+
assertThat(r.status()).isEqualTo(HTTP_CREATED);
38+
assertThat(r.body()).isEqualTo(resource);
39+
assertThat(r.headers()).isEmpty();
40+
}
41+
42+
@Test
43+
void createdWithLocation() {
44+
Response r = Response.created(Map.of("id", "x-1"), "/things/x-1");
45+
46+
assertThat(r.status()).isEqualTo(HTTP_CREATED);
47+
assertThat(r.headers()).containsEntry("Location", "/things/x-1");
48+
}
49+
50+
@Test
51+
void notFoundNoBody() {
52+
Response r = Response.notFound();
53+
54+
assertThat(r.status()).isEqualTo(HTTP_NOT_FOUND);
55+
assertThat(r.body()).isNull();
56+
}
57+
58+
@Test
59+
void notFoundWithBody() {
60+
Map<String, String> problem = Map.of("title", "Missing");
61+
Response r = Response.notFound(problem);
62+
63+
assertThat(r.status()).isEqualTo(HTTP_NOT_FOUND);
64+
assertThat(r.body()).isEqualTo(problem);
65+
}
66+
67+
@Test
68+
void notImplementedNoBody() {
69+
Response r = Response.notImplemented();
70+
71+
assertThat(r.status()).isEqualTo(HTTP_NOT_IMPLEMENTED);
72+
assertThat(r.body()).isNull();
73+
}
74+
}

0 commit comments

Comments
 (0)