Introduction to HTTP clients in Spring
Spring framework has offered two different options to perform http requests:
- RestTemplate: It was introduced in Spring 3 over a decade ago. It is an implementation of the Template pattern providing synchronous blocking communication.
- WebClient: It was released in Spring 5 as part of Spring WebFlux library. It provides a fluent API and it follows a reactive model.
RestRemplate approach exposed too many HTTP features leading to a big number of overloaded methods. It employs the one thread per request paradigm from the Jakarta Servlet API.
WebClient is the replacement for RestTemplate supporting both synchronous and asynchronous calls. It is part of the Spring Web Reactive project.
Now Spring 6.1 M1 version presents RestClient. A new synchronous http client which works in a similar way to WebClient, using the same infrastructure as RestTemplate.
Setup project
We will be using Spring Boot 3.2 and the Spring web dependency. You can go to the Spring Initializr page and generate a new project selecting Spring Web dependency. With maven the pom.xml will contain
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
That's it. Spring Reactive Web dependency is not needed at all.
Preparing the project
As this is a simple project to get familiar with RestClient, we are going to make http calls to the customer web service from previous articles. Also, the embedded tomcat will be disabled as there is no need to have a web container running. To do so, application.properties file will contain the property
spring.main.web-application-type=none
Then, a CommandLineRunner class will do all the job. The basic structure of the class can be seen below
@Configuration
public class Initializer implements CommandLineRunner {
private Logger logger =
LoggerFactory.getLogger(Initializer.class);
private ClientProperties properties;
public Initializer(ClientProperties properties) {
this.properties = properties;
}
public void run(String... args) throws Exception {
}
}
In the run method is where neccessary objects are constructed in order to interact with the customer endpoint.
Creating a RestClient
To create an instance of the RestClient we have available convinient static methods:
- create() method delegates in a default rest client.
- create(String url) accepts a default base url.
- create(RestTemplate restTemplate) initializes a new RestClient based on the configuration of the given rest template.
- builder() allows to customise a RestClient with headers, error handlers, interceptors and more options.
- builder(RestTemplate restTemplate) obtain a RestClient builder based on the configuration of the given RestTemplate.
Let's write a RestClient with the builder method to call the customer API.
RestClient restClient = RestClient.builder()
.baseUrl(properties.getUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION,
encodeBasic(properties.getUsername(),
properties.getPassword())
).build();
Let's take a closer look at the above code:
- baseUrl method is self-explanatory. it sets the base url for the client
- defaultHeader allows to set an http header. There is another method named defaultHeaders which takes Consumer as argument for multiple headers. we are setting the Authorization header to pass the credentials.
- properties is a simple Configuration Properties class to store the rest API data needed for the requests. And it is constructor-injected in the CommmandLineRunner class.
@Configuration
@ConfigurationProperties(prefix = "application.rest.v1.customer")
public class ClientProperties {
String url;
String username;
String password;
// getter/setter omitted
}
Three new key-values are added to the application properties file
application.rest.v1.customer.url=http://localhost:8080/api/v1/customers
application.rest.v1.customer.username=user1234
application.rest.v1.customer.password=password5678
Finally, the encodeBasic routine just for reference
private String encodeBasic(String username, String password) {
return "Basic "+Base64
.getEncoder()
.encodeToString((username+":"+password).getBytes());
}
Receiving Data
Next step is to use the client to send http requests and receive the response. RestClient offers methods for each HTTP method. For instance, to search all active customers a GET request must be done. The retrieve method fetches the responce and declares how to extract it.
Let's start with a simple case by getting the full body as String.
String data = restClient.get()
.uri("?status={STATUS}&vip={vip}","activated", true)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(String.class);
logger.info(data);
The uri method can setup the http parameters to filter by status and vip. The first argument (a String template) is the query string appended to the base url defined in the RestClient. The second argument are uri variables (varargs) of the template.
We also specify the Media type as JSON. The output is displayed in the console:
[{"id":6,"status":"ACTIVATED","personInfo":{"name":"name 6 surname 6","email":"organisation6@email.com","dateOfBirth":"19/07/1976"},"detailsInfo":{"info":"Customer info details 6","vip":true}}]
What if we need to check the response status code or response headers? No worries, the method toEntity returns a ResponseEntity.
ResponseEntity response = restClient.get()
.uri("?status={STATUS}&vip={vip}","activated", true)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toEntity(String.class);
logger.info("Status " + response.getStatusCode());
logger.info("Headers " + response.getHeaders());
Converting JSON
RestClient can also convert a response body in JSON format. Spring will automatically register by default MappingJackson2HttpMessageConverter or MappingJacksonHttpMessageConverter if Jackson 2 library or Jackson library are detected in the classpath. But you can register your own message converters and override the default settings.
In our case, the response can be directly converted to a record. For example, to retrieve a particular customer from the API:
CustomerResponse customer = restClient.get()
.uri("/{id}",3)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(CustomerResponse.class);
logger.info("Customer name: " + customer.personInfo().name());
And output to extract the customer name
Customer name: name 3 surname 3
To search for customers we just need to use the List class as demonstrated in the following code
List<CustomerResponse> customers = restClient.get()
.uri("?status={STATUS}&vip={vip}","activated", true)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(List.class);
logger.info("Customers size " + customers.size());
The record classes for the customer response are shown for reference
public record CustomerResponse(
long id,
String status,
CustomerPersonInfo personInfo,
CustomerDetailsInfo detailsInfo) {}
public record CustomerPersonInfo(
String name, String email, String dateOfBirth) {}
public record CustomerDetailsInfo(String info, boolean vip) {}
Posting Data
To send a post request just call the post method. The next code snippet creates a new customer.
CustomerRequest customer = new CustomerRequest(
"John Smith",
"john.smith@mycompany.com",
LocalDate.of(1998, 10, 25),
"Customer detailed info here",
true
);
ResponseEntity<Void> response = restClient.post()
.accept(MediaType.APPLICATION_JSON)
.body(customer)
.retrieve()
.toBodilessEntity();
if (response.getStatusCode().is2xxSuccessful()) {
logger.info("Created " + response.getStatusCode());
logger.info("New URL " + response.getHeaders().getLocation());
}
The response code confirms the customer was created successfully
Created 201 CREATED
New URL http://localhost:8080/api/v1/customers/11
To verify the customer was added, the above URL can be retreived via postman
{
"id": 11,
"status": "ACTIVATED",
"personInfo": {
"name": "John Smith",
"email": "john.smith@mycompany.com",
"dateOfBirth": "25/10/1998"
},
"detailsInfo": {
"info": "Customer detailed info here",
"vip": true
}
}
Of course it could be fetched using the RestClient with code similar to the previous section.
The record class for the customer request is shown for reference
public record CustomerRequest(
String name,
String email,
LocalDate dateOfBirth,
String info,
Boolean vip) { }
Deleting Data
Making an HTTP delete request to try to remove a resource is as simple as invoking the delete method.
ResponseEntity<Void> response = restClient.delete()
.uri("/{id}",2)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.toBodilessEntity();
logger.info("Deleted with status " + response.getStatusCode());
It is worth mentioning that the response body will be empty if the operation succeeded. For this situation the method toBodilessEntity comes in handy. The customer id to be deleted is passed as uri variables.
Deleted with status 204 NO_CONTENT
Handling Errors
What happens if we try to delete or retrieve a non-existing customer? The customer endpoint will return a 404 error code along with a message details. However, RestClient will throw a subclass of RestClientException whenever a client error status (400-499) or server error status (500-599) are received.
To define our custom exception handlers there are two options that work at different levels:
- In the RestClient with defaultStatusHandler method (for all http request sent with it)
- For each http request with the onstatus method after the call to retreive method (this method returns a ResponseSpec interface).
The first is presented in this code snippet
RestClient restClient = RestClient.builder()
.baseUrl(properties.getUrl())
.defaultHeader(HttpHeaders.AUTHORIZATION,
encodeBasic(properties.getUsername(),
properties.getPassword()))
.defaultStatusHandler(
HttpStatusCode::is4xxClientError,
(request, response) -> {
logger.error("Client Error Status " +
response.getStatusCode());
logger.error("Client Error Body "+new
String(response.getBody().readAllBytes()));
})
.build();
And the console after running the delete command line runner:
Client Error Status 404 NOT_FOUND
Client Error Body {"status":404,"message":"Entity Customer for id 2 was not found.","timestamp":"2023-07-23T09:24:55.4088208"}
The other option is implementing the onstatus method for the delete operation. It takes precedence over the RestClient defaultt handler behaviour. Hence, it is overriden as proved in the below code lines
ResponseEntity response = restClient.delete()
.uri("/{id}",2)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError,
(req, res) ->
logger.error("Couldn't delete "+res.getStatusText())
)
.toBodilessEntity();
if (response.getStatusCode().is2xxSuccessful())
logger.info("Deleted with status " +
response.getStatusCode());
Now the message in the console will be
Couldn't delete Not Found
Exchange Method
The exchange method is useful for situations where the response must be decoded differently depending on the response status. Status handlers are ignored when the exchange method is employed.
In this fictitious sample code, the response is mapped to an entity based on the status
SimpleResponse simpleResponse = restClient.get()
.uri("/{id}",4)
.accept(MediaType.APPLICATION_JSON)
.exchange((req,res) ->
switch (res.getStatusCode().value()) {
case 200 -> SimpleResponse.FOUND;
case 404 -> SimpleResponse.NOT_FOUND;
default -> SimpleResponse.ERROR;
}
);
Summary
In this article we have covered the main features of the new RestClient shipped with Spring 6.1 M1+. Now you should be in a position to perform the most common tasks when calling Rest APIs as well as customise error handling and use the low level http request/response with exchange.
As you can see this new API is easier to manage than the old RestTemplate. It also avoids adding the reactive web libraries in case your project only uses the WebClient API.
Thanks for reading the article and stay tuned for more articles on Java and Spring.
Top comments (2)
Is there any github link?
Thanks
Springboot 3 needs
thank you very informative