20 September 2018 - on 

In this article I want to explore how to apply in the API Gateway (1) the ideas about using Consumer Driven Contracts in conjunction with BDD in a Microservices environment. In a previous article I presented a high level overview of the ideas behind this, and in another one I started to show in more detail how would the development look like in the frontend. If you haven't already read both articles, maybe you should consider starting with them first.

You can find the code of the sample application in https://github.com/jagilpe/bdd-pact-microservices.

The API Gateway

The API Gateway is the single entry point for our frontend application, hiding the details of how the application is divided in microservices to the consumer of the API. In this case we'll use Spring Cloud and Netflix Zuul to implement the API Gateway.

The API Gateway application is in the api-gateway directory of the sample code repository.

Create the Spring Boot Application (2)

Create a directory api-gateway, that will be the root of our project. Then create a file build.gradle inside of with the following content:

// api-gateway/build.gradle
buildscript {
	ext {
		springBootVersion = '2.0.5.RELEASE'
	}
	repositories {
		jcenter()
	}
	dependencies {
		classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
	}
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
	jcenter()
}


ext {
	springCloudVersion = 'Finchley.SR1'
}

dependencies {
	compile('org.springframework.cloud:spring-cloud-starter-netflix-zuul')
	testCompile('org.springframework.boot:spring-boot-starter-test')
}

dependencyManagement {
	imports {
		mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
	}
}

Now create the directory src/main/java/apigateway in your project directory, and create the main SpringBoot application class inside of it:

// api-gateway/src/main/java/apigateway/ApiGatewayApplication.java
package apigateway;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@SpringBootApplication
@EnableZuulProxy
public class ApiGatewayApplication {

	public static void main(String[] args) {
		SpringApplication.run(ApiGatewayApplication.class, args);
	}
}

To enable the Zuul proxy we only have to include the annotation @EnableZuulProxy. Now, the only thing left is to configure the mappings to the different backend services, but we'll come later to this.

Setup the Consumer Driven Contract framework

In the end of the previous article we were able to create the contract between the frontend application and the API Gateway starting from the BDD scenarios. From the point of view of the frontend application it only has one provider (the api-gateway), and therefore only one contract that this provider has to fulfill.

In the reality although there'll be different microservices responsible for different parts of this contract. In relation with the Consumer Driven Contract testing, in the API Gateway application we'll have to:

  • Be sure that all the expected requests of the consumer (the frontend application) is mapped to some of the backend services.
  • Generate the contracts that the different backend microservices will have to fulfill for their corresponding parts of the original contract.

For this we are going to use the Pact JVM Provider Spring library. We simply add the testing dependency in our gradle project:

// api-gateway/build.gradle

//...

plugins {
    // ...
    id "au.com.dius.pact" version "3.2.13"
}

//...
dependencies {
    //...
    testCompile ('au.com.dius:pact-jvm-provider-spring_2.12:3.5.21')
}

Unfortunately the Pact JVM Provider was not designed for the purpose we are interested in, so we'll have to extend it to support our use case.

Interactions test class

First we have our test class in src/test/java/apigateway:

// api-gateway/src/test/java/apigateway/ApiGatewayPathMappingIT.java
package apigateway;

import org.junit.After;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;
import org.springframework.context.annotation.Bean;
import org.springframework.test.context.ActiveProfiles;

import au.com.dius.pact.model.PactSpecVersion;
import au.com.dius.pact.provider.junit.Provider;
import au.com.dius.pact.provider.junit.loader.PactBroker;
import au.com.dius.pact.provider.junit.target.Target;
import au.com.dius.pact.provider.junit.target.TestTarget;

@RunWith(ApiGatewayPactRunner.class)
@Provider("api-gateway")
@SpringBootTest(classes = { ApiGateway.class, ApiGatewayPathMappingIT.ApiGatewayITConfig.class })
@PactBroker(host = "localhost", port = "1080")
@ActiveProfiles("test")
public class ApiGatewayPathMappingIT {

    @TestTarget
    @Autowired
    public Target target;

    @After
    public void writePacts() {
        if (target instanceof PactsWriter) {
            ((PactsWriter) target).writePacts("build/pacts", PactSpecVersion.V3);
        }
    }

