Externe APIs mit RestClient aufrufen (Spring Boot 3.2+)
1. Überblick
Seit Spring Framework 6.1 (mit Spring Boot 3.2) bietet Spring einen neuen RestClient. Das ist ein synchroner (blocking) HTTP-Client mit einer Fluent-API ähnlich wie WebClient, benötigt aber keine Reactor-Abhängigkeit. In diesem Artikel zeige ich dir, wie du RestClient erstellst, GET/POST-Anfragen sendest, Fehler behandelst, Timeouts konfigurierst, Interceptor hinzufügst und Tests schreibst. Danach kannst du RestTemplate in neuen Projekten durch RestClient ersetzen.
2. Installation
RestClient ist Teil des Moduls spring-web. Der Web-Starter enthält dieses Modul bereits und Jackson für JSON-Konvertierung:
<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>
Im Laufe des Artikels rufen wir eine fiktive Bestellungs-API unter https://api.shop.example auf. Wir definieren die Daten als Records:
public record Order(Long id, String customerName, BigDecimal total, String status) {}
public record CreateOrderRequest(String customerName, BigDecimal total) {}
Die Base-URL wird in application.properties definiert:
shop.api.base-url=https://api.shop.example
3. RestClient erstellen
3.1. Der schnellste Weg
RestClient restClient = RestClient.create("https://api.shop.example");
Diese Variante eignet sich für Quick Tests oder Skripte. In einer Spring-Boot-Anwendung solltest du den von Boot bereitgestellten Builder verwenden.
3.2. Mit RestClient.Builder von Spring Boot
Spring Boot konfiguriert automatisch eine Bean namens RestClient.Builder. Dieser Builder enthält bereits alle HttpMessageConverter der Anwendung, wie deinen benutzerdefinierten ObjectMapper. Er unterstützt auch Observability über 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();
}
}
Erklärung:
baseUrl(...): Nachfolgende Aufrufe benötigen nur relative Pfade wie/orders/42.defaultHeader(...): Dieser Header wird zu jeder Anfrage hinzugefügt.build(): erstellt eineRestClient-Instanz. Diese ist thread-safe, also erstelle sie einmal und verwende sie wieder.
Hinweis: Die
RestClient.Builder-Bean hat Prototype-Scope. Bei jeder Injektion erhältst du einen neuen Builder. Deshalb beeinflussen Konfigurationen inOrderClientandere Clients nicht. Wenn du gemeinsame Konfigurationen für alle Builder möchtest, deklariere eineRestClientCustomizer-Bean.
4. GET-Anfragen senden
4.1. Body als Objekt auslesen
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
get(): wählt die HTTP-Methode.uri("/orders/{id}", id): die Variable{id}wird ersetzt und automatisch kodiert. Resultat:GET https://api.shop.example/orders/42.retrieve(): sendet die Anfrage und bereitet das Lesen der Response vor.body(Order.class): Jackson konvertiert JSON zu deinem RecordOrder.
4.2. Status und Header auslesen
Wenn du Status-Code oder Header benötigst, nutze 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. Liste und Query-Parameter
Generische Typen wie List<Order> werden zur Laufzeit gelöscht. Daher benötigst du 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>>() {});
}
Der Aufruf findOrders("PAID", 1) erzeugt eine Anfrage wie GET /orders?status=PAID&page=1. Die Base-URL bleibt erhalten, wenn du uriBuilder verwendest.
5. POST-, PUT- und DELETE-Anfragen senden
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): das Objekt wird zu JSON serialisiert, dank des deklariertencontentType.toBodilessEntity(): nutze diese Methode, wenn du die Response nicht lesen musst. Sie löst die Anfrage aus und behandelt Fehler.
put() und patch() funktionieren genauso wie post().
Hinweis: Wenn du
retrieve()aufrufst, aber nicht mitbody(...),toEntity(...)odertoBodilessEntity()fortsetzt, wird die Anfrage nicht gesendet. Das ist ein häufiger Fehler bei DELETE-Aufrufen.
6. Fehlerbehandlung
6.1. Standardverhalten
Standardmäßig wirft retrieve() eine Exception bei Fehler-Status:
- 4xx: wirft
HttpClientErrorException, z. B.HttpClientErrorException.NotFound. - 5xx: wirft
HttpServerErrorException.
Verbindungs- oder Timeout-Fehler werfen ResourceAccessException.
6.2. Anpassung mit onStatus
Du möchtest HTTP-Fehler oft in geschäftslogische Exceptions umwandeln:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Bestellung " + id + " nicht gefunden");
}
}
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(
"Shop API Fehler: " + response.getStatusCode());
})
.body(Order.class);
}
Handler werden in der Reihenfolge ihrer Deklaration überprüft. Der erste übereinstimmende Handler wird ausgeführt. Wenn kein Handler passt, wird das Standardverhalten aus Punkt 6.1 angewendet.
6.3. Vollständige Kontrolle mit exchange
Wenn du den Fehler-Body lesen oder nach Status differenzieren musst, nutze 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("Fehler " + response.getStatusCode());
}
return Optional.ofNullable(
response.bodyTo(Order.class));
});
}
Hinweis: Innerhalb von
exchangewerden Standard-Status-Handler nicht angewendet. Du musst den Status selbst überprüfen. Wenn du das vergisst, kann der Fehler-Body alsOrdermitnull-Feldern geparst werden.
7. Timeout und zugrundeliegender HTTP-Client
Standardmäßig wählt RestClient die verfügbare HTTP-Bibliothek in dieser Reihenfolge: Apache HttpClient 5, Jetty, Reactor Netty, java.net.http.HttpClient der JDK, dann SimpleClientHttpRequestFactory. Du solltest immer einen Timeout setzen. Sonst kann eine langsame API dinen Thread lange blockieren.
@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 wird auf jeden RestClient.Builder angewendet, den Spring Boot erstellt. Wenn du nur für einen Client einen Timeout setzen möchtest, rufe builder.requestFactory(factory) direkt im Konstruktor deines Clients auf.
Bei einer langsamen API schlägt die Anfrage nach 5 Sekunden fehl:
org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out
Ab Spring Boot 3.4 kannst du Timeouts auch über die Properties spring.http.client.connect-timeout und spring.http.client.read-timeout setzen.
8. Interceptor: Token anhängen, Logging
ClientHttpRequestInterceptor ermöglicht es dir, in jede Anfrage einzugreifen. Beispiel: Code, der einen API-Schlüssel anhängt und die Antwortzeit protokolliert:
@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;
}
}
Du registrierst den Interceptor beim Erstellen des Clients:
this.restClient = builder
.baseUrl(baseUrl)
.requestInterceptor(new ApiKeyInterceptor(apiKey))
.build();
INFO ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)
Hinweis: Lies nicht
response.getBody()im Interceptor zum Logging. Der Stream kann nur einmal gelesen werden. Falls notwendig, wickle die Request Factory mitBufferingClientHttpRequestFactoryein.
9. HTTP-Interface: Client durch Interface deklarieren
Statt jede Anfrage selbst zu schreiben, kannst du die API durch ein Interface beschreiben. Spring generiert die Implementierung basierend auf 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);
}
}
Jetzt injizierst du einfach OrderApi und rufst orderApi.getOrder(42L) auf. Diese Methode ist elegant bei APIs mit vielen Endpoints. Timeout, Interceptor und Status-Handler werden weiterhin auf dem zugrundeliegenden RestClient konfiguriert.
10. Test mit @RestClientTest
Ab Spring Boot 3.2 unterstützt @RestClientTest den RestClient. Diese Annotation initialisiert nur HTTP-Client-Beans. Sie verbindet auch MockRestServiceServer mit dem RestClient.Builder, sodass keine echten Anfragen ins Netzwerk gehen.
Wir testen OrderClient mit der getOrder-Version mit onStatus aus Punkt 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("Bestellung 99 nicht gefunden");
}
}
Die statischen Imports stammen aus MockRestRequestMatchers und MockRestResponseCreators in den Packages org.springframework.test.web.client.match und org.springframework.test.web.client.response.
server.expect(...): deklariert, welche Anfrage der Test erwartet.andRespond(...): liefert eine Mock-Response für diese Anfrage.server.verify(): prüft, dass alle erwarteten Anfragen tatsächlich gesendet wurden.
Hinweis:
MockRestServiceServerfunktioniert nur, wenn der Client aus einemRestClient.Buildererstellt wird, den Spring injiziert. Wenn duRestClient.create(...)in deiner Klasse aufrufst, werden echte Netzwerkanfragen gesendet.
11. Wann solltest du RestClient nicht verwenden
- WebFlux-Anwendungen (reaktiv):
RestClientblockiert Threads. Wenn du es in einer reaktiven Pipeline aufrufst, wird die Event-Loop blockiert. Nutze stattdessenWebClient. - Großes Streaming oder Server-Sent Events ohne Blockierung:
WebClientist besser geeignet. - Alter Code läuft mit
RestTemplategut: Du musst nicht neu schreiben.RestTemplatewird weiterhin unterstützt, erhält nur keine neuen Features. Du kannst schrittweise migrieren mitRestClient.create(restTemplate), um die aktuelle Konfiguration zu bewahren.
Wenn deine Anwendung auf Java 21 mit Virtual Threads läuft (spring.threads.virtual.enabled=true), verliert das Blocking-Modell von RestClient fast seine Nachteile bei der Skalierbarkeit. Der Code bleibt einfach und leicht zu debuggen.
12. Fazit
In diesem Artikel hast du RestClient verwendet, um externe APIs mit einer eleganten Fluent-API zu rufen. Du hast gelernt: einen Client vom Spring-Boot-Builder erstellen, GET/POST-Anfragen senden, HTTP-Fehler mit onStatus oder exchange in geschäftslogische Exceptions umwandeln, Timeouts setzen, Interceptor hinzufügen und APIs über HTTP-Interface deklarieren. Abschließend hast du Tests mit @RestClientTest und MockRestServiceServer ohne echte Netzwerkanfragen geschrieben.
Für Spring-Boot-3.2+-Projekte mit Servlet-Modell sollte RestClient die Standard-Wahl gegenüber RestTemplate sein. Merke dir zwei Dinge: Setze immer einen Timeout, und beende die retrieve()-Kette immer mit einer Response-Lese-Methode.