How to Build a Lightweight RESTful Client with Java

In our previous tutorial, we implemented RESTful CRUD API. Now, we are going to implement a REST client to consume those APIs with JWT authentication in Java without using any framework.

Introduction

When you want to build a lightweight REST client with very few external libraries, then using a framework like Spring to develop the consumer application is not an option. Hence, in this tutorial, we are gonna implement a client in Java with very few third-party libraries.

What You’ll Build

A REST client to consume REST APIs with JWT authentication in order to perform CRUD operations.

What You’ll Need

  • Spring Tool Suite or any IDE of your choice
  • JDK 11
  • MySQL Server
  • Apache Maven

Project Structure

This is how our project will look like once created

Java CRUD REST API Client

Project Dependencies

Let’s create a simple maven project and add a few basic dependencies like Jackson, Lombok & Junit in the pom.xml. We will also add the jackson-datatype-jsr310 module which is required to support JSR-310 (Java 8 Date & Time API) data types.

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
	xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.javachinna</groupId>
	<artifactId>product-api-client</artifactId>
	<version>0.0.1-SNAPSHOT</version>
	<name>product-api-client</name>
	<description>Demo project for Java REST Client</description>
	<properties>
		<java.version>11</java.version>
	</properties>
	<dependencies>
		<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind -->
		<dependency>
			<groupId>com.fasterxml.jackson.core</groupId>
			<artifactId>jackson-databind</artifactId>
			<version>2.13.2</version>
		</dependency>
		<!-- https://mvnrepository.com/artifact/com.fasterxml.jackson.datatype/jackson-datatype-jsr310 -->
		<dependency>
			<groupId>com.fasterxml.jackson.datatype</groupId>
			<artifactId>jackson-datatype-jsr310</artifactId>
			<version>2.13.3</version>
		</dependency>
		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.18.22</version>
		</dependency>
		<dependency>
			<groupId>org.junit.jupiter</groupId>
			<artifactId>junit-jupiter</artifactId>
			<version>5.9.0</version>
			<scope>test</scope>
		</dependency>
	</dependencies>

	<build>
		<plugins>
		</plugins>
	</build>

</project>

Creating DTOs

Let’s create some classes for mapping the request and response of the REST APIs.

LoginRequest.java

LoginRequest class holds the credentials required for the authentication.

package com.javachinna.rest.client.model;

import lombok.AllArgsConstructor;
import lombok.Data;

@Data
@AllArgsConstructor
public class LoginRequest {
	private String email;
	private String password;
}

JwtAuthenticationResponse.java

JwtAuthenticationResponse holds the access token returned by the authentication API.

package com.javachinna.rest.client.model;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

import lombok.Data;

@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class JwtAuthenticationResponse {
	private String accessToken;
}

ApiResponse.java

ApiResponse maps the response of Create/Update/Delete API.

package com.javachinna.rest.client.model;

import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
public class ApiResponse {
	private Boolean success;
	private String message;
}

Product.java

Product maps the response of GET API.

/**
 * @author Chinna
 */
package com.javachinna.rest.client.model;

import java.time.LocalDate;

import lombok.Data;

/**
 * Product
 */
@Data
public class Product {
	private LocalDate validFrom;

	private String description;

	private String edition;

	private Long id;

	private String name;

	private String version;
}

ProductRequest.java

ProductRequest class maps the request of POST API which creates a new product with the product details passed in the request.

package com.javachinna.rest.client.model;

import java.time.LocalDate;

import lombok.Data;

@Data
public class ProductRequest {
	private String name;
	private String version;
	private String edition;
	private String description;
	private LocalDate validFrom;
}

Creating Client API

ProductManagerConfig.java

ProductManagerConfig class holds the REST API base URL and credentials required for authentication.

package com.javachinna.rest.client.config;

import lombok.Value;

@Value
public class ProductManagerConfig {
	private String baseUrl;
	private String username;
	private String password;
}

ProductManager.java

ProductManager is an interface in which we have defined all the public methods required for obtaining access tokens and consuming CRUD REST APIs.

package com.javachinna.rest.client;

import java.util.HashMap;
import java.util.List;

import com.javachinna.rest.client.model.ApiResponse;
import com.javachinna.rest.client.model.Product;
import com.javachinna.rest.client.model.ProductRequest;

public interface ProductManager {

	Product getProduct(Integer l) throws Exception;

	List<HashMap<String, Object>> getAllProducts() throws Exception;

	ApiResponse createProduct(ProductRequest request) throws Exception;

	ApiResponse updateProduct(Integer productId, ProductRequest request) throws Exception;

	ApiResponse deleteProduct(Integer productId) throws Exception;

	String getAccessToken() throws Exception;

}

ProductManagerImpl.java

