Background
Historically, RestTemplate
has been the main choice as the REST client to call synchronous API. Since Spring 6, there are two other
options being provided - RestClient
and @HttpExchange
as the alternatives.
RestClient
RestClient
provides fluent API which makes it more readable.
RestClient
can be constructed through two options - RestClient.create
and RestClient.Builder
. In this tutorial we will use RestClient.Builder
as it is more
convenient for us to utilise RestClientTest
.
We will start by creating a repository interface for User
:
interface UserRepository {
List<User> findAll();
User findById(Long id);
}
For those who are familiar with Spring Data, these methods name are following Spring Data’s standard method naming. Next we will write tests and its respective implementation.
Our implementation for UserRepository
will be done in UserRestRepository
. This is to align with standard Spring’s repository practices.
Declaring RestClient
As mentioned earlier, we will use RestClient.Builder
to construct RestClient
:
@Repository
class UserRestRepository implements UserRepository {
private final RestClient restClient;
UserRestRepository(RestClient.Builder restClientBuilder) {
this.restClient = restClientBuilder
.baseUrl("https://jsonplaceholder.typicode.com/users")
.defaultHeader(ACCEPT, APPLICATION_JSON_VALUE)
.build();
}
}
Our RestClient
will communicate with {JSON} Placeholder to retrieve all User
and all requests
will be equipped with application/json
as the expected response header.
Get all users
@Repository
class UserRestRepository implements UserRepository {
private final RestClient restClient;
@Override
public List<User> findAll() {
return restClient.get()
.retrieve()
.body(new ParameterizedTypeReference<>() {});
}
}
Our first implementation is fairly simple. We will retrieve a List
of User
and we use ParamterizedTypeReference
to convert it. In our test, we will verify that
the restClient
will trigger a call to https://jsonplaceholder.typicode.com/users
and we will mock the responses. As our intention is to ensure we are calling the right endpoint.
@RestClientTest(UserRestRepository.class)
class UserRepositoryTests {
@Autowired
private UserRepository repository;
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private ObjectMapper mapper;
@Test
@DisplayName("When findAll Then all users should be returned")
void findAll() throws JsonProcessingException {
var response = mapper.writeValueAsString(List.of(
new User(84L, "Rashidi Zin", "rashidi.zin", "rashidi@zin.my", URI.create("rashidi.zin.my")),
new User(87L, "Zaid Zin", "zaid.zin", "zaid@zin.my", URI.create("zaid.zin.my"))
));
mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users")).andRespond(withSuccess(response, APPLICATION_JSON));
assertThat(repository.findAll()).hasSize(2);
mockServer.verify();
}
}
-
UserRepository
- the class that we want to test -
MockRestServiceServer
- the class that we will use to mock responses from JSONPlaceholder -
ObjectMapper
- the class that we will use to convert anObject
toString
to be used as the mocked response
In the test above, we mocked the response from https://jsonplaceholder.typicode.com/users
and we verify that when UserRepository.findAll()
is called then
a request to https://jsonplaceholder.typicode.com/users
is triggered.
Next, let’s simulate a situation where an error is returned in the response.
Get a user by id
We are going to implement a method that will return a particular User
based on provided id
:
@Repository
class UserRestRepository implements UserRepository {
private final RestClient restClient;
@Override
public User findById(Long id) {
return restClient.get().uri("/{id}", id)
.retrieve()
.onStatus(HttpStatusCode::is4xxClientError, ((request, response) -> {
throw new UserNotFoundException();
}))
.body(User.class);
}
static class UserNotFoundException extends RuntimeException {}
}
In the implementation above, UserNotFoundException
will be thrown when client error is returned as the response. In our test we will
simulate a situation where error resource not found is returned (404
):
@RestClientTest(UserRestRepository.class)
class UserRepositoryTests {
@Autowired
private UserRepository repository;
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private ObjectMapper mapper;
@Test
@DisplayName("When an invalid user id is provided Then UserNotFoundException will be thrown")
void findByInvalidId() {
mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/users/84")).andRespond(withResourceNotFound());
assertThatThrownBy(() -> repository.findById(84L)).isInstanceOf(UserNotFoundException.class);
mockServer.verify();
}
}
Full implementation of the test and its production code can be found in UserRepository and UserRepositoryTests.
HTTP Interface
Spring allows us to define HTTP service as Java interface with @HttpExchange
methods - @DeleteExchange
, @GetExchange
, @PatchExchange
, @PostExchange
, and @PutExchange
.
In this tutorial we will use @GetExchange
to retrieve all Post
and to retrieve one Post
by its id
.
PostRepository interface
These methods are implemented in PostRepository
:
@HttpExchange(url = "/posts", accept = APPLICATION_JSON_VALUE)
interface PostRepository {
@GetExchange
List<Post> findAll();
@GetExchange("/{id}")
Post findById(@PathVariable Long id);
}
-
All methods in this class will call an endpoint that ends with
/posts
-
Each REST calls accepts
application/json
in the response -
findAll
will return allPost
-
findById
will returnPost
that belongs to the requestedid
PostRepository configuration class
Spring requires us to define which REST Client to use for API calls in PostRepository
. In this tutorial, our choice will be RestClient
. Our aim is to have
same outcome as UserRepository
.
@Configuration
class PostRepositoryConfiguration {
@Bean
public PostRepository postRepository(RestClient.Builder restClientBuilder) {
var restClient = restClientBuilder
.baseUrl("https://jsonplaceholder.typicode.com")
.defaultStatusHandler(HttpStatusCode::is4xxClientError, new PostErrorResponseHandler())
.build();
return builderFor(create(restClient))
.build()
.createClient(PostRepository.class);
}
static class PostErrorResponseHandler implements ErrorHandler {
@Override
public void handle(HttpRequest request, ClientHttpResponse response) throws IOException {
if (response.getStatusCode() == NOT_FOUND) { throw new PostNotFoundException(); }
}
static class PostNotFoundException extends RuntimeException {}
}
}
PostRepositoryConfiguration
, we have defined:-
Our
RestClient
will trigger calls tohttps://jsonplaceholder.typicode.com
-
When error
404
is returned thenPostNotFoundException
will be thrown -
@HttpExchange
inPostRepository
will use theRestClient
that we have defined inpostRepository
Verify PostRepository implementation
We will write same tests as UserRepositoryTests
where we will validate retrieving all Post
and an error will be thrown when invalid id
is provided.
Test configuration
Given that we have a @Configuration
class, the class need to be included in our test when defining RestClientTest
:
@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class))
class PostRepositoryTests {
@Autowired
private PostRepository repository;
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private ObjectMapper mapper;
}
Now our test is aware about PostRepositoryConfiguration
. The dependencies are the same as UserRepositoryTests
except for our target repository - PostRepository
.
Get all posts
In this test, we are expecting a HTTP call to https://jsonplaceholder.typicode.com/posts
will be made when we trigger PostRepository.findAll()
:
@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class))
class PostRepositoryTests {
@Autowired
private PostRepository repository;
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private ObjectMapper mapper;
@Test
@DisplayName("When requesting for all posts then response should contain all posts available")
void findAll() throws JsonProcessingException {
var content = mapper.writeValueAsString(posts());
mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts")).andRespond(withSuccess(content, APPLICATION_JSON));
repository.findAll();
mockServer.verify();
}
private List<Post> posts() {
return List.of(
new Post(1L, 84L, "Spring Web: REST Clients Example with RESTClient", "An example of using RESTClient"),
new Post(2L, 84L, "Spring Web: REST Clients Example with HTTPExchange", "An example of using HttpExchange interface")
);
}
}
Get a post with invalid id
Next, we want to validate that when we provide an invalid id to PostRepository.findById()
the error PostNotFoundException
will be thrown. To simulate this,
we will mock a response that returns 404
:
@RestClientTest(components = PostRepository.class, includeFilters = @Filter(type = ASSIGNABLE_TYPE, classes = PostRepositoryConfiguration.class))
class PostRepositoryTests {
@Autowired
private PostRepository repository;
@Autowired
private MockRestServiceServer mockServer;
@Autowired
private ObjectMapper mapper;
@Test
@DisplayName("When requesting with an invalid post id Then an error PostNotFoundException will be thrown")
void findByInvalidId() {
mockServer.expect(requestTo("https://jsonplaceholder.typicode.com/posts/10101011")).andRespond(withResourceNotFound());
assertThatThrownBy(() -> repository.findById(10101011L)).isInstanceOf(PostNotFoundException.class);
}
}
All the tests can be found in PostRepository.
Conclusion
@HttpExchange
provides a cleaner implementation and the flexibility to choose which REST Client to be used. In this example, we are dealing with a synchronous API and we
chose RestClient
over RestTemplate
. If you are dealing with asynchronous API then WebClient
should be your choice.