HttpClient: POSTing JSON
Send JSON with POST, PUT, PATCH and DELETE, set the headers the server needs, and make retried writes safe with an idempotency key.
HttpRequest.newBuilder(uri)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json)) // PUT(...), DELETE(), method("PATCH", ...)
.build();Writing to another service means sending a body. With HttpClient you pass a BodyPublisher to the method on the request builder: POST(publisher), PUT(publisher), DELETE(), or method("PATCH", publisher) for anything without its own shortcut.
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
Content-Type is your job
The client works out Content-Length from the publisher, but it never sets Content-Type. Without it, many servers answer 415 Unsupported Media Type, and some frameworks try to read the body as a form and see no fields at all, which then shows up as a confusing validation error. Set Accept: application/json as well when you want JSON errors back.
BodyPublishers.ofString(s) encodes with UTF-8, which is what JSON requires. Other publishers cover the other cases: ofByteArray for bytes you already have, ofFile(path) to upload from disk without loading the file, and noBody() for a POST that carries nothing.
Reading the result
A create usually answers 201 Created with a Location header and the new resource in the body; an update may answer 200 or 204. Check for the codes you expect, and treat a 4xx as a bug or bad data on your side (log the response body, it usually says what was wrong) and a 5xx as a failure that may be retried later.
Retries and idempotency
When a POST times out you do not know whether the server processed it. Retrying blindly can create two orders or two payments. The common fix, used by payment providers among others, is an idempotency key: generate a unique value per logical operation (a UUID), send it in a header such as Idempotency-Key, and reuse the same value on every retry. The server stores the result under that key and replays it instead of doing the work again. PUT and DELETE are idempotent by definition, so they can be retried without one.
The JDK has no JSON mapper, so for anything beyond a few fields build the body with Jackson or JSON-B. Never assemble JSON by concatenating unescaped user input, on the client any more than on the server.
Example
When to use it
- Create an order in a downstream fulfilment service and store the id from its 201 response.
- Update a user profile in another service with PATCH, sending only the changed fields.
- Retry a timed-out payment request safely by resending it with the same idempotency key.
More examples
The missing Content-Type
Both requests carry identical bytes; only the header differs. The server compares with <code>startsWith</code> on the lower-cased value because a <code>charset</code> parameter is allowed after the media type, and <code>discarding()</code> is the right body handler when only the status matters.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/events", exchange -> {
String type = exchange.getRequestHeaders().getFirst("Content-Type");
exchange.getRequestBody().readAllBytes();
boolean json = type != null && type.toLowerCase(Locale.ROOT).startsWith("application/json");
exchange.sendResponseHeaders(json ? 202 : 415, -1);
exchange.close();
});
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/events");
String event = "{\"type\":\"signup\",\"user\":\"u-7\"}";
try (HttpClient client = HttpClient.newHttpClient()) {
HttpRequest bare = HttpRequest.newBuilder(uri)
.POST(HttpRequest.BodyPublishers.ofString(event)).build();
HttpRequest typed = HttpRequest.newBuilder(uri)
.header("Content-Type", "application/json; charset=utf-8")
.POST(HttpRequest.BodyPublishers.ofString(event)).build();
IO.println("without header: " + client.send(bare, HttpResponse.BodyHandlers.discarding()).statusCode());
IO.println("with header: " + client.send(typed, HttpResponse.BodyHandlers.discarding()).statusCode());
} finally {
server.stop(0);
}
}
// Output:
// without header: 415
// with header: 202PUT, PATCH and DELETE
PUT replaces the whole resource, so it sends every field; PATCH sends only the changes, here in the JSON Merge Patch format (RFC 7396). There is no <code>PATCH()</code> shortcut on the builder, so <code>method("PATCH", ...)</code> is the standard way to send one.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/users/7", exchange -> {
String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8);
byte[] out = (exchange.getRequestMethod() + " " + (body.isEmpty() ? "(no body)" : body))
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, out.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(out);
}
});
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/users/7");
List<HttpRequest> requests = List.of(
HttpRequest.newBuilder(uri).header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString("{\"name\":\"Ada\",\"role\":\"admin\"}")).build(),
HttpRequest.newBuilder(uri).header("Content-Type", "application/merge-patch+json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("{\"role\":\"viewer\"}")).build(),
HttpRequest.newBuilder(uri).DELETE().build());
try (HttpClient client = HttpClient.newHttpClient()) {
for (HttpRequest request : requests) {
IO.println(client.send(request, HttpResponse.BodyHandlers.ofString()).body());
}
} finally {
server.stop(0);
}
}
// Output:
// PUT {"name":"Ada","role":"admin"}
// PATCH {"role":"viewer"}
// DELETE (no body)Safe retries with an idempotency key
The retry carries the same key, so the server replays the stored result and the card is charged once. In production the key is usually a random UUID created when the operation starts and stored with it; a name-based UUID is used here only to keep the example deterministic.
import com.sun.net.httpserver.HttpServer;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
void main() throws Exception {
Map<String, String> processed = new ConcurrentHashMap<>(); // key -> stored response
AtomicInteger charges = new AtomicInteger();
HttpServer server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
server.createContext("/payments", exchange -> {
exchange.getRequestBody().readAllBytes();
String key = exchange.getRequestHeaders().getFirst("Idempotency-Key");
// computeIfAbsent runs the charge only for a key it has not seen.
String result = processed.computeIfAbsent(key,
k -> "{\"paymentId\":\"p-" + charges.incrementAndGet() + "\"}");
byte[] body = result.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(201, body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
URI uri = URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/payments");
// One logical operation gets one key, reused for every attempt.
String key = UUID.nameUUIDFromBytes("order-501-payment".getBytes(StandardCharsets.UTF_8)).toString();
HttpRequest request = HttpRequest.newBuilder(uri)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString("{\"amountCents\":2500}"))
.build();
try (HttpClient client = HttpClient.newHttpClient()) {
for (int attempt = 1; attempt <= 2; attempt++) { // pretend the first response was lost
HttpResponse<String> r = client.send(request, HttpResponse.BodyHandlers.ofString());
IO.println("attempt " + attempt + ": " + r.statusCode() + " " + r.body());
}
IO.println("cards charged: " + charges.get());
} finally {
server.stop(0);
}
}
// Output:
// attempt 1: 201 {"paymentId":"p-1"}
// attempt 2: 201 {"paymentId":"p-1"}
// cards charged: 1
Discussion