Appeler des API externes avec RestClient (Spring Boot 3.2+)
1. Présentation générale
À partir de Spring Framework 6.1 (fourni avec Spring Boot 3.2), Spring propose un nouveau RestClient. Il s’agit d’un client HTTP synchrone (blocking), utilisant une API fluent similaire à WebClient mais sans nécessiter Reactor. Cet article vous montre comment créer un RestClient, faire des appels GET/POST, gérer les erreurs, configurer les timeouts, ajouter des interceptors et écrire des tests. Après cette lecture, vous pourrez remplacer RestTemplate par RestClient dans vos nouveaux projets.
2. Installation
RestClient se trouve dans le module spring-web. Le starter web inclut déjà ce module et Jackson pour la conversion JSON :
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
Tout au long de cet article, nous appelons une API de commandes hypothétique à l’adresse https://api.shop.example. Nous déclarons les données avec des records :
public record Order(Long id, String customerName, BigDecimal total, String status) {}
public record CreateOrderRequest(String customerName, BigDecimal total) {}
L’URL de base est définie dans application.properties :
shop.api.base-url=https://api.shop.example
3. Créer un RestClient
3.1. La façon la plus rapide
RestClient restClient = RestClient.create("https://api.shop.example");
Cette approche convient pour des essais ou des scripts. Dans une application Spring Boot, vous devriez utiliser le builder fourni par Boot.
3.2. Utiliser RestClient.Builder de Spring Boot
Spring Boot configure automatiquement un bean RestClient.Builder. Ce builder a déjà tous les HttpMessageConverter de l’application, par exemple votre ObjectMapper personnalisé. Il supporte aussi l’observabilité via Micrometer.
@Component
public class OrderClient {
private final RestClient restClient;
public OrderClient(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
this.restClient = builder
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
}
}
Explication :
baseUrl(...): les appels suivants n’ont besoin de passer que le chemin relatif comme/orders/42.defaultHeader(...): cet en-tête est ajouté à chaque requête.build(): crée une instanceRestClient. Cette instance est thread-safe, vous pouvez donc la créer une fois et la réutiliser.
Note : Le bean
RestClient.Buildera une portéeprototype. À chaque injection, vous recevez un nouveau builder. Par conséquent, la configuration dansOrderClientn’affecte pas les autres clients. Pour appliquer une configuration commune à tous les builders, déclarez un beanRestClientCustomizer.
4. Envoyer des requêtes GET
4.1. Récupérer le body en tant qu’objet
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
get(): choisit la méthode HTTP.uri("/orders/{id}", id): la variable{id}est remplacée et encodée automatiquement. Le résultat estGET https://api.shop.example/orders/42.retrieve(): envoie la requête et prépare la lecture de la réponse.body(Order.class): Jackson convertit le JSON en recordOrder.
4.2. Récupérer le statut et les en-têtes
Quand vous avez besoin du code de statut ou des en-têtes, vous utilisez toEntity :
ResponseEntity<Order> response = restClient.get()
.uri("/orders/{id}", 42)
.retrieve()
.toEntity(Order.class);
System.out.println(response.getStatusCode());
System.out.println(response.getHeaders().getContentType());
System.out.println(response.getBody());
200 OK
application/json
Order[id=42, customerName=Lan, total=250000, status=PAID]
4.3. Récupérer une liste et query params
Les types génériques comme List<Order> sont effacés à la compilation. C’est pourquoi vous avez besoin de ParameterizedTypeReference :
public List<Order> findOrders(String status, int page) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/orders")
.queryParam("status", status)
.queryParam("page", page)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<Order>>() {});
}
L’appel findOrders("PAID", 1) génère une requête GET /orders?status=PAID&page=1. L’URL de base est conservée quand vous utilisez uriBuilder.
5. Envoyer des requêtes POST, PUT, DELETE
public Order createOrder(CreateOrderRequest request) {
return restClient.post()
.uri("/orders")
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.retrieve()
.body(Order.class);
}
public void cancelOrder(Long id) {
restClient.delete()
.uri("/orders/{id}", id)
.retrieve()
.toBodilessEntity();
}
body(request): l’objet est sérialisé en JSON grâce aucontentTypedéclaré.toBodilessEntity(): à utiliser quand vous n’avez pas besoin de lire le body. Cette méthode active toujours la requête et gère les erreurs.
put() et patch() fonctionnent exactement comme post().
Note : Si vous appelez
retrieve()sans appeler ensuitebody(...),toEntity(...)outoBodilessEntity(), la requête ne sera pas envoyée. C’est une erreur courante avec les appels DELETE.
6. Gestion des erreurs
6.1. Comportement par défaut
Par défaut, retrieve() lève une exception quand la réponse a un statut d’erreur :
- 4xx : lève
HttpClientErrorException, par exempleHttpClientErrorException.NotFound. - 5xx : lève
HttpServerErrorException.
Les erreurs de connexion ou de timeout lèvent ResourceAccessException.
6.2. Personnaliser avec onStatus
Vous voudrez généralement convertir les erreurs HTTP en exceptions métier :
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Commande introuvable : " + id);
}
}
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (request, response) -> {
throw new OrderNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
throw new IllegalStateException(
"Erreur API Shop : " + response.getStatusCode());
})
.body(Order.class);
}
Les handlers sont vérifiés dans l’ordre de déclaration. Le premier handler correspondant au statut sera exécuté. Si aucun handler ne correspond, le comportement par défaut de la section 6.1 s’applique.
6.3. Contrôle total avec exchange
Quand vous avez besoin de lire le body d’erreur ou de ramifier selon le statut, vous utilisez exchange :
public Optional<Order> findOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.exchange((request, response) -> {
if (response.getStatusCode().value() == 404) {
return Optional.empty();
}
if (response.getStatusCode().isError()) {
throw new IllegalStateException("Erreur " + response.getStatusCode());
}
return Optional.ofNullable(
response.bodyTo(Order.class));
});
}
Note : Dans
exchange, les status handlers par défaut ne s’appliquent pas. Vous devez vérifier le statut vous-même. Si vous oubliez, le body d’erreur pourrait être parsé enOrderavec des champsnull.
7. Timeouts et client HTTP sous-jacent
Par défaut, RestClient choisit la bibliothèque HTTP disponible sur le classpath dans cet ordre : Apache HttpClient 5, Jetty, Reactor Netty, java.net.http.HttpClient du JDK, puis SimpleClientHttpRequestFactory. Vous devriez toujours définir un timeout. Sinon, une API lente peut bloquer vos threads très longtemps.
@Configuration
public class ShopClientConfig {
@Bean
public RestClientCustomizer timeoutCustomizer() {
return builder -> {
var factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(Duration.ofSeconds(2));
factory.setReadTimeout(Duration.ofSeconds(5));
builder.requestFactory(factory);
};
}
}
RestClientCustomizer s’applique à tous les RestClient.Builder créés par Spring Boot. Si vous souhaitez définir un timeout pour un seul client, appelez builder.requestFactory(factory) directement dans le constructeur de ce client.
Avec une API lente, la requête échouera après 5 secondes :
org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out
À partir de Spring Boot 3.4, vous pouvez aussi déclarer les timeouts avec les propriétés spring.http.client.connect-timeout et spring.http.client.read-timeout.
8. Interceptor : joindre un token, enregistrer les logs
ClientHttpRequestInterceptor vous permet d’intervenir dans chaque requête. Par exemple, le code suivant ajoute une clé API et enregistre le temps de réponse :
@Slf4j
public class ApiKeyInterceptor implements ClientHttpRequestInterceptor {
private final String apiKey;
public ApiKeyInterceptor(String apiKey) {
this.apiKey = apiKey;
}
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
request.getHeaders().set("X-Api-Key", apiKey);
long start = System.currentTimeMillis();
ClientHttpResponse response = execution.execute(request, body);
log.info("{} {} -> {} ({} ms)", request.getMethod(), request.getURI(),
response.getStatusCode(), System.currentTimeMillis() - start);
return response;
}
}
Vous enregistrez l’interceptor au moment de la construction du client :
this.restClient = builder
.baseUrl(baseUrl)
.requestInterceptor(new ApiKeyInterceptor(apiKey))
.build();
INFO ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)
Note : Ne lisez pas
response.getBody()dans l’interceptor pour enregistrer les logs. Le flux ne peut être lu qu’une fois. Si vous devez absolument le lire, enrobez votre request factory avecBufferingClientHttpRequestFactory.
9. HTTP interface : déclarer le client via une interface
Au lieu d’écrire chaque appel vous-même, vous pouvez décrire l’API avec une interface. Spring génèrera l’implémentation basée sur RestClient :
public interface OrderApi {
@GetExchange("/orders/{id}")
Order getOrder(@PathVariable Long id);
@PostExchange("/orders")
Order createOrder(@RequestBody CreateOrderRequest request);
}
@Configuration
public class OrderApiConfig {
@Bean
public OrderApi orderApi(RestClient.Builder builder,
@Value("${shop.api.base-url}") String baseUrl) {
RestClient restClient = builder.baseUrl(baseUrl).build();
return HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(OrderApi.class);
}
}
Maintenant, vous n’avez qu’à injecter OrderApi et appeler orderApi.getOrder(42L). Cette approche est plus concise quand l’API a de nombreux endpoints. Les timeouts, interceptors et status handlers sont toujours configurés sur le RestClient sous-jacent.
10. Écrire des tests avec @RestClientTest
À partir de Spring Boot 3.2, @RestClientTest supporte RestClient. Cette annotation n’initialise que les beans liés au client HTTP. Elle connecte aussi MockRestServiceServer à RestClient.Builder, donc aucune vraie requête n’est envoyée sur le réseau.
Nous testons OrderClient avec la version de getOrder ayant onStatus de la section 6.2 :
@RestClientTest(components = OrderClient.class,
properties = "shop.api.base-url=https://api.shop.example")
class OrderClientTest {
@Autowired
private OrderClient orderClient;
@Autowired
private MockRestServiceServer server;
@Test
void givenExistingOrder_whenGetOrder_thenReturnsOrder() {
server.expect(requestTo("https://api.shop.example/orders/42"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("""
{"id":42,"customerName":"Lan","total":250000,"status":"PAID"}
""", MediaType.APPLICATION_JSON));
Order order = orderClient.getOrder(42L);
assertThat(order.id()).isEqualTo(42L);
assertThat(order.customerName()).isEqualTo("Lan");
assertThat(order.total()).isEqualByComparingTo("250000");
server.verify();
}
@Test
void givenMissingOrder_whenGetOrder_thenThrowsOrderNotFound() {
server.expect(requestTo("https://api.shop.example/orders/99"))
.andRespond(withStatus(HttpStatus.NOT_FOUND));
assertThatThrownBy(() -> orderClient.getOrder(99L))
.isInstanceOf(OrderNotFoundException.class)
.hasMessage("Commande introuvable : 99");
}
}
Les imports statiques requis se trouvent dans MockRestRequestMatchers et MockRestResponseCreators du package org.springframework.test.web.client.match et org.springframework.test.web.client.response.
server.expect(...): déclarez la requête que le test attend.andRespond(...): retournez une réponse fictive pour cette requête.server.verify(): vérifiez que toutes les requêtes déclarées ont bien été envoyées.
Note :
MockRestServiceServerne fonctionne que quand le client est créé à partir duRestClient.Builderinjecté par Spring. Si vous appelezRestClient.create(...)à l’intérieur de la classe, le test enverra des vraies requêtes sur le réseau.
11. Quand ne pas utiliser RestClient
- Applications WebFlux (réactives) :
RestClientbloque les threads. Si vous l’appelez dans un pipeline réactif, la boucle d’événements sera bloquée. Utilisez plutôtWebClient. - Besoin de streamer de grandes données ou Server-Sent Events non-bloquants :
WebClientconvient mieux. - Code legacy fonctionnant bien avec
RestTemplate: vous n’êtes pas obligé de le réécrire.RestTemplateest toujours supporté, seulement il ne reçoit pas de nouvelles fonctionnalités. Vous pouvez migrer progressivement avecRestClient.create(restTemplate)pour conserver votre configuration existante.
Si votre application s’exécute sur Java 21 avec les virtual threads (spring.threads.virtual.enabled=true), le modèle bloquant de RestClient n’a pratiquement plus de désavantages en termes de scalabilité. Le code reste simple et facile à déboguer.
12. Conclusion
Dans cet article, vous avez utilisé RestClient pour appeler des APIs externes avec une API fluent et lisible. Nous avons parcouru les étapes suivantes : créer un client à partir du builder de Spring Boot, envoyer des requêtes GET/POST, convertir les erreurs HTTP en exceptions métier avec onStatus ou exchange, configurer les timeouts, ajouter des interceptors et déclarer le client via HTTP interface. Finalement, vous avez écrit des tests avec @RestClientTest et MockRestServiceServer sans avoir besoin d’appels réseau réels.
Pour les projets Spring Boot 3.2+ utilisant le modèle servlet, RestClient devrait être le choix par défaut à la place de RestTemplate. Rappelez-vous deux choses : définissez toujours un timeout, et terminez toujours la chaîne retrieve() par une méthode de lecture de la réponse.