    @TestConfiguration
    public static class ApiGatewayITConfig {
        @Autowired
        private RouteLocator routeLocator;

        @Autowired
        private ApiGatewayPactMappingConfiguration configuration;

        @Bean
        public Target getTarget() {
            return new ApiGatewayTarget(routeLocator, configuration);
        }
    }
}

When using Pact Spring Provider to check the provider side of a contract for a Spring Boot application, we have to run the tests using the SpringRestPactRunner class, and configure some additional data like the provider name and how to access the pact broker (in case we are using one). In our case:

  • We run the tests using our custom ApiGatewayPactRunner, that extends the SpringRestPactRunner (more on this later)
  • We configure the provider name using the @Provider annotation
  • We configure the pact broker data using the @PactBroker annotation

Additionally we have to provide a test target (annotated with @TestTarget), that basically will tell Pact how to access our application to check that all the interactions defined in the contract to check. In our case we are using also a custom target implementation (ApiGatewayTarget) that is configured and injected using a inner configuration class (ApiGatewayITConfig).

ApiGatewayPactRunner and ApiGatewayPactInteractionRunner classes

The main reason to implement our custom PactRunner is to be able to override the PactInteractionRunner. The default implementation of the Spring Interaction Runner requires a method annotated with @State for each of the states defined in the pact. This methods are responsible to bring the application to a state in which the interaction can be checked. In our case we are not interested in checking the interactions of the pact, but to divide the original pact into the different contracts for the real backend services.

// api-gateway/src/test/java/apigateway/ApiGatewayPactRunner.java
package apigateway;

import org.junit.runners.model.TestClass;
import org.springframework.test.context.TestContextManager;

import au.com.dius.pact.model.Pact;
import au.com.dius.pact.model.PactSource;
import au.com.dius.pact.provider.junit.InteractionRunner;
import au.com.dius.pact.provider.junit.RestPactRunner;

public class ApiGatewayPactRunner extends RestPactRunner {
    private TestContextManager testContextManager;

    public ApiGatewayPactRunner(final Class<?> clazz) {
        super(clazz);
    }

    @Override
    protected InteractionRunner newInteractionRunner(final TestClass testClass, final Pact pact, final PactSource pactSource) {
        return new ApiGatewayInteractionRunner(testClass, pact, pactSource, initTestContextManager(testClass));
    }

    private TestContextManager initTestContextManager(final TestClass testClass) {
        if (testContextManager == null) {
            testContextManager = new TestContextManager(testClass.getJavaClass());
        }
        return testContextManager;
    }
}

The ApiGatewayPactRunner extends the RestPactRunner and simply overrides the newInteractionRunner method to return our custom implementation of InteractionRunner.

// api-gateway/src/test/java/apigateway/ApiGatewayInteractionRunner.java
package apigateway;

import org.junit.runners.model.Statement;
import org.junit.runners.model.TestClass;
import org.springframework.test.context.TestContextManager;

import au.com.dius.pact.model.Interaction;
import au.com.dius.pact.model.Pact;
import au.com.dius.pact.model.PactSource;
import au.com.dius.pact.provider.spring.SpringInteractionRunner;

class ApiGatewayInteractionRunner extends SpringInteractionRunner {
    ApiGatewayInteractionRunner(final TestClass testClass, final Pact pact, final PactSource pactSource, final TestContextManager testContextManager) {
        super(testClass, pact, pactSource, testContextManager);
    }

    @Override
    protected Statement withStateChanges(final Interaction interaction, final Object target, final Statement statement) {
        return statement;
    }
}

The ApiGatewayInteractionRunner extends the SpringInteractionRunner to override the withStateChanges method in order to ignore the check for the states required by the interaction.

ApiGatewayTarget class

The ApiGatewayTarget class is our most important one, and it's where most of the logic is implemented.

// api-gateway/src/test/java/apigateway/ApiGatewayInteractionRunner.java
package apigateway;

import au.com.dius.pact.model.*;
import au.com.dius.pact.provider.ConsumerInfo;
import au.com.dius.pact.provider.ProviderInfo;
import au.com.dius.pact.provider.ProviderVerifier;
import au.com.dius.pact.provider.junit.target.BaseTarget;
import apigateway.exception.ProviderNameNotDefinedException;
import org.assertj.core.util.Maps;
import org.springframework.cloud.netflix.zuul.filters.Route;
import org.springframework.cloud.netflix.zuul.filters.RouteLocator;

