DEV Community

Cover image for Building a REST API Client with Java HttpClient + Jackson
Deividas Strole
Deividas Strole

Posted on

Building a REST API Client with Java HttpClient + Jackson

Building a REST API Client with Java HttpClient + Jackson

Java's built-in HttpClient is great for sending HTTP requests.

Jackson is great for converting JSON into Java objects.

Put them together, and you have everything you need to build a clean REST API client without introducing a heavyweight framework.

In this tutorial, we'll build a reusable API client that can:

  • Send GET requests
  • Send POST requests
  • Convert JSON responses into Java objects
  • Convert Java objects into JSON
  • Deserialize JSON arrays into List<T>
  • Work with Java records
  • Handle HTTP errors
  • Reuse HttpClient
  • Reuse Jackson's ObjectMapper
  • Support bearer-token authentication
  • Add timeouts
  • Build a cleaner generic REST client

By the end, we'll have a small client that feels much closer to production code than a simple one-off HTTP request.


1. The Problem

Suppose an API returns this JSON:

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com"
}
Enter fullscreen mode Exit fullscreen mode

Using only HttpClient, we can retrieve it as a String:

HttpResponse<String> response = client.send(
        request,
        HttpResponse.BodyHandlers.ofString()
);

String json = response.body();
Enter fullscreen mode Exit fullscreen mode

That works.

But now our application has this:

String json;
Enter fullscreen mode Exit fullscreen mode

when what we really want is this:

User user;
Enter fullscreen mode Exit fullscreen mode

That's where Jackson comes in.

Jackson can convert:

JSON
 ↓
Java object
Enter fullscreen mode Exit fullscreen mode

and also:

Java object
 ↓
JSON
Enter fullscreen mode Exit fullscreen mode

So the architecture becomes:

HttpClient
    ↓
HTTP request
    ↓
REST API
    ↓
JSON response
    ↓
Jackson
    ↓
Java object
Enter fullscreen mode Exit fullscreen mode

2. Add Jackson to Your Project

If you're using Maven, add Jackson Databind:

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.20.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

If you're using Gradle:

implementation("com.fasterxml.jackson.core:jackson-databind:2.20.0")
Enter fullscreen mode Exit fullscreen mode

Jackson Databind pulls in the core Jackson libraries we need for JSON serialization and deserialization.


3. Create a Java Model

Let's start with a simple API response:

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com"
}
Enter fullscreen mode Exit fullscreen mode

We can represent it using a Java record:

public record User(
        int id,
        String name,
        String email
) {
}
Enter fullscreen mode Exit fullscreen mode

Records are a great fit for API models because they are concise and immutable.

Instead of writing:

public class User {

    private int id;
    private String name;
    private String email;

    // constructor
    // getters
    // setters
}
Enter fullscreen mode Exit fullscreen mode

we can simply write:

public record User(
        int id,
        String name,
        String email
) {
}
Enter fullscreen mode Exit fullscreen mode

4. Create an ObjectMapper

Jackson's main class is:

ObjectMapper
Enter fullscreen mode Exit fullscreen mode

Create one like this:

import com.fasterxml.jackson.databind.ObjectMapper;

ObjectMapper mapper = new ObjectMapper();
Enter fullscreen mode Exit fullscreen mode

You can think of ObjectMapper as the object that translates between Java and JSON.

For example:

JSON String
    ↓
ObjectMapper
    ↓
User
Enter fullscreen mode Exit fullscreen mode

And in the opposite direction:

User
    ↓
ObjectMapper
    ↓
JSON String
Enter fullscreen mode Exit fullscreen mode

Just like HttpClient, you should generally reuse one ObjectMapper instead of creating a new one for every request.


5. Converting JSON into a Java Object

Suppose we have:

String json = """
        {
          "id": 1,
          "name": "Alice",
          "email": "alice@example.com"
        }
        """;
Enter fullscreen mode Exit fullscreen mode

Jackson can convert it into a User:

User user = mapper.readValue(
        json,
        User.class
);
Enter fullscreen mode Exit fullscreen mode

Now:

System.out.println(user.name());
Enter fullscreen mode Exit fullscreen mode

prints:

Alice
Enter fullscreen mode Exit fullscreen mode

And:

System.out.println(user.email());
Enter fullscreen mode Exit fullscreen mode

prints:

alice@example.com
Enter fullscreen mode Exit fullscreen mode

This is called deserialization.

JSON
 ↓
Java object
Enter fullscreen mode Exit fullscreen mode

6. Converting a Java Object into JSON

Jackson can also go the other way.

Suppose we have:

User user = new User(
        1,
        "Alice",
        "alice@example.com"
);
Enter fullscreen mode Exit fullscreen mode

Convert it into JSON:

String json = mapper.writeValueAsString(user);
Enter fullscreen mode Exit fullscreen mode

The result will look like:

{"id":1,"name":"Alice","email":"alice@example.com"}
Enter fullscreen mode Exit fullscreen mode

This is called serialization.

Java object
 ↓
JSON
Enter fullscreen mode Exit fullscreen mode

7. Your First HttpClient + Jackson GET Request

Now let's combine both libraries.

We'll use JSONPlaceholder for our examples.

