The Request

WebRequest is the handler’s view of what the client sent. The plain reads are listed first, followed by the parts that need more than a getter: cookies, repeated parameters, the query-string and form-body split, content negotiation, file uploads, and body size limits.

What the request is

Every ordinary question about a request has a method, so none of them needs raw().

req.method(); // "GET"
req.path(); // "/decks/3", with no query string on the end
req.header("Accept"); // the first value; the lookup ignores case
req.headers("X-Tag"); // every value of a repeated header, empty when absent
req.headers(); // every header, by name, each with all of its values
req.contentType(); // the body's declared type, null when the request sent none
req.queryString(); // "q=a+b&page=2", undecoded, null when the URL carried none
req.isSecure(); // whether the request arrived over TLS
req.scheme(); // "http" or "https"
req.host(); // "shop.example.com", with the port when it is not the default
req.remoteAddress(); // "203.0.113.7", the proxy's address when one is in front

req.scheme() + "://" + req.host() + req.path() is the absolute URL of the request. host() therefore carries the port whenever it is not the default for the scheme, since a development server on 8080 is the case where building the URL by hand goes wrong.

isSecure(), scheme(), and host() report what the container believes. Behind a TLS-terminating proxy that means the container has to be told to trust X-Forwarded-Proto, or a request that reached the browser over HTTPS still reads as plain HTTP here.

raw() remains for what these do not cover: an async context, a client certificate, a container-specific attribute. It is an escape hatch rather than a shortcut, and what is read through it is read behind the framework’s back.

Cookies

The request reads the cookies the client sent, and the response sets the ones the server sends. Cookies split along the same line as the rest of the API.

String theme = req.cookie("theme"); // null when absent
Map<String, String> all = req.cookies(); // a repeated name keeps the first value

WebResponse.cookie(…​) sets one, with the defaults The Response describes.

Absent values

A read’s name states what an absent value does. The plain name requires the value and answers 400 when it is absent: param(name), queryParam(name), formParam(name), and file(name). The same name with OrNull answers null instead: paramOrNull(name), queryParamOrNull(name), formParamOrNull(name), and fileOrNull(name). A default as the last argument makes a read optional without a null: param(name, default) and paramLong(name, default). header(name), cookie(name), and session().get(key) answer null under the plain name, because a request without that header, cookie, or attribute is the usual case rather than a bad request. JsonObject follows the same rule, as JSON describes.

Repeated parameters

params(name) reads every value a repeated parameter carries.

List<String> tags = req.params("tag"); // ?tag=java&tag=web, or a checkbox group

The values come back in request order, and an absent parameter gives an empty list: "no boxes checked" is an answer, not a 400. param(name) still returns the first value and still answers 400 when there is none. paramOrNull(name) returns the first value, or null when there is none. param(name, default) returns the default instead. A null default is spelled paramOrNull(name), because param(name, null) matches the parser form as well and does not compile. paramLong("page", 1) and paramEnum("direction", Direction.class, Direction.FRONT) supply a default for an optional typed parameter. The default covers absence only. A parameter that is present but unparseable is still a 400. paramBoolean(name) and paramBoolean(name, default) read true or false in any case and answer 400 for anything else, so ?archived=yes is rejected rather than read as false. A checkbox therefore carries value="true", or is read through params(name) where its presence is the answer.

Any other type: a parser

param(name, parser) reads a type that has no named form, where the parser is a Function<String, T> written at the call site.

LocalDate since = req.param("since", LocalDate::parse);
UUID owner = req.param("owner", UUID::fromString);
int page = req.param("page", Integer::parseInt, 1); // default covers absence only
int deckId = req.pathParam("deckId", Integer::parseInt); // path variables too

A parser that rejects the text answers 400 naming the parameter, the same contract the named forms have. Rejecting means throwing IllegalArgumentException or DateTimeException, which is what UUID::fromString, Integer::parseInt, and LocalDate::parse already do. Anything else the parser throws is a fault in the parser rather than in the request, and stays a 500. No reflection is involved: the mapping is the lambda you wrote.

Only the request-wide param and the path variables have named forms: paramLong, paramBoolean, paramEnum, pathParamLong, and pathParamEnum. The query string and the form body read every other type through a parser, so there is no queryParamLong to look for.

Query string vs. form body

The servlet API merges the two, so param("id") answers an id in the URL and an id in the form alike. Name the source when the difference matters:

String page = req.queryParamOrNull("page"); // query string only, null when absent
String name = req.formParam("name"); // form body only, 400 when absent
List<String> tags = req.formParams("tag");

A form body is read on POST, PUT, and PATCH, form-encoded or multipart, the same on every server. On any other method its fields are not read, and formParam answers as it does for a request with no form.

A parser reads a named source into a type, with the contract param(name, parser) has:

