Introducción a Spring Cloud Contract

spring-cloud-contract

spring-cloud-contract


En esta entrada veremos una introducción y ejemplo a Spring Cloud Contract, el cual nació para ayudarnos a implementar el Consumer Driven Contracts (CDC). Es decir, asegurar un contracto entre un Productor y un consumidor en un sistema distribuido, a través de mensajes o peticiones HTTP.

Para mostrar las principales características y el ejemplo asociado vamos a crear por un lado un consumidor y un productor, los cuales estarán asociados a través de un contrato.

En nuestro ejemplo usaremos Lombok para eliminar el boilerplate y facilitar la implementación.

Objetivo de Spring Cloud Contract

Como hemos mencionado anteriormente Spring Cloud Contract nació con el objetivo de implementar y dar validez el Consumer Driven Contracts. El cual es un patrón en el que el consumidor captura sus expectativas que le proporciona un proveedor en un contrato. Para dar validez a estos contratos y saber en qué momento se puede romper hacemos uso de Spring Cloud Contract. Entre las características principales tenemos:

  • se asegura que los mensajes o las peticiones HTTP hagan exactamente lo que se ha implementado en el lado productor o servidor.
  • crear pruebas de aceptación para nuestros servicios
  • proporcionar una forma de publicar cambios en los contratos que son inmediatamente visibles en ambos lados de la comunicación.
  • generar código de test basado en los contratos realizados en el servidor

Ejemplo Spring Cloud Contract

En este punto vamos a crear dos proyectos para hacer uso de Spring Cloud Contract. Estos proyectos harán uso de JUnit 5 para realizar la verificación de los contratos.

El ejemplo constará de un consumidor y un productor. El consumidor mediante un webclient hará una petición al productor, y estas llamadas serán las que Spring Cloud Contract verificará que no se cambian. Nuestro productor constará de dos endpoints muy simples /cars y /cars/{id}.

Introducción y ejemplo a Spring Cloud Contract
Introducción y ejemplo a Spring Cloud Contract

El ejemplo completo se encuentra en nuestro github.

Productor

La primera pieza que vamos a crear es nuestro productor, el cual tendrá dos endpoints básicos que recibirá peticiones HTTP. El productor tendrá una base de datos en memoria H2 donde se guardarán coches. La estructura será la clásica de controlador-servicio-repositorio, a continuación mostraremos las 3 capas.

Dependencias Maven

Las dedependencias que necesitamos para nuestro productor necesitaremos las siguientes dependencias:

		<dependency>
			<groupId>org.springframework.cloud</groupId>
			<artifactId>spring-cloud-starter-contract-verifier</artifactId>
			<scope>test</scope>
		</dependency>

y también deberemos configurar nuestro spring-cloud-contract-maven-plugin.

<plugin>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-maven-plugin</artifactId>
    <version>2.1.1.RELEASE</version>
    <extensions>true</extensions>
    <configuration>
        <baseClassForTests>
            com.refactorizando.sample.springcloudcontract.producer.BaseTestClass
        </baseClassForTests>
    </configuration>
</plugin>

Creación contralador para el Productor

A continuación mostramos nuestro contralador para nuestro Productor, el cual se compone de dos endpoints básicos que harán llamadas a un servicio CarService.

@RestController
@RequiredArgsConstructor
@RequestMapping("/cars")
public class CarController {

    private final CarService carService;

    @GetMapping
    public ResponseEntity<List<Car>> getCars() {

        var car = carService.findAll();

        return new ResponseEntity<>(car, HttpStatus.OK);
    }

    @GetMapping("/{id}")
    public ResponseEntity<Car> getCarById(@PathVariable Long id) {

        var car = carService.findById(id);

        return new ResponseEntity<>(car, HttpStatus.OK);

    }

}

Creación del Servicio del Productor

El servicio será una capa intermedia para llamar al repositorio:

@RequiredArgsConstructor
@Service
public class CarService {

    private final CarRepository carRepository;

    public Car findById(Long id) {

        return carRepository.findById(id).orElseThrow();

    }

    public List<Car> findAll() {

        return carRepository.findAll();
    }
}

Creación del Repositorio del Productor

Esta es la capa de comunicación con la base de datos, para ello utilizamos JPA:

public interface CarRepository extends JpaRepository<Car, Long> {

    Optional<Car> findByColor(String color);
}

Creación de test en Productor

