Reading Request Bodies and Query Strings

Parse query strings and form bodies safely, decode them in the right order, and cap body size before it reaches your heap.

SyntaxString raw = exchange.getRequestURI().getRawQuery(); // split on & and =, then decode String value = URLDecoder.decode(part, StandardCharsets.UTF_8); byte[] body = exchange.getRequestBody().readNBytes(MAX + 1);

HttpServer hands you the raw request: a URI and an InputStream. Parsing parameters and bodies is your job, and the order of operations matters.

Query strings: split, then decode

Take the query with getRequestURI().getRawQuery(), split on &, split each pair on the first =, and only then decode each name and value with URLDecoder.decode(s, StandardCharsets.UTF_8). The decoded getQuery() is tempting but wrong for parameters: a value containing an encoded ampersand (%26) comes back as a literal & and splits into two parameters.

URLDecoder implements HTML form decoding, so it turns + into a space. That is what browsers send for forms and what most clients send in query strings, but it means a literal plus sign must arrive as %2B. A raw + in a phone number or a Base64 value is a classic source of "the space came from nowhere" bugs. URLDecoder also throws IllegalArgumentException on a malformed escape such as %zz; catch it and answer 400, or one bad link becomes a dropped connection.

Keys can repeat (?tag=a&tag=b), so collect into Map<String, List<String>>. Treat every value as untrusted text: parse numbers with a fallback or a 400, and enforce sensible limits such as a maximum page size.

Request bodies

getRequestBody() is a stream you can read once. For small JSON or form bodies, readAllBytes() and new String(bytes, StandardCharsets.UTF_8) are fine. Check Content-Type first and answer 415 Unsupported Media Type when it is not what the endpoint accepts.

Never call readAllBytes() on an unbounded body. A client can send gigabytes, and the server will try to hold them all on the heap. Read at most your limit plus one byte with readNBytes(max + 1); if you got more than max, answer 413 Content Too Large. Do not rely on Content-Length alone: a chunked request has none, and a client can lie about it.

Form bodies

An HTML form posted as application/x-www-form-urlencoded uses exactly the query string format in the body, so the same parser handles both. Multipart uploads (multipart/form-data) need a real parser; that is a good point to let a framework or library do the work.

Example

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

When to use it

  • Parse ?status=paid&page=2&tag=a&tag=b on a list endpoint without losing repeated keys.
  • Accept a classic HTML login or sign-up form posted as application/x-www-form-urlencoded.
  • Reject oversized webhook payloads with 413 before they are buffered in memory.

More examples

Why decoding first breaks parameters

The raw query keeps the encoded <code>%26</code>, so splitting it first preserves the value. For brevity this decodes whole pairs; a real parser splits on <code>=</code> first too, because a name or value can contain an encoded equals sign.

Example · java
void main() {
    URI uri = URI.create("/notes?title=salt%26pepper&size=2&phone=%2B441234");

    IO.println("raw:     " + uri.getRawQuery());
    IO.println("decoded: " + uri.getQuery());

    // Splitting the decoded form invents a parameter called "pepper".
    IO.println("wrong:   " + List.of(uri.getQuery().split("&")));

    List<String> right = new ArrayList<>();
    for (String pair : uri.getRawQuery().split("&")) {
        right.add(URLDecoder.decode(pair, StandardCharsets.UTF_8));
    }
    IO.println("right:   " + right);

    // URLDecoder treats '+' as a space, so a literal plus must be sent as %2B.
    IO.println("plus:    " + URLDecoder.decode("+441234", StandardCharsets.UTF_8) + "|");
}

// Output:
// raw:     title=salt%26pepper&size=2&phone=%2B441234
// decoded: title=salt&pepper&size=2&phone=+441234
// wrong:   [title=salt, pepper, size=2, phone=+441234]
// right:   [title=salt&pepper, size=2, phone=+441234]
// plus:     441234|

Reading a form-encoded POST body

The second request sends the same bytes without a <code>Content-Type</code>, because <code>HttpClient</code> never guesses one. Checking the type (lower-cased, since media types are case-insensitive) before parsing keeps a JSON or binary body from being misread as form fields.

Example · java
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

Map<String, String> parseForm(String body) {
    Map<String, String> form = new LinkedHashMap<>();
    for (String pair : body.split("&")) {
        String[] kv = pair.split("=", 2);
        form.put(URLDecoder.decode(kv[0], StandardCharsets.UTF_8),
                 kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : "");
    }
    return form;
}

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
    server.createContext("/signup", exchange -> {
        String type = exchange.getRequestHeaders().getFirst("Content-Type");
        int status;
        String text;
        if (type == null || !type.toLowerCase(Locale.ROOT).startsWith("application/x-www-form-urlencoded")) {
            status = 415;
            text = "expected a form";
        } else {
            String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
            Map<String, String> form = parseForm(body);
            status = 200;
            text = "welcome " + form.get("name") + " <" + form.get("email") + ">";
        }
        byte[] out = text.getBytes(StandardCharsets.UTF_8);
        exchange.sendResponseHeaders(status, out.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(out);
        }
    });
    server.start();
    URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/signup");

    try (HttpClient client = HttpClient.newHttpClient()) {
        String form = "name=Ada+Lovelace&email=ada%40example.com";
        HttpRequest withType = HttpRequest.newBuilder(uri)
                .header("Content-Type", "application/x-www-form-urlencoded")
                .POST(HttpRequest.BodyPublishers.ofString(form)).build();
        HttpRequest withoutType = HttpRequest.newBuilder(uri)
                .POST(HttpRequest.BodyPublishers.ofString(form)).build();
        for (HttpRequest request : List.of(withType, withoutType)) {
            HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
            IO.println(r.statusCode() + " " + r.body());
        }
    } finally {
        server.stop(0);
    }
}

// Output:
// 200 welcome Ada Lovelace <[email protected]>
// 415 expected a form

Capping the body size with 413

Memory use per request is now bounded at 17 bytes however much the client sends. Real limits are usually a few hundred kilobytes for JSON APIs; the point is that the limit is enforced while reading, not after.

Example · java
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

static final int MAX_BODY = 16;

void main() throws Exception {
    HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
    server.createContext("/notes", exchange -> {
        // Read one byte past the limit: that is enough to know the body is too big.
        byte[] body = exchange.getRequestBody().readNBytes(MAX_BODY + 1);
        String text = body.length > MAX_BODY
                ? "too large"
                : "stored " + body.length + " bytes";
        int status = body.length > MAX_BODY ? 413 : 201;
        byte[] out = text.getBytes(StandardCharsets.UTF_8);
        exchange.sendResponseHeaders(status, out.length);
        try (OutputStream os = exchange.getResponseBody()) {
            os.write(out);
        }
    });
    server.start();
    URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/notes");

    try (HttpClient client = HttpClient.newHttpClient()) {
        for (String note : List.of("buy milk", "x".repeat(10_000))) {
            HttpRequest request = HttpRequest.newBuilder(uri)
                    .header("Content-Type", "text/plain")
                    .POST(HttpRequest.BodyPublishers.ofString(note)).build();
            HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
            IO.println(r.statusCode() + " " + r.body());
        }
    } finally {
        server.stop(0);
    }
}

// Output:
// 201 stored 8 bytes
// 413 too large

Discussion

  • Be the first to comment on this lesson.