1. Overview
The Spring Framework release 6, as well as Spring Boot version 3, enables us to define declarative HTTP services using Java interfaces. The approach is inspired by popular HTTP client libraries like Feign and is similar to how we define repositories in Spring Data.
In this tutorial, we’ll first look at how to define an HTTP interface. Then, we’ll check the available exchange method annotations, as well as the supported method parameters and return values. Next, we’ll see how to create an actual HTTP interface instance, a proxy client that performs the declared HTTP exchanges.
Finally, we’ll check how to perform exception handling and testing of the declarative HTTP interface and its proxy client.
2. HTTP Interface
The declarative HTTP interface includes annotated methods for HTTP exchanges. We can simply express the remote API details using an annotated Java interface and let Spring generate a proxy that implements this interface and performs the exchanges. This helps reduce the boilerplate code.
2.1. Exchange Methods
@HttpExchange is the root annotation we can apply to an HTTP interface and its exchange methods. In case we apply it on the interface level, then it applies to all exchange methods. This can be useful for specifying attributes common to all interface methods like content type or URL prefix.
Additional annotations for all the HTTP methods are available:
- @GetExchange for HTTP GET requests
- @PostExchange for HTTP POST requests
- @PutExchange for HTTP PUT requests
- @PatchExchange for HTTP PATCH requests
- @DelectExchange for HTTP DELETE requests
Let’s define a sample declarative HTTP interface using the method-specific annotations for a simple REST service:
interface BooksService {
@GetExchange("/books")
List<Book> getBooks();
@GetExchange("/books/{id}")
Book getBook(@PathVariable long id);
@PostExchange("/books")
Book saveBook(@RequestBody Book book);
@DeleteExchange("/books/{id}")
ResponseEntity<Void> deleteBook(@PathVariable long id);
}
We should note that all the HTTP method-specific annotations are meta-annotated with @HttpExchange. Therefore, @GetExchange(“/books”) is equivalent to @HttpExchange(url = “/books”, method = “GET”).
2.2. Method Parameters
In our example interface, we used @PathVariable and @RequestBody annotations for method parameters. In addition, we may use the following set of method parameters for our exchange methods:
- URI: dynamically sets the URL for the request, overriding the annotation attribute
- HttpMethod: dynamically sets the HTTP method for the request, overriding the annotation attribute
- @RequestHeader: adds the request header names and values, the argument may be a Map or MultiValueMap
- @PathVariable: replaces a value that has a placeholder in the request URL
- @RequestBody: provides the body of the request either as an object to be serialized, or a reactive streams publisher such as Mono or Flux
- @RequestParam: adds request parameter names and values, the argument may be a Map or MultiValueMap
- @CookieValue: adds cookie names and values, the argument may be a Map or MultiValueMap
We should note that request parameters are encoded in the request body only for content type “application/x-www-form-urlencoded”. Otherwise, request parameters are added as URL query parameters.
2.3. Return Values
In our example interface, the exchange methods return blocking values. However, declarative HTTP interface exchange methods support both blocking and reactive return values.
In addition, we may choose to return only the specific response information, such as status codes or headers. As well as returning void in case we are not interested in the service response at all.
To summarize, HTTP interface exchange methods support the following set of return values:
- void, Mono
: performs the request and releases the response content - HttpHeaders, Mono
: performs the request, releases the response content, and returns the response headers -
, Mono : performs the request and decodes the response content to the declared type -
, Flux : performs the request and decodes the response content to a stream of the declared type - ResponseEntity
, Mono<ResponseEntity : performs the request, releases the response content, and returns a ResponseEntity containing status and headers> - ResponseEntity
, Mono<ResponseEntity : performs the request, releases the response content, and returns a ResponseEntity containing status, headers, and the decoded body> - Mono<ResponseEntity<Flux
> : performs the request, releases the response content, and returns a ResponseEntity containing status, headers, and the decoded response body stream
We can also use any other async or reactive types registered in the ReactiveAdapterRegistry.
3. Client Proxy
Now that we have defined our sample HTTP service interface, we’ll need to create a proxy that implements the interface and performs the exchanges.
3.1. Proxy Factory
Spring framework provides us with a HttpServiceProxyFactory that we can use to generate a client proxy for our HTTP interface:
HttpServiceProxyFactory httpServiceProxyFactory = HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(webClient))
.build();
booksService = httpServiceProxyFactory.createClient(BooksService.class);
To create a proxy using the provided factory, besides the HTTP interface, we’ll also require an instance of a reactive web client:
WebClient webClient = WebClient.builder()
.baseUrl(serviceUrl)
.build();
Now, we can register the client proxy instance as a Spring bean or component and use it to exchange data with the REST service.
3.2. Exception Handling
By default, WebClient throws WebClientResponseException for any client or server error HTTP status codes. We can customize exception handling by registering a default response status handler that applies to all responses performed through the client:
BooksClient booksClient = new BooksClient(WebClient.builder()
.defaultStatusHandler(HttpStatusCode::isError, resp ->
Mono.just(new MyServiceException("Custom exception")))
.baseUrl(serviceUrl)
.build());
As a result, in case we request a book that doesn’t exist, we’ll receive a custom exception:
BooksService booksService = booksClient.getBooksService();
assertThrows(MyServiceException.class, () -> booksService.getBook(9));
4. Testing
Let’s see how we can test our sample declarative HTTP interface and its client proxy that performs the exchanges.
4.1. Using Mockito
As we aim to test the client proxy created using our declarative HTTP interface, we’ll need to mock the underlying WebClient’s fluent API using Mockito’s deep stubbing feature:
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private WebClient webClient;
Now, we can use Mockito’s BDD methods to call the chained WebClient methods and provide a mocked response:
given(webClient.method(HttpMethod.GET)
.uri(anyString(), anyMap())
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Book>>(){}))
.willReturn(Mono.just(List.of(
new Book(1,"Book_1", "Author_1", 1998),
new Book(2, "Book_2", "Author_2", 1999)
)));
Once we have our mocked response in place, we can call our service using the methods defined in the HTTP interface:
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
4.2. Using MockServer
In case we want to avoid mocking the WebClient, we can use a library like MockServer to generate and return fixed HTTP responses:
new MockServerClient(SERVER_ADDRESS, serverPort)
.when(
request()
.withPath(PATH + "/1")
.withMethod(HttpMethod.GET.name()),
exactly(1)
)
.respond(
response()
.withStatusCode(HttpStatus.SC_OK)
.withContentType(MediaType.APPLICATION_JSON)
.withBody("{\"id\":1,\"title\":\"Book_1\",\"author\":\"Author_1\",\"year\":1998}")
);
Now that we have the mocked responses in place and a running mock server, we can call our service:
BooksClient booksClient = new BooksClient(WebClient.builder()
.baseUrl(serviceUrl)
.build());
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
In addition, we can verify that our code under test called the correct mocked service:
mockServer.verify(
HttpRequest.request()
.withMethod(HttpMethod.GET.name())
.withPath(PATH + "/1"),
VerificationTimes.exactly(1)
);
5. Conclusion
In this article, we explored declarative HTTP service interfaces available in Spring release 6. We looked at how to define an HTTP interface using the available exchange method annotations, as well as the supported method parameters and return values.
We explored how to create a proxy client that implements the HTTP interface and performs the exchanges. Also, we saw how to perform exception handling by defining a custom status handler. Finally, we saw how to test the declarative interface and its client proxy using Mockito and MockServer.
As always, the complete source code is available over on GitHub.