External JSON Binding

An application can bind JSON with a library of its own, through a seam of two methods that were always there. Core’s JSON is hand-written 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.

The seam

Going out, WebResponse.json(String rawJson) takes a document that is already serialized. Coming in, req.bodyStream() and req.bodyReader() hand over the body unread.

public static WebResponse json(String rawJson)   // 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>: it returns a Json.JsonValue, which would mean re-parsing a document the library had just finished writing. The library serializes, and json(String) sends what it produced.

A body is read one way: as the text body() keeps, or unread through bodyStream(), bodyReader(), or bodyNdjson(). body() reads the text once and keeps it 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. 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

static final ObjectMapper MAPPER = new ObjectMapper();

app.get("/api/decks", req -> {
    try {
        String resBody = MAPPER.writeValueAsString(deckService.decks());
        return WebResponse.json(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.json(resBody);
});

Read through bodyStream() rather than body() on the way in. Jackson then parses the socket, and the request never exists as one String.

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.json(resBody);
});

app.post("/api/decks", req -> {
    NewDeck body = GSON.fromJson(req.bodyReader(), NewDeck.class);
    String resBody = GSON.toJson(deckService.create(body.name()));
    return WebResponse.json(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.

avaje-jsonb is the version of this that keeps the property:

@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.json(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. Where it comes out is the application’s call, and core is not involved either way.

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 any of this. An application can bind one endpoint with a library and hand-write the next. WebResponse.json takes a String and does not ask where it came from.

The snippets on this page are illustrations, not part of the build. No module of this project depends on Jackson, Gson, or avaje-jsonb.