The Response

A WebResponse is an immutable value. Every method returns a new response, so an after-filter can rewrite the response a route returned. A result left unused is therefore a change lost. WebResponse carries Error Prone’s @CheckReturnValue, so Error Prone and IntelliJ flag such a call at compile time.

The immutability covers the envelope: the status, the headers, the cookies, and the entries of a template model. A cookie is copied when it is added and again when cookies() hands it out, so changing a Cookie object afterwards changes nothing the response sends. A template model is a Model, which is immutable, so nothing the handler does after building it reaches the page. The copies are shallow, and a mutable object stored as a model value is still the one the handler passed. The array of a bytes body and the writer of a stream body are handed over rather than copied, because those two bodies exist to avoid a second copy of a large body. The value of a json body is held rather than copied as well, and is read when the answer is written, so a value the handler changes after returning it changes the answer.

Bodies

html, text, rawJson(text), json(JsonValue), json(value, writer), bytes(contentType, data), template(name), template(name, model), stream(contentType, out → …​), file(path), sse(stream → …​), raw((req, res) → …​). The content type is the first argument of every body that takes one. A template name carries no extension. The engine appends its own, as Templates describes.

body() returns a sealed WebResponse.Body: Empty, Text, Bytes, Json, Template, Stream, Sse, or Raw. A switch over it needs no default case, and a test can assert on the answer without a servlet response to read it out of. WebResponse.json answers with Json, which holds the value and its writer, and its toJson() writes the document for a test to read:

JsonObject requestBody = Json.object().put("name", "Spanish");
WebResponse response = controller.createDeck(TestRequest.post("/api/decks")
        .jsonBody(requestBody)
        .build());

assertThat(response.status()).isEqualTo(HttpStatus.CREATED);
assertThat(response.header("Location")).isEqualTo("/api/decks/1");
assertThat(((WebResponse.Json<?>) response.body()).toJson()).isEqualTo("{\"id\":1}");

The servlet writes a Json body once every filter has answered, while exception handling still applies, so a writer that throws reaches an exception handler. It writes the document into the buffer the thread keeps, sends that buffer after a Content-Length, and hands it back, so no array the size of the document is allocated on the way to the client. The buffer never leaves the servlet, so a response held as a constant or kept by a filter is written again for every request it answers.

body(replacement) preserves all headers. You must update or remove metadata that describes the previous body, including Content-Length, ETag, Last-Modified, and Content-Encoding. Set Content-Type to the replacement type when it changes. For example, a filter replacing a body with plain text removes its old metadata:

return response.body(new WebResponse.Text(replacement))
        .contentType("text/plain; charset=UTF-8")
        .withoutHeader("Content-Length")
        .withoutHeader("ETag")
        .withoutHeader("Last-Modified")
        .withoutHeader("Content-Encoding");

The writer computes the new length for text, bytes, and JSON when no length is set. A streaming replacement may omit a length.

A file on disk

file(path) answers with the content type the file’s name implies, its size as Content-Length, and a Stream body.

app.get("/decks/{deckId}/export", req ->
        WebResponse.file(service.writeExport(req.pathParamLong("deckId")))
                .attachment("deck.csv"));

The type comes from the extension, and an extension the table does not know answers application/octet-stream. When a file’s name says nothing useful about its content, .contentType(…​) states the type and overrides the one worked out from the name. The table itself is not public: an application uses it only by answering with a file.

attachment(name) sets Content-Disposition, so the browser saves the answer under that name. A header value is ISO-8859-1 on the wire, and a quote would end the quoted name early. A name outside printable ASCII, or one holding a quote or a backslash, therefore also goes out as RFC 6266’s filename*, percent-encoded UTF-8, beside an ASCII fallback with " and \ escaped. Every browser in use reads filename* first, so attachment("덱.csv") is saved as 덱.csv. A control character in the name, a line break included, throws IllegalArgumentException.

A path that is not a readable regular file throws UncheckedIOException rather than answering 404. The framework cannot tell an export that was cleaned up from a path the handler built wrong, and the handler can. A handler that wants a 404 for a missing file checks for the file before calling file:

if (!Files.isRegularFile(export)) {
    throw new HttpException(HttpStatus.NOT_FOUND, "No export for that deck");
}
return WebResponse.file(export);

The check runs when the response is built, not while the body is written, so the failure is still inside the handler and app.exception(…​) can answer it.

Validators and conditional requests belong to Static Files, not here. A file a handler chose is not a static file, and only the handler knows whether it can change.

Headers

