Networking & HTTP

Project: A REST API Client

15 min Lesson 10 of 13

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.