Una vez hemos creado nuestra aplicción, vamos a comenzar a trabajar con Spring Cloud Contract. Para ello, vamos a añadir nuestros contratos, ya sea en REST o por mensajería en src/test/resources/contracts esta ruta es por defecto y se encuentra definida en contractsDslDir. Estos ficheros podrán ser expresados en Groovy DSL o YAML.

Nuestros contratos tendrán que verificar el endpoint /cars y /cars/{id} que hemos definido en nuestro controlador, en nuestro caso los hemos creado con Groovy y realizaremos un GET, a estos endpoints en los que verificaremos que se ha hecho correctamente la llamada.

package contracts

import org.springframework.cloud.contract.spec.Contract

Contract.make {
    description 'Should return one car in a list'

    request {
        method GET()
        url '/cars'
    }
    response {
        status OK()
        body '''\
            [
                {
                    "id": 1,
                    "color": "yellow",
                    "model": "Mustang",
                    "brand": "Ford"
              }
            ]
        '''
        headers {
            contentType('application/json')
        }
    }
}

Una vez hemos añadido los contratos, tenemos que añadir nuestro BaseTestClass en la ruta que hemos definido en nuestro pom.xml, es decir, en el plugin spring-cloud-contract-maven-plugin:

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK)
@DirtiesContext
@AutoConfigureMessageVerifier
public class BaseTestClass {

    @Autowired
    private CarController carController;

    @BeforeEach
    public void setup() {
        StandaloneMockMvcBuilder standaloneMockMvcBuilder = MockMvcBuilders.standaloneSetup(carController);
        RestAssuredMockMvc.standaloneSetup(standaloneMockMvcBuilder);
    }
}

Recuerda poner @AutoConfigureMessageVerifier en la clase base de tus pruebas generadas. De lo contrario, la parte de mensajería de Spring Cloud Contract Verifier no funciona.

Cuando ejecutemos:

mvn clean install

el plugin de maven configurado generará una clase de test títulada ContractVerifierTest que extenderá de nuestra clase base y lo dejará en  /target/generated-test-sources/contracts. En esta clase verás la creación de los tests que se han añadido dentro de la carpeta resources. Ten en cuenta que debe estar bien definida la ruta de los contratos en nuestro pom.xml.

public class ContractVerifierTest extends BaseTestClass {

	@Test
	public void validate_shouldReturnCars() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();


		// when:
			ResponseOptions response = given().spec(request)
					.get("/cars");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
			assertThat(response.header("Content-Type")).matches("application/json.*");

		// and:
			DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
			assertThatJson(parsedJson).array().contains("['id']").isEqualTo(1);
			assertThatJson(parsedJson).array().contains("['color']").isEqualTo("yellow");
			assertThatJson(parsedJson).array().contains("['model']").isEqualTo("Mustang");
			assertThatJson(parsedJson).array().contains("['brand']").isEqualTo("Ford");
	}

	@Test
	public void validate_shouldReturnOneCar() throws Exception {
		// given:
			MockMvcRequestSpecification request = given();


		// when:
			ResponseOptions response = given().spec(request)
					.get("/cars/id");

		// then:
			assertThat(response.statusCode()).isEqualTo(200);
			assertThat(response.header("Content-Type")).matches("application/json.*");

		// and:
			DocumentContext parsedJson = JsonPath.parse(response.getBody().asString());
			assertThatJson(parsedJson).field("['id']").isEqualTo(1);
			assertThatJson(parsedJson).field("['color']").isEqualTo("yellow");
			assertThatJson(parsedJson).field("['model']").isEqualTo("Mustang");
			assertThatJson(parsedJson).field("['brand']").isEqualTo("Ford");
	}

}

Además dentro de la carpeta target se habrá generado el Wiremock stub, con nombre spring-cloud-contract-producer-0.0.1-SNAPSHOT-stubs.jar. Este jar lo podemos disponibilizar y subir a Maven Central o a Artifactory para ser verificado durante la ejecución de nuestro cliclo de CI, para la validación de nuestros servicios.

Consumidor o cliente

El lado cliente o consumidor se encargará de consumir los stubs generados del productor, por lo que cualquier cambio en el productor provocará una ruptura del contrato.

En nuestro ejemplo crearemos un controlador que a través de un webclient invocará al servicio productor que hemos creado.

Para realizar el test de interacción con el servicio externo, en este caso con el consumidor, se hará uso en el test de wiremock para así simular la llamada.

Dependencias Maven del Consumidor

