1. Introduction

A common use case for the Spring Cloud Gateway is to act as a facade to one or more services, thus offering clients a simpler way to consume them.

In this tutorial, we’ll show different ways to customize the exposed APIs by rewriting the URLs before sending the request to the backends.

2. Spring Cloud Gateway Quick Recap

The Spring Cloud Gateway project is built on top of the popular Spring Boot 2 and Project Reactor, so it inherits its main treats:

  • Low resource usage, thanks to its reactive nature
  • Support for all goodies from the Spring Cloud ecosystem (discovery, configuration, etc.)
  • Easy to extend and/or customize using standard Spring patterns

We’ve already covered its main features in earlier articles, so here we’ll just list the  main concepts:

  • Route: a set of processing steps that a matching incoming request goes through in the Gateway
  • Predicate: A Java 8 Predicate that gets evaluated against a ServerWebExchange.
  • Filters: GatewayFilter instances that can inspect and/or change a ServerWebExchange. The Gateway supports both global filters and per-route ones.

In a nutshell, here’s the processing sequence an incoming request goes through:

  • The Gateway uses the Predicates associated with each route to find which one will handle the request
  • Once a route is found, the request (a ServerWebExchange instance) goes through each configured filter until it is eventually sent to a backend.
  • When the backend sends a response back, or there’s an error (timeout or connection reset, for instance), filters get again a chance to process the response before it is sent back to the client.

3. Configuration-based URL Rewrite

Going back to this article’s main subject, let’s see how to define a route that rewrites the incoming URL before sending it to the backend. For example, suppose that given an incoming request of the form /api/v1/customer/*, the backend URL should be http://v1.customers/api/\*. Here, we’re using “*” to represent “anything beyond this point”.

To create a configuration-based rewrite, we just need to add a few properties to the application’s configuration. Here, we’ll use YAML-based configuration for clarity, but this information could come from any supported PropertySource:

spring:
  cloud:
    gateway:
      routes:
      - id: rewrite_v1
        uri: ${rewrite.backend.uri:http://example.com}
        predicates:
        - Path=/v1/customer/**
        filters:
        - RewritePath=/v1/customer/(?<segment>.*),/api/$\{segment}

Let’s dissect this configuration. Firstly, we have the route’s id, which is just its identifiers. Next, we have the backend URI given by the uri property. Notice that only hostname/port are considered, as the final path comes from the rewrite logic.

The predicates property defines the conditions that must be met to activate this route. In our case, we use the Path predicate, which takes an ant-like path expression to match against the path of the incoming request.

Finally, the filters property has the actual rewrite logic. The RewritePath filter takes two arguments: a regular expression and a replacement string. The filter’s implementation works by simply executing the replaceAll() method on the request’s URI, using the provided parameters as arguments.

A caveat of the way that Spring handles configuration files is we can’t use the standard ${group} replacement expression, as Spring will think it is a property reference and try to replace its value. To avoid this, we need to add a backslash between the “$” and “{” characters that will be removed by the filter implementation before using it as the actual replacement expression.

4. DSL-based URL Rewrite

While RewritePath is quite powerful and easy to use, it falls short in scenarios where the rewrite rule has some dynamic aspects. Depending on the case, it might still be possible to write multiple rules using predicates as guards for each branch of the rule.

However, if this is not the case, we can create a route using the DSL-based approach. All we need to do is create a RouteLocator bean that implements the route’s logic. As an example, let’s create a simple route that, as before, rewrites the incoming URI using a regular expression. This time, however, the replacement string will be dynamically generated on each request:

@Configuration
public class DynamicRewriteRoute {
    
    @Value("${rewrite.backend.uri}")
    private String backendUri;
    private static Random rnd = new Random();
    
    @Bean
    public RouteLocator dynamicZipCodeRoute(RouteLocatorBuilder builder) {
        return builder.routes()
          .route("dynamicRewrite", r ->
             r.path("/v2/zip/**")
              .filters(f -> f.filter((exchange, chain) -> {
                  ServerHttpRequest req = exchange.getRequest();
                  addOriginalRequestUrl(exchange, req.getURI());
                  String path = req.getURI().getRawPath();
                  String newPath = path.replaceAll(
                    "/v2/zip/(?<zipcode>.*)", 
                    "/api/zip/${zipcode}-" + String.format("%03d", rnd.nextInt(1000)));
                  ServerHttpRequest request = req.mutate().path(newPath).build();
                  exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, request.getURI());
                  return chain.filter(exchange.mutate().request(request).build());
              }))
              .uri(backendUri))
          .build();
    }
}

Here, the dynamic part is just a random number appended to the replacement string. A real-world application might have more complex logic, but the essential mechanism would be the same as shown.

A few remarks about the steps this code went through: Firstly, it calls the addOriginalRequestUrl(), which comes from the ServerWebExchangeUtils class, to store the original URL under the exchange’s attribute GATEWAY_ORIGINAL_REQUEST_URL_ATTR. The value of this attribute is a list to which we’ll append the received URL before going any modification and used internally by the gateway as part of the X-Forwarded-For header’s handling.

Secondly, once we’ve applied the rewrite logic, we must save the modified URL in the GATEWAY_REQUEST_URL_ATTR exchange’s attribute. This step is not directly mentioned in the documentation but ensures that our custom filter plays nicely with other available filters.

5. Testing

To test our rewrite rules, we’ll use standard JUnit 5 classes with a small twist: we’ll spin up a simple server based on Java SDK’s com.sun.net.httpserver.HttpServer class. The server will start on a random port, thus avoiding port conflicts.

The downside of this approach, however, is we have to find out which port was actually assigned to the server and pass it to Spring, so we can use it to set the route’s uri property. Fortunately, Spring provides us with an elegant solution for this problem: @DynamicPropertySource. Here, we’ll use it to start the server and register a property with the bound port’s value:

@DynamicPropertySource
static void registerBackendServer(DynamicPropertyRegistry registry) {
    registry.add("rewrite.backend.uri", () -> {
        HttpServer s = startTestServer();
        return "http://localhost:" + s.getAddress().getPort();
    });
}

The test handler simply echoes back the received URI in the response body. This allows us to verify that the rewrite rules work as expected. For instance, this is the

@Test
void testWhenApiCall_thenRewriteSuccess(@Autowired WebTestClient webClient) {
    webClient.get()
      .uri("http://localhost:" + localPort + "/v1/customer/customer1")
      .exchange()
      .expectBody()
      .consumeWith((result) -> {
          String body = new String(result.getResponseBody());
          assertEquals("/api/customer1", body);
      });
}

6. Conclusion

In this quick tutorial, we’ve shown different ways to rewrite URLs using the Spring Cloud Gateway library. As usual, all code is available over on GitHub.