The endpoint:

https://jsonplaceholder.typicode.com/users/1
Enter fullscreen mode Exit fullscreen mode

returns JSON similar to:

{
  "id": 1,
  "name": "Leanne Graham",
  "username": "Bret",
  "email": "Sincere@april.biz"
}
Enter fullscreen mode Exit fullscreen mode

For simplicity, our record can ignore fields we don't need.

public record User(
        int id,
        String name,
        String email
) {
}
Enter fullscreen mode Exit fullscreen mode

However, by default Jackson may complain about JSON fields that are not represented in our record.

We can configure it to ignore unknown fields:

import com.fasterxml.jackson.databind.DeserializationFeature;

ObjectMapper mapper = new ObjectMapper()
        .configure(
                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                false
        );
Enter fullscreen mode Exit fullscreen mode

Now let's make the request:

HttpClient client = HttpClient.newHttpClient();

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(
                "https://jsonplaceholder.typicode.com/users/1"
        ))
        .GET()
        .build();

HttpResponse<String> response = client.send(
        request,
        HttpResponse.BodyHandlers.ofString()
);

User user = mapper.readValue(
        response.body(),
        User.class
);

System.out.println(user);
Enter fullscreen mode Exit fullscreen mode

Complete example:

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class Main {

    public static void main(String[] args) throws Exception {

        HttpClient client = HttpClient.newHttpClient();

        ObjectMapper mapper = new ObjectMapper()
                .configure(
                        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                        false
                );

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(
                        "https://jsonplaceholder.typicode.com/users/1"
                ))
                .GET()
                .build();

        HttpResponse<String> response = client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

        User user = mapper.readValue(
                response.body(),
                User.class
        );

        System.out.println(user);
    }
}
Enter fullscreen mode Exit fullscreen mode

And our record:

public record User(
        int id,
        String name,
        String email
) {
}
Enter fullscreen mode Exit fullscreen mode

8. Check the Status Code Before Parsing JSON

There is one important problem with the previous example.

We immediately parse:

response.body()
Enter fullscreen mode Exit fullscreen mode

But what if the server returns:

404 Not Found
Enter fullscreen mode Exit fullscreen mode

or:

500 Internal Server Error
Enter fullscreen mode Exit fullscreen mode

The response body may not contain the JSON structure we expect.

So check the status code first:

if (response.statusCode() >= 200 &&
    response.statusCode() < 300) {

    User user = mapper.readValue(
            response.body(),
            User.class
    );

    System.out.println(user);

} else {

    System.err.println(
            "HTTP error: " + response.statusCode()
    );
}
Enter fullscreen mode Exit fullscreen mode

A useful rule is:

Send request
    ↓
Check status
    ↓
Parse JSON
Enter fullscreen mode Exit fullscreen mode

Not:

Send request
    ↓
Immediately parse everything
Enter fullscreen mode Exit fullscreen mode

9. Create a Reusable GET Method

Let's start making this more useful.

Instead of repeating the same code, we can create:

public <T> T get(
        String url,
        Class<T> responseType
)
Enter fullscreen mode Exit fullscreen mode

The Class<T> argument tells Jackson which Java type to create.

Example implementation:

public <T> T get(
        String url,
        Class<T> responseType
) throws IOException, InterruptedException {

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .header("Accept", "application/json")
            .GET()
            .build();

    HttpResponse<String> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );

    if (response.statusCode() >= 200 &&
        response.statusCode() < 300) {

        return mapper.readValue(
                response.body(),
                responseType
        );
    }

    throw new IOException(
            "HTTP " + response.statusCode()
    );
}
Enter fullscreen mode Exit fullscreen mode

Now we can write:

User user = api.get(
        "https://jsonplaceholder.typicode.com/users/1",
        User.class
);
Enter fullscreen mode Exit fullscreen mode

Instead of manually dealing with JSON every time.


10. Build the API Client Class

Let's put the pieces into a reusable class.

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
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 RestClient {

    private final HttpClient client;
    private final ObjectMapper mapper;

    public RestClient() {

        client = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(10))
                .build();

        mapper = new ObjectMapper()
                .configure(
                        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                        false
                );
    }

    public <T> T get(
            String url,
            Class<T> responseType
    ) throws IOException, InterruptedException {

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(20))
                .header("Accept", "application/json")
                .GET()
                .build();

        HttpResponse<String> response = client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

        if (response.statusCode() >= 200 &&
            response.statusCode() < 300) {

            return mapper.readValue(
                    response.body(),
                    responseType
            );
        }

        throw new IOException(
                "HTTP " +
                response.statusCode() +
                ": " +
                response.body()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

RestClient client = new RestClient();

User user = client.get(
        "https://jsonplaceholder.typicode.com/users/1",
        User.class
);

System.out.println(user.name());
Enter fullscreen mode Exit fullscreen mode

This is already much cleaner.


11. Sending a Java Object with POST

Now let's add POST support.

Suppose we want to send:

CreatePostRequest post = new CreatePostRequest(
        "Learning Java",
        "HttpClient and Jackson work nicely together",
        1
);
Enter fullscreen mode Exit fullscreen mode

Define the record:

public record CreatePostRequest(
        String title,
        String body,
        int userId
) {
}
Enter fullscreen mode Exit fullscreen mode

Jackson can serialize it:

String json = mapper.writeValueAsString(post);
Enter fullscreen mode Exit fullscreen mode

Then we can send that JSON with HttpClient.

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .header("Content-Type", "application/json")
        .POST(
                HttpRequest.BodyPublishers.ofString(json)
        )
        .build();
Enter fullscreen mode Exit fullscreen mode

12. Create a Generic POST Method

Let's make POST reusable too.

public <T, R> R post(
        String url,
        T requestBody,
        Class<R> responseType
) throws IOException, InterruptedException {

    String json = mapper.writeValueAsString(
            requestBody
    );

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .timeout(Duration.ofSeconds(20))
            .header(
                    "Accept",
                    "application/json"
            )
            .header(
                    "Content-Type",
                    "application/json"
            )
            .POST(
                    HttpRequest.BodyPublishers.ofString(json)
            )
            .build();

    HttpResponse<String> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );

    if (response.statusCode() >= 200 &&
        response.statusCode() < 300) {

        return mapper.readValue(
                response.body(),
                responseType
        );
    }

    throw new IOException(
            "HTTP " +
            response.statusCode() +
            ": " +
            response.body()
    );
}
Enter fullscreen mode Exit fullscreen mode

