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.

Syntaxserver.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 /users also 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 Allow header 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

Try it yourself
Loading editor…
Press Run to execute the code.

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.

Example · java
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 /usersettings

Longest 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.

Example · java
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.

Example · java
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

  • Be the first to comment on this lesson.