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.
String 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
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.
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.
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 formCapping 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.
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