Notice the generic types:

<T, R>
Enter fullscreen mode Exit fullscreen mode

Here:

T = request body type
R = response body type
Enter fullscreen mode Exit fullscreen mode

For example:

CreatePostRequest
        
        T

Post
        
        R
Enter fullscreen mode Exit fullscreen mode

Usage:

CreatePostRequest request =
        new CreatePostRequest(
                "Learning Java",
                "HttpClient + Jackson",
                1
        );

Post response = client.post(
        "https://jsonplaceholder.typicode.com/posts",
        request,
        Post.class
);
Enter fullscreen mode Exit fullscreen mode

13. Complete POST Example

Let's create the request type:

public record CreatePostRequest(
        String title,
        String body,
        int userId
) {
}
Enter fullscreen mode Exit fullscreen mode

And the response type:

public record Post(
        int id,
        String title,
        String body,
        int userId
) {
}
Enter fullscreen mode Exit fullscreen mode

Then:

RestClient client = new RestClient();

CreatePostRequest request =
        new CreatePostRequest(
                "Java HTTP Client",
                "Sending objects instead of manually building JSON",
                1
        );

Post post = client.post(
        "https://jsonplaceholder.typicode.com/posts",
        request,
        Post.class
);

System.out.println(post);
Enter fullscreen mode Exit fullscreen mode

Now our application does not manually deal with JSON strings at all.

We send:

CreatePostRequest
Enter fullscreen mode Exit fullscreen mode

and receive:

Post
Enter fullscreen mode Exit fullscreen mode

The client handles the JSON conversion.


14. The Flow Now Looks Much Better

Our application code:

CreatePostRequest request =
        new CreatePostRequest(
                "Hello",
                "REST API",
                1
        );

Post post = client.post(
        endpoint,
        request,
        Post.class
);
Enter fullscreen mode Exit fullscreen mode

Internally:

CreatePostRequest
       ↓
Jackson
       ↓
JSON
       ↓
HttpClient
       ↓
REST API
       ↓
JSON
       ↓
Jackson
       ↓
Post
Enter fullscreen mode Exit fullscreen mode

This separation is very useful.

Your application works with Java objects.

The API client deals with HTTP and JSON.


15. What About JSON Arrays?

Suppose this endpoint:

https://jsonplaceholder.typicode.com/users
Enter fullscreen mode Exit fullscreen mode

returns:

[
  {
    "id": 1,
    "name": "Leanne Graham"
  },
  {
    "id": 2,
    "name": "Ervin Howell"
  }
]
Enter fullscreen mode Exit fullscreen mode

We want:

List<User>
Enter fullscreen mode Exit fullscreen mode

You might expect this to work:

client.get(
        url,
        List<User>.class
);
Enter fullscreen mode Exit fullscreen mode

But Java does not allow:

List<User>.class
Enter fullscreen mode Exit fullscreen mode

because of type erasure.

So we need another approach.


16. Using TypeReference

Jackson provides:

TypeReference<T>
Enter fullscreen mode Exit fullscreen mode

We can deserialize a list like this:

List<User> users = mapper.readValue(
        json,
        new TypeReference<List<User>>() {}
);
Enter fullscreen mode Exit fullscreen mode

You will need:

import com.fasterxml.jackson.core.type.TypeReference;
Enter fullscreen mode Exit fullscreen mode

The strange-looking:

new TypeReference<List<User>>() {}
Enter fullscreen mode Exit fullscreen mode

preserves the generic type information Jackson needs.


17. Add a TypeReference GET Method

We can overload our get() method:

public <T> T get(
        String url,
        TypeReference<T> responseType
) throws IOException, InterruptedException {

    HttpRequest request = HttpRequest.newBuilder()
            .uri(URI.create(url))
            .timeout(Duration.ofSeconds(20))
            .header("Accept", "application/json")
            .GET()
            .build();

    HttpResponse<String> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );

    if (response.statusCode() >= 200 &&
        response.statusCode() < 300) {

        return mapper.readValue(
                response.body(),
                responseType
        );
    }

    throw new IOException(
            "HTTP " +
            response.statusCode() +
            ": " +
            response.body()
    );
}
Enter fullscreen mode Exit fullscreen mode

