Consuming REST APIs
Modern Java applications rarely work in isolation. They call weather services, payment gateways, internal microservices, and third-party platforms — all over HTTP using JSON. In this lesson you will learn how to consume a REST API end-to-end: build the request, send it with HttpClient, parse the JSON response into typed Java objects, handle errors correctly, and structure the integration code so it stays maintainable as the API grows.
The Core Workflow
Every REST API call follows the same five steps:
- Build an
HttpRequest (URL, headers, method, optional body).
- Send it through
HttpClient and receive an HttpResponse<String>.
- Check the HTTP status code.
- Parse the JSON response body into a typed model.
- Return the model to the caller (or throw a domain exception).
You already know how to do steps 1-3 from the previous lessons. This lesson focuses on steps 4-5 and on designing a production-quality API client class.
Choosing a JSON Library
Java SE has no built-in JSON parser. The two dominant options are:
- Jackson (
com.fasterxml.jackson.core:jackson-databind) — the de-facto standard in enterprise Java. Extremely fast, handles complex nested structures, rich annotation model.
- Gson (
com.google.code.gson:gson) — simpler API, slightly less configurable, fine for straightforward mappings.
The examples below use Jackson because it is what you will encounter most in real codebases. The concepts transfer directly to Gson.
One ObjectMapper, shared forever. ObjectMapper is thread-safe after configuration and expensive to create. Create it once (a static field or a singleton injected via DI) and reuse it everywhere. Creating a new instance per request is one of the most common performance mistakes in Java HTTP clients.
Defining the Model (Record vs. POJO)
Start by modelling what the API returns. For the examples below, imagine we are calling a public user-management API that returns:
// JSON from GET /users/1
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"city": "Gwenborough"
}
}
With Java 17+ records are the cleanest way to model read-only API responses:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
@JsonIgnoreProperties(ignoreUnknown = true) // tolerate extra fields from the API
public record Address(String city) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record User(long id, String name, String username,
String email, Address address) {}
@JsonIgnoreProperties(ignoreUnknown = true) is critical in production: REST APIs evolve and add new fields. Without this annotation, Jackson throws an exception on any field your record does not declare. Always annotate API response models with it.
Building the API Client Class
Encapsulate all HTTP logic in a dedicated client class. This keeps HTTP concerns out of your business logic and makes the API easy to test.
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class UserApiClient {
private static final String BASE_URL = "https://jsonplaceholder.typicode.com";
private static final ObjectMapper MAPPER = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
private final HttpClient http;
public UserApiClient() {
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public User getUser(long id) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/users/" + id))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new ApiException(
"GET /users/" + id + " failed — HTTP " + response.statusCode(),
response.statusCode());
}
return MAPPER.readValue(response.body(), User.class);
}
}
Always set both timeouts. connectTimeout on the HttpClient limits how long you wait to establish the TCP connection. timeout() on the HttpRequest limits the total request duration. Omitting either means a slow or dead server can hang your thread indefinitely.
A Custom API Exception
Do not expose raw IOException or HTTP status codes to callers. Wrap them in a domain exception that carries the status code:
public class ApiException extends RuntimeException {
private final int statusCode;
public ApiException(String message, int statusCode) {
super(message);
this.statusCode = statusCode;
}
public int getStatusCode() { return statusCode; }
}
Callers can then write catch (ApiException e) and branch on e.getStatusCode() without knowing anything about HTTP internals.
Deserializing Collections
Many endpoints return JSON arrays. Use a TypeReference to preserve the generic type at runtime (Java type erasure would otherwise lose it):
import com.fasterxml.jackson.core.type.TypeReference;
import java.util.List;
public List<User> getAllUsers() throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/users"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new ApiException("GET /users failed — HTTP " + response.statusCode(),
response.statusCode());
}
return MAPPER.readValue(response.body(),
new TypeReference<List<User>>() {});
}
Sending a POST Request with a JSON Body
To create a resource, serialize your request object to JSON and send it as the body:
public record CreateUserRequest(String name, String username, String email) {}
public User createUser(CreateUserRequest payload) throws Exception {
String json = MAPPER.writeValueAsString(payload);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/users"))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response =
http.send(request, HttpResponse.BodyHandlers.ofString());
// 201 Created is the expected success for POST
if (response.statusCode() != 201) {
throw new ApiException("POST /users failed — HTTP " + response.statusCode(),
response.statusCode());
}
return MAPPER.readValue(response.body(), User.class);
}
Adding Authentication Headers
Most real APIs require authentication — typically a Bearer token or an API key header. Pass these through the request builder:
private final String apiKey;
public User getAuthenticatedUser(long id) throws Exception {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(BASE_URL + "/users/" + id))
.header("Accept", "application/json")
.header("Authorization", "Bearer " + apiKey)
.timeout(Duration.ofSeconds(10))
.GET()
.build();
// ... same send/parse logic
}
Never hardcode credentials. Load API keys from environment variables or a secrets manager — never from source-controlled files. A leaked key in a git repository is a serious security incident that cannot be fully undone even after rotation.
Putting It All Together
Here is a complete main method that exercises the client:
public class Main {
public static void main(String[] args) {
var client = new UserApiClient();
try {
User user = client.getUser(1);
System.out.println("Fetched: " + user.name() + " <" + user.email() + ">");
var all = client.getAllUsers();
System.out.println("Total users: " + all.size());
var newUser = new CreateUserRequest("Ada Lovelace", "ada", "ada@example.com");
User created = client.createUser(newUser);
System.out.println("Created with id: " + created.id());
} catch (ApiException e) {
System.err.println("API error " + e.getStatusCode() + ": " + e.getMessage());
} catch (Exception e) {
System.err.println("Unexpected error: " + e.getMessage());
}
}
}
Summary
To consume a REST API professionally in Java: model responses with annotated records, share a single ObjectMapper, wrap HTTP logic in a dedicated client class, check status codes explicitly and throw typed exceptions, use TypeReference for collections, and never embed credentials in code. This pattern scales from a single endpoint to a full API SDK without structural changes.