ProductManagerImpl provides implementation to the ProductManager interface. We have already secured the CRUD APIs with token based authentication. Hence, we need to invoke the authentication API to obtain an access token first. Then, we need to send this token in the Authorization header of the request to the CRUD REST API.

package com.javachinna.rest.client;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

import com.javachinna.rest.client.config.ProductManagerConfig;
import com.javachinna.rest.client.model.ApiResponse;
import com.javachinna.rest.client.model.JwtAuthenticationResponse;
import com.javachinna.rest.client.model.LoginRequest;
import com.javachinna.rest.client.model.Product;
import com.javachinna.rest.client.model.ProductRequest;
import com.javachinna.rest.client.util.HttpRequestUtils;

import lombok.RequiredArgsConstructor;

@RequiredArgsConstructor
public class ProductManagerImpl implements ProductManager {
	private static final String AUTH_API = "auth/signin";
	private static final String PRODUCT_API = "products";

	private final ProductManagerConfig productManagerConfig;

	public Product getProduct(Integer productId) throws Exception {
		String url = productManagerConfig.getBaseUrl() + PRODUCT_API + "/" + productId;
		return HttpRequestUtils.get(url, getAccessToken(), Product.class);
	}

	@SuppressWarnings("unchecked")
	public List<HashMap<String,Object>> getAllProducts() throws Exception {
		String url = productManagerConfig.getBaseUrl() + PRODUCT_API;
		Map<String, Object> response = HttpRequestUtils.get(url, getAccessToken(), Map.class);
		return (List<HashMap<String, Object>>) response.get("content");
	}

	public ApiResponse createProduct(ProductRequest request) throws Exception {
		return HttpRequestUtils.post(productManagerConfig.getBaseUrl() + PRODUCT_API, request, ApiResponse.class,
				getAccessToken());
	}

	public ApiResponse updateProduct(Integer productId, ProductRequest request) throws Exception {
		return HttpRequestUtils.put(productManagerConfig.getBaseUrl() + PRODUCT_API + "/" + productId, request,
				ApiResponse.class, getAccessToken());
	}

	public ApiResponse deleteProduct(Integer productId) throws Exception {
		return HttpRequestUtils.delete(productManagerConfig.getBaseUrl() + PRODUCT_API + "/" + productId,
				ApiResponse.class, getAccessToken());
	}

	public String getAccessToken() throws Exception {
		LoginRequest loginRequest = new LoginRequest(productManagerConfig.getUsername(),
				productManagerConfig.getPassword());
		JwtAuthenticationResponse jwtResponse = HttpRequestUtils.post(productManagerConfig.getBaseUrl() + AUTH_API,
				loginRequest, JwtAuthenticationResponse.class);
		return jwtResponse.getAccessToken();
	}
}

Creating HTTP Request Utils

HttpRequestUtils.java

HttpRequestUtils contains generic methods used to make HTTP GET, POST, PUT and DELETE requests.

package com.javachinna.rest.client.util;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpRequest.BodyPublisher;
import java.net.http.HttpRequest.Builder;
import java.net.http.HttpResponse;
import java.time.Duration;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;

/**
 * 
 * @author Chinna [javachinna.com]
 *
 */
public class HttpRequestUtils {

	private static final String CONTENT_TYPE = "Content-Type";
	private static final String AUTHORIZATION = "Authorization";
	private static final String BEARER = "Bearer ";
	private static final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
	private static final HttpClient httpClient = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2)
			.connectTimeout(Duration.ofSeconds(10)).build();

	private HttpRequestUtils() {
	}

	public static <T> T get(String url, String token, Class<T> valueType)
			throws IOException, InterruptedException, JsonProcessingException, JsonMappingException {
		HttpRequest request = HttpRequest.newBuilder().GET().uri(URI.create(url))
				.setHeader(AUTHORIZATION, BEARER + token).build();
		HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
		return objectMapper.readValue(response.body(), valueType);
	}

	public static <T> T post(String uri, Object request, Class<T> valueType)
			throws JsonProcessingException, IOException, InterruptedException, JsonMappingException {
		return post(uri, request, valueType, null);
	}

	public static <T> T post(String uri, Object request, Class<T> valueType, String token)
			throws JsonProcessingException, IOException, InterruptedException, JsonMappingException {
		Builder builder = HttpRequest.newBuilder().uri(URI.create(uri)).POST(getBodyPublisher(request)).header(CONTENT_TYPE, "application/json");
		return send(valueType, token, builder);
	}

	public static <T> T put(String uri, Object request, Class<T> valueType, String token)
			throws JsonProcessingException, IOException, InterruptedException, JsonMappingException {
		Builder builder = HttpRequest.newBuilder().uri(URI.create(uri)).PUT(getBodyPublisher(request)).header(CONTENT_TYPE, "application/json");
		return send(valueType, token, builder);
	}
	
	public static <T> T delete(String uri, Class<T> valueType, String token)
			throws JsonProcessingException, IOException, InterruptedException, JsonMappingException {
		Builder builder = HttpRequest.newBuilder().uri(URI.create(uri)).DELETE();
		return send(valueType, token, builder);
	}

	private static BodyPublisher getBodyPublisher(Object request) throws JsonProcessingException {
		return HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(request));
	}

	private static <T> T send(Class<T> valueType, String token, Builder builder)
			throws IOException, InterruptedException, JsonProcessingException, JsonMappingException {
		builder.header(CONTENT_TYPE, "application/json");
		if (token != null) {
			builder.header(AUTHORIZATION, BEARER + token);
		}
		HttpResponse<String> response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofString());
		if (response.statusCode() != 200) {
			throw new RuntimeException(response.statusCode() + " : " + response.body());
		}
		return objectMapper.readValue(response.body(), valueType);
	}
}

