Routing by Path and Method
Match method and path with a route table, pull ids out of the path, and answer 404 and 405 correctly instead of guessing.
server.createContext("/", exchange -> {
String method = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
// find routes by path, then by method: 404, 405 + Allow, or dispatch
});Routing decides which code handles a request. HttpServer only routes by path prefix: createContext("/users", handler) sends every request whose path starts with /users to that handler, whatever the method. Everything else, including the method check and path parameters such as the 42 in /users/42, is up to you.
How contexts match
When several contexts match, the longest prefix wins, so /api/admin beats /api, which beats /. Two details catch people out:
- The match is a plain string prefix. A context at
/usersalso receives/usersettings. Check for an exact match or a following slash. - A context at
/receives every request no other context claims, so the server's built-in 404 never fires. Your root handler must return 404 itself.
A route table
The usual pattern is one context per resource (or a single / context) that dispatches through a table of routes. Each route holds a method and a regular expression for the path. Match the path first, then the method:
- No route matches the path: 404 Not Found.
- Routes match the path but none has this method: 405 Method Not Allowed, with an
Allowheader listing the methods that do work. Clients and API gateways use it, and HTTP requires it on a 405.
Answering 404 for a wrong method is the common mistake: the client then believes the resource does not exist.
record Route(String method, Pattern path, BiFunction<HttpExchange, Matcher, Result> handler) {}
Path parameters
Named groups such as (?<id>\d+) keep the extraction readable: matcher.group("id"). Constrain the pattern (digits only, a UUID shape) so /users/abc is a clean 404 instead of a NumberFormatException deep inside the handler. Match against getRequestURI().getPath(), which is already percent-decoded and has the query string removed.
Decide on trailing slashes once. Either treat /users/ as /users (with /? at the end of the pattern) or redirect; do not leave it to chance. Once you have dozens of routes, versioned prefixes and per-route filters, this table is exactly what Spring MVC's @GetMapping and friends maintain for you.
Example
When to use it
- Serve GET, PUT and DELETE on /orders/{id} from one context and answer 405 with Allow for anything else.
- Mount /api/admin separately from /api so it can carry its own authentication filter.
- Reject /users/abc with a clean 404 before any handler tries to parse the id.
More examples
The prefix trap and its fix
The <code>/usersettings</code> request reached the users handler, which proves the context match is a plain string prefix. The one-line <code>underPrefix</code> check is what keeps an unrelated endpoint from leaking into the wrong resource.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
boolean underPrefix(String path, String prefix) {
return path.equals(prefix) || path.startsWith(prefix + "/");
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/users", exchange -> {
String path = exchange.getRequestURI().getPath();
int status = underPrefix(path, "/users") ? 200 : 404;
byte[] body = ("context saw " + path).getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(status, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/users", "/users/7", "/usersettings")) {
HttpResponse<String> r = client.send(
HttpRequest.newBuilder(URI.create(base + path)).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(r.statusCode() + " " + r.body());
}
} finally {
server.stop(0);
}
}
// Output:
// 200 context saw /users
// 200 context saw /users/7
// 404 context saw /usersettingsLongest prefix wins, and / catches the rest
Registration order does not matter; the most specific context is chosen. The last line shows why a root context must produce its own 404: browsers and scanners will hit paths you never planned for, and here they all get a 200.
import com.sun.net.httpserver.HttpHandler;
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
HttpHandler named(String name) {
return exchange -> {
byte[] body = name.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
};
}
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/", named("root"));
server.createContext("/api", named("api"));
server.createContext("/api/admin", named("admin"));
server.start();
String base = "http://127.0.0.1:" + server.getAddress().getPort();
try (HttpClient client = HttpClient.newHttpClient()) {
for (String path : List.of("/api/admin/users", "/api/orders", "/favicon.ico")) {
HttpResponse<String> r = client.send(
HttpRequest.newBuilder(URI.create(base + path)).build(),
HttpResponse.BodyHandlers.ofString());
IO.println(path + " -> " + r.body() + " (" + r.statusCode() + ")");
}
} finally {
server.stop(0);
}
}
// Output:
// /api/admin/users -> admin (200)
// /api/orders -> api (200)
// /favicon.ico -> root (200)A router you can test without a server
Keeping the matching logic free of <code>HttpExchange</code> means you can unit test every route in milliseconds. The sealed result forces the caller to handle 404 and 405 explicitly, and the trailing <code>/?</code> settles the trailing slash question in one place.
import java.util.regex.Matcher;
import java.util.regex.Pattern;
sealed interface Match permits Found, NotFound, WrongMethod {}
record Found(String handler, Map<String, String> params) implements Match {}
record NotFound() implements Match {}
record WrongMethod(List<String> allow) implements Match {}
record Route(String method, Pattern pattern, String handler, List<String> names) {}
Route route(String method, String regex, String handler, String... names) {
return new Route(method, Pattern.compile(regex), handler, List.of(names));
}
List<Route> routes = List.of(
route("GET", "/orders/(?<orderId>\\d+)/items/(?<itemId>\\d+)/?", "getItem", "orderId", "itemId"),
route("PUT", "/orders/(?<orderId>\\d+)/items/(?<itemId>\\d+)/?", "putItem", "orderId", "itemId"));
Match match(String method, String path) {
List<String> allow = new ArrayList<>();
for (Route r : routes) {
Matcher m = r.pattern().matcher(path);
if (!m.matches()) continue;
if (!r.method().equals(method)) {
allow.add(r.method());
continue;
}
Map<String, String> params = new LinkedHashMap<>();
for (String name : r.names()) params.put(name, m.group(name));
return new Found(r.handler(), params);
}
return allow.isEmpty() ? new NotFound() : new WrongMethod(allow);
}
void main() {
IO.println(match("GET", "/orders/7/items/3"));
IO.println(match("PUT", "/orders/7/items/3/"));
IO.println(match("DELETE", "/orders/7/items/3"));
IO.println(match("GET", "/orders/seven/items/3"));
}
// Output:
// Found[handler=getItem, params={orderId=7, itemId=3}]
// Found[handler=putItem, params={orderId=7, itemId=3}]
// WrongMethod[allow=[GET, PUT]]
// NotFound[]
Discussion