Llamar a APIs externas con RestClient (Spring Boot 3.2+)
1. Tổng quan
Desde Spring Framework 6.1 (que acompaña a Spring Boot 3.2), Spring incluye RestClient. Este es un cliente HTTP síncrono (bloqueante), que usa una API fluent similar a WebClient pero sin necesidad de Reactor. En este artículo, te guiaremos sobre cómo crear un RestClient, realizar llamadas GET/POST, manejar errores, configurar timeouts, añadir interceptores y escribir pruebas. Al terminar, podrás reemplazar RestTemplate con RestClient en tus nuevos proyectos.
2. Cài đặt
RestClient se encuentra en el módulo spring-web. El starter web ya incluye este módulo y Jackson para la conversión de 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>
A lo largo del artículo, llamaremos a una API de órdenes hipotética en https://api.shop.example. Declararemos los datos usando records:
public record Order(Long id, String customerName, BigDecimal total, String status) {}
public record CreateOrderRequest(String customerName, BigDecimal total) {}
La URL base se establece en application.properties:
shop.api.base-url=https://api.shop.example
3. Crear RestClient
3.1. La forma más rápida
RestClient restClient = RestClient.create("https://api.shop.example");
Esta forma es adecuada cuando escribes código de prueba o ejecutas un script. En una aplicación Spring Boot, debes usar el builder que proporciona Boot.
3.2. Usando RestClient.Builder de Spring Boot
Spring Boot configura automáticamente un bean RestClient.Builder. Este builder ya tiene los HttpMessageConverter de tu aplicación, por ejemplo, el ObjectMapper que haya personalizado. También soporta observabilidad mediante 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();
}
}
Explicación:
baseUrl(...): las llamadas posteriores solo necesitan pasar la ruta relativa como/orders/42.defaultHeader(...): este encabezado se añade a cada solicitud.build(): crea una instancia deRestClient. Esta instancia es thread-safe, por lo que la creas una vez y la reutilizas.
Nota: El bean
RestClient.Buildertiene scopeprototype. Cada vez que lo inyectas, recibes un builder nuevo. Por lo tanto, la configuración enOrderClientno afecta a otros clientes. Para aplicar configuración común a todos los builders, debes declarar un beanRestClientCustomizer.
4. Enviar solicitudes GET
4.1. Obtener el body como un objeto
public Order getOrder(Long id) {
return restClient.get()
.uri("/orders/{id}", id)
.retrieve()
.body(Order.class);
}
get(): elige el método HTTP.uri("/orders/{id}", id): la variable{id}se reemplaza y codifica automáticamente. El resultado esGET https://api.shop.example/orders/42.retrieve(): envía la solicitud y prepara la lectura de la respuesta.body(Order.class): Jackson convierte JSON en el recordOrder.
4.2. Obtener el estado y los encabezados
Cuando necesitas el código de estado o encabezados, usas 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. Obtener una lista y parámetros de consulta
Los tipos genéricos como List<Order> se pierden en tiempo de ejecución. Por lo tanto, necesitas 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>>() {});
}
La llamada findOrders("PAID", 1) genera la solicitud GET /orders?status=PAID&page=1. La URL base se mantiene cuando usas uriBuilder.
5. Enviar solicitudes 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): el objeto se serializa a JSON gracias alcontentTypedeclarado.toBodilessEntity(): se usa cuando no necesitas leer el body. Este método aún activa la solicitud y maneja los errores.
put() y patch() funcionan exactamente igual que post().
Nota: Si llamas a
retrieve()sin llamar posteriormente abody(...),toEntity(...)otoBodilessEntity(), la solicitud no se enviará. Este es un error común con llamadas DELETE.
6. Manejo de errores
6.1. Comportamiento predeterminado
Por defecto, retrieve() lanza una excepción cuando la respuesta tiene un código de estado de error:
- 4xx: lanza
HttpClientErrorException, por ejemploHttpClientErrorException.NotFound. - 5xx: lanza
HttpServerErrorException.
Los errores de conexión o timeout lanzan ResourceAccessException.
6.2. Personalizar con onStatus
Generalmente quieres convertir errores HTTP en excepciones de negocio:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(Long id) {
super("Orden no encontrada " + 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(
"Error en Shop API: " + response.getStatusCode());
})
.body(Order.class);
}
Los handlers se verifican en el orden declarado. El primer handler que coincida con el estado se ejecutará. Si ningún handler coincide, se aplica el comportamiento predeterminado de la sección 6.1.
6.3. Control total con exchange
Cuando necesitas leer el body del error o ramificar según el estado, usas 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("Error " + response.getStatusCode());
}
return Optional.ofNullable(
response.bodyTo(Order.class));
});
}
Nota: En
exchange, los status handlers predeterminados no se aplican. Debes verificar el estado por tu cuenta. Si lo olvidas, el body del error puede parsearse en unOrdercon camposnull.
7. Timeout y cliente HTTP subyacente
Por defecto, RestClient elige la biblioteca HTTP disponible en el classpath en este orden: Apache HttpClient 5, Jetty, Reactor Netty, el java.net.http.HttpClient de JDK, luego SimpleClientHttpRequestFactory. Siempre debes establecer un timeout. Si no lo haces, una API lenta puede mantener tu thread ocupado durante mucho tiempo.
@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 se aplica a todos los RestClient.Builder que Spring Boot crea. Si solo quieres establecer timeout para un cliente, llamas a builder.requestFactory(factory) directamente en el constructor de ese cliente.
Con una API lenta, la solicitud fallará después de 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 de Spring Boot 3.4, también puedes declarar timeouts usando las propiedades spring.http.client.connect-timeout y spring.http.client.read-timeout.
8. Interceptor: adjuntar token, registrar logs
ClientHttpRequestInterceptor te permite intervenir en todas las solicitudes. Por ejemplo, el siguiente código adjunta una API key y registra el tiempo de respuesta:
@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;
}
}
Registras el interceptor al construir el 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: No leas
response.getBody()en el interceptor para registrar logs. El stream solo se puede leer una vez. Si tienes que leerlo, envuelve la request factory conBufferingClientHttpRequestFactory.
9. HTTP interface: declarar cliente mediante interfaz
En lugar de escribir cada llamada manualmente, puedes describir la API usando una interfaz. Spring generará la implementación basada en 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);
}
}
Ahora solo necesitas inyectar OrderApi y llamar a orderApi.getOrder(42L). Este enfoque es más limpio cuando la API tiene muchos endpoints. El timeout, interceptor y status handler se siguen configurando en el RestClient subyacente.
10. Escribir pruebas con @RestClientTest
A partir de Spring Boot 3.2, @RestClientTest soporta RestClient. Esta anotación solo inicializa los beans relacionados con el cliente HTTP. También conecta MockRestServiceServer al RestClient.Builder, por lo que ninguna solicitud real se envía.
Probamos OrderClient con la versión de getOrder que tiene onStatus de la sección 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("Orden no encontrada 99");
}
}
Los imports estáticos necesarios se encuentran en MockRestRequestMatchers y MockRestResponseCreators en los paquetes org.springframework.test.web.client.match y org.springframework.test.web.client.response.
server.expect(...): declara la solicitud que espera la prueba.andRespond(...): devuelve una respuesta simulada para esa solicitud.server.verify(): verifica que todas las solicitudes declaradas se hayan enviado realmente.
Nota:
MockRestServiceServersolo funciona cuando el cliente se crea a partir deRestClient.Builderque Spring inyecta. Si llamas aRestClient.create(...)dentro de la clase, la prueba enviará solicitudes reales a la red.
11. Cuándo no usar RestClient
- Aplicaciones WebFlux (reactivas):
RestClientbloquea el thread. Si lo llamas dentro de un pipeline reactivo, el event loop se congestionará. UsaWebClienten su lugar. - Necesitas transmitir datos grandes o Server-Sent Events no bloqueantes:
WebClientes más apropiado. - El código antiguo funciona bien con
RestTemplate: no tienes que reescribirlo.RestTemplateaún es compatible, solo que no recibe nuevas características. Puedes migrar gradualmente usandoRestClient.create(restTemplate)para mantener la configuración existente.
Si tu aplicación se ejecuta en Java 21 con virtual threads (spring.threads.virtual.enabled=true), el modelo bloqueante de RestClient prácticamente no tiene desventajas en términos de escalabilidad. El código sigue siendo simple y fácil de depurar.
12. Conclusión
En este artículo, usaste RestClient para llamar a APIs externas con una API fluent y fácil de leer. Hemos pasado por estos pasos: crear un cliente desde el builder de Spring Boot, enviar GET/POST, convertir errores HTTP en excepciones de negocio usando onStatus o exchange, establecer timeouts, añadir interceptores y declarar el cliente usando HTTP interface. Finalmente, escribiste pruebas con @RestClientTest y MockRestServiceServer sin necesidad de hacer llamadas reales a la red.
Para proyectos Spring Boot 3.2+ con el modelo servlet, RestClient debe ser tu opción predeterminada en lugar de RestTemplate. Recuerda dos cosas: siempre establece timeout, y siempre termina la cadena retrieve() con un método de lectura de respuesta.