import java.util.*;
import java.util.stream.Collectors;

class ApiGatewayTarget extends BaseTarget implements PactsWriter {

    private final RouteLocator routeLocator;
    private final ApiGatewayPactMappingConfiguration configuration;
    private final Map<String, RequestResponsePact> outputPacts = new HashMap<>();

    ApiGatewayTarget(final RouteLocator routeLocator, final ApiGatewayPactMappingConfiguration configuration) {
        this.routeLocator = routeLocator;
        this.configuration = configuration;
    }

    @Override
    public void testInteraction(final String consumerName, final Interaction interaction, final PactSource source) {
        ProviderInfo providerInfo = getProviderInfo(source);
        ConsumerInfo consumerInfo = new ConsumerInfo(consumerName);
        ProviderVerifier verifier = setupVerifier(interaction, providerInfo, consumerInfo);

        Map<String, Object> failures = new HashMap<>();
        if (interaction instanceof RequestResponseInteraction) {
            failures.putAll(verifyRequestMapping((RequestResponseInteraction) interaction, source));
            reportTestResult(failures.isEmpty(), verifier);
        }

        try {
            if (!failures.isEmpty()) {
                verifier.displayFailures(failures);
                throw getAssertionError(failures);
            }
        } finally {
            verifier.finialiseReports();
        }
    }

    @Override
    protected ProviderInfo getProviderInfo(final PactSource source) {
        return new ProviderInfo(testClass.getAnnotation(au.com.dius.pact.provider.junit.Provider.class).value());
    }

    @Override
    protected ProviderVerifier setupVerifier(final Interaction interaction, final ProviderInfo provider, final ConsumerInfo consumer) {
        ProviderVerifier verifier = new ProviderVerifier();

        setupReporters(verifier, provider.getName(), interaction.getDescription());

        verifier.initialiseReporters(provider);
        verifier.reportVerificationForConsumer(consumer, provider);

        interaction.getProviderStates().forEach(
            providerState -> verifier.reportStateForInteraction(providerState.getName(), provider, consumer, true));

        verifier.reportInteractionDescription(interaction);
        return verifier;
    }

    @Override
    public void writePacts(final String pactDirectory, final PactSpecVersion pactSpecVersion) {
        outputPacts.forEach((routeId, pact) -> pact.write(pactDirectory, pactSpecVersion));
    }

    private Map<String,Boolean> verifyRequestMapping(final RequestResponseInteraction interaction, final PactSource source) {
        String requestPath = interaction.getRequest().getPath();
        Route matchingRoute = getMatchingRoute(requestPath);
        String error = null;
        if (matchingRoute != null) {
            try {
                addInteractionToProvidersPacts(interaction, source, matchingRoute);
            } catch (ProviderNameNotDefinedException e) {
                error = "No provider name for route " + matchingRoute.getPath();
            }
        } else {
            error = requestPath + " has no mapping to any service.";
        }
        return error != null ? Maps.newHashMap(error, false) : Collections.emptyMap();
    }

    private void addInteractionToProvidersPacts(
        final RequestResponseInteraction interaction,
        final PactSource source,
        final Route matchingRoute) throws ProviderNameNotDefinedException {
        String providerName = getProviderForRoute(matchingRoute).orElseThrow(ProviderNameNotDefinedException::new);
        RequestResponsePact pact = outputPacts.get(providerName);
        if (pact == null) {
            Provider provider = new Provider(providerName);
            Consumer consumer = new Consumer(getProviderInfo(source).getName());
            pact = new RequestResponsePact(provider, consumer, new ArrayList<>());
            outputPacts.put(providerName, pact);
        }
        pact.mergeInteractions(getNewInteraction(interaction, matchingRoute));
    }

    private Optional<String> getProviderForRoute(final Route route) {
        return configuration.getServicesUrls().entrySet().stream()
            .filter(entry -> entry.getValue().equals(route.getLocation()))
            .map(Map.Entry::getKey)
            .findFirst();
    }

