Project: A REST API Client
Throughout this tutorial you have built up every piece you need: raw sockets, the modern HttpClient, asynchronous requests, JSON parsing, and best practices. This final lesson assembles all of it into a cohesive, production-quality console application that consumes a real public REST API. The goal is not just working code — it is well-structured, resilient, testable code you would be comfortable shipping.
What We Are Building
A command-line tool that queries the JSONPlaceholder public API (https://jsonplaceholder.typicode.com) — a free, no-authentication REST stub used widely for prototyping. Our client will:
- Fetch a list of posts and print a summary table.
- Fetch a single post with its comments concurrently (two async HTTP calls, joined).
- Create a new post via a
POST request and display the server response.
- Wrap all network and parsing logic in a thin service layer so business logic stays testable.
Project Layout
src/
model/
Post.java
Comment.java
service/
JsonPlaceholderClient.java
util/
JsonParser.java
Main.java
Keeping models, service, and utilities in separate packages follows the same separation-of-concerns principle you applied in earlier modules.
Step 1 — Define the Domain Models
Use Java records for immutable, data-only value objects — they are the idiomatic Java 16+ choice for DTOs.
// model/Post.java
package model;
public record Post(int userId, int id, String title, String body) {}
// model/Comment.java
package model;
public record Comment(int postId, int id, String name, String email, String body) {}
Why records? Records give you equals, hashCode, toString, and accessor methods for free, with zero boilerplate. They also signal to every reader that these objects are pure data carriers — not entities with mutable state.
Step 2 — A Minimal JSON Parser Utility
Production code would use Jackson or Gson. For this self-contained project we write a tiny hand-rolled parser just for the shapes we need. This reinforces what you learned in Lesson 8 and keeps the project dependency-free.
// util/JsonParser.java
package util;
import model.Comment;
import model.Post;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class JsonParser {
private JsonParser() {}
/** Extract the string value of a named JSON field. */
public static String extractString(String json, String field) {
Pattern p = Pattern.compile("\"" + field + "\"\\s*:\\s*\"(.*?)\"");
Matcher m = p.matcher(json);
return m.find() ? m.group(1) : "";
}
/** Extract the integer value of a named JSON field. */
public static int extractInt(String json, String field) {
Pattern p = Pattern.compile("\"" + field + "\"\\s*:\\s*(\\d+)");
Matcher m = p.matcher(json);
return m.find() ? Integer.parseInt(m.group(1)) : 0;
}
/** Split a JSON array string into individual object strings. */
public static List<String> splitObjects(String jsonArray) {
List<String> objects = new ArrayList<>();
int depth = 0;
int start = -1;
for (int i = 0; i < jsonArray.length(); i++) {
char c = jsonArray.charAt(i);
if (c == '{') { if (depth++ == 0) start = i; }
else if (c == '}') { if (--depth == 0) objects.add(jsonArray.substring(start, i + 1)); }
}
return objects;
}
public static Post parsePost(String json) {
return new Post(
extractInt(json, "userId"),
extractInt(json, "id"),
extractString(json, "title"),
extractString(json, "body")
);
}
public static Comment parseComment(String json) {
return new Comment(
extractInt(json, "postId"),
extractInt(json, "id"),
extractString(json, "name"),
extractString(json, "email"),
extractString(json, "body")
);
}
}
Step 3 — The API Client Service
All HTTP logic lives in one service class. It holds a shared HttpClient instance (thread-safe and connection-pool-aware) and exposes clean, typed methods.
// service/JsonPlaceholderClient.java
package service;
import model.Comment;
import model.Post;
import util.JsonParser;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class JsonPlaceholderClient {
private static final String BASE = "https://jsonplaceholder.typicode.com";
private final HttpClient http;
public JsonPlaceholderClient() {
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
// ── Synchronous: fetch all posts ────────────────────────────────────────
public List<Post> fetchPosts() throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/posts"))
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
requireSuccess(res);
return JsonParser.splitObjects(res.body())
.stream()
.map(JsonParser::parsePost)
.toList();
}
// ── Asynchronous: fetch post + comments concurrently ───────────────────
public CompletableFuture<Post> fetchPostAsync(int id) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/posts/" + id))
.GET()
.build();
return http.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(res -> { requireSuccessUnchecked(res); return JsonParser.parsePost(res.body()); });
}
public CompletableFuture<List<Comment>> fetchCommentsAsync(int postId) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/posts/" + postId + "/comments"))
.GET()
.build();
return http.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(res -> {
requireSuccessUnchecked(res);
return JsonParser.splitObjects(res.body())
.stream()
.map(JsonParser::parseComment)
.toList();
});
}
// ── POST: create a new post ─────────────────────────────────────────────
public Post createPost(int userId, String title, String body) throws Exception {
String json = """
{"userId":%d,"title":"%s","body":"%s"}
""".formatted(userId, title, body).strip();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/posts"))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
requireSuccess(res);
return JsonParser.parsePost(res.body());
}
// ── Helpers ─────────────────────────────────────────────────────────────
private void requireSuccess(HttpResponse<?> res) throws Exception {
if (res.statusCode() < 200 || res.statusCode() >= 300) {
throw new Exception("HTTP error " + res.statusCode());
}
}
private void requireSuccessUnchecked(HttpResponse<?> res) {
if (res.statusCode() < 200 || res.statusCode() >= 300) {
throw new RuntimeException("HTTP error " + res.statusCode());
}
}
}
One shared HttpClient, not one per request. HttpClient manages a connection pool internally. Creating a new instance per call defeats pooling, wastes threads, and can exhaust OS file descriptors under load. Always share one instance — per service class, or via a DI container.
Step 4 — The Main Entry Point
// Main.java
import model.Comment;
import model.Post;
import service.JsonPlaceholderClient;
import java.util.List;
import java.util.concurrent.CompletableFuture;
public class Main {
public static void main(String[] args) throws Exception {
JsonPlaceholderClient client = new JsonPlaceholderClient();
// 1. Fetch and display first 5 posts
System.out.println("=== Posts (first 5) ===");
List<Post> posts = client.fetchPosts();
posts.stream().limit(5).forEach(p ->
System.out.printf("[%d] %s%n", p.id(), p.title())
);
// 2. Fetch post #1 and its comments concurrently
System.out.println("\n=== Post #1 with comments (async) ===");
CompletableFuture<Post> postFuture = client.fetchPostAsync(1);
CompletableFuture<List<Comment>> commentFuture = client.fetchCommentsAsync(1);
CompletableFuture.allOf(postFuture, commentFuture).join();
Post post = postFuture.join();
List<Comment> comments = commentFuture.join();
System.out.println("Title : " + post.title());
System.out.println("Author : userId " + post.userId());
System.out.println("Comments (" + comments.size() + "):");
comments.forEach(c -> System.out.println(" - " + c.name() + " <" + c.email() + ">"));
// 3. Create a new post
System.out.println("\n=== Create Post ===");
Post created = client.createPost(1, "My New Post", "Hello from Java HttpClient");
System.out.println("Created id: " + created.id() + ", title: " + created.title());
}
}
Step 5 — Error Handling Strategy
A production client needs more than a happy path. Apply these layers:
- Network-level: wrap calls in try-catch for
java.io.IOException (connection refused, timeout). Log and surface a user-friendly message.
- HTTP-level: check the status code before parsing. A
404 or 503 body is not a valid domain object.
- Parse-level: guard against unexpected JSON shapes with null checks or
Optional.
- Retry: for transient failures (5xx, timeouts) implement exponential back-off — at minimum cap retries at three attempts.
Never swallow exceptions silently. A catch block that does nothing — or only prints a stack trace and returns null — produces confusing, data-corrupting bugs downstream. Either handle the failure meaningfully, wrap it in a domain exception, or let it propagate.
Trade-offs to Know
Before calling this client production-ready, be honest about its limitations and the trade-offs you accepted:
- Hand-rolled JSON parser vs. a library: our regex-based parser is brittle for nested objects, escaped characters, and arrays of primitives. Jackson or Gson handles all edge cases in one line. Use a library in real code.
- No retry logic: the client fails immediately on the first error. A real client wraps calls with a retry decorator or uses Resilience4j.
- No authentication: JSONPlaceholder needs none. Real APIs require Bearer tokens, API keys, or OAuth — add them as
Authorization headers in a centralized interceptor-style method.
- Thread model:
sendAsync uses the common fork-join pool by default. For high concurrency, supply a custom Executor via HttpClient.newBuilder().executor(...).
Summary
You now have a fully working, well-structured REST API client in plain Java. The key takeaways: share one HttpClient; separate HTTP mechanics from business logic in a service class; use records for immutable DTOs; validate status codes before parsing; handle failures at every layer. These patterns apply whether you are calling a payment gateway, a weather API, or an internal microservice. Congratulations on completing the Networking tutorial — you have the tools to build professional networked Java applications.