Para el lado consumidor añadiremos spring-cloud-contract-stub-runner en lugar de spring-cloud-contract-verifier y spring-cloud-contract-wiremock, para poder simular la llamada al servicio externo. No hace falta añadir el stub.jar generado anteriormente para hacer uso de el en nuestro ejemplo.

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-wiremock</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-contract-stub-runner</artifactId>
    <scope>test</scope>
</dependency>

Controlador cliente

Nuestro controlador en el lado cliente tendrá dos endpoints que se encargará de invocar a través de un webclient, al servicio productor o consumidor.

@RestController
public class CarController {

    private WebClient webClient;

    @PostConstruct
    public void setUpWebClient() {
        var httpClient = HttpClient.create()
                .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000)
                .doOnConnected(connection ->
                        connection.addHandlerLast(new ReadTimeoutHandler(2))
                                .addHandlerLast(new WriteTimeoutHandler(2)));

        this.webClient = WebClient.builder()
                .baseUrl("http://localhost:8090")
                .clientConnector(new ReactorClientHttpConnector(httpClient))
                .build();
    }

    @GetMapping("/cars")
    public ResponseEntity<List<Car>> getCars() {

        var cars = webClient.get().uri("/cars").accept(MediaType.APPLICATION_JSON)
                .retrieve().bodyToMono(Car[].class).block();

        return new ResponseEntity<>(Arrays.stream(cars).collect(Collectors.toList()), HttpStatus.OK);
    }

    @GetMapping("/cars/{id}")
    public ResponseEntity<Car> getCarById(@PathVariable Long id) {


        var car = webClient.get().uri("/cars").retrieve().bodyToMono(Car.class).block();

        return new ResponseEntity<>(car, HttpStatus.OK);
    }

}

Creación de test con Spring Cloud Contract en consumidor

A continuación vamos a ver como sería la creación de test en el consumidor haciendo uso de Spring Cloud Contract. Para ello lo que necesitamos es un test que haga uso del stub.jar que ha sido generado por nuestro productor.

@ExtendWith(SpringExtension.class)
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureStubRunner(stubsMode = StubRunnerProperties.StubsMode.LOCAL,
        ids = "com.refactorizando.sample:spring-cloud-contract-producer:+:stubs:8080")
public class CarControllerIntegrationTest {

    @Autowired
    private CarController carController;

    @Test
    void testContractToCarsProducer() {

        var result = carController.getCars().getBody();

        assertTrue(1 == result.size());

        var car = result.get(0);

        assertTrue(car.getBrand().equalsIgnoreCase("Ford"));
        assertTrue(car.getId().equals(1L));
        assertTrue(car.getColor().equalsIgnoreCase("Yellow"));
        assertTrue(car.getModel().equalsIgnoreCase("Mustang"));

    }

    @Test
    void testContractToCarByIdProducer() {

        var result = carController.getCarById(1L).getBody();

        assertTrue(1 == result.getId());

        assertTrue(result.getBrand().equalsIgnoreCase("Ford"));
        assertTrue(result.getId().equals(1L));
        assertTrue(result.getColor().equalsIgnoreCase("Yellow"));
        assertTrue(result.getModel().equalsIgnoreCase("Mustang"));

    }

}

A continuación vamos a explicar lo que significa cada componente dentro de @AutoConfigureStubRunner

  • com.refactorizando.sample — es el groupId del artifact definido the groupId of our artifact.
  • spring-cloud-contract-producer — es el artifactId del productor del stub.jar.
  • 8080 — el puerto en el que se ejecutará el stub generado.

Este consumidor hará uso de wiremock para simular la llamada al productor. Una vez finalizado la ejecución se podrá ver si los test han devuelto el resultado esperado de la ejecución del contrato.

En este caso hemos realizado la comprobación del contrato en nuestro local con el stub.jar en nuestra carpeta .m2, aunque por ejemplo en un ciclo de CI se podría guardar de manera remota para ejecutar estos test.

Conclusión

En esta entrada hemos visto una introducción y ejemplo a Spring Cloud Contract, lo cual nos ayudará a mantener nuestros contratos entre productor y consumidor. Lo cual nos ayudará bastante en una arquitectura de microservicios, para que todos los servicios involucrados trabajen de una manera cohesionada entre si.

Si quieres puedes consultar el ejemplo completo de nuestro github.

Si necesitas más información puedes escribirnos un comentario o un correo electrónico a refactorizando.web@gmail.com y te ayudaremos encantados!


No te olvides de seguirnos en nuestras redes sociales Facebook o Twitter para estar actualizado.


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *