JSON

Core builds and parses JSON with silk-json, the project’s own JSON library, and the mapping between a document and a Java type is code you write or code the build generates from the type.

Writers and readers

JsonWriter<T> and JsonReader<T> name a mapping so that it can be reused. Both interfaces have a single method, so both are lambdas. A writer puts each member straight into the JsonOutput it is handed, and a reader takes what it needs out of a JsonInput:

static final JsonWriter<Deck> DECK = (deck, out) -> out.object()
        .put("id", deck.id())
        .put("name", deck.name())
        .end();

static final JsonWriter<List<Deck>> DECKS = JsonWriter.list(DECK);

record NewDeck(String name) { }

static final JsonReader<NewDeck> NEW_DECK =
        JsonReader.object(object -> new NewDeck(object.getString("name")));
app.get("/api/decks", req -> WebResponse.json(deckService.decks(), DECKS));

app.post("/api/decks", req -> {
    Deck deck = deckService.create(req.bodyJson(NEW_DECK).name()); // no key -> 400
    return WebResponse.json(deck, DECK).status(HttpStatus.CREATED);
});

Writers and readers involve no reflection. The mapping is code you wrote, so renaming a Java field changes the wire format only if you also edit the mapping. Generated JSON Codecs is the other way to a mapping: an annotation processor writes the same kind of codec from the type’s declaration at compile time, and a handler names it the same way.

Writing

JsonOutput is where a writer writes: UTF-8 bytes, straight into a buffer, with no tree in between. object() and array() open a container as the next value, end() closes the innermost one, and the commas are the output’s business. Inside an object a member is put(key, value), or name(key) followed by a value. Inside an array each element is value(…​), and values(strings) adds every string of an iterable. put and value take a string, a long, a double, a float, a boolean, a BigDecimal, a BigInteger, a JsonValue, or another value with its own writer, so writers compose:

static final JsonWriter<Card> CARD = (card, out) -> out.object()
        .put("id", card.id())
        .put("deck", card.deck(), DECK)
        .array("tags").values(card.tags()).end()
        .put("due", card.due().toString())
        .end();

A string that is null is written as JSON null, and so is a null value handed over with its writer. A double goes out as the shortest decimal that reads back as the same double, the text Double.toString answers, and a float as the text Float.toString answers. 0.1f is written as 0.1, not as the 0.10000000149011612 of the double it widens to. A double or a float that is NaN or an infinity throws JsonException, since JSON has no syntax for either. A call out of place, such as put outside an object or end() with nothing open, throws IllegalStateException.

writer.toJson(value) and writer.toJsonBytes(value) answer the document for a test, an SSE event, or a log line. WebResponse.json(value, writer) writes it into the bytes the response carries, and a key the document repeats, the keys of a list of records, is encoded once. A writer that would rather build a tree with Json.object() and Json.array() is JsonWriter.tree(value → …​), and the tree is written through the same output.

Reading

JsonInput is where a reader reads: the document’s UTF-8 bytes, one value at a time, with no string of the document made first. JsonReader.object(fromObject) is the usual reader of an object. It parses the object as a tree and takes it once, so the function reads each field straight off the JsonObject:

static final JsonReader<CardDraft> CARD_DRAFT = JsonReader.object(object -> new CardDraft(
        object.getString("text"),
        object.getString("meaning", ""),
        object.getString("tags", "")));

A body that is not an object throws JsonException, which req.bodyJson(reader) answers with 400. JsonReader.list(element) builds a reader for a JSON array out of the reader for one element, as JsonWriter.list does for writers, and reads the elements one at a time. JsonReader.tree(fromTree) reads any other shape through the whole value as a tree.

A reader written against the input itself never builds a tree, which is what a generated codec does. object() enters an object, nextKey() steps to each member and leaves the object when it answers false, and keyIs(key) matches the member’s name against a JsonKey without making a string of it. The read methods take a value, readValue() takes one whole as a tree, readValueBytes() takes one whole as the bytes it is written in, and skipValue() passes one over:

static final JsonKey ID = JsonKey.of("id");
static final JsonKey NAME = JsonKey.of("name");

static final JsonReader<Deck> DECK_IN = in -> {
    in.object();
    long id = 0;
    String name = "";
    while (in.nextKey()) {
        if (in.keyIs(ID)) {
            id = in.readLong();
        } else if (in.keyIs(NAME)) {
            name = in.readString();
        } else {
            in.skipValue();
        }
    }
    return new Deck(id, name);
};

nextKeyIs(key) moves to the next member only when its key is that one, written as JsonOutput writes it, with no whitespace between the key and its colon. It compares the bytes where the key starts with the key’s own, colon included, and scans nothing. Whitespace before the key or after its comma, as a pretty printer or Python’s json.dumps writes it, is passed over first. For any other member it answers false and moves nowhere. nextKey() then reads that member. A generated codec tries the key it declares next this way, so a document written from the same declaration is read with none of its keys scanned.

keyHead() answers the four bytes where the next member stands, as an int whose low byte is the first, and moves nowhere. A generated codec switches on it to pick the keys worth trying with nextKeyIs. A document that lists its keys in another order then has them found in place too, as long as it is written with no whitespace around them.