    private Route getMatchingRoute(final String requestPath) {
        return Optional.ofNullable(routeLocator.getMatchingRoute(requestPath))
            .filter(route -> !configuration.getExcluded().contains(route.getId()))
            .orElse(null);
    }

    private List<Interaction> getNewInteraction(final RequestResponseInteraction interaction, final Route matchingRoute) {
        RequestResponseInteraction outInteraction = new RequestResponseInteraction();

        Request request = interaction.getRequest().copy();
        request.setPath(matchingRoute.getPath());
        List<ProviderState> providerStates = interaction.getProviderStates().stream()
            .map(providerState -> new ProviderState(providerState.getName(), providerState.getParams()))
            .collect(Collectors.toList());

        outInteraction.setRequest(request);
        outInteraction.setResponse(interaction.getResponse().copy());
        outInteraction.setDescription(interaction.getDescription());
        outInteraction.setProviderStates(providerStates);

        return Collections.singletonList(outInteraction);
    }
}

This class extends the BaseTarget so it can check each of the interactions of the contract, and implements our PactWriter interface in order to generate the contracts for each of the backend services.

In the testInteraction method each of the interactions is checked. If the request defined in the interaction is not mapped to a service it'll be marked as a failure and will throw an assertion exception, failing the test.

The request mapping is checked in the verifyRequestMapping method. It gets the mapping that corresponds to the path of the request using the RouteLocator service of Netflix Zuul, and it adds the mapped request to the contract with the corresponding service.

In the end, when all the interactions have been checked, the ApiGatewayTarget will have stored in the outputPacts map all the interactions that each of the backend service will have to fulfill. The method writePacts of ApiGatewayPathMappingIT, annotated with @After will call the writePacts method of this class to export the contracts after the whole contract has been checked.

Other classes

The ApiGatewayPactMappingConfiguration class has the definition of the url corresponding with each of the backend services, and a list of Zuul routes that we do not want to include as backend routes.

// api-gateway/src/test/java/apigateway/ApiGatewayPactMappingConfiguration.java
package apigateway;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

@Component
@ConfigurationProperties(prefix = "api-gateway.providers-mapping")
public final class ApiGatewayPactMappingConfiguration {

    private final List<String> excluded = new ArrayList<>();
    private final Map<String, String> servicesUrls = new HashMap<>();

    public List<String> getExcluded() {
        return excluded;
    }

    public Map<String, String> getServicesUrls() {
        return servicesUrls;
    }

}

The PactsWriter interface and ProviderNameNotDefinedException are simply supporting classes.

// api-gateway/src/test/java/apigateway/exception/ProviderNameNotDefinedException.java
package apigateway.exception;

public class ProviderNameNotDefinedException extends Exception {
}


// api-gateway/src/test/java/apigateway/PactsWriter.java
package apigateway;

import au.com.dius.pact.model.PactSpecVersion;

public interface PactsWriter {

    void writePacts(String pactDirectory, PactSpecVersion pactSpecVersion);

}

Testing the contract

If we now run the tests we'll see that we have four failing tests. This is logical, because we haven't already configured the route mapping in Zuul.

gradle test

> Task :compileTestJava
Note: Some input files use unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

> Task :test

apigateway.ApiGatewayPathMappingIT > ng-frontend - A request for products in the category 1 FAILED
    java.lang.AssertionError

apigateway.ApiGatewayPathMappingIT > ng-frontend - A request a list of categories FAILED
    java.lang.AssertionError

apigateway.ApiGatewayPathMappingIT > ng-frontend - a request for the details of a product FAILED
    java.lang.AssertionError

apigateway.ApiGatewayPathMappingIT > ng-frontend - a request for the offers of a product FAILED
    java.lang.AssertionError
2018-09-20 15:21:00.393  INFO 12839 --- [      Thread-12] ConfigServletWebServerApplicationContext : Closing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@327e8373: startup date [Thu Sep 20 15:20:55 CEST 2018]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@364149ba
2018-09-20 15:21:00.395  INFO 12839 --- [      Thread-12] o.s.c.n.zuul.ZuulFilterInitializer       : Stopping filter initializer

4 tests completed, 4 failed