Now:

List<User> users = client.get(
        "https://jsonplaceholder.typicode.com/users",
        new TypeReference<List<User>>() {}
);
Enter fullscreen mode Exit fullscreen mode

And then:

for (User user : users) {
    System.out.println(user.name());
}
Enter fullscreen mode Exit fullscreen mode

18. Why Class<T> Works for One Object but Not List<T>

This works:

User.class
Enter fullscreen mode Exit fullscreen mode

because the runtime knows:

User
Enter fullscreen mode Exit fullscreen mode

But this doesn't exist:

List<User>.class
Enter fullscreen mode Exit fullscreen mode

At runtime, Java mostly sees:

List
Enter fullscreen mode Exit fullscreen mode

instead of:

List<User>
Enter fullscreen mode Exit fullscreen mode

This is due to type erasure.

Jackson's TypeReference gives it enough information to reconstruct:

List<User>
Enter fullscreen mode Exit fullscreen mode

So a useful rule is:

Single object
    → Class<T>

Generic type
    → TypeReference<T>
Enter fullscreen mode Exit fullscreen mode

Examples:

User.class
Enter fullscreen mode Exit fullscreen mode

for:

User
Enter fullscreen mode Exit fullscreen mode

and:

new TypeReference<List<User>>() {}
Enter fullscreen mode Exit fullscreen mode

for:

List<User>
Enter fullscreen mode Exit fullscreen mode

19. Supporting Bearer Tokens

Many real APIs require authentication.

For example:

Authorization: Bearer YOUR_TOKEN
Enter fullscreen mode Exit fullscreen mode

We could add the token when creating our client.

public class RestClient {

    private final HttpClient client;
    private final ObjectMapper mapper;
    private final String token;

    public RestClient(String token) {
        this.token = token;

        client = HttpClient.newHttpClient();
        mapper = new ObjectMapper();
    }
}
Enter fullscreen mode Exit fullscreen mode

Then add it to every request:

HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create(url))
        .header(
                "Authorization",
                "Bearer " + token
        )
        .header(
                "Accept",
                "application/json"
        )
        .GET()
        .build();
Enter fullscreen mode Exit fullscreen mode

But don't hardcode real secrets into your source code.

Prefer something like:

String token = System.getenv("API_TOKEN");
Enter fullscreen mode Exit fullscreen mode

Then:

RestClient client =
        new RestClient(token);
Enter fullscreen mode Exit fullscreen mode

20. Avoid Repeating Headers

Once the client becomes larger, repeated request-building code starts becoming annoying.

For example, we keep writing:

.header("Accept", "application/json")
Enter fullscreen mode Exit fullscreen mode

and maybe:

.header(
        "Authorization",
        "Bearer " + token
)
Enter fullscreen mode Exit fullscreen mode

We can create a helper method:

private HttpRequest.Builder requestBuilder(
        String url
) {

    HttpRequest.Builder builder =
            HttpRequest.newBuilder()
                    .uri(URI.create(url))
                    .timeout(
                            Duration.ofSeconds(20)
                    )
                    .header(
                            "Accept",
                            "application/json"
                    );

    if (token != null &&
        !token.isBlank()) {

        builder.header(
                "Authorization",
                "Bearer " + token
        );
    }

    return builder;
}
Enter fullscreen mode Exit fullscreen mode

Then GET becomes:

HttpRequest request = requestBuilder(url)
        .GET()
        .build();
Enter fullscreen mode Exit fullscreen mode

And POST becomes:

HttpRequest request = requestBuilder(url)
        .header(
                "Content-Type",
                "application/json"
        )
        .POST(
                HttpRequest.BodyPublishers.ofString(json)
        )
        .build();
Enter fullscreen mode Exit fullscreen mode

Much cleaner.


21. Don't Create a New HttpClient Per Request

This is worth repeating.

Avoid this:

public User getUser() {

    HttpClient client =
            HttpClient.newHttpClient();

    // request
}
Enter fullscreen mode Exit fullscreen mode

every time a request is made.

Instead:

private final HttpClient client;
Enter fullscreen mode Exit fullscreen mode

and create it once:

client = HttpClient.newBuilder()
        .connectTimeout(
                Duration.ofSeconds(10)
        )
        .build();
Enter fullscreen mode Exit fullscreen mode

Then reuse it.

A shared HttpClient can reuse connections and manage resources more efficiently.


22. Reuse ObjectMapper Too

The same idea applies to Jackson.

Avoid:

new ObjectMapper()
Enter fullscreen mode Exit fullscreen mode

inside every method.

Instead:

private final ObjectMapper mapper;
Enter fullscreen mode Exit fullscreen mode

Then initialize it once:

mapper = new ObjectMapper()
        .configure(
                DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                false
        );
Enter fullscreen mode Exit fullscreen mode

ObjectMapper is designed to be reused after configuration.


23. Handling Empty Responses

