Chamando APIs externas com RestClient (Spring Boot 3.2+)

1. Visão geral

A partir do Spring Framework 6.1 (incluído no Spring Boot 3.2), o Spring ganhou um novo RestClient. É um cliente HTTP síncrono (bloqueante), com uma API fluente semelhante ao WebClient, mas sem precisar de Reactor. Este guia ensina você a criar um RestClient, fazer chamadas GET/POST, tratar erros, configurar timeouts, adicionar interceptadores e escrever testes. Depois de ler, você poderá substituir RestTemplate por RestClient em novos projetos.

2. Instalação

RestClient está no módulo spring-web. O starter web já inclui este módulo e Jackson para conversão 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>

Ao longo deste artigo, vamos chamar uma API de pedidos simulada em https://api.shop.example. Declaramos os dados usando records:

public record Order(Long id, String customerName, BigDecimal total, String status) {}

public record CreateOrderRequest(String customerName, BigDecimal total) {}

A URL base é definida em application.properties:

shop.api.base-url=https://api.shop.example

3. Criando RestClient

3.1. O caminho mais rápido

RestClient restClient = RestClient.create("https://api.shop.example");

Este caminho é adequado ao fazer experimentos ou executar scripts. Em uma aplicação Spring Boot, você deve usar o builder fornecido pelo Boot.

3.2. Usando RestClient.Builder do Spring Boot

O Spring Boot configura automaticamente um bean RestClient.Builder. Este builder já vem com os HttpMessageConverter da aplicação, como o ObjectMapper que você personalizou. Ele também suporta observabilidade 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();
    }
}

Explicação:

  • baseUrl(...): as chamadas subsequentes precisam apenas passar um caminho relativo como /orders/42.
  • defaultHeader(...): este header é adicionado a cada requisição.
  • build(): cria uma instância de RestClient. Esta instância é thread-safe, então você cria uma vez e reutiliza.

Nota: O bean RestClient.Builder tem escopo prototype. Cada vez que você injeta, recebe um novo builder. Portanto, a configuração em OrderClient não afeta outros clientes. Para aplicar uma configuração compartilhada a todos os builders, declare um bean RestClientCustomizer.

4. Enviando requisições GET

4.1. Obtendo o corpo como objeto

public Order getOrder(Long id) {
    return restClient.get()
            .uri("/orders/{id}", id)
            .retrieve()
            .body(Order.class);
}
  • get(): seleciona o método HTTP.
  • uri("/orders/{id}", id): a variável {id} é substituída e codificada automaticamente. O resultado é GET https://api.shop.example/orders/42.
  • retrieve(): envia a requisição e se prepara para ler a resposta.
  • body(Order.class): o Jackson converte JSON para o record Order.

4.2. Obtendo status e headers

Quando você precisa do código de status ou dos headers, use 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. Obtendo listas e parâmetros de consulta

Tipos genéricos como List<Order> são apagados em tempo de execução. Portanto, você precisa 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>>() {});
}

A chamada findOrders("PAID", 1) cria uma requisição GET /orders?status=PAID&page=1. A URL base é mantida quando você usa uriBuilder.

5. Enviando requisições 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): o objeto é serializado para JSON graças ao contentType declarado.
  • toBodilessEntity(): use quando você não precisa ler o corpo. Este método ainda ativa a requisição e trata erros.

put() e patch() funcionam igual a post().

Nota: Se você chamar retrieve() sem chamar depois body(...), toEntity(...) ou toBodilessEntity(), a requisição não será enviada. Este é um erro comum com chamadas DELETE.

6. Tratamento de erros

6.1. Comportamento padrão

Por padrão, retrieve() lança uma exceção quando a resposta tem um status de erro:

  • 4xx: lança HttpClientErrorException, por exemplo HttpClientErrorException.NotFound.
  • 5xx: lança HttpServerErrorException.

Erros de conexão ou timeout lançam ResourceAccessException.

6.2. Personalizando com onStatus

Você geralmente deseja converter erros HTTP em exceções de negócio:

public class OrderNotFoundException extends RuntimeException {
    public OrderNotFoundException(Long id) {
        super("Pedido " + id + " não encontrado");
    }
}
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(
                        "API de loja com erro: " + response.getStatusCode());
            })
            .body(Order.class);
}

Os handlers são verificados na ordem declarada. O primeiro handler que corresponde ao status será executado. Se nenhum handler corresponder, o comportamento padrão da seção 6.1 será aplicado.

6.3. Controle total com exchange

Quando você precisa ler o corpo do erro ou ramificar por status, use 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("Erro " + response.getStatusCode());
                }
                return Optional.ofNullable(
                        response.bodyTo(Order.class));
            });
}

Nota: Em exchange, os handlers de status padrão não são aplicados. Você deve verificar o status por conta própria. Se esquecer, o corpo do erro pode ser analisado em um Order com campos null.

