Building a REST API Client with Java HttpClient + Jackson
Java's built-in HttpClient offers a modern and efficient way to send HTTP requests. When paired with Jackson, a powerful JSON processing library, you gain the ability to seamlessly convert JSON data into Java objects and vice versa. This combination is ideal for constructing clean, reusable REST API clients without the overhead of larger frameworks. This guide details how to build such a client, covering essential features from basic requests to error handling and authentication.
Core Components: HttpClient and Jackson
At its heart, a REST API client needs to perform two primary functions: making HTTP requests and processing the data exchanged. Java 11 introduced the java.net.http.HttpClient class, a significant upgrade over older APIs like HttpURLConnection. It supports asynchronous operations, follows redirects, and offers a fluent API for building requests. On the data processing side, FasterXML's Jackson library is the de facto standard for JSON serialization and deserialization in Java. Its ObjectMapper class is the workhorse for converting JSON strings to Java objects (deserialization) and Java objects to JSON strings (serialization).
By integrating these two components, developers can create clients that are both performant and maintainable. This approach avoids adding dependencies like Apache HttpClient or Retrofit when the built-in capabilities suffice, leading to smaller application footprints and fewer potential conflicts.
Setting Up the Project
To begin, you'll need to include the Jackson Databind dependency in your project. If you're using Maven, add the following to your pom.xml:
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.0</version><!-- Use the latest version -->
</dependency>
For Gradle, add this to your build.gradle:
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.http.HttpClient;
public abstract class BaseApiClient {
protected final HttpClient httpClient;
protected final ObjectMapper objectMapper;
public BaseApiClient() {
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
this.objectMapper = new ObjectMapper();
}
// Further methods will be added here
}
This base class ensures that a single instance of HttpClient and ObjectMapper is used throughout the client's lifecycle, which is more efficient than creating new instances for each request.
Implementing GET Requests
To fetch data, we'll implement a method that handles GET requests. This method should take the endpoint URL, deserialize the JSON response into a specified Java class, and handle potential errors.
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
public class ExampleApiClient extends BaseApiClient {
private final String baseUrl;
public ExampleApiClient(String baseUrl) {
super();
this.baseUrl = baseUrl;
}
public <T> T get(String endpoint, Class<T> responseType) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + endpoint))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return objectMapper.readValue(response.body(), responseType);
} else {
throw new IOException("HTTP Error: " + response.statusCode() + ": " + response.body());
}
}
public <T> List<T> getList(String endpoint, TypeReference<List<T>> typeReference) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + endpoint))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return objectMapper.readValue(response.body(), typeReference);
} else {
throw new IOException("HTTP Error: " + response.statusCode() + ": " + response.body());
}
}
}
The get method handles single JSON objects. For JSON arrays, we use TypeReference with readValue to correctly deserialize into a List<T>. This is crucial because Java's type erasure makes it difficult for Jackson to infer generic types directly.
Implementing POST Requests
To send data, we implement a POST method. This involves serializing a Java object into a JSON request body and setting the appropriate content type header.
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class ExampleApiClient extends BaseApiClient {
// ... (previous methods)
public <T, R> T post(String endpoint, R requestBody, Class<T> responseType) throws IOException, InterruptedException {
String requestJson = objectMapper.writeValueAsString(requestBody);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + endpoint))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestJson))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return objectMapper.readValue(response.body(), responseType);
} else {
throw new IOException("HTTP Error: " + response.statusCode() + ": " + response.body());
}
}
}
The post method takes the request body object, serializes it to JSON using objectMapper.writeValueAsString(), and then uses HttpRequest.BodyPublishers.ofString() to create the request body. The Content-Type header is set to application/json.
Handling HTTP Errors
Robust clients must gracefully handle errors. The current implementation throws an IOException with the status code and response body for non-2xx status codes. This is a basic but effective strategy. For more sophisticated error handling, you could define custom exception classes to represent different HTTP error scenarios (e.g., NotFoundException, BadRequestException) and map status codes to these exceptions.
Adding Timeouts
Network requests can hang indefinitely. Implementing timeouts is crucial for a responsive client. This can be achieved by configuring the HttpClient itself or by using Java's concurrency utilities to cancel requests that take too long.
Configuring the HttpClient directly is the cleaner approach:
import java.net.Duration;
public BaseApiClient() {
super();
this.httpClient = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10)) // Connection timeout
.build();
this.objectMapper = new ObjectMapper();
}
For request-specific timeouts, you might need to implement a custom wrapper around the sendAsync method and use CompletableFuture.orTimeout().
Supporting Bearer Token Authentication
Many APIs require authentication, often via bearer tokens in the Authorization header. This can be added to any request builder.
public <T> T getWithToken(String endpoint, String token, Class<T> responseType) throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + endpoint))
.header("Authorization", "Bearer " + token)
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
return objectMapper.readValue(response.body(), responseType);
}
// ... (error handling)
}
This pattern can be extended to POST requests and other HTTP methods as needed.
Working with Java Records
Java records (introduced in Java 16) are a concise way to declare immutable data carriers. Jackson fully supports them, making them an excellent choice for request and response DTOs (Data Transfer Objects).
public record User(Long id, String name, String email) {}
public record CreateUserRequest(String name, String email) {}
// Usage in client:
// User newUser = apiClient.post("/users", new CreateUserRequest("Jane Doe", "jane@example.com"), User.class);
Using records simplifies the boilerplate code associated with traditional POJOs, leading to cleaner client code.
Conclusion
By combining Java's modern HttpClient with Jackson's robust JSON capabilities, you can build efficient, lightweight, and highly customizable REST API clients. This approach provides a solid foundation for interacting with any RESTful service, offering control over request building, data serialization, error handling, and security, all without the need for external heavyweight frameworks.