LocalDate due = req.formParam("due", LocalDate::parse); // 400 when the form carries none
int page = req.queryParam("page", Integer::parseInt, 1); // default covers absence only

queryParam(name) and formParam(name) require a value, with or without a parser, and answer 400 when it is absent. queryParamOrNull(name) and formParamOrNull(name) return null when it is absent. An empty string is a supplied value and is passed to the parser unchanged. A default makes the parser form optional, and a value the parser rejects is a 400 either way. A name the other source carries does not count, so formParam("page", parser) answers 400 on a request whose only page is in the URL. There is no (name, defaultString) form, since queryParamOrNull(name) already covers the optional string without overlapping the parser overload.

A query string that will not decode, such as ?q=%zz or ?q=%FF, whose byte is not UTF-8, answers 400 through param, queryParam, and formParam in every form, whichever name is read. A request with no form body, neither form-encoded nor multipart or on another method, answers param and params from the query string alone, so the container’s parser never sees it. A form body the container will not parse answers 400 through param, params, and formParam in every form, with the container’s reason as the message. That covers an escape that will not decode and a body over the container’s own form limit. A multipart form answers as file(name) does: 413 for a part or a body over the multipart limits, and 400 for one that will not parse. A form whose Content-Type names a charset this JVM cannot decode answers 415 through those reads and through file, fileOrNull, and files, as body() does. Undertow drops a field whose escape will not decode, and refuses a form over its own limit with 413 before the application runs.

Content negotiation

accepts reads the Accept header and returns the media type the caller prefers, out of the ones the handler offers:

app.get("/decks", req -> switch (req.accepts("text/html", "application/json")) {
    case "application/json" -> WebResponse.json(deckService.decks(), Codecs::writeDecks);
    default -> WebResponse.template("decks", Model.of("decks", deckService.decks()));
});

