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 eine RestClient-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 in OrderClient andere Clients nicht. Wenn du gemeinsame Konfigurationen für alle Builder möchtest, deklariere eine RestClientCustomizer-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 Record Order.

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 deklarierten contentType.
  • 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 mit body(...), toEntity(...) oder toBodilessEntity() 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 exchange werden Standard-Status-Handler nicht angewendet. Du musst den Status selbst überprüfen. Wenn du das vergisst, kann der Fehler-Body als Order mit null-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 mit BufferingClientHttpRequestFactory ein.

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: MockRestServiceServer funktioniert nur, wenn der Client aus einem RestClient.Builder erstellt wird, den Spring injiziert. Wenn du RestClient.create(...) in deiner Klasse aufrufst, werden echte Netzwerkanfragen gesendet.

11. Wann solltest du RestClient nicht verwenden

  • WebFlux-Anwendungen (reaktiv): RestClient blockiert Threads. Wenn du es in einer reaktiven Pipeline aufrufst, wird die Event-Loop blockiert. Nutze stattdessen WebClient.
  • Großes Streaming oder Server-Sent Events ohne Blockierung: WebClient ist besser geeignet.
  • Alter Code läuft mit RestTemplate gut: Du musst nicht neu schreiben. RestTemplate wird weiterhin unterstützt, erhält nur keine neuen Features. Du kannst schrittweise migrieren mit RestClient.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.

spring-bootrestclienthttprest-apitesting