Appearance
RestClient and Its Fluent API
Chapter 1 ended with the limits of RestTemplate: dozens of overloaded methods, no room for new features, maintenance mode. This chapter makes the same order-service to product-service call with its replacement, RestClient.
Spring has two alternatives to RestTemplate:
- WebClient: asynchronous, non-blocking; the client does not wait for the response. It belongs to reactive programming (Spring WebFlux).
- RestClient: synchronous, blocking, like
RestTemplate; the client waits for the response. Introduced in Spring Framework 6.1 / Spring Boot 3.2.
We stay with synchronous calls, so RestClient it is. It offers a modern, fluent API that is easier to read and maintain.
Step 1: the call
The setup is unchanged: order-service on port 8081, product-service on port 8082 with its GET /products/{id} endpoint.
Create a RestClient bean in order-service:
java
@Configuration
public class AppConfig {
@Bean
public RestClient restClient() {
return RestClient.create(); // same as RestClient.builder().build()
}
}Use it in the controller:
java
@RestController
@RequestMapping("/orders")
public class OrderController {
@Autowired
RestClient restClient;
@GetMapping("/{id}")
public ResponseEntity<String> getOrder(@PathVariable String id) {
String response = restClient
.get()
.uri("http://localhost:8082/products/" + id)
.retrieve()
.body(String.class);
System.out.println("Response from Product API called from order service: " + response);
return ResponseEntity.ok("order call successful");
}
}GET /orders/1 returns order call successful and prints Product fetched with id: 1.
Read the chain aloud: get, from this uri, retrieve the response, give me the body as a String. Every request, whatever its method, follows this one template. There are no overloads to remember.
Step 2: what "fluent API" means
A fluent API is built on method chaining: each call returns an object, and that object offers the next set of methods.
The methods in a chain can live in different classes. Each step decides which methods are available next, so order matters: once a step has returned an object that no longer offers an earlier method, you cannot go back. The chain only works in the order the types allow.
You do not have to memorize RestClient's order. It follows from a handful of interfaces.
Step 3: how RestClient's chain works
The types come in three groups:
UriSpec: methods that set the URI.RequestHeadersSpec: methods that set headers and cookies, plusretrieve()andexchange().RequestBodySpecextendsRequestHeadersSpec: methods that set the body, content type and length (for POST and PUT).RequestHeadersUriSpecextends bothUriSpecandRequestHeadersSpec.RequestBodyUriSpecextendsRequestBodySpecandRequestHeadersUriSpec.DefaultRequestBodyUriSpecimplements all of them. Every request, whatever its method, is one object of this class; the interfaces only control which of its methods you can see at each step.ResponseSpec: knows how to handle the response: convert the body (body,toEntity) and react to error statuses (onStatus).
The headers interface is declared with a self-referencing generic type:
java
interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
S accept(MediaType... acceptableMediaTypes);
S header(String headerName, String... headerValues);
ResponseSpec retrieve();
...
}accept and header return S: "the spec you are already on". That is what keeps a chain on the same step after setting a header.
Following a GET
java
restClient.get() // 1
.uri("http://localhost:8082/products/1") // 2
.accept(MediaType.APPLICATION_JSON) // 3
.header("X-Custom-Header", "xyz") // 3
.retrieve() // 4
.body(String.class); // 5get()creates aDefaultRequestBodyUriSpec, sets its method to GET, and returns it typed asRequestHeadersUriSpec<?>. Both URI and header methods are now visible.uri(...)sets the URI and returnsS, here aRequestHeadersSpec. From now on, only header methods are visible.acceptandheaderset headers and returnSagain: stillRequestHeadersSpec.retrieve()returns aResponseSpec, a different object with response methods.body(String.class)converts the response body.
Breaking the chain
Swap steps 2 and 3:
java
restClient.get()
.accept(MediaType.APPLICATION_JSON)
.uri("http://localhost:8082/products/1"); // compile error: cannot find symbolget() returns RequestHeadersUriSpec<?>. Its type argument is a wildcard, so the compiler only knows S is some RequestHeadersSpec. accept therefore returns a plain RequestHeadersSpec, which has no uri method. Nothing in the chain leads back to UriSpec, so the chain is broken. This is why the URI always comes right after the HTTP method.
Following a POST
java
ResponseEntity<Product> response = restClient
.post() // RequestBodyUriSpec
.uri("http://localhost:8082/products/create") // RequestBodySpec
.accept(MediaType.APPLICATION_JSON) // RequestBodySpec
.header("Content-Type", "application/json") // RequestBodySpec
.body(new Product("Ice-cream")) // RequestBodySpec
.retrieve() // ResponseSpec
.toEntity(Product.class);
Product created = response.getBody();post() returns a RequestBodyUriSpec, which can reach every method. Its uri returns a RequestBodySpec: body methods plus, through its parent, header methods. And because RequestBodySpec passes itself as S, accept and header return RequestBodySpec too, so body(...) stays reachable after the headers.
toEntity returns the full ResponseEntity (status, headers, body); body returns only the body.
Following a DELETE
java
ResponseEntity<Void> response = restClient
.delete()
.uri("http://localhost:8082/products/" + id)
.retrieve()
.toBodilessEntity();
HttpStatusCode deletionStatus = response.getStatusCode();A delete sends no body and expects none, so toBodilessEntity() returns just status and headers.
Step 4: handling errors
By default, a 4xx response makes body() throw HttpClientErrorException (for example HttpClientErrorException$BadRequest: 400 Bad Request), and a 5xx throws HttpServerErrorException.
To throw your own exceptions, register handlers with onStatus on the ResponseSpec. Each handler is a predicate on the status code plus an error handler:
java
String response = restClient
.get()
.uri("http://localhost:8082/products/" + id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, (request, resp) -> {
throw new MyCustomException("Invalid request passed");
})
.onStatus(HttpStatusCode::is5xxServerError, (request, resp) -> {
throw new IllegalStateException("Something wrong at server");
})
.body(String.class);A 400 from product-service now surfaces as MyCustomException: Invalid request passed. onStatus returns the same ResponseSpec, so you can chain several handlers before body.
onStatus also has an overload taking a ResponseErrorHandler. That interface has two abstract methods (hasError and handleError), so it is not a functional interface and cannot be written as a lambda: onStatus(response -> { ... return false; }) does not compile. Use the predicate form above.
Full control with exchange
retrieve() hands the response to a ResponseSpec that decides how to convert it. To do it yourself, call exchange instead of retrieve, right after the URI and headers, and pass a function that receives the raw request and response:
java
String response = restClient
.get()
.uri("http://localhost:8082/products/" + id)
.exchange((request, resp) -> {
if (resp.getStatusCode().is4xxClientError()) {
throw new MyCustomException("Invalid request passed");
} else if (resp.getStatusCode().is5xxServerError()) {
throw new IllegalStateException("Something wrong at server");
}
return StreamUtils.copyToString(resp.getBody(), StandardCharsets.UTF_8);
});You check the status and map the body yourself, so ResponseSpec is not needed at all.
How a request is executed
Every path ends in the same internal exchange method. It does three things:
- Create the request through the request factory.
- Execute it: open (or reuse) the TCP connection, send the request, wait for the response.
- Run the exchange function on the response: your lambda from
exchange(...), or, withretrieve(), the oneResponseSpecbuilds from youronStatushandlers and the target type.
RestClient.create() uses the JdkClientHttpRequestFactory, built on the JDK's java.net.http.HttpClient (the newer client added in Java 11, not the HttpURLConnection of chapter 1). That client manages connections itself and supports HTTP/1.1 and HTTP/2. It even sends requests asynchronously internally, but RestClient waits for the result, so the call stays blocking.
Two details worth knowing:
- The factory depends on how the client is built. If Apache HttpClient or Jetty is on the classpath,
RestClient.create()uses it instead. And theRestClient.Builderthat Spring Boot 3.3 can inject for you picks its own factory, which falls back toHttpURLConnection. Product-service seesUser-Agent: Java-http-client/...from the first andJava/...from the second. - HTTP/2 is a feature of the HTTP library, not of RestClient.
RestTemplatecan also use HTTP/2 if you give it aJdkClientHttpRequestFactory. Its default factory,SimpleClientHttpRequestFactory, is what limits it to HTTP/1.1.
When is the request sent?
In Spring Framework 6.1 (Spring Boot 3.3), retrieve() sends the request. body() or toEntity() then reads and converts the response, applying the onStatus handlers first. You can check it with a log line on each side:
java
RestClient.ResponseSpec spec = restClient.get().uri(url).retrieve();
System.out.println("after retrieve()");
String body = spec.body(String.class);text
product hit id=lazy <- product-service
after retrieve() <- order-serviceStep 5: interceptors
An interceptor runs around every request a client sends. A typical use is adding a header. Implement ClientHttpRequestInterceptor:
java
public class MyCustomRequestInterceptor implements ClientHttpRequestInterceptor {
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().add("X-Custom-Header", "myvalue");
return execution.execute(request, body); // continue with the request
}
}Register it when building the client:
java
@Configuration
public class AppConfig {
@Bean
public RestClient restClient(ClientHttpRequestInterceptor myCustomInterceptor) {
return RestClient.builder()
.requestInterceptor(myCustomInterceptor)
.build();
}
@Bean
public ClientHttpRequestInterceptor myCustomInterceptor() {
return new MyCustomRequestInterceptor();
}
}The controller does not change. Every call now carries X-Custom-Header: myvalue, which product-service can read. The interceptor runs in step 2 of the internal flow, just before the request is executed; calling execution.execute(...) lets it continue.
RestTemplate and RestClient side by side
| RestTemplate | RestClient | |
|---|---|---|
| API | many overloaded methods | one fluent chain |
| Status | maintenance mode | actively developed |
| Since | Spring 3 | Spring 6.1 / Boot 3.2 |
| Default HTTP library | HttpURLConnection | JDK HttpClient (with create()) |
| Errors | throws HttpClientErrorException / HttpServerErrorException | same by default, customizable with onStatus |
| Interceptors | setInterceptors(...) | builder().requestInterceptor(...) |
Next: Both clients need the full URL of product-service, port included, written into order-service. Chapter 3 introduces FeignClient, the Spring Cloud client, where you declare an interface and Spring writes the HTTP call for you.
Check yourself
- How do WebClient and RestClient differ?
- What is a fluent API?
- What does
get()actually create, and what type does it return? - Why does
restClient.get().accept(...).uri(...)not compile? - In a POST chain, why can you still call
body(...)afterheader(...)? - What is the difference between
body(...),toEntity(...)andtoBodilessEntity()? - What does a
404from the server do toretrieve().body(String.class)by default? - Why does
onStatus(response -> { ... return false; })not compile, and what do you write instead? - When would you call
exchangeinstead ofretrieve? - Which HTTP library does
RestClient.create()use by default, and what does it add? - Where does a
ClientHttpRequestInterceptorrun, and what must it call?
Answers
- WebClient is asynchronous and non-blocking (reactive); RestClient is synchronous and blocking, waiting for the response.
- An API built on method chaining, where each call returns an object that offers the next set of methods.
- A
DefaultRequestBodyUriSpecwith its method set to GET, returned asRequestHeadersUriSpec<?>. acceptreturns the self typeS, which through the wildcard is only known to be aRequestHeadersSpec, and that interface has nourimethod. The chain cannot go back toUriSpec.- After
uri, the chain is onRequestBodySpec, which passes itself asS, soheaderreturnsRequestBodySpecagain. bodyreturns only the converted body;toEntityreturns aResponseEntitywith status, headers and body;toBodilessEntityreturns aResponseEntity<Void>with status and headers only.- It throws
HttpClientErrorException(hereHttpClientErrorException.NotFound). - That overload takes a
ResponseErrorHandler, which has two abstract methods and is not a functional interface. WriteonStatus(HttpStatusCode::is4xxClientError, (request, response) -> { throw ...; }). - When you want to check the status and convert the raw response yourself, without
ResponseSpec. - The JDK's
java.net.http.HttpClient(throughJdkClientHttpRequestFactory). It manages connections and supports HTTP/2 as well as HTTP/1.1. - Just before the request is executed; it must call
execution.execute(request, body)to continue.