Mastering Spring Cloud Gateway Testing: Predicates (part 1)
Tags: spring, gateway, testing, spock
Published: 2024-04-23 23:52:59

[Spring Cloud Gateway](https://spring.io/projects/spring-cloud-gateway) stands as a reactive HTTP gateway designed to streamline microservices communication through its routing, filtering, and integration features.

Within Spring Cloud Gateway, two pivotal components play key roles: **Predicates** and **Filters**. Before delving into these components and the challenge of testing them, it's essential to understand the core concept of **routing**, which serves as the primary function of any 'gateway'."

![Spring Cloud Gateway](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/k7n55r2javlea7x1wvoi.png)

## Routing 
Routing is the process of directing traffic from one source to a specific destination. In the context of software development, routing refers to the mechanism by which requests are directed to the appropriate endpoints or resources within an application or system based on predefined rules or criteria.

## Predicates
Predicates are conditions that define when and how a request should be routed. They adds flexibility to customize routing rules based on various factors such as headers, paths, parameters and even body. By leveraging predicates, we can efficiently control traffic flow and implement dynamic routing strategies.

## Filters 
Filters are interceptors that can modify incoming and outgoing HTTP requests and responses. Filters are applied to routes and can be customized to suit specific needs, giving the flexibility to modify the request, as well as the response,  to enhance the functionality and behavior of the applications without tightly coupling code to individual services.

> To summarize, when request goes through a Spring Cloud Gateway, the request goes into list of routes. Each route consists of list of predicates and filters. The request much match at most one route and the evaluation is based on getting acceptance of all defined predicates attached to the route. Once the route matched, the list of route's filters are executed in the same sequence as they were defined. 

## Let us go!
Route logic can become quite complex. This complexity is further even more when implementing custom predicates and filters. It depends on your business.

For our demo, we have two backend endpoints:

1. `/v1/**` endpoint:
  - Designed for requests coming from specific Geo locations, such as countries.
  - This endpoint requires the allowed shipping providers (logistics) to be included in the request header.
2. `/v2/**` endpoint:
  - Designed to accept requests from any country.
  - No special headers are required for requests to this endpoint.

Based on our above description, here is the Spring Cloud Gateway configuration may look like:

```yaml
spring:
  cloud:
    gateway:
      routes:
        - id: route1
          uri: http://localhost:8080/
          predicates:
            - Path=/**
            - name: Geo
              args:
                countries: [ "DE", "FR" ]
          filters:
            - name: AddLogisticProvidersHeader
              args:
                headerName: X-Shipping-Providers
            - RewritePath=/(?<segment>.*), /v1/$\{segment}
        - id: route2
          uri: http://localhost:8080/
          predicates:
            - Path=/**
          filters:
            - RewritePath=/(?<segment>.*), /v2/$\{segment}
```

In the configurations above, when a request originates from `Germany` or `France`, our gateway:
- Retrieves the logistics companies and adds them to the request header with the key `X-Shipping-Providers`.
- Rewrites the request path to include the root path `/v1/`.
- Forwards the modified request to the target URI.

For requests originating from other geographical locations, the gateway:
- Appends `/v2/` to the request path.
- Forwards the modified request to the target URI.

## Testing Predicates
Let's demonstrate what the `GeoRoutePredicateFactory` predicate might look like.
```kotlin
@Component
class GeoRoutePredicateFactory(val countryService: CountryService) :
    AbstractRoutePredicateFactory<GeoRoutePredicateFactory.Config>(Config::class.java) {

    override fun apply(config: Config): Predicate<ServerWebExchange>? {
        return GatewayPredicate { exchange ->
            val countries = config.countries.map { country -> country.uppercase(Locale.getDefault()) }
            if (countries.contains("ALL")) {
                return@GatewayPredicate true
            }

            // Get the client IP address from the request
            val clientIP = exchange.request.headers.getFirst("X-Forwarded-For") ?: "127.0.0.1"
            val country = clientIP.let { countryService.getCountry(clientIP) }
            // Check if at least one delivery option is available for the country
            countries.contains(country?.uppercase(Locale.getDefault()))

        }
    }

    class Config(val countries: List<String>)
}
```
Put simply, if all countries are allowed, the predicate returns true. If not, the predicate checks the client's request IP from the `X-Forwarded-For` header and returns true only if the request originates from one of the allowed countries.

## Testing strategy
Predicates act as conditions for selecting a route for a request. If we can establish that a particular route is acceptable, it means that the predicates for that route have been satisfied. If there is only one predicate and the route is selected, it means the predicate evaluated to true; otherwise, it did not. Voilà!

## Base mocks and Spring boot setup
To ensure the correctness of our predicates, relying solely on unit tests is insufficient. Predicates are part of a larger set of predicates and may rely on the Spring Cloud Integration framework to function properly, such as the exchange method.

Because of this, I advocate for using integration tests for the predicates (and filters, as we'll discuss later).

I love using the [Spock framework](https://spockframework.org) for its simplicity, readability, and maintainability. That's why we use Spock to drive our integration tests.

To facilitate this, we create an abstract integration test Specification. Here, we initialize the `webTestClient` to make our REST calls and use WireMock to mock our target backends. This setup ensures that our integration tests are comprehensive and reliable.

```kotlin
@SpringBootTest(classes = [GatwaytestApplication])
@AutoConfigureWireMock(port = 0)
@AutoConfigureMockMvc
@ActiveProfiles("test")
abstract class AbstractIntegrationSpec extends Specification {

    @Autowired
    ObjectMapper objectMapper

    @Autowired
    WebTestClient webTestClient

    @Autowired
    WireMockServer wireMockServer

    def setup() {
        WireMock.reset()
    }
}
```
## Base test predicates setup
To verify that the request is routed through the chosen route, we can revisit two important attributes of Spring Cloud Gateway:
1. `GATEWAY_HANDLER_MAPPER_ATTR`: This attribute should have the value RoutePredicateHandlerMapping when the request is being routed through the predicates.
2. `GATEWAY_ROUTE_ATTR`: This attribute should contain the name of the route that matches the request.

If we can capture the values of the two attributes and use them to validate whether the predicate behaves as expected, then we can utilize them effectively. To do this, we require the abstract predicate integration specification provided below as a foundation for our predicate integration specifications.

```kotlin
@Import(Config.class)
abstract class AbstractPredicateIntegrationSpec extends AbstractIntegrationSpec {

    protected static final String HANDLER_MAPPER_HEADER = "X-Gateway-Handler-Mapper-Class"
    protected static final String ROUTE_ID_HEADER = "X-Gateway-RouteDefinition-Id"

    @TestConfiguration(proxyBeanMethods = false)
    static class Config {

        @Bean
        @Order(500)
        GlobalFilter modifyResponseFilter() {
            return (exchange, chain) -> {
                String value = exchange.getAttributeOrDefault(GATEWAY_HANDLER_MAPPER_ATTR, "N/A");
                if (!exchange.getResponse().isCommitted()) {
                    exchange.getResponse().getHeaders().add(HANDLER_MAPPER_HEADER, value);
                }
                Route route = exchange.getAttributeOrDefault(GATEWAY_ROUTE_ATTR, null);
                if (route != null) {
                    if (!exchange.getResponse().isCommitted()) {
                        exchange.getResponse().getHeaders().add(ROUTE_ID_HEADER, route.getId());
                    }
                }
                return chain.filter(exchange);
            }
        }
    }
}

```
In the filter described above, we intercept the response and extract the values of two attributes: `GATEWAY_HANDLER_MAPPER_ATTR` and `GATEWAY_ROUTE_ATTR`. These values are then added to the response header using the names `HANDLER_MAPPER_HEADER` and `ROUTE_ID_HEADER` respectively.

Having this information allows us to assert the route ID, ensuring that the result of the predicate under test is as expected

## Test GeoRoutePredicateFactory configuration
For our integration test to work, we need to define the spring cloud gateway routes. For that, we define a testing profile by the name of the predicate `application-GeoRoutePredicateFactoryIntegrationSpec.yml` and have two routes. one route by id `route_based_target_predicate` when the predicate actually passed, and `route_others` if the predicate doesn't passed and the request goes to a different route. 
To enable our integration test, we must define the Spring Cloud Gateway routes. To achieve this, we create a testing profile named `application-GeoRoutePredicateFactoryIntegrationSpec.yml`. Within this profile, we define two routes:
1. `route_based_target_predicate`: This route is activated when the predicate successfully passes.
2. `route_others`: This route is activated if the predicate fails, and the request is directed to a different route.

```yaml
spring:
  cloud:
    gateway:
      routes:
        - id: route_based_target_predicate
          uri: http://localhost:${wiremock.server.port}
          predicates:
            - Path=/**
            - name: Geo
              args:
                countries: [ "DE", "FR" ]
          filters:
            - PrefixPath=/prefix
        - id: route_others
          uri: http://localhost:${wiremock.server.port}
          predicates:
            - Path=/**
          filters:
            - PrefixPath=/prefix
```

## GeoRoutePredicateFactory specification
We complete the integration testing setup by defining the integration test class for our predicate specification.

```kotlin
@ActiveProfiles("GeoRoutePredicateFactoryIntegrationSpec")
class GeoRoutePredicateFactoryIntegrationSpec extends AbstractPredicateIntegrationSpec {


    @Unroll
    def "given request from #country by ip #ip, expected route #expectedRoute"() {

        given:
        wireMockServer.stubFor(
                get(ANY).willReturn(aResponse().withStatus(200))
        )

        when:
        def request = webTestClient.get()
        if (ip != null) {
            request.header("X-Forwarded-For", ip)
        }


        def result = request.exchange()

        then:
        result.expectStatus().isEqualTo(200)
                .expectHeader().valueEquals(HANDLER_MAPPER_HEADER, RoutePredicateHandlerMapping.class.getSimpleName())
                .expectHeader().valueEquals(ROUTE_ID_HEADER, expectedRoute)

        where:
        country   | ip              | expectedRoute
        "Germany" | "77.21.147.170" | "route_based_target_predicate"
        "France"  | "103.232.172.0" | "route_based_target_predicate"
        "USA"     | "30.10.0.10"    | "route_others"
        null      | null            | "route_others"
    }
}
```
In our test setup, we define test cases in a "where" table containing the test data. The IP address serves as the input for our predicate logic. Based on our configuration, requests originating from `Germany` and `France` should be routed through `route_based_target_predicate`, and the decision is made based on the IP address of the incoming request.

For example, for IP addresses "77.21.147.170" and "103.232.172.0", we expect the route to be `route_based_target_predicate` as we anticipate the predicate to pass. Otherwise, the route should be `route_others`.

To verify this behavior, we assert that the value of the `ROUTE_ID_HEADER` matches the `expectedRoute`.

> To try out the example by yourself, be sure to check out the accompanying GitHub repository [here](https://github.com/mohammedalics/spring-cloud-gateway-test).

In [part 2](https://dev.to/mohammedalics/mastering-spring-cloud-gateway-testing-filters-part-2-3c5f), I'll describe in details how you can master testing for filters.

I hope you found it useful!