External JSON Binding
An application can bind JSON with a library of its own, through a seam of two methods that core already has.
Core’s JSON is hand-written or generated from the type on purpose, but that decision is the framework’s to make for core, not for the application.
An application with three hundred DTOs, or one whose wire format is generated from a schema it does not own, has a reason to bind objects with a library.
Jackson has a module of its own, spider-silk-jackson, which binds through the JsonWriter and JsonReader that core already takes.
The seam
Going out, WebResponse.rawJson(String json) takes a document that is already serialized.
Coming in, req.bodyStream() and req.bodyReader() hand over the body unread.
public static WebResponse rawJson(String json) // whatever produced it
public InputStream bodyStream() // the bytes, unparsed
public BufferedReader bodyReader() // the characters, unparsed
That is the whole integration.
Do not route another library’s output through JsonWriter<T>: a JsonWriter writes values, not text, so the document the library had just finished writing would be parsed only to be written again.
The library serializes, and rawJson(String) sends what it produced.
A body is read one way: as the bytes body() and bodyJson() keep, or unread through bodyStream(), bodyReader(), or bodyNdjson().
body() reads the bytes once and keeps them for the rest of the request, so bodyJson() and a second body() answer the same body, whichever filter or handler asked first.
A request whose text was read throws IllegalStateException from bodyStream(), bodyReader(), and bodyNdjson(), since the reader underneath is already at its end.
A body handed over unread throws IllegalStateException from body() and bodyJson(), since nobody can say how much of it was consumed.
The stream and the reader exclude each other as well, which is the servlet API’s own rule.
A Content-Type naming a charset this JVM cannot decode, the empty charset= among them, answers 415 from body(), bodyJson(), bodyReader(), and bodyNdjson(), before a byte of the body is read.
What a handler reads through raw() bypasses this bookkeeping.
A form-encoded POST is also spent by its first param() read, because the container parses the form by reading the body to its end.
bodyStream() then answers an empty stream rather than throwing, and a body read as bytes first leaves formParam() with no fields.
Jackson
spider-silk-jackson binds with Jackson 3 through the JsonWriter and JsonReader that core already takes.
dependencies {
implementation 'net.benelog.spidersilk:spider-silk-jackson:1.1.0'
}
JacksonCodecs takes the ObjectMapper once, in its constructor.
Every writer and reader it makes binds with that mapper, so a handler names a type and never the mapper.
static final JacksonCodecs JACKSON = new JacksonCodecs(JsonMapper.builder().build());
static final JsonWriter<Deck> DECK = JACKSON.writer(Deck.class);
static final JsonReader<NewDeck> NEW_DECK = JACKSON.reader(NewDeck.class);
app.post("/api/decks", req -> {
NewDeck body = req.bodyJson(NEW_DECK);
return WebResponse.json(deckService.create(body.name()), DECK);
});
new JacksonCodecs() binds with a JsonMapper on Jackson’s defaults.
A mapper of your own carries its modules, its naming strategy, and its features into every writer and reader.
| Method | Answers |
|---|---|
|
A |
|
A |
|
A |
|
A |
The writer hands Jackson a generator over the JsonOutput that core passes in.
The document is therefore serialized once, straight into the buffer the response goes out from, and its Content-Length is counted as for any other writer.
No text Jackson produced is parsed again.
The reader parses the body’s bytes with Jackson.
Input that Jackson rejects answers 400, as a hand-written reader’s rejection does: bad syntax, a value of the wrong type, trailing content, and a creator that throws IllegalArgumentException, or NullPointerException for a property the body left out.
A body that is JSON null answers 400 as well, so a handler never receives null in place of a value.
A mapping that Jackson cannot build is the program’s mistake, not the client’s, and answers 500.
Both work wherever core takes a writer or a reader.
req.bodyNdjson(NEW_DECK) reads each line with Jackson, and sink.write(deck, DECK) writes each element of a jsonArray or ndjson response.
Inside JsonWriter.list and JsonReader.list, they write and read one element at a time.
A list reads faster handed to Jackson whole, through JACKSON.reader(new TypeReference<List<NewDeck>>() { }).
An element reader inside JsonReader.list starts a Jackson parser for every element, and takes about 1.75 times as long to read a list of 100 records.
A JsonOutput holds only checked JSON.
A value marked @JsonRawValue is therefore parsed and written as the value it holds, and a raw fragment written through writeRaw is refused.
A JsonOutput writes every string as UTF-8, so new JacksonCodecs(mapper) refuses a mapper with JsonWriteFeature.ESCAPE_NON_ASCII on, with IllegalArgumentException, rather than ignore the feature.
Jackson binds by reflection, and this module is where an application chooses it. A native image then needs a reflection entry for every bound type.
Through the seam
The module carries Jackson 3, whose package is tools.jackson.
Jackson 2, whose package is com.fasterxml.jackson, goes through the seam instead:
static final ObjectMapper MAPPER = new ObjectMapper();
app.get("/api/decks", req -> {
try {
String resBody = MAPPER.writeValueAsString(deckService.decks());
return WebResponse.rawJson(resBody);
} catch (JsonProcessingException e) {
throw new IllegalStateException(e);
}
});
app.post("/api/decks", req -> {
NewDeck body;
try (InputStream in = req.bodyStream()) {
body = MAPPER.readValue(in, NewDeck.class);
} catch (IOException e) {
throw new HttpException(HttpStatus.BAD_REQUEST, "Malformed JSON body");
}
String resBody = MAPPER.writeValueAsString(deckService.create(body.name()));
return WebResponse.rawJson(resBody);
});
Read through bodyStream() rather than body() on the way in.
Jackson then parses straight from the request stream, and the body never exists as one String.
bodyStream() and bodyReader() are also outside the body size limits, which bound only what body() and bodyNdjson() hold.
Move the two try blocks into an exception(JsonProcessingException.class, …) handler on the App once there is more than one handler.
Gson
static final Gson GSON = new Gson();
app.get("/api/decks", req -> {
String resBody = GSON.toJson(deckService.decks());
return WebResponse.rawJson(resBody);
});
app.post("/api/decks", req -> {
NewDeck body = GSON.fromJson(req.bodyReader(), NewDeck.class);
String resBody = GSON.toJson(deckService.create(body.name()));
return WebResponse.rawJson(resBody);
});
Gson reads characters, so bodyReader() is its half of the seam.
fromJson returns null for an empty body and throws JsonSyntaxException for a malformed one.
Both are the application’s to answer, since core has not been told this body is JSON.
A binder that generates its code
The framework’s no-reflection rule is about core, and a library an application adds answers for itself. Jackson and Gson both read and write fields reflectively, which is a real cost under a native image: every bound type needs a reflection entry.
Core’s own answer to a type that should bind itself is a generated codec: @JsonBound on the type, and the build writes a JsonCodec that a handler names like any other.
A library that generates its code takes the same trade from outside the framework.
avaje-jsonb is such a binder, and keeps the runtime free of reflection:
@Json
public record Deck(long id, String name) { }
static final Jsonb JSONB = Jsonb.builder().build();
static final JsonType<Deck> DECK = JSONB.type(Deck.class);
app.get("/api/decks/{deckId}", req -> {
String resBody = DECK.toJson(deckService.deck(req.pathParamLong("deckId")));
return WebResponse.rawJson(resBody);
});
Its annotation processor generates a Java source adapter per type at compile time and registers it through ServiceLoader.
There is no reflective fallback.
The runtime therefore stays reflection-free, and the native image needs no entry for a bound type, which is the same trade jte makes for templates.
What it does not keep is the stronger promise underneath the no-reflection rule: the wire format changes only when someone edits it.
A generated adapter derives its field names from the record’s components, so renaming a component renames a JSON field.
It does so silently, at compile time, in a build that still succeeds.
Pinning each name with @Json.Property("…") restores the guarantee, and costs about as much typing as the hand-written writer it replaced.
That is the trade, and silk-json’s generated codec makes it on the same terms, with an option that turns a missing name into a compile error.
The application decides which side of that trade it takes.
DSL-JSON’s @CompiledJson works the same way, with one difference worth knowing.
It keeps a runtime reflection fallback for types the processor did not see, so "no reflection" there is a configuration to hold rather than a property of the build.
What core still does
Routing, parameter extraction, and the request and response envelope are unchanged by a JSON library.
An application can bind one endpoint with a library and hand-write the next.
WebResponse.rawJson takes a String and does not ask where it came from.
The snippets on this page, apart from those of spider-silk-jackson, are illustrations, not part of the build.
Only spider-silk-jackson depends on Jackson, and no module of this project depends on Gson or avaje-jsonb.
|