> Task :test FAILED

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///Users/xxxxxx/tmp/bdd-cdc/api-gateway/build/reports/tests/test/index.html

If we open the report in a browser we'll see which are the failing tests:

Tests results - failing

Configuration of Netflix Zuul

If we take a look at the contract between the frontend application and the API Gateway will see the following interactions (I also have included the desired mapping to the corresponding service)

Interaction Request Backend Service Mapped Url
A request for a list of categories GET /api/v1/categories product-catalogue-service /categories
A request for products in the category 1 GET /api/v1/products?category=1 product-catalogue-service /products?category=1
A request for the details of a product GET /api/v1/products/1 product-catalogue-service /products/1
A request for the offers of a product GET /api/v1/offers/product/1 offers-service /product/1

In order to route the different requests to the right backend service we only have to configure the mappings in the spring application configuration file (3).

# api-gateway/src/main/resources/application.yml
bdd-pact:
  products-catalogue-service:
    base-url: http://localhost:8081
  offers-service:
    base-url: http://localhost:8082
zuul:
  prefix: /api/v1
  strip-prefix: true
  routes:
    offers:
      path: /offers/**
      url: ${bdd-pact.offers-service.base-url}
    products:
      path: /products/**
      url: ${bdd-pact.products-catalogue-service.base-url}
      strip-prefix: false
    categories:
      path: /categories/**
      url: ${bdd-pact.products-catalogue-service.base-url}
      strip-prefix: false

The last thing we have to do is to add the configuration for the tests.

# api-gateway/src/test/resources/application-test.yml
api-gateway:
  providers-mapping:
    services-urls:
      products-catalogue-service: ${bdd-pact.products-catalogue-service.base-url}
      offers-service: ${bdd-pact.offers-service.base-url}
    excluded:
      - "ng-frontend"

If we now run the tests, we'll see that all of them are passing.

 gradle test

> Task :compileTestJava
Note: Some input files use unchecked or unsafe operations.
Note: Recompile with -Xlint:unchecked for details.

> Task :test
2018-09-20 16:28:39.652  INFO 14167 --- [      Thread-12] ConfigServletWebServerApplicationContext : Closing org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext@5ce200ed: startup date [Thu Sep 20 16:28:35 CEST 2018]; parent: org.springframework.context.annotation.AnnotationConfigApplicationContext@6660e44a
2018-09-20 16:28:39.655  INFO 14167 --- [      Thread-12] o.s.c.n.zuul.ZuulFilterInitializer       : Stopping filter initializer

BUILD SUCCESSFUL in 9s
5 actionable tasks: 3 executed, 2 up-to-date
Tests results - passing

We'll also see that two pact files have been generated in api-gateway/build/pacts:

  • api-gateway-offers-service.json
  • api-gateway-products-catalogue-service.json

Publish the contracts to the pact broker

In order to publish the two generated contracts to the pact broker we simply have to configure the pacts directory and the pact broker url in the gradle plugin.

//...

pact {
    publish {
        pactDirectory = "$projectDir/build/pacts"
        pactBrokerUrl = 'http://localhost:1080'
    }
}

If we now execute the pactPublish gradle task, we'll see that in our pact broker the two new contracts have appeared, one with offer-list-service and the other with product-catalogue-service as providers and both with api-gateway as consumer.

Pact broker after API Gateway implementation

What's next?

After implementing the API Gateway using Netflix Zuul and the contract generated from our front end application, we were able to divide this contract into two additional contracts for the backend services that will compose our Rest API. In the next article of the series we'll see how to go on with the implementation of the backend microservices starting with this contracts.

Implementing a SpringBoot RestAPI using Pact Consumer Driven Contracts

 

Foot notes

(1) The API Gateway pattern is a widely used technique to abstract the complexity behind a REST API, so that the clients get isolated from how the implementation is partitioned into microservices. You can find more information about it in the following links:

(2) There are different options to create the Spring Boot Application: use gradle, maven, Spring Initializr, eclipe, Intellij idea... In this case I'm using gradle directly, but the steps are easily transferable to the other options.

(3) You can find more information about how to configure Zuul in http://cloud.spring.io/spring-cloud-static/Finchley.SR1/multi/multi__router_and_filter_zuul.html