Examinaremos aquí cómo utilizar Spring WebClient para realizar llamadas a un servicio enviando solicitudes.
¿Qué es Spring WebClient?
WebClient provee una interfaz común para realizar solicitudes web de un modo no bloqueante. Forma parte del módulo de Spring Web Reactive y es el reemplazo del conocido RestTemplate. Veremos a continuación las dependencias que necesitamos, como crear un cliente web y algunas configuraciones más que podemos usar con Spring WebClient.
¿Qué dependencias necesitamos para utilizar Spring WebClient?
Lo primero que necesitamos es definir las dependencias.
- spring-boot-starter-webflux es la dependencia necesaria para el webclient
- mockserver-netty lo usaremos para mockear el web server en las pruebas unitarias para este ejemplo
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.mock-server</groupId>
<artifactId>mockserver-netty</artifactId>
<version>5.11.1</version>
</dependency>
</dependencies>
La estructura de este ejemplo se ve así
¿Cómo crear un WebClient?
Para utilizar WebClient nos valdremos del builder que nos provee WebClient WebClient.builder()
De este modo vamos a crear una instancia de WebClient con una url base.
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8899")
.build();
¿Cómo agregar headers por default al WebClient?
Comúnmente, cuando se trata de llamadas con un body Json, se agrega el header indicando que es una llamada application/json .
Podemos indicar headers por default.
WebClient webClient = WebClient.builder()
.baseUrl("https://run.mocky.io")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
¿Cómo enviar un request y recibir un response con WebClient?
Una vez que tenemos nuestra instancia de WebClient podemos enviar una petición.
En este ejemplo tenemos nuestro request que hemos definido como ClientRequest y nuestro response en ClientResponse.
Estaremos enviando un POST a una uri mediante webClient.post().uri(…)
El método retrieve ejecuta el HTTP request y devuelve la respuesta.
El método bodyToMono toma el body del retrieve y lo convierte en nuestra clase de response que le especifiquemos.
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
.bodyToMono(ClientResponse.class)
.block();
¿Cómo configurar un timeout en el WebClient?
Podemos configurar el timeout del nuestro WebClient indicando una duración en segundos o cualquier otra duración que provea la clase Duration .
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
.bodyToMono(ClientResponse.class)
.timeout(Duration.ofSeconds(3)) // timeout
.block();
Otra forma de especificar un timeout es a través de la configuración de un TcpClient en donde podemos ser más específicos.
A continuación vemos la especificación de un timeout para la conexión y lectura / escritura del WebClient.
// tcp client timeout
TcpClient tcpClient = TcpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.doOnConnected(connection ->
connection.addHandlerLast(new ReadTimeoutHandler(3))
.addHandlerLast(new WriteTimeoutHandler(3)));
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8899")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient))) // timeout
.build();
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
.bodyToMono(ClientResponse.class)
.block();
return response;
Manejar los http status en el WebClient
Podemos conocer y manejar cualquier http status en nuestro WebClient utilizando el método onStatus.
Por ejemplo, aquí ante un error 500 logueamos y tiramos un error usando onStatus(…) tirando una exception nuestra ApiWebClientException
Veamos lo que hacemos a continuation, hay mucho aquí:
- Creamos nuestro webclient
- Indicamos que hará un POST
- Recibirá un request de nuestra clase ClientRequest
- Hará un retrieve ejecutando así el HTTP request
- Si ocurre un error HttpStatus 500 lo evaluará en onStatus y decidirá que hacer con ese error.
- Convertirá la respuesta hacia nuestra clase ClientResponse
- Esperará 3 segundos como máximo para la conexión.
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
// handle status
.onStatus(HttpStatus::is5xxServerError, clientResponse -> {
logger.error("Error endpoint with status code {}", clientResponse.statusCode());
throw new ApiWebClientException("HTTP Status 500 error"); // throw custom exception
})
.bodyToMono(ClientResponse.class)
.timeout(Duration.ofSeconds(3)) // timeout
.block();
Como queda nuestro web client terminado
Sumando todo lo visto antes nuestro web client nos queda así.
@Component
public class ApiWebClient {
private static final Logger logger = LoggerFactory.getLogger(ApiWebClient.class);
public ClientResponse createClient(ClientRequest request) {
// tcp client timeout
TcpClient tcpClient = TcpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.doOnConnected(connection ->
connection.addHandlerLast(new ReadTimeoutHandler(3))
.addHandlerLast(new WriteTimeoutHandler(3)));
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8899")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.clientConnector(new ReactorClientHttpConnector(HttpClient.from(tcpClient))) // timeout
.build();
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
// handle status
.onStatus(HttpStatus::is5xxServerError, clientResponse -> {
logger.error("Error endpoint with status code {}", clientResponse.statusCode());
throw new ApiWebClientException("HTTP Status 500 error"); // throw custom exception
})
.bodyToMono(ClientResponse.class)
.block();
return response;
}
public ClientResponse createClientWithDuration(ClientRequest request) {
WebClient webClient = WebClient.builder()
.baseUrl("http://localhost:8899")
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
.build();
ClientResponse response = webClient.post()
.uri("/accounts")
.body(Mono.just(request), ClientRequest.class)
.retrieve()
// handle status
.onStatus(HttpStatus::is5xxServerError, clientResponse -> {
logger.error("Error endpoint with status code {}", clientResponse.statusCode());
throw new ApiWebClientException("HTTP Status 500 error"); // throw custom exception
})
.bodyToMono(ClientResponse.class)
.timeout(Duration.ofSeconds(3)) // timeout
.block();
return response;
}
}
Como crear un test para probar Spring WebClient
Primero definimos un test de Spring en el que usaremos MockServer .
Por cada test levantamos un server en el puerto 8899 y al finalizar cada test lo paramos.
Vamos a crear varios test para probar diferentes casos. Probaremos:
- Una conexión correcta.
- Una conexión con un timeout expirado.
- Una conexión que tira un error 500 y devuelve una un error ApiWebClientException
Observa a continuación que levantamos el mock del servidor y luego lo paramos.
ClientAndServer mockServer = ClientAndServer.startClientAndServer(8899);
mockServer.stop();
Luego seteamos las expectativas que queremos para el server.
En este ejemplo, indicamos que cuando ocurra un POST para el path /account responda con el body que indicamos, el header y con un tiempo de demora.
mockServer.when(HttpRequest.request().withMethod("POST")
.withPath("/accounts")).
respond(HttpResponse.response()
.withBody("{ \"name\": \"Frank\", \"email\": \"frank@mail.com\"}")
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withDelay(TimeUnit.MILLISECONDS, 5000));
Veamos como queda los unit test para los distintos casos.
@SpringBootTest
public class ClientServiceTest {
private static final Logger logger = LoggerFactory.getLogger(ClientServiceTest.class);
@Autowired
private ApiWebClient webClient;
private ClientAndServer mockServer;
@BeforeEach
public void startServer() {
mockServer = ClientAndServer.startClientAndServer(8899);
}
@AfterEach
public void stopServer() {
mockServer.stop();
}
@Test
void createShouldReturnTheResponse() {
// set up mock server with a delay of 1 seconds
mockServer.when(HttpRequest.request().withMethod("POST")
.withPath("/accounts")).
respond(HttpResponse.response()
.withBody("{ \"name\": \"Frank\", \"email\": \"frank@mail.com\"}")
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withDelay(TimeUnit.MILLISECONDS, 1000));
ClientRequest request = new ClientRequest();
request.setName("Frank");
request.setName("frank@mail.com");
ClientResponse response = webClient.createClient(request);
assertThat(response).isNotNull();
assertThat(response.getName()).isEqualTo("Frank");
assertThat(response.getEmail()).isEqualTo("frank@mail.com");
}
@Test
void createWithTimeoutShouldThrowReadTimeOut() {
// set up mock server with a delay of 5 seconds
mockServer.when(HttpRequest.request().withMethod("POST")
.withPath("/accounts")).
respond(HttpResponse.response()
.withBody("{ \"name\": \"Frank\", \"email\": \"frank@mail.com\"}")
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withDelay(TimeUnit.MILLISECONDS, 5000));
ClientRequest request = new ClientRequest();
request.setName("Frank");
request.setName("frank@mail.com");
assertThrows(ReadTimeoutException.class, () -> webClient.createClient(request));
}
@Test
void createWithStatusShouldThrowACustomException() {
// set up mock server with a http status 500
mockServer.when(HttpRequest.request().withMethod("POST")
.withPath("/accounts"))
.respond(HttpResponse.response().withStatusCode(500)
.withHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.withDelay(TimeUnit.MILLISECONDS, 1000));
ClientRequest request = new ClientRequest();
request.setName("Frank");
request.setName("frank@mail.com");
ApiWebClientException apiWebClientException = assertThrows(ApiWebClientException.class, () -> webClient.createClient(request));
assertTrue(apiWebClientException.getMessage().contains("HTTP Status 500 error"));
}
}
Conclusión
Vimos cómo crear de forma simple un cliente web usando Spring WebClient. Examinamos cómo configurar el cliente, enviar una solicitud y recibir la respuesta.
Puedes ver este código en: https://github.com/gustavopeiretti/spring-boot-examples