Spring Boot - WebClient

Spring Boot WebClient class is a non-blocking, reactive client to perform HTTP requests.

The WebClient class is a core component in Spring Boot applications for making non-blocking and reactive HTTP requests. It’s designed as the successor to the older RestTemplate and offers several advantages:

Reactive approach: WebClient leverages the reactive programming paradigm, making it efficient in handling asynchronous operations and working well with event-driven systems. This can significantly improve the performance of your application, especially under high load.

Non-blocking: It avoids blocking the main thread while waiting for responses, allowing your application to remain responsive and handle other tasks concurrently.

Flexibility: It supports both synchronous and asynchronous operations, catering to various application needs.
Builder pattern: It uses a fluent builder pattern for configuring requests, making the code more readable and maintainable.

Dependency

To use WebClient in your Spring Boot application, you need to add the spring-boot-starter-webflux dependency to your pom.xml file:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Gradle Dependency

1
implementation 'org.springframework.boot:spring-boot-starter-webflux'

Create WebClient Bean

You can create a WebClient bean in your Spring Boot application by using the WebClient.create() method.

1
WebClient.create();

You can also create WebClient with a base URL:

1
WebClient.create("https://jsonplaceholder.typicode.com");

You can use WebClient.builder() to create a WebClient. You can set the baseUrl and configurations such as setting default headers, etc.

1
2
3
4
5
WebClient webClient = WebClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();

Sending Request

You can use get(), post(), put(), delete() methods to send requests.

Use uri() method to set the request URI.

1
2
3
4
5
String responseBody = webClient.get()
.uri("/posts/1")
.retrieve()
.bodyToMono(String.class)
.block();
1
2
3
4
5
webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class)
.block();

Request Header

You can use header() method to set headers for the request. WebClient also provides methods to set content type, accept, and accept charset headers.

1
2
3
4
5
6
7
webClient
.post()
.uri("/posts")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.acceptCharset(StandardCharsets.UTF_8)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + "token-here")

Request Body

You can use bodyValue() method to set the request body.

1
2
3
Post response = webClient.post()
.uri("/posts")
.bodyValue(post)

Another option is to use BodyInserters to set the request body.

1
2
3
4
5
webClient
.post()
.uri("/posts")
.contentType(MediaType.APPLICATION_JSON)
.body(BodyInserters.fromValue(post))

Response

Use retrieve() method to declare how to extract the response body. retrieve() method returns ResponseSpec which provides methods to extract the response body.

use toEntity() method to get the response body and headers.

1
2
3
4
Mono<ResponseEntity<Post>> postEntityMono = webClient.get()
.uri("/posts/{id}", 1)
.retrieve()
.toEntity(Post.class);

toEntity() method returns a Mono object. You can use block() method to get the response entity.

1
2
3
4
5
6
7
ResponseEntity<Post> postEntity = webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.toEntity(Post.class)
.block();
System.out.println(postEntity.getStatusCode().value());
System.out.println(postEntity.getBody());

If interested only the response body, you can use bodyToMono() or bodytoFlux() method.

1
2
3
4
Mono<Post> post = webClient.get()
.uri("/posts/{id}", id)
.retrieve()
.bodyToMono(Post.class)

use bodyToFlux() method to get a list of response body.

1
2
3
4
5
Flux<Post> posts = webClient.get()
.uri("/posts")
.retrieve()
.bodyToFlux(Post.class)
.collectList();

If response doesn’t have a body, can use toBodilessEntity() method to get the response entity.

1
2
3
4
5
6
7
ResponseEntity<Void> result = webClient.post()
.uri("/posts")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(post)
.retrieve()
.toBodilessEntity()
.block();

Error Handling

You can use onStatus() method to handle the response status.

1
2
3
4
5
6
7
8
9
10
11
12
13
post = webClient.get()
.uri("/badRequest")
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, clientResponse -> {
log.error("Client Error {}", clientResponse.statusCode());
return Mono.error(new RuntimeException("Client Error"));
})
.onStatus(HttpStatusCode::is5xxServerError, clientResponse -> {
log.error("Server Error {}", clientResponse.statusCode());
return Mono.error(new RuntimeException("Server Error"));
})
.bodyToMono(Post.class)
.block();

Synchonous way to handle error:

1
2
3
4
5
6
7
8
9
10
11
12
13
try {
post = webClient.get()
.uri("/badRequest")
.retrieve()
.bodyToMono(Post.class)
.block();
} catch (WebClientResponseException e) {
if( e.getStatusCode().is4xxClientError()) {
log.error("Client Error {}", e.getStatusCode().value(), e);
} else if (e.getStatusCode().is5xxServerError()) {
log.error("Server Error {} ", e.getStatusCode().value(), e);
}
}

Filters

