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, and file uploads.
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.
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, and a null default is spelled paramOrNull(name): 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.
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 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, answers 400 through param, queryParam, and formParam in every form, whichever name is read.
Content negotiation
accepts reads the Accept header and reports which of the media types a handler offers the caller wants:
app.get("/decks", req -> switch (req.accepts("text/html", "application/json")) {
case "application/json" -> WebResponse.json(deckService.decks(), Codecs::writeDecks);
default -> WebResponse.template("decks", Map.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 one it refused even when a wildcard would otherwise have covered it.
Two edges are worth stating, because they are the ones every hand-written parser gets wrong:
-
A request with no
Acceptat all is a caller that never said, not one that 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 wayparam(name)answers 400 rather than returning null.
A handler therefore never has a null branch to write.
Asking the question 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 would be a locale story 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.asText());
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.
bytes() and asText() 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, and it is the same content either way: 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.
It writes the content once, 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.