[Spring Boot] Declarative HTTP Clients in Spring MVC and Spring WebFlux
Introduction
When I first learned about spring-cloud-openfeign
, I found it very appealing. Because it supported some annotations from spring-mvc
, it was easy to learn. In addition to that, one application can make HTTP calls with minimal efforts: which is to add a dependency, create a Java interface, and annotate appropriately. Lastly, it integrates nicely if you are building cloud applications using Spring Cloud
.
However, it had some drawbacks as well. It was dependent on the core OpenFeign
module and it did not support the non-blocking calls for Reactive stack (WebFlux).
Recently, I found out that Spring Framework 6 provides similar HTTP interface. Here is the introduction regarding HTTP Interface from Spring Docs:
The Spring Framework lets you define an HTTP service as a Java interface with annotated methods for HTTP exchanges. You can then generate a proxy that implements this interface and performs the exchanges. This helps to simplify HTTP remote access which often involves a facade that wraps the details of using the underlying HTTP client.
With this, we could achieve similar outcome as before (maybe even better). Let’s see how we can make use of this in both Spring MVC as well as Spring WebFlux.
Spring MVC Application
Dependency
For Spring MVC, we use spring-boot-starter-web
. However, because the proxy needs to be built with WebClient
, we also need the spring-boot-starter-webflux
dependency as well.
plugins {
java
id("org.springframework.boot") version "3.1.0"
id("io.spring.dependency-management") version "1.1.0"
}
group = "io.jay"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-webflux")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Implementation Details
So the idea is that we can define a Java interface and create a proxy class. So let’s see that in action.
First, let’s define the Java interface. This looks very similar to spring-cloud-openfeign
. Notice how some annotations are different. Instead of @GetMapping
, it supports with @GetExchange
. Same rules apply for post, put, patch, delete mappings. The good news is that it supports a lot of what we are familiar with for method paramters such as @RequestHeader
, @PathVariable
, @RequestBody
, and so on.
interface ProductClient {
@GetExchange("/products")
ProductClientResponse fetchAll();
}
So I was curious about how to distinguish if this client is blocking or not because the proxy will use WebClient
. The answer was simple, it looks at the return type and it will block for you if necessary. We will see later that we wrap the response with Reactor publisher for WebFlux example.
Then, proxy can be built using WebClient
. This part is the same in the Reactive stack because WebFlux uses WebClient
out of box.
@Configuration
class HttpClientConfiguration {
@Bean
ProductClient productClient(WebClient.Builder builder) {
var wca = WebClientAdapter.forClient(builder.baseUrl("https://dummyjson.com").build());
return HttpServiceProxyFactory.builder()
.clientAdapter(wca)
.build()
.createClient(ProductClient.class);
}
}
Full Code
package io.jay.mvcapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.util.List;
interface ProductClient {
@GetExchange("/products")
ProductClientResponse fetchAll();
}
@SpringBootApplication
public class MvcApplication {
public static void main(String[] args) {
SpringApplication.run(MvcApplication.class, args);
}
}
@Controller
@ResponseBody
class ProductController {
private final ProductClient productClient;
public ProductController(ProductClient productClient) {
this.productClient = productClient;
}
@GetMapping("/v1/products")
public List<Product> getAll() {
return productClient.fetchAll().products();
}
}
record Product(int id, String title, String description, int price, double discountPercentage, double rating, int stock,
String brand, String category) {
}
record ProductClientResponse(List<Product> products) {
}
@Configuration
class HttpClientConfiguration {
@Bean
ProductClient productClient(WebClient.Builder builder) {
var wca = WebClientAdapter.forClient(builder.baseUrl("https://dummyjson.com").build());
return HttpServiceProxyFactory.builder()
.clientAdapter(wca)
.build()
.createClient(ProductClient.class);
}
}
Spring WebFlux Application
Most explanations are done in the MVC example so I will not repeat in the WebFlux example.
Dependency
plugins {
java
id("org.springframework.boot") version "3.1.0"
id("io.spring.dependency-management") version "1.1.0"
}
group = "io.jay"
version = "0.0.1-SNAPSHOT"
java {
sourceCompatibility = JavaVersion.VERSION_17
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.springframework.boot:spring-boot-starter-webflux")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("io.projectreactor:reactor-test")
}
tasks.withType<Test> {
useJUnitPlatform()
}
Implementation Details
The only difference compared to the MVC is the return type in the Java interface.
interface ProductClient {
@GetExchange("/products")
Mono<ProductClientResponse> fetchAll();
}
Full Implementation
package io.jay.webfluxapp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import java.util.List;
interface ProductClient {
@GetExchange("/products")
Mono<ProductClientResponse> fetchAll();
}
@SpringBootApplication
public class WebFluxApplication {
public static void main(String[] args) {
SpringApplication.run(WebFluxApplication.class, args);
}
}
@Controller
@ResponseBody
class ProductController {
private final ProductClient productClient;
public ProductController(ProductClient productClient) {
this.productClient = productClient;
}
@GetMapping("/v1/products")
public Flux<Product> getAll() {
return productClient.fetchAll()
.flatMapMany(response -> Flux.fromIterable(response.products()));
}
}
record Product(int id, String title, String description, int price, double discountPercentage, double rating, int stock,
String brand, String category) {
}
record ProductClientResponse(List<Product> products) {
}
@Configuration
class HttpClientConfiguration {
@Bean
ProductClient productClient(WebClient.Builder builder) {
var wca = WebClientAdapter.forClient(builder.baseUrl("https://dummyjson.com").build());
return HttpServiceProxyFactory.builder()
.clientAdapter(wca)
.build()
.createClient(ProductClient.class);
}
}
Tip
The tests are written using WebTestClient
, so the same test code can be used for both MVC and WebFlux applications.
package io.jay.webfluxapp;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.SpyBean;
import org.springframework.test.web.reactive.server.WebTestClient;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WebFluxApplicationTests {
@Autowired
WebTestClient webTestClient;
@SpyBean
ProductClient productClient;
@Test
void test_getAllProducts_invokesProductClient() {
webTestClient.get()
.uri("/v1/products")
.exchange()
.expectBodyList(Product.class)
.returnResult();
verify(productClient, times(1)).fetchAll();
}
@Test
void test_getAllProducts_returnsProducts() {
var response = webTestClient.get()
.uri("/v1/products")
.exchange()
.expectBodyList(Product.class)
.returnResult()
.getResponseBody();
assertEquals(30, response.size());
}
}
Full source can be found here: