Sending JSON Responses

Return JSON with the right Content-Type and byte length, escape strings properly, and know when to hand the job to a JSON library.

Syntaxbyte[] body = json.getBytes(StandardCharsets.UTF_8); exchange.getResponseHeaders().set("Content-Type", "application/json"); exchange.sendResponseHeaders(200, body.length); try (OutputStream out = exchange.getResponseBody()) { out.write(body); }

Almost every API response is JSON. The JDK has no JSON library, so with HttpServer alone you either build the text yourself or add Jackson or a JSON-B implementation. Either way, three things must be right: the body, the Content-Type and the length.

The headers

Send Content-Type: application/json. JSON exchanged between systems must be UTF-8 (RFC 8259), so a charset parameter adds nothing, though it does no harm. Without the header, browsers, fetch and many client libraries will not treat the body as JSON, and some security scanners flag the response.

The length passed to sendResponseHeaders is a byte count. Convert the string with getBytes(StandardCharsets.UTF_8) and use the array's length. Using json.length() works in every test with ASCII data and fails in production the first time a customer is called Zoë: the server throws because you wrote more bytes than announced, and the client gets a broken response.

Building JSON by hand

For a handful of fixed fields, a method on the record that writes its own JSON is fine. The one part you cannot skip is escaping strings: a quote, backslash, newline or control character inside a value produces invalid JSON, and a value that contains "} can inject fields into your output. Escape ", \ and every character below U+0020; never concatenate untrusted text straight into JSON.

record User(long id, String name) {
    String toJson() {
        return "{\"id\":" + id + ",\"name\":" + quote(name) + "}";
    }
}

Numbers go in unquoted. Use null for a missing value rather than the string "null", and be consistent about whether absent fields are omitted or sent as null; clients code against whichever you pick.

Shape and errors

Return lists inside an object ({"data": [...]}) when you may later need pagination fields next to them. Errors should be JSON too, with the matching status code; the REST API stage covers the Problem Details format for that.

When to stop hand-writing

As soon as you serialise nested objects, dates, BigDecimal or optional fields, or need to parse JSON, use Jackson or JSON-B. They handle escaping, records and java.time types correctly, and Spring Boot ships Jackson by default.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return a user or order record from a JDK-only service with correct Content-Type and Content-Length.
  • Send consistent JSON error bodies alongside 400, 404 and 500 responses.
  • Wrap list results in a data envelope so page and count fields can be added without breaking clients.

More examples

Escaping is not optional

The naive version lets a user-supplied nickname add an <code>admin</code> field that any client parsing the response will see. The escaped version keeps the whole input inside one string value, which is exactly what a JSON library does for you.

Example · java
String quote(String s) {
    StringBuilder sb = new StringBuilder("\"");
    for (char c : s.toCharArray()) {
        switch (c) {
            case '"' -> sb.append("\\\"");
            case '\\' -> sb.append("\\\\");
            case '\n' -> sb.append("\\n");
            case '\r' -> sb.append("\\r");
            case '\t' -> sb.append("\\t");
            default -> {
                if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
                else sb.append(c);
            }
        }
    }
    return sb.append('"').toString();
}

void main() {
    String nickname = "x\",\"admin\":true,\"y\":\"";   // hostile input

    String naive = "{\"nickname\":\"" + nickname + "\"}";
    String safe = "{\"nickname\":" + quote(nickname) + "}";

    IO.println("naive: " + naive);
    IO.println("safe:  " + safe);
    IO.println("path:  " + quote("C:\\temp\nnext line"));
}

// Output:
// naive: {"nickname":"x","admin":true,"y":""}
// safe:  {"nickname":"x\",\"admin\":true,\"y\":\""}
// path:  "C:\\temp\nnext line"

Characters versus bytes

ë and ó take two bytes each in UTF-8 and the check mark takes three, so the byte count is four higher than the character count. With <code>HttpServer</code> the mismatch shows up as an exception when you write the extra bytes; behind other servers it shows up as truncated JSON that the client cannot parse.

Example · java
void main() {
    String json = "{\"note\":\"\u2713\",\"name\":\"Zoë\",\"city\":\"Kraków\",\"active\":true}";
    byte[] utf8 = json.getBytes(StandardCharsets.UTF_8);

    IO.println("chars: " + json.length());
    IO.println("bytes: " + utf8.length);

    // What a client receives if you announce chars but send bytes: the tail is cut off.
    String truncated = new String(utf8, 0, json.length(), StandardCharsets.UTF_8);
    IO.println("cut:   " + truncated);
}

// Output:
// chars: 55
// bytes: 59
// cut:   {"note":"✓","name":"Zoë","city":"Kraków","active":t

A list response with an envelope

<code>Collectors.joining</code> with a prefix and suffix produces a valid array, including the empty case. Catalogue text is escaped too, because staff-entered product names contain quotes as often as user input does; prices go out as integer cents to avoid floating-point rounding.

Example · java
static String quote(String s) {
    StringBuilder sb = new StringBuilder("\"");
    for (char c : s.toCharArray()) {
        if (c == '"' || c == '\\') sb.append('\\').append(c);
        else if (c < 0x20) sb.append(String.format("\\u%04x", (int) c));
        else sb.append(c);
    }
    return sb.append('"').toString();
}

record Product(String sku, String name, int priceCents) {
    String toJson() {
        return "{\"sku\":" + quote(sku) + ",\"name\":" + quote(name) + ",\"priceCents\":" + priceCents + "}";
    }
}

String listJson(List<Product> items, int page, int pageSize) {
    String data = items.stream()
            .map(Product::toJson)
            .collect(Collectors.joining(",", "[", "]"));
    return "{\"data\":" + data + ",\"page\":" + page + ",\"pageSize\":" + pageSize
            + ",\"count\":" + items.size() + "}";
}

void main() {
    List<Product> products = List.of(
            new Product("A-1", "Kettle", 2499),
            new Product("B-7", "Teapot \"Classic\"", 1850));
    IO.println(listJson(products, 1, 20));
    IO.println(listJson(List.of(), 2, 20));
}

// Output:
// {"data":[{"sku":"A-1","name":"Kettle","priceCents":2499},{"sku":"B-7","name":"Teapot \"Classic\"","priceCents":1850}],"page":1,"pageSize":20,"count":2}
// {"data":[],"page":2,"pageSize":20,"count":0}

Discussion

  • Be the first to comment on this lesson.