header(name, value) sets a header and header(name) reads one back, under any spelling of the name. Field names are case-insensitive in HTTP, and the map that holds the headers compares them the same way. withoutHeader(name) removes a header under any spelling and preserves the other headers, cookies, status, and body.

WebResponse response = WebResponse.text("ok").header("X-Trace-Id", trace);

response.header("x-trace-id"); // the trace id: the spelling asked for does not matter
response.headers(); // every field, in the order they were set

A response holds one value per field. Setting a field again, under whatever spelling, replaces the value and leaves the name and its position alone, so headers() reads back exactly what goes on the wire. vary(field) is the one method that adds rather than replaces, because Vary holds a list and compression and CORS each add a field to it (Accept-Encoding and Origin).

A header sent more than once is out of scope, and headers() says so by its type: a Map<String, String> promises one value per name. Cookies are the exception that has a list of its own, added with cookie(…​) and read back with cookies(). Any other header that must repeat, such as two Link lines in one answer, is written through raw:

return WebResponse.raw((req, res) -> {
    res.addHeader("Link", "<https://example.com/decks?page=2>; rel=\"next\"");
    res.addHeader("Link", "<https://example.com/decks?page=9>; rel=\"last\"");
    res.setStatus(HttpStatus.NO_CONTENT.code());
});

Statuses

empty(), empty(HttpStatus), noContent(), redirect(location), redirect(location, HttpStatus).

A status is an HttpStatus constant, so a code that does not exist cannot be written. HttpStatus.of(int) looks one up when the number arrives only at runtime.

Redirects

redirect(location) answers 302 Found, with the location in the Location header. A character a header cannot carry, anything outside ASCII, a space, or a control character, is percent-encoded as UTF-8, as a browser encodes an href, and the rest goes out as given: redirect("/decks/한국어") sends /decks/%ED%95%9C%EA%B5%AD%EC%96%B4. A path like /decks/3 is what an application normally wants, and is what HTTP allows.

WebResponse.redirect("/decks/" + deck.id()); // 302
WebResponse.redirect("/decks", HttpStatus.SEE_OTHER); // 303, after a POST
WebResponse.redirect("/new-home", HttpStatus.MOVED_PERMANENTLY); // 301, moved for good
WebResponse.redirect("/api/v2/decks", HttpStatus.PERMANENT_REDIRECT); // 308, keeps the method

302 is the default because a redirect is reversible only while it is temporary. Browsers and intermediaries cache a 301, often indefinitely, so a wrong one keeps sending visitors to the wrong place long after the code is fixed. 302 is also what HttpServletResponse.sendRedirect sends, and what Javalin, Spark, Spring Web MVC, Express, Rails, and Django all default to.

The second argument states another status, and takes an HttpStatus rather than an int for the same reason every other status does. It rejects anything that is not a 3xx. A Location header on a 200 is not a redirect, and failing at the call is better than shipping a response no client will follow.

Two of the four are worth knowing by name. 303 is what a redirect after a POST means. 302 does that job in practice, and 303 says it exactly. 307 and 308 keep the request method. A browser following a 301 or 302 after a POST will usually issue a GET, and those two forbid the change.

Cookies

return WebResponse.html(page)
        .cookie("theme", "dark") // session cookie
        .cookie("token", value, Duration.ofDays(7)) // survives a browser restart
        .expireCookie("stale");

The two-argument form sets Path=/, HttpOnly, and SameSite=Lax, which is what a cookie holding anything worth stealing should have. cookie(Cookie) takes a hand-built jakarta.servlet.http.Cookie for Secure, a Domain, or SameSite=None. The response keeps a copy of it, attributes included. expireCookie(name) tells the browser to delete a cookie set with the defaults. It adds a Set-Cookie for that name with Max-Age=0 rather than taking a cookie out of this response. A cookie set with another path or a domain is expired with cookie(Cookie), carrying the same path and domain and a max age of zero. A value RFC 6265 does not allow, one with a space, a comma, a semicolon, a backslash, a quote, or anything outside ASCII, throws IllegalArgumentException where the cookie is set, so it reaches exception(…​) like any other failure of the handler. A value that has to carry such characters is encoded first, with Base64.getUrlEncoder() for instance. The Request covers reading the cookies the client sent.

The lambda families

stream, sse, and raw each take a lambda of their own, and the suffix of the lambda’s type names the family it belongs to. A …Handler answers a request with a WebResponse: Handler and ExceptionHandler. A …Writer returns nothing and fills the body of a response that has already been decided: StreamWriter(OutputStream), SseWriter(SseStream), and ServletWriter(HttpServletRequest, HttpServletResponse). ServletWriter is therefore a sibling of StreamWriter, not a relative of Handler.