Not every API response contains JSON.

For example:

204 No Content
Enter fullscreen mode Exit fullscreen mode

If you blindly do:

mapper.readValue(
        response.body(),
        responseType
);
Enter fullscreen mode Exit fullscreen mode

you may get a parsing error because there is no body.

One approach is to check:

if (response.statusCode() == 204) {
    return null;
}
Enter fullscreen mode Exit fullscreen mode

Or create separate methods for operations that do not return data.

For example:

public void delete(String url)
Enter fullscreen mode Exit fullscreen mode

instead of forcing every request to return an object.


24. Add DELETE Support

A simple DELETE method might look like this:

public void delete(
        String url
) throws IOException, InterruptedException {

    HttpRequest request = requestBuilder(url)
            .DELETE()
            .build();

    HttpResponse<String> response = client.send(
            request,
            HttpResponse.BodyHandlers.ofString()
    );

    if (response.statusCode() < 200 ||
        response.statusCode() >= 300) {

        throw new IOException(
                "HTTP " +
                response.statusCode() +
                ": " +
                response.body()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

client.delete(
        "https://example.com/users/10"
);
Enter fullscreen mode Exit fullscreen mode

25. Add PUT Support

We can use the same serialization pattern as POST:

public <T, R> R put(
        String url,
        T requestBody,
        Class<R> responseType
) throws IOException, InterruptedException {

    String json =
            mapper.writeValueAsString(
                    requestBody
            );

    HttpRequest request =
            requestBuilder(url)
                    .header(
                            "Content-Type",
                            "application/json"
                    )
                    .PUT(
                            HttpRequest.BodyPublishers
                                    .ofString(json)
                    )
                    .build();

    HttpResponse<String> response =
            client.send(
                    request,
                    HttpResponse.BodyHandlers.ofString()
            );

    if (response.statusCode() >= 200 &&
        response.statusCode() < 300) {

        return mapper.readValue(
                response.body(),
                responseType
        );
    }

    throw new IOException(
            "HTTP " +
            response.statusCode() +
            ": " +
            response.body()
    );
}
Enter fullscreen mode Exit fullscreen mode

26. Add PATCH Support

HttpRequest.Builder doesn't have a dedicated .PATCH() method.

Use:

.method()
Enter fullscreen mode Exit fullscreen mode

Example:

HttpRequest request = requestBuilder(url)
        .header(
                "Content-Type",
                "application/json"
        )
        .method(
                "PATCH",
                HttpRequest.BodyPublishers.ofString(json)
        )
        .build();
Enter fullscreen mode Exit fullscreen mode

We can create:

public <T, R> R patch(
        String url,
        T requestBody,
        Class<R> responseType
)
Enter fullscreen mode Exit fullscreen mode

using the same pattern as POST and PUT.


27. We Are Starting to Repeat Ourselves

Look at GET:

build request
send request
check status
deserialize
Enter fullscreen mode Exit fullscreen mode

POST:

serialize
build request
send request
check status
deserialize
Enter fullscreen mode Exit fullscreen mode

PUT:

serialize
build request
send request
check status
deserialize
Enter fullscreen mode Exit fullscreen mode

PATCH:

serialize
build request
send request
check status
deserialize
Enter fullscreen mode Exit fullscreen mode

This is a sign that we can refactor.


28. Create a Shared Response Handler

We can move the repeated response processing into one method:

private <T> T handleResponse(
        HttpResponse<String> response,
        Class<T> responseType
) throws IOException {

    int status = response.statusCode();

    if (status < 200 || status >= 300) {

        throw new IOException(
                "HTTP " +
                status +
                ": " +
                response.body()
        );
    }

    return mapper.readValue(
            response.body(),
            responseType
    );
}
Enter fullscreen mode Exit fullscreen mode

Now methods become smaller:

public <T> T get(
        String url,
        Class<T> responseType
) throws IOException, InterruptedException {

    HttpRequest request =
            requestBuilder(url)
                    .GET()
                    .build();

    HttpResponse<String> response =
            client.send(
                    request,
                    HttpResponse.BodyHandlers.ofString()
            );

    return handleResponse(
            response,
            responseType
    );
}
Enter fullscreen mode Exit fullscreen mode

Much cleaner.


29. Create a Custom API Exception

Throwing a generic:

IOException
Enter fullscreen mode Exit fullscreen mode

for an HTTP 404 or 500 works for a small example, but we can make errors clearer.

Create:

public class ApiException
        extends RuntimeException {

    private final int statusCode;

    public ApiException(
            int statusCode,
            String message
    ) {
        super(message);
        this.statusCode = statusCode;
    }

    public int statusCode() {
        return statusCode;
    }
}
Enter fullscreen mode Exit fullscreen mode

Then:

throw new ApiException(
        response.statusCode(),
        response.body()
);
Enter fullscreen mode Exit fullscreen mode

Now callers can distinguish:

catch (ApiException e) {

    if (e.statusCode() == 404) {
        System.out.println(
                "Resource not found"
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

That's more expressive than treating every HTTP status as a networking problem.


30. Network Errors and API Errors Are Different

A useful distinction is:

Network problem
    ↓
IOException

Server responded with 404 / 500
    ↓
ApiException
Enter fullscreen mode Exit fullscreen mode

For example:

try {

    User user = client.get(
            url,
            User.class
    );

} catch (ApiException e) {

    System.err.println(
            "API error: " +
            e.statusCode()
    );

} catch (IOException e) {

    System.err.println(
            "Network error: " +
            e.getMessage()
    );

}
Enter fullscreen mode Exit fullscreen mode

This makes error handling much clearer.


31. A More Complete RestClient

Let's combine the ideas into a cleaner reusable client.

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;

import java.io.IOException;
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 RestClient {

    private final HttpClient client;
    private final ObjectMapper mapper;
    private final String token;

    public RestClient() {
        this(null);
    }

    public RestClient(String token) {

        this.token = token;

        this.client = HttpClient.newBuilder()
                .connectTimeout(
                        Duration.ofSeconds(10)
                )
                .build();

        this.mapper = new ObjectMapper()
                .configure(
                        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
                        false
                );
    }

    public <T> T get(
            String url,
            Class<T> responseType
    ) throws IOException, InterruptedException {

        HttpRequest request =
                requestBuilder(url)
                        .GET()
                        .build();

        HttpResponse<String> response =
                send(request);

        return read(
                response,
                responseType
        );
    }

    public <T> T get(
            String url,
            TypeReference<T> responseType
    ) throws IOException, InterruptedException {

        HttpRequest request =
                requestBuilder(url)
                        .GET()
                        .build();

        HttpResponse<String> response =
                send(request);

        validate(response);

        return mapper.readValue(
                response.body(),
                responseType
        );
    }

    public <T, R> R post(
            String url,
            T requestBody,
            Class<R> responseType
    ) throws IOException, InterruptedException {

        String json =
                mapper.writeValueAsString(
                        requestBody
                );

        HttpRequest request =
                requestBuilder(url)
                        .header(
                                "Content-Type",
                                "application/json"
                        )
                        .POST(
                                HttpRequest.BodyPublishers
                                        .ofString(json)
                        )
                        .build();

        HttpResponse<String> response =
                send(request);

        return read(
                response,
                responseType
        );
    }

    public void delete(
            String url
    ) throws IOException, InterruptedException {

        HttpRequest request =
                requestBuilder(url)
                        .DELETE()
                        .build();

        HttpResponse<String> response =
                send(request);

        validate(response);
    }

    private HttpRequest.Builder requestBuilder(
            String url
    ) {

        HttpRequest.Builder builder =
                HttpRequest.newBuilder()
                        .uri(URI.create(url))
                        .timeout(
                                Duration.ofSeconds(20)
                        )
                        .header(
                                "Accept",
                                "application/json"
                        );

        if (token != null &&
            !token.isBlank()) {

            builder.header(
                    "Authorization",
                    "Bearer " + token
            );
        }

        return builder;
    }

    private HttpResponse<String> send(
            HttpRequest request
    ) throws IOException, InterruptedException {

        try {

            return client.send(
                    request,
                    HttpResponse.BodyHandlers.ofString()
            );

        } catch (InterruptedException e) {

            Thread.currentThread()
                    .interrupt();

            throw e;
        }
    }

    private void validate(
            HttpResponse<String> response
    ) {

        int status =
                response.statusCode();

        if (status < 200 ||
            status >= 300) {

            throw new ApiException(
                    status,
                    response.body()
            );
        }
    }

    private <T> T read(
            HttpResponse<String> response,
            Class<T> responseType
    ) throws IOException {

        validate(response);

        if (response.statusCode() == 204 ||
            response.body() == null ||
            response.body().isBlank()) {

            return null;
        }

        return mapper.readValue(
                response.body(),
                responseType
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

And the custom exception:

public class ApiException
        extends RuntimeException {

    private final int statusCode;

    public ApiException(
            int statusCode,
            String message
    ) {

        super(message);

        this.statusCode =
                statusCode;
    }

    public int statusCode() {
        return statusCode;
    }
}
Enter fullscreen mode Exit fullscreen mode

32. Using the Client

GET one object

RestClient client =
        new RestClient();

User user = client.get(
        "https://jsonplaceholder.typicode.com/users/1",
        User.class
);

System.out.println(user.name());
Enter fullscreen mode Exit fullscreen mode

GET a list

List<User> users = client.get(
        "https://jsonplaceholder.typicode.com/users",
        new TypeReference<List<User>>() {}
);
Enter fullscreen mode Exit fullscreen mode

Then:

users.forEach(
        user ->
                System.out.println(
                        user.name()
                )
);
Enter fullscreen mode Exit fullscreen mode

POST an object

CreatePostRequest request =
        new CreatePostRequest(
                "Java REST Client",
                "Using HttpClient and Jackson",
                1
        );

Post post = client.post(
        "https://jsonplaceholder.typicode.com/posts",
        request,
        Post.class
);

System.out.println(post);
Enter fullscreen mode Exit fullscreen mode

DELETE

client.delete(
        "https://jsonplaceholder.typicode.com/posts/1"
);
Enter fullscreen mode Exit fullscreen mode

33. Using an Authenticated Client

If an API requires a bearer token:

String token =
        System.getenv("API_TOKEN");

RestClient client =
        new RestClient(token);
Enter fullscreen mode Exit fullscreen mode

Every request created by the client will include:

Authorization: Bearer ...
Enter fullscreen mode Exit fullscreen mode

This avoids repeating authentication code everywhere.


34. What About Date and Time Types?

Suppose your API returns:

{
  "id": 1,
  "createdAt": "2026-09-06T18:30:00"
}
Enter fullscreen mode Exit fullscreen mode

And your model uses:

LocalDateTime
Enter fullscreen mode Exit fullscreen mode

For example:

public record Event(
        int id,
        LocalDateTime createdAt
) {
}
Enter fullscreen mode Exit fullscreen mode

You should add Jackson's Java Time module.

Maven:

<dependency>
    <groupId>com.fasterxml.jackson.datatype</groupId>
    <artifactId>jackson-datatype-jsr310</artifactId>
    <version>2.20.0</version>
</dependency>
Enter fullscreen mode Exit fullscreen mode

Then:

ObjectMapper mapper =
        new ObjectMapper();

mapper.findAndRegisterModules();
Enter fullscreen mode Exit fullscreen mode

Or explicitly:

mapper.registerModule(
        new JavaTimeModule()
);
Enter fullscreen mode Exit fullscreen mode

For modern Java applications, registering the Java Time module is usually a good idea.


35. Naming Differences Between JSON and Java

Suppose the API returns:

{
  "user_name": "Alice"
}
Enter fullscreen mode Exit fullscreen mode

But your Java record uses:

userName
Enter fullscreen mode Exit fullscreen mode

You can map the property explicitly:

import com.fasterxml.jackson.annotation.JsonProperty;

public record User(
        @JsonProperty("user_name")
        String userName
) {
}
Enter fullscreen mode Exit fullscreen mode

Now Jackson knows:

user_name
    ↓
userName
Enter fullscreen mode Exit fullscreen mode

36. Ignore JSON Fields You Don't Need

Suppose the server returns:

{
  "id": 1,
  "name": "Alice",
  "internal_code": "ABC",
  "created_by": "system",
  "something_else": true
}
Enter fullscreen mode Exit fullscreen mode

But your application only needs:

public record User(
        int id,
        String name
) {
}
Enter fullscreen mode Exit fullscreen mode

You can configure Jackson globally:

mapper.configure(
        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
        false
);
Enter fullscreen mode Exit fullscreen mode

Or annotate a particular type:

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@JsonIgnoreProperties(ignoreUnknown = true)
public record User(
        int id,
        String name
) {
}
Enter fullscreen mode Exit fullscreen mode

This is very useful because APIs often return more data than your application needs.


37. Don't Put API Logic Everywhere

Without a client layer, application code often becomes:

HttpClient client = ...
HttpRequest request = ...
HttpResponse<String> response = ...
ObjectMapper mapper = ...
User user = ...
Enter fullscreen mode Exit fullscreen mode

inside controllers, services, UI code, scheduled jobs, and other places.

That quickly becomes messy.

Instead:

Application
    ↓
UserApi
    ↓
RestClient
    ↓
HttpClient + Jackson
Enter fullscreen mode Exit fullscreen mode

For example:

public class UserApi {

    private final RestClient client;

    public UserApi(
            RestClient client
    ) {
        this.client = client;
    }

    public User getUser(
            int id
    ) throws Exception {

        return client.get(
                "https://example.com/users/" + id,
                User.class
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Then application code becomes:

User user =
        userApi.getUser(10);
Enter fullscreen mode Exit fullscreen mode

This is much easier to read.


38. Create API-Specific Classes

A generic RestClient is useful for infrastructure.

But your application should preferably expose methods related to the domain.

Instead of:

restClient.get(
        "https://example.com/users/10",
        User.class
);
Enter fullscreen mode Exit fullscreen mode

everywhere, create:

public class UserApi {

    private final RestClient client;
    private final String baseUrl;

    public UserApi(
            RestClient client,
            String baseUrl
    ) {

        this.client = client;
        this.baseUrl = baseUrl;
    }

    public User findById(
            int id
    ) throws Exception {

        return client.get(
                baseUrl + "/users/" + id,
                User.class
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

Usage:

User user =
        userApi.findById(10);
Enter fullscreen mode Exit fullscreen mode

Now the rest of your application doesn't need to know:

HTTP
URLs
JSON
Jackson
headers
Enter fullscreen mode Exit fullscreen mode

It just works with:

User
Enter fullscreen mode Exit fullscreen mode

39. A Better Architecture

A clean application might look like this:

Application / Service
        ↓
UserApi
        ↓
RestClient
        ↓
HttpClient
        +
ObjectMapper
        ↓
External REST API
Enter fullscreen mode Exit fullscreen mode

Each layer has one responsibility.

HttpClient

Handles HTTP communication.

ObjectMapper

Handles JSON.

RestClient

Handles reusable HTTP + JSON mechanics.

UserApi

Knows the external API's endpoints.

Application

Works with domain objects.

This separation becomes increasingly valuable as the project grows.


40. Synchronous vs Asynchronous APIs

Everything we've built so far uses:

client.send()
Enter fullscreen mode Exit fullscreen mode

which is synchronous.

But Java also supports:

client.sendAsync()
Enter fullscreen mode Exit fullscreen mode

which returns:

CompletableFuture<HttpResponse<String>>
Enter fullscreen mode Exit fullscreen mode

We could create:

public <T> CompletableFuture<T> getAsync(
        String url,
        Class<T> responseType
)
Enter fullscreen mode Exit fullscreen mode

For example:

public <T> CompletableFuture<T> getAsync(
        String url,
        Class<T> responseType
) {

    HttpRequest request =
            requestBuilder(url)
                    .GET()
                    .build();

    return client.sendAsync(
            request,
            HttpResponse.BodyHandlers.ofString()
    ).thenApply(response -> {

        validate(response);

        try {

            return mapper.readValue(
                    response.body(),
                    responseType
            );

        } catch (IOException e) {

            throw new RuntimeException(e);
        }
    });
}
Enter fullscreen mode Exit fullscreen mode

Usage:

CompletableFuture<User> future =
        client.getAsync(
                url,
                User.class
        );

future.thenAccept(
        user ->
                System.out.println(
                        user.name()
                )
);
Enter fullscreen mode Exit fullscreen mode

For many applications, synchronous methods are simpler.

But async methods can be useful when multiple requests can run concurrently.


41. Quick Cheat Sheet

Java object to JSON

String json =
        mapper.writeValueAsString(object);
Enter fullscreen mode Exit fullscreen mode

JSON to Java object

User user =
        mapper.readValue(
                json,
                User.class
        );
Enter fullscreen mode Exit fullscreen mode

JSON array to List<User>

List<User> users =
        mapper.readValue(
                json,
                new TypeReference<List<User>>() {}
        );
Enter fullscreen mode Exit fullscreen mode

GET request

HttpRequest request =
        HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header(
                        "Accept",
                        "application/json"
                )
                .GET()
                .build();
Enter fullscreen mode Exit fullscreen mode

POST JSON

String json =
        mapper.writeValueAsString(object);

HttpRequest request =
        HttpRequest.newBuilder()
                .uri(URI.create(url))
                .header(
                        "Content-Type",
                        "application/json"
                )
                .POST(
                        HttpRequest.BodyPublishers
                                .ofString(json)
                )
                .build();
Enter fullscreen mode Exit fullscreen mode

Parse response

HttpResponse<String> response =
        client.send(
                request,
                HttpResponse.BodyHandlers.ofString()
        );

User user =
        mapper.readValue(
                response.body(),
                User.class
        );
Enter fullscreen mode Exit fullscreen mode

Bearer token

.header(
    "Authorization",
    "Bearer " + token
)
Enter fullscreen mode Exit fullscreen mode

Ignore unknown JSON fields

mapper.configure(
        DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
        false
);
Enter fullscreen mode Exit fullscreen mode

42. The Mental Model

The most important thing to understand is the division of responsibilities.

HttpClient
    ↓
handles HTTP

ObjectMapper
    ↓
handles JSON
Enter fullscreen mode Exit fullscreen mode

For a GET:

GET /users/1
      ↓
HttpClient
      ↓
JSON response
      ↓
ObjectMapper
      ↓
User
Enter fullscreen mode Exit fullscreen mode

For a POST:

CreateUserRequest
      ↓
ObjectMapper
      ↓
JSON
      ↓
HttpClient
      ↓
POST /users
      ↓
JSON response
      ↓
ObjectMapper
      ↓
User
Enter fullscreen mode Exit fullscreen mode

Once you see the flow this way, building REST clients becomes much easier.


Final Thoughts

Java's built-in HttpClient and Jackson make a very capable combination.

HttpClient gives us:

  • HTTP/1.1 and HTTP/2
  • GET
  • POST
  • PUT
  • DELETE
  • PATCH
  • Headers
  • Authentication
  • Timeouts
  • Synchronous requests
  • Asynchronous requests

Jackson gives us:

  • JSON serialization
  • JSON deserialization
  • Records support
  • Generic type handling
  • Custom property mappings
  • Date/time support
  • Flexible configuration

Together, they allow us to write application code like:

User user =
        userApi.findById(10);
Enter fullscreen mode Exit fullscreen mode

instead of spreading this everywhere:

HttpClient
HttpRequest
HttpResponse
ObjectMapper
JSON
status codes
headers
Enter fullscreen mode Exit fullscreen mode

That's the real goal of a good REST API client:

keep HTTP and JSON details at the boundary of your application, while the rest of your code works with normal Java objects.

A good progression is:

HttpClient basics
        ↓
HttpClient + Jackson
        ↓
Reusable RestClient
        ↓
API-specific client classes
        ↓
Authentication
        ↓
Retries / rate limits / pagination
        ↓
Production-ready API integration
Enter fullscreen mode Exit fullscreen mode

The next useful step is building a production-ready Java REST client with retries, rate limiting, pagination, authentication, and custom error responses.


About the Author

Deividas Strole is a Full-Stack Developer based in California, specializing in Java, Spring Boot, JavaScript, React, SQL, and AI-powered applications. He writes about software engineering, modern full-stack development, and digital marketing.

Connect with me:

Top comments (0)