The answer is one of the strings passed in, so it reads back as a case label and no media type is spelled twice. Quality values and specificity are applied the way the specification asks: text/html is matched before text/*, which is matched before a bare wildcard, and a type the caller weighted q=0 is refused even when a wildcard would otherwise cover it.

Two edges are worth stating, because they are the ones every hand-written parser gets wrong:

  • A request with no Accept at all means the caller has no preference, not that it will take nothing. The first candidate wins, so the list is written in the order the handler prefers.

  • A caller that will take none of them is answered 406 Not Acceptable, the same way param(name) answers 400 rather than returning null.

  • An Accept sent on several lines is read as the lines joined with commas, as RFC 9110 reads a list field, and so is the Accept-Encoding that app.gzip() and pre-compressed static files read.

A handler therefore never has a null branch to write.

Calling accepts or acceptedTypes() makes the answer depend on a request header, so the response carries Vary: Accept without the handler saying so. That covers the 406, and an answer that turned out to be the only type on offer. That is the same bookkeeping app.gzip() does for Accept-Encoding, for the same reason: a shared cache must not hand JSON to the next browser.

req.acceptedTypes() is the parsed view behind accepts, for the rare decision accepts cannot phrase. It reports the media types asked for, best first, with the refused ones dropped, and is empty when the request sent no Accept.

Accept-Language and Accept-Encoding are not negotiated for you. Encoding is the compression layer’s own business, and language negotiation would need locale support that core does not have.

File uploads

req.file(name) reads a file out of a multipart form, answering 400 when the part is missing and 400 when the request is not multipart at all:

app.post("/decks/{deckId}/import", req -> {
    UploadedFile file = req.file("file");
    int imported = deckService.importCsv(req.pathParamLong("deckId"), file.text());
    req.flash("message", "Imported " + imported + " cards.");
    return WebResponse.redirect("/decks/" + req.pathParam("deckId"), HttpStatus.SEE_OTHER);
});

UploadedFile answers fileName(), contentType(), and size(), and hands over the content four ways. fileName() is the name the client sent, unchecked, so it can hold ../ or a path of the client’s choosing; it is for display, and a file written to disk goes under a name the application makes, as in the example below. bytes() and text() hold it in memory, as one byte array or as UTF-8 text. inputStream() and writeTo(path) do not, so an upload of any size reaches a parser or a file with no byte array in between:

app.post("/scans", req -> {
    Path saved = uploads.resolve(UUID.randomUUID() + ".pdf");
    req.file("scan").writeTo(saved);
    return WebResponse.text("saved");
});

The caller closes the stream. inputStream() and bytes() read the same content, and what has been read is read, so a handler that wants the bytes as well buffers them itself rather than asking twice. writeTo takes an absolute path, or a relative one resolved against the working directory. Call it once, after any other read of the content, because a container that had already buffered the upload to disk may move that file rather than copy it.

req.fileOrNull(name) answers null for an upload the form need not carry, which is the shape req.cookie(name) and req.queryParamOrNull(name) already have for a value whose absence is an answer. Null covers every way the file can be absent: no part of that name, a file input the browser sent empty because nothing was chosen, and a request that is not multipart at all. req.file(name) answers 400 for all three of those, since a handler that asked for the file plainly needs it.

req.files(name) answers every file sent under one field name, in the order the request sent them, which is what an <input type="file" multiple> produces. The list is empty when the field carried none, the way req.params(name) is empty for a repeated parameter that was not sent. Only the parts that carry a file are counted, so the text fields of the same multipart form are still read with req.param(name).

The limits are the servlet container’s multipart configuration, set on the server: the maximum sizes, and where a large upload is buffered. multipart(new MultipartConfigElement(…​)) sets them on JettyServer, TomcatServer, and UndertowServer alike. The default buffers to the system temp directory with no size cap and a 1MB in-memory threshold. multipart(null) turns multipart handling off, and a parameter read of a multipart request then answers from the query string alone, on every server. Every server also takes at most 1000 parts in one form, text fields included, and Jetty and Tomcat at most 8KB of headers per part; beyond that Jetty and Tomcat answer 413 and Undertow 400.

An upload the container refuses is a failed upload, not an absent one. A part or a body over those limits answers 413, and a multipart body that will not parse, such as one cut off before its closing boundary, answers 400. file(name), fileOrNull(name), and files(name) answer both alike, so a handler never reads a failed upload as a form with nothing chosen. The Content-Type decides whether a request is multipart at all: only multipart/form-data is.

Body size limits

body() and bodyNdjson(reader) hold at most 1MB of a request body in memory, and answer 413 for more. body(), and bodyJson() over it, hold the whole body, so their limit bounds the body. bodyNdjson(reader) holds one line at a time, so its limit bounds a line and leaves the number of lines free. app.bodyLimits(BodyLimits) changes either limit:

app.bodyLimits(BodyLimits.defaults()
        .maxBytes(8 * 1024 * 1024) // body() and bodyJson(): 8MB
        .maxNdjsonLineBytes(64 * 1024)); // one NDJSON line: 64KB

Both limits count bytes, not characters. The bytes are counted as they arrive, before any character is decoded, so é counts as two in UTF-8. A limit of N bytes reads a body of exactly N bytes and refuses one of N + 1. A line’s count leaves out its line break, including the \r of a \r\n break.

The limit does not trust Content-Length. A declared length over the limit is refused before a byte is read. A body that declares no length, such as a chunked one, is refused at the first byte past the limit, and so is one that declares less than it sends. An NDJSON line is refused as soon as it outgrows the limit, so a line that never ends costs the limit and one 8KB chunk rather than the whole line.

The 413 is an HttpException with the status CONTENT_TOO_LARGE, and takes the ordinary error path through exception(…​) and statusPage(…​). A body refused once stays refused. A handler that catches the exception and reads again gets another 413 from body(), bodyStream(), and bodyReader(), never the rest of a body whose start was already consumed. A body that ends before its Content-Length, or whose connection fails while it is read, answers 400 through body(), bodyJson(), and bodyNdjson(reader), and stays refused the same way.

bodyStream() and bodyReader() are not limited. A caller that reads a stream decides for itself how much of it to keep, so a large upload streamed to a file or to a parser needs no setting. A handler that takes a large body reads it through bodyStream() from the start, and copies it to a file with no byte array in between:

app.put("/backups", req -> {
    Path saved = backups.resolve(UUID.randomUUID() + ".tar.gz");
    try (InputStream in = req.bodyStream()) {
        Files.copy(in, saved);
    }
    return WebResponse.text(saved.getFileName().toString()).status(HttpStatus.CREATED);
});

The body goes out once, one way, so this handler never calls body() on the same request. A second reader of any kind after bodyStream(), bodyReader(), or bodyNdjson(reader) throws IllegalStateException, except that asking for the same stream or reader again answers it where the first caller left it. A 413 that body() already answered is not undone by a later bodyStream(), so the endpoint chooses the stream up front. Nothing bounds this copy but the handler itself and whatever proxy stands in front of the application, so a handler that must cap an upload counts the bytes it copies.

BodyLimits.unlimited() lifts both limits, for an application whose proxy or container already bounds the body.

Neither limit counts form fields or multipart uploads, because the servlet container parses those. A form’s size is the container’s own setting, and an upload’s is the multipart configuration the previous section describes.

AppServlet enforces the limits, so they hold the same way under Jetty, Tomcat, Undertow, and an external container. A limit the container or a proxy in front of it sets applies in addition, and the lower of the two answers first.

The defaults change what an existing application accepts. A body over 1MB read through body() or bodyJson(), or an NDJSON line over 1MB, now answers 413 until bodyLimits raises the limit.