7. Timeout e cliente HTTP subjacente

Por padrão, RestClient escolhe a biblioteca HTTP disponível no classpath nesta ordem: Apache HttpClient 5, Jetty, Reactor Netty, java.net.http.HttpClient do JDK, depois SimpleClientHttpRequestFactory. Você sempre deve definir um timeout. Caso contrário, uma API lenta pode manter sua thread por muito tempo.

@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 é aplicado a todos os RestClient.Builder criados pelo Spring Boot. Se você quiser definir um timeout apenas para um cliente, chame builder.requestFactory(factory) diretamente no construtor desse cliente.

Com uma API lenta, a requisição falhará após 5 segundos:

org.springframework.web.client.ResourceAccessException: I/O error on GET request
for "https://api.shop.example/orders/42": Read timed out

A partir do Spring Boot 3.4, você também pode declarar timeouts usando as propriedades spring.http.client.connect-timeout e spring.http.client.read-timeout.

8. Interceptadores: anexando tokens, registrando logs

ClientHttpRequestInterceptor permite que você intercepte cada requisição. Por exemplo, o código a seguir anexa uma chave de API e registra o tempo de resposta:

@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;
    }
}

Você registra o interceptador ao construir o cliente:

this.restClient = builder
        .baseUrl(baseUrl)
        .requestInterceptor(new ApiKeyInterceptor(apiKey))
        .build();
INFO  ApiKeyInterceptor : GET https://api.shop.example/orders/42 -> 200 OK (87 ms)

Nota: Não leia response.getBody() dentro do interceptador para registrar logs. O stream pode ser lido apenas uma vez. Se precisar obrigatoriamente ler, envolva a factory de requisição com BufferingClientHttpRequestFactory.

9. HTTP interface: declarando o cliente via interface

Em vez de escrever manualmente cada chamada, você pode descrever a API com uma interface. O Spring gerará a implementação baseada em 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);
    }
}

Agora você apenas injeta OrderApi e chama orderApi.getOrder(42L). Este é um jeito conciso quando a API tem muitos endpoints. Timeouts, interceptadores e handlers de status ainda são configurados no RestClient subjacente.

10. Escrevendo testes com @RestClientTest

A partir do Spring Boot 3.2, @RestClientTest suporta RestClient. Esta anotação inicializa apenas beans relacionados ao cliente HTTP. Ela também conecta MockRestServiceServer ao RestClient.Builder, então nenhuma requisição real é enviada para fora.

Testamos OrderClient com a versão getOrder que tem onStatus na seção 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("Pedido 99 não encontrado");
    }
}

Os imports estáticos necessários estão em MockRestRequestMatchers e MockRestResponseCreators nos pacotes org.springframework.test.web.client.match e org.springframework.test.web.client.response.

  • server.expect(...): declara a requisição que o teste espera.
  • andRespond(...): retorna uma resposta simulada para essa requisição.
  • server.verify(): verifica que todas as requisições declaradas foram realmente enviadas.

Nota: MockRestServiceServer funciona apenas quando o cliente é criado a partir de RestClient.Builder que o Spring injeta. Se você chamar RestClient.create(...) dentro da classe, o teste enviará requisições reais pela rede.

11. Quando não usar RestClient

  • Aplicações WebFlux (reativas): RestClient bloqueia a thread. Se você chamá-lo dentro de um pipeline reativo, o event loop será interrompido. Use WebClient.
  • Precisa fazer streaming de dados grandes ou Server-Sent Events não bloqueantes: WebClient é mais adequado.
  • Código legado rodando bem com RestTemplate: você não é obrigado a reescrever. RestTemplate ainda é suportado, apenas não recebe novos recursos. Você pode migrar gradualmente usando RestClient.create(restTemplate) para manter a configuração existente.

Se seu aplicativo for executado em Java 21 com virtual threads (spring.threads.virtual.enabled=true), o modelo bloqueante de RestClient praticamente não tem mais desvantagens em termos de escalabilidade. O código continua simples e fácil de depurar.

12. Conclusão

Neste artigo, você usou RestClient para chamar APIs externas com uma API fluente e legível. Passamos por: criar o cliente a partir do builder do Spring Boot, enviar GET/POST, converter erros HTTP em exceções de negócio usando onStatus ou exchange, definir timeouts, adicionar interceptadores e declarar o cliente via HTTP interface. Por fim, você escreveu testes com @RestClientTest e MockRestServiceServer sem fazer chamadas de rede reais.

Para projetos Spring Boot 3.2+ seguindo o modelo servlet, RestClient deve ser a escolha padrão em vez de RestTemplate. Lembre-se de duas coisas: sempre defina um timeout, e sempre termine a cadeia retrieve() com um método de leitura de resposta.

spring-bootrestclienthttprest-apitesting