Note: The ObjectMapper().findAndRegisterModules() is used to find and register the modules like jackson-datatype-jsr310 present in the classpath. So that the jackson library will be able to handle the conversion of java.time.LocalDate from JSON to string and vice versa.

Testing Time

ProductManagerTest.java

@Order annotation is used to configure the order in which the annotated element (i.e., field, method, or class) should be evaluated or executed relative to other elements of the same category.

If this annotation is used with @RegisterExtension or @ExtendWith, the category applies to extension fields.

Likewise, when it is used with MethodOrderer.OrderAnnotation, the category applies to test methods.

Also, if it is used with ClassOrderer.OrderAnnotation, the category applies to test classes.

If @Order is not explicitly declared on an element, the DEFAULT order value will be assigned to the element.

We have used this annotation in order to make sure that testCreateProduct() is executed first since there will not be any records in the database.

package com.javachinna.rest.client;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.time.LocalDate;
import java.util.HashMap;
import java.util.List;

import org.junit.jupiter.api.MethodOrderer;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestMethodOrder;

import com.javachinna.rest.client.config.ProductManagerConfig;
import com.javachinna.rest.client.model.ApiResponse;
import com.javachinna.rest.client.model.Product;
import com.javachinna.rest.client.model.ProductRequest;

@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
class ProductManagerTest {
	
	ProductManagerConfig productManagerServiceConfig = new ProductManagerConfig(
			"http://localhost:8080/api/", "[email protected]", "admin@");
	ProductManager productManager = new ProductManagerImpl(productManagerServiceConfig);
	
	@Test
	@Order(1)
	void testCreateProduct() throws Exception {
		ProductRequest request = new ProductRequest();
		request.setName("Dell Inspiron");
		request.setDescription("Inspiron 16 plus");
		request.setVersion("7620");
		request.setEdition("2021");
		request.setValidFrom(LocalDate.now());
		ApiResponse response = productManager.createProduct(request);
		assertNotNull(response);
		assertEquals(true, response.getSuccess());
	}

	@Test
	@Order(2)
	void testGetAllProducts() throws Exception {
		List<HashMap<String, Object>> list = productManager.getAllProducts();
		assertNotNull(list);
		assertEquals("7620", list.get(0).get("version"));
	}
	
	@Test
	@Order(3)
	void testGetProduct() throws Exception {
		Integer productId = getProductId();
		Product product = productManager.getProduct(productId);
		assertNotNull(product);
		assertEquals("7620", product.getVersion());
	}
	
	@Test
	@Order(4)
	void testUpdateProduct() throws Exception {
		Integer productId = getProductId();
		Product product = productManager.getProduct(productId);
		ProductRequest request = new ProductRequest();
		request.setName(product.getName());
		request.setDescription(product.getDescription());
		request.setVersion(product.getVersion());
		request.setValidFrom(product.getValidFrom());
		request.setEdition("2022");
		ApiResponse response = productManager.updateProduct(productId, request);
		assertNotNull(response);
		assertEquals(true, response.getSuccess());
		product = productManager.getProduct(productId);
		assertEquals("2022", product.getEdition());
	}

	private Integer getProductId() throws Exception {
		List<HashMap<String, Object>> list = productManager.getAllProducts();
		Integer productId = (Integer) list.get(0).get("id");
		return productId;
	}

	@Test
	@Order(5)
	void testDeleteProduct() throws Exception {
		Integer productId = getProductId();
		ApiResponse response = productManager.deleteProduct(productId);
		assertNotNull(response);
		assertEquals(true, response.getSuccess());
	}
}

Output
JAVA REST API Client Junit Test Result

Source Code

https://github.com/JavaChinna/java-rest-api-client

What’s Next?

In this article, we have implemented a thin REST API client application to consume secured CRUD REST APIs in Java. Also, we have written a Junit test to verify the successful execution of REST API Calls.

Next, we will see how to add support for proxy authentication for this client.

Leave a Reply