A command-line interface turns arguments into an operation and communicates its result through output and an exit code. An HTTP API has a similar boundary: a method and path select an operation, while status, headers and a body describe the result. Keep parsing at those boundaries and put reusable behavior in ordinary methods.
This Java 21 example starts a local server, calls it, and stops it. It needs no public service, credentials or third-party dependencies. HttpClient belongs to Java SE; the small HttpServer belongs to the documented JDK-specific jdk.httpserver module. Neither requires preview flags.
Run both sides locally
Save as ApiDemo.java:
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
public class ApiDemo {
static void handle(HttpExchange exchange) throws IOException {
try (exchange) {
int status;
String body;
if (!exchange.getRequestMethod().equals("GET")) {
status = 405;
body = "{\"error\":\"method not allowed\"}";
exchange.getResponseHeaders().set("Allow", "GET");
} else if (exchange.getRequestURI().getPath().equals("/health")) {
status = 200;
body = "{\"status\":\"ok\"}";
} else {
status = 404;
body = "{\"error\":\"not found\"}";
}
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set(
"Content-Type", "application/json; charset=utf-8");
exchange.sendResponseHeaders(status, bytes.length);
exchange.getResponseBody().write(bytes);
}
}
static int run(String[] args) throws IOException, InterruptedException {
String endpoint = args.length == 0 ? "health" : args[0];
String method = args.length < 2 ? "GET" : args[1];
if (args.length > 2 || !List.of("health", "missing").contains(endpoint)
|| !List.of("GET", "POST").contains(method)) {
System.err.println("Usage: java ApiDemo [health|missing] [GET|POST]");
return 2;
}
HttpServer server = HttpServer.create(
new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", ApiDemo::handle);
server.start();
try (HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build()) {
URI uri = URI.create("http://127.0.0.1:"
+ server.getAddress().getPort() + "/" + endpoint);
HttpRequest request = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(3))
.header("Accept", "application/json")
.method(method, HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(response.statusCode());
System.out.println(response.body());
return response.statusCode() == 200 ? 0 : 1;
} finally {
server.stop(0);
}
}
public static void main(String[] args) {
int status;
try {
status = run(args);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
System.err.println("Request interrupted");
status = 1;
} catch (IOException e) {
System.err.println("HTTP operation failed: " + e.getMessage());
status = 1;
}
System.exit(status);
}
}
Compile and execute:
javac --release 21 --add-modules jdk.httpserver ApiDemo.java
java --add-modules jdk.httpserver ApiDemo
java --add-modules jdk.httpserver ApiDemo missing
java --add-modules jdk.httpserver ApiDemo health POST
The first run prints 200 and {"status":"ok"} and exits with code zero. The second prints 404 and a not-found object; the third prints 405 and a method-not-allowed object. Both failure responses exit with code one. Invalid arguments print usage on standard error and exit with code two. Inspect the previous exit status with echo $? in a POSIX shell or $LASTEXITCODE in PowerShell.
Follow the request and response
Port zero asks the operating system to choose an available port. Binding to 127.0.0.1 keeps this exercise on the local machine. The handler checks the exact path because server context matching uses prefixes; registering a context alone is not exact route validation.
The server sets the content type before sending headers. Content length counts encoded bytes, not Java string code units. Closing the exchange releases its streams. The outer finally block stops the server even if the request fails.
The client's connection timeout limits establishing a new connection; the request timeout limits waiting for the response under the request API's contract. A timeout is an I/O failure, while receiving 404 is a normal HTTP response that application code must interpret. send blocks; sendAsync returns a CompletableFuture when asynchronous composition is useful.
JSON and practical boundaries
The response bodies are fixed JSON literals, so no serializer is needed. BodyHandlers.ofString decodes bytes into text; it does not parse JSON. Java SE 21 has no general-purpose JSON object-mapping API. For variable data, choose a maintained JSON library and validate the resulting fields rather than splitting on commas or concatenating unescaped input into JSON.
Reuse an HTTP client for repeated requests so connections can be reused. Real integrations also need response-size limits, explicit redirect and retry policies, and suitable authentication. Retrying a state-changing operation can duplicate work. This local example intentionally uses tiny known bodies and no automatic retry policy.
Quote arguments containing spaces, keep useful data on standard output, and send diagnostics to standard error. Keep System.exit at the application boundary so helpers remain testable and their cleanup can finish first.
Exercise and solution guidance
Add a version command and /version endpoint returning a fixed JSON object. Test GET success, POST rejection, an unknown path and invalid CLI arguments. Also confirm that /health/extra is not treated as the health endpoint.
Extend the accepted endpoint list and add one exact-path branch. Keep the existing 405 method check. Write a local integration test using HttpClient that asserts status and content type; use an ephemeral port, then stop the server in finally. For a later variable-data version, add a real JSON serializer before accepting arbitrary strings.
Official references
- Java 21 HttpClient API.
- Java 21 request builder and timeout.
- JDK 21 HttpServer routing.
- JDK 21 HttpExchange lifecycle.
Previous: Testing and Debugging. Next: Final Project.

