Routing

A route is a method, a path pattern, and a handler, registered as one statement: get, post, put, patch, delete, and — rarely, as the last section explains — head and options. Routing is an explicit list: there is no annotation scanning, so only what is registered runs, and app.routes() hands the list back as data.

Path patterns

app.get("/decks", decks::list);                     // this path, exactly
app.get("/decks/{deckId}", decks::show);            // {deckId} matches one segment
app.before("/admin/*", req -> requireAdmin(req));   // /admin and everything under it

A pattern is compared segment by segment; there are no regular expressions. A {name} segment matches exactly one segment, never a slash, so /decks/{deckId} matches /decks/3 but not /decks or /decks/3/cards. A trailing slash on the request is ignored: /decks/ and /decks are the same path.

A final matches the rest of the path, including nothing at all, so /admin/ covers /admin, /admin/users, and /admin/users/7 — which is what a filter path usually means, a prefix and the prefix itself. * is only allowed as the last segment. A route can carry one too; the matched remainder is not captured as a variable, so req.path() is how such a handler reads it.

Path variables

What a {name} segment matched is read by name, typed at the reading end:

long deckId = req.pathParamLong("deckId");   // non-numeric input becomes a 400
String name = req.pathParam("name");
Direction direction = req.pathParamEnum("direction", Direction.class);

The value always exists when the handler runs — a request without the segment would not have matched the route — so the 400 covers the type, not the presence.

Groups

Routes sharing a prefix register through path, and the group is an argument rather than ambient state:

app.path("/api/decks", decks -> {
    decks.before(req -> requireApiKey(req));    // guards /api/decks and everything under it
    decks.get("", api::listDecks);              // GET  /api/decks
    decks.get("/{deckId}", api::showDeck);      // GET  /api/decks/{deckId}
    decks.path("/{deckId}/cards", cards ->     // groups nest, prefixes concatenate
        cards.get("", api::listCards));         // GET  /api/decks/{deckId}/cards
});

An empty path (or "/") registers the prefix itself. A group’s before/after with no path of its own filters the whole group. The prefix is resolved at registration, so app.routes() reports full paths: a group changes how routes are written, not what is registered.

Matching order

Registration order breaks ties between overlapping patterns, so a literal route registered before a variable one keeps winning:

app.get("/study/today", study::today);      // wins for /study/today
app.get("/study/{mode}", study::byMode);    // everything else under /study

A second route matching exactly the same requests — the same path, or the same shape with a variable renamed — could never run, so registering one throws IllegalStateException on the spot instead of leaving a dead entry in the table.

When nothing matches

A path no route knows answers 404; a path only other methods know answers 405, with an Allow header listing what would have worked. For a GET or HEAD, static files are consulted before the 404 — routes first, so a route can shadow a file. notFound(handler) is shorthand for error(HttpStatus.NOT_FOUND, handler), and Filters and Errors covers how an error handler fills in the body.

HEAD and OPTIONS

Both are answered without registering anything. A HEAD runs the GET route and drops the body, keeping the headers, including a Content-Length counted from what the GET would have sent. An OPTIONS answers with the Allow header the path’s routes imply, and a 404 when the path has none:

$ curl -X OPTIONS -i localhost:8080/decks
HTTP/1.1 200 OK
Allow: GET, POST, HEAD, OPTIONS

app.head(…​) and app.options(…​) register a route of their own when the automatic answer is not the one you want: a CORS preflight, usually. Neither automatic answer appears in app.routes(), which reports only what was registered.