Spring Boot - Como usar WebClient

Spring ha deprecado la conocida librería RestTemplate en favor de la librería WebClient. Examinaremos aquí cómo utilizar Spring WebClient para realizar llamadas a un servicio enviando solicitudes.

¿Qué es Spring WebClient?

De forma simple, 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 será 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í

Spring WebClient

¿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

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 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