Route Introspection
app.routes() hands the routing table back as data: an immutable snapshot of record Route(String method, String path, String description), in registration order, which is also the order the router breaks ties in.
Routes are an explicit list, so no annotation scanning, no plugin, and no reflection is involved:
app.get("/_routes", req -> WebResponse.template("routes", Model.of("routes", app.routes())));
Because order breaks ties, a second route matching exactly the same requests (the same path, or the same shape with a variable renamed) could never run.
Registering one therefore throws IllegalStateException on the spot instead of leaving a dead entry in the table.
Group prefixes are already resolved, so a path reads as /api/decks/{deckId}/cards.
That form is OpenAPI’s path-template syntax verbatim, so an export needs no translation:
for (Route route : app.routes()) {
paths.put(route.path(), Json.object().put(route.method().toLowerCase(Locale.ROOT), ...));
}
A wildcard route is reported as it was written.
/files/\{path*} is one variable in the path template, and /files/* is a pattern with none, which is the difference the OpenAPI export acts on: it renders the first and refuses the second.
The handler is left out.
It is a lambda, and the only name it has is what reflection would dig out of its synthetic class.
Anything built on top of the list stays out of core too, because the list is plain data: the example app renders a /_routes overview page from it, and spider-silk-openapi turns the same list into an OpenAPI 3.1 document.
The automatic HEAD and OPTIONS answers are not listed.
routes() reports what was registered, which is the honest answer for a framework whose claim is that only what you register runs.
The matched route
req.route() reports, on the request, the entry of that same list that answered it.
Its path is the pattern with group prefixes resolved, so a tracing span, a metric, or a log line groups requests by /api/decks/{deckId} rather than by every id:
app.beforeRoute(req -> {
Span.current().updateName(req.method() + " " + req.route().path()); // "GET /api/decks/{deckId}"
return null;
});
req.route() is null before routing and where no route matched: in a beforeRequest filter, and for a static file, a 404, a 405, and the automatic OPTIONS answer.
From beforeRoute on, it stays set through the handler, the after-filters, the exception handlers and status pages, the response filters, and the request logger.
A HEAD answered by a GET route reports that GET route.
The route reported is the one the router chose, so it follows the registration order that breaks ties rather than a preference of its own.
Under the OpenTelemetry Java agent, a Spider Silk application is one servlet mapped at /*, so every request is the one span GET /* unless something reports the route.
spider-silk-opentelemetry-agent reports this route to the agent, so the span is named GET /api/decks/{deckId} with no code in the application.
Descriptions
A description states what a route is for, which is the one thing a method and a path do not say, and which nothing but the registration knows.
Every registration method takes it as an optional second argument, on App and on a group alike:
app.get("/api/decks", "List every deck", api::listDecks);
app.path("/api/decks", decks -> {
decks.post("", "Create a deck", api::createDeck);
decks.get("/{deckId}/cards", "List the cards of one deck", api::listCards);
});
The description sits between the path and the handler so the handler stays last, where a lambda reads best.
A route registered without one reports "" rather than null, so a reader can print route.description() without asking whether it is there.
A path pattern never holds whitespace, so a description passed where the path goes fails at registration with an IllegalArgumentException instead of registering a route nothing reaches.
A description is documentation written at the registration site, which is what an annotation is with the reflection taken out. Keeping it there is deliberate, because documentation kept anywhere else is a second place to keep in step with the registration. It stays a string argument and nothing more: the framework never reads it, and a route without one is registered, matched, and reported exactly as before.
spider-silk-openapi writes a non-empty description as the operation’s summary, and omits the field for a route that has none.
Hooks
routes() lists what answers a path.
app.hooks() lists what else runs around it: the beforeRequest, beforeRoute, and afterRoute filters, the statusPage(status, …) handlers, and the responseFilter(…) filters, read off the same registrations, as an immutable snapshot of a sealed Hook:
for (Hook hook : app.hooks()) {
String scope = switch (hook) {
case Hook.BeforeRequest before -> "before request " + before.path();
case Hook.BeforeRoute before -> "before " + before.path();
case Hook.AfterRoute after -> "after " + after.path();
case Hook.StatusPage page -> "status page " + page.status().code();
case Hook.EveryResponse ignored -> "every response";
};
}
There are five records rather than one with a kind field, because a filter is scoped to a path, a status page to a status, and a response filter to every response, and a component that is null half the time would report that dishonestly.
Hook.EveryResponse has no components, since it runs on every response, and registering two gives two entries.
The list is grouped by when it runs: the request filters, then the before-route filters, then the after-route filters, then the status pages, then the response filters. Within each group, entries follow registration order, which is also the order they run in.
A filter’s coverage is a pattern and not a path, and it is reported as one.
/admin/* stays /admin/*, and covers /admin along with everything under it.
beforeRoute(filter) reports the /* it registers, and a group’s decks.beforeRoute(filter) reports the resolved /api/decks/*.
Matching a request against those patterns is the dispatcher’s job, not this list’s: what is exposed here is plain strings, the same as for routes.
An audit of which paths a filter leaves open belongs on top of the list, the way the OpenAPI export is built on routes().
The exception(Type, handler) handlers are not listed.
An exception handler is scoped to a type, so neither a path nor a status describes where it applies.
CORS, compression, and the security headers are not listed either: those are named on App rather than registered as filters, with a fixed order after response filters.