Mastering Spring Cloud Gateway Testing: Predicates (part 1)

Mastering Spring Cloud Gateway Testing: Predicates (part 1)

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’.”

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:

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

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

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.

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

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

GATEWAY_HANDLER_MAPPER_ATTR: This attribute should have the value RoutePredicateHandlerMapping when the request is being routed through the predicates.

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.

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

route_based_target_predicate: This route is activated when the predicate successfully passes.

route_others: This route is activated if the predicate fails, and the request is directed to a different route.

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.

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

Leave a Reply

Your email address will not be published. Required fields are marked *