A string from a fixed set, such as an enum constant’s name, is held as a JsonKey too. JsonKey.names(Level.values()) encodes each constant’s name once, in the order of the ordinals. out.put(key, names[level.ordinal()]) and out.value(names[level.ordinal()]) copy the quoted name, and in.readOneOf(names) answers the index of the name that comes next, compared in place with no string made. A value that is none of them is refused with JsonException. A generated codec writes and reads an enum this way, wherever it stands: a property, an Optional, a collection element, or a map value.

reader.fromJson(text) and reader.fromJsonBytes(utf8) read a document that holds that one value and nothing after it. A failure names where the input stands, as in Near character 12: Not a JSON string: a number.

readDouble() answers the double that Double.parseDouble answers for the number’s text, and readFloat() the float that Float.parseFloat answers. Both convert the digits where they stand, with no string made, and round once. (float) readDouble() rounds twice, and is one bit off for a number just short of halfway between two floats, such as 1.00000017881393432617187499. A number past the largest double or float is refused as Number out of range, and one closer to zero than the smallest reads as zero with its sign: -0 reads as -0.0.

Reading values

getString throws JsonException on a missing key or a value of the wrong type, and req.bodyJson(reader) turns that into a 400. The message names what it found, such as Not a JSON object: an array, and never quotes more than a short scalar of the body back to the client. JsonArray.get(index) throws it for an index outside the array as well, so a short array is a 400 too. A handler gets a whole value or none, the same contract as pathParamLong. JsonException is an IllegalArgumentException, so a reader that throws a plain IllegalArgumentException for a validation rule of its own also gets a 400. DateTimeException from a reader, such as a failed LocalDate.parse, also becomes a 400. Other reader exceptions remain server errors. bodyJson(reader) wraps input rejection in HttpException, so custom handling of these errors targets that type or the 400 status page. A JsonException that handler code throws directly is not wrapped, so an exception handler registered for JsonException still receives it. The parser accepts the syntax of RFC 8259 with no extension, and bodyJson answers 400 for anything else. A body in a charset this JVM cannot decode answers 415 instead, since its bytes never reach the parser. A number such as 01, +1, .5, or 1. is invalid syntax, and so is a control character written into a string without an escape. A malformed UTF-8 sequence inside a string is invalid syntax too, rather than a replacement character in the value. asLong and getLong reject a number with a fractional part rather than truncating it: 1.5 is not a long, while 2.0 and 1e3 are whole and read as one. The conversion reads the number’s own digits rather than the nearest double, so 9007199254740993.0 reads as exactly that long and 1.0000000000000001 is rejected as a fraction. A parsed number is written back as the same digits, so a tree passed through unchanged, as in WebResponse.json(req.bodyJson()), sends the numbers it received. asDouble and getDouble answer the nearest double, which is approximate by nature. An integer outside the range of a long, such as 12345678901234567890, parses as a number that asDouble reads and asLong rejects. For a key that is allowed to be absent, getString(key, defaultValue), getLong, getDouble, and getBoolean with a default answer the default instead, for a missing key and for an explicit JSON null alike. getObjectOrNull and getArrayOrNull answer null on the same terms, and throw when the key is present with a value of another type. The names follow the rule WebRequest follows: the plain name requires the value, a default as the last argument makes it optional, and OrNull answers null. isString(), isNumber(), and isBoolean() report what a primitive holds, beside isNull(), so a value that may be a string or a number is told apart before it is read. A parsed array reads with for-each: for (JsonValue v : json.asArray()).

Keys not known in advance

A parsed object reads with for-each as well, one Map.Entry per member, in document order. keys() lists the keys in that order and size() counts the members, so a document whose keys are data, a map of tag names to counts for instance, reads into a Map:

Map<String, Long> counts = new LinkedHashMap<>();
for (var member : req.bodyJson().asObject()) {
    counts.put(member.getKey(), member.getValue().asLong());
}

The key list is a copy and the members handed to the loop are read-only, so writing through either does not reach the object.

JsonCodec<T>

JsonCodec<T> is a writer and a reader at once, for a type that travels both ways. JsonCodec.of(writer, reader) builds one, and JsonCodec.list(codec) gives the list form. The writer and the reader are separate interfaces because most types only go out, and a single interface would leave those types with a read they cannot implement. A generated codec is a JsonCodec, so it goes wherever a writer or a reader goes.

Where this stops being enough

WebResponse.json(value, writer) writes the whole document into memory before sending any of it, which an answer of a hundred thousand rows cannot afford. Large JSON and NDJSON writes one value at a time instead, and reads a body the same way.

Writing the mapping by hand or generating it from the type is core’s decision for core, not for the application. An application with a wire format it does not own can bind objects with a library instead, through WebResponse.rawJson(String) going out and req.bodyStream() or req.bodyReader() coming in: External JSON Binding.

silk-json on its own

Everything on this page but req.bodyJson and WebResponse.json is silk-json, a JSON library that core is built on and that needs nothing but the JDK at run time. Its types are in the package net.benelog.silkjson, and an application that has core has them too. An application that is not a Spider Silk one takes the library alone:

dependencies {
    implementation 'net.benelog.silkjson:silk-json:1.1.0'
}
<dependency>
  <groupId>net.benelog.silkjson</groupId>
  <artifactId>silk-json</artifactId>
  <version>1.1.0</version>
</dependency>

Generated JSON Codecs adds its processor, silk-json-processor, the same way. The library is released with the framework, at the framework’s version, under a name of its own so that it reads as a library of its own.