You can use filter() method to add filters to the WebClient. For example, you can add logging filter to log the request and response.
filter() method takes an ExchangeFilterFunction as an argument.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
WebClient webClient = WebClient.builder()
.baseUrl("https://jsonplaceholder.typicode.com")
.filter(logRequest())
.filter(logResponse())
.build();

private ExchangeFilterFunction logRequest() {
return ExchangeFilterFunction.ofRequestProcessor(clientRequest -> {
log.info("Request: {} {}", clientRequest.method(), clientRequest.url());
clientRequest.headers().forEach((name, values) -> values.forEach(value -> log.info("{}={}", name, value)));
return Mono.just(clientRequest);
});
}

private ExchangeFilterFunction logResponse() {
return ExchangeFilterFunction.ofResponseProcessor(clientResponse -> {
log.info("Response: {}", clientResponse.statusCode());
return Mono.just(clientResponse);
});
}

The helper class ExchangeFilterFunctions has method basicAuthentication to add Basic Authentication header to the request.

1
2
3
4
WebClient webClient = WebClient.builder()
.baseUrl("https://url.com")
.filter(ExchangeFilterFunctions.basicAuthentication("user", "password"))
.build();

Test

We can use MockWebServer to test WebClient. For more info on MockWebServer, please refer to MockWebServer.

We can use Mockito to mock WebClient. However it is preferred to use MockWebServer to test WebClient.

Maven Dependency

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>mockwebserver</artifactId>
<version>4.12.0</version>
<scope>test</scope>
</dependency>

Sample test case code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Create a MockWebServer.
MockWebServer mockBackEnd = new MockWebServer();

Post post = new Post()
.setUserId(1)
.setTitle("My first post")
.setBody("Mock Post Body");

// stub some responses.
mockBackEnd.enqueue(new MockResponse()
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.setBody(new ObjectMapper().writeValueAsString(post)));

// Start the mockBackEnd.
mockBackEnd.start();

// Create a WebClient that uses the MockWebServer.
WebClient webClient = WebClient.builder()
.baseUrl(mockBackEnd.url("/").toString())
.build();

// Create a DemoService that uses the WebClient.
DemoService demoService = new DemoService(webClient);

// Call the method.
Post result = demoService.getPost(1);

assertEquals(post.getBody(), result.getBody());

// Optional - verify the requests.
RecordedRequest recordedRequest = mockBackEnd.takeRequest();
assertEquals("GET", recordedRequest.getMethod());
assertEquals("/posts/1", recordedRequest.getPath());

// Shut down the mockBackEnd.
mockBackEnd.shutdown();

Another example using static MockWebServer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class DemoServiceTest {
private static MockWebServer mockBackEnd;
private static DemoService demoService;

@BeforeAll
public static void setUp() throws IOException {
mockBackEnd = new MockWebServer();
mockBackEnd.start();

// Create a WebClient that uses the MockWebServer.
WebClient webClient = WebClient.builder()
.baseUrl(mockBackEnd.url("/").toString())
.build();

// Create a DemoService that uses the WebClient.
demoService = new DemoService(webClient);
}

@AfterAll
public static void tearDown() throws IOException {
mockBackEnd.shutdown();
}

@Test
void getPost() throws IOException, InterruptedException {
Post mockResponsePost = new Post()
.setUserId(1)
.setTitle("My first post")
.setBody("Mock Post Body");

// stub some responses.
mockBackEnd.enqueue(new MockResponse()
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.setBody(new ObjectMapper().writeValueAsString(mockResponsePost)));

// Call the method.
Post result = demoService.createPost(new Post().setUserId(1).setTitle("My first post").setBody("Mock Post Body"));

// Verify the result
assertEquals(mockResponsePost.getBody(), result.getBody());

// Optional - verify the requests.
RecordedRequest recordedRequest = mockBackEnd.takeRequest();
assertEquals("POST", recordedRequest.getMethod());
assertEquals("/posts", recordedRequest.getPath());
assertEquals("{\"id\":null,\"title\":\"My first post\",\"body\":\"Mock Post Body\",\"userId\":1}",
recordedRequest.getBody().readString(Charset.defaultCharset()));
}
}

Dispatch

By default MockWebServer uses a queue to specify a series of responses. We can also use a Dispatcher (import okhttp3.mockwebserver.Dispatcher) to handle requests using policy.

example of using Dispatcher to handle requests

1
2
3
4
5
6
7
8
9
10
11
12
mockBackEnd.setDispatcher(new Dispatcher() {
@SneakyThrows
@Override
public MockResponse dispatch(RecordedRequest request) throws InterruptedException {
return switch (request.getPath()) {
case "/posts/1", "/posts/2" -> new MockResponse()
.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.setBody(new ObjectMapper().writeValueAsString(mockResponsePost));
default -> new MockResponse().setResponseCode(500);
};
}
});

References