'Spring Cloud Gateway - composite API calls

I have a YAML-configured API Gateway that is hiding a large amount of services. In the near future, we will need a way to create composite API calls, such as the one described in this thread. Seems like it's not yet available in Spring Cloud Gateway, as it is an open issue. I can see that Spencer Gibb has referred to the usage of ProxyExchange, but I'm a bit unclear how I can use that in a gateway filter, as it seems more suited towards a controller instead.

So here is my setup: I have two exposed endpoints running at localhost:9000. Here's the controller class:

@RestController
@RequestMapping("/composite")
public class CompositeCallController {

    @GetMapping("/test/one")
    public Map<String, Object> first() {
        return Map.of("response-1", "FIRST");

    }

    @GetMapping("/test/two")
    public Map<String, Object> second() {
        return Map.of("response-2", "SECOND");
    }
}

My gateway is running on localhost:8080. I have a route that hits the first service from the above controller - /composite/test/one. My idea is to have a route filter that uses ModifyResponseBodyGatewayFilterFactory and modifies the body to include the response that comes from calling the second endpoint - /composite/test/two. The expected result is something like this:

{
    "1": {
        "response-1": "FIRST"
    },
    "2": {
        "response-2": "SECOND"
    }
}

To capture the second response, I have made a DTO:

@Getter
@Setter
public class ApiCallDTO {
    private Map<String, Object> response;
}

Here's the filter that I try to do all of this in:

@Component
public class CompositeApiCallFilter extends AbstractGatewayFilterFactory<CompositeApiCallFilter.Config> {
    public static final String AG_HOST = "8080";
    public static final String SERVICES_HOST = "9000";
    private final ModifyResponseBodyGatewayFilterFactory modifyResponseBodyFilterFactory;

    @Autowired
    public CompositeApiCallFilter(ModifyResponseBodyGatewayFilterFactory factory) {
        super(Config.class);
        this.modifyResponseBodyFilterFactory = factory;
    }

    @Override
    public GatewayFilter apply(final Config config) {
        final ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyFilterFactoryConfig = new ModifyResponseBodyGatewayFilterFactory.Config();
        modifyResponseBodyFilterFactoryConfig.setRewriteFunction(Map.class, Map.class, (exchange, firstCallBody) -> {
            ServerHttpRequest request = exchange.getRequest();
            String serviceURL = getServiceURL(request);
            WebClient client = WebClient.create();

            Mono<ApiCallDTO> apiCallDTOMono = client.get()
                    .uri(uriBuilder -> uriBuilder.path(serviceURL + "/composite/test/two").build())
                    .retrieve()
                    .bodyToMono(ApiCallDTO.class);

            Map<String, Object> output = new HashMap<>();
            apiCallDTOMono
                    .doOnNext(r -> output.put("1", firstCallBody))
                    .subscribe(value -> output.put("2", value.getResponse()));

            return Mono.just(output);
        });
        return modifyResponseBodyFilterFactory.apply(modifyResponseBodyFilterFactoryConfig);
    }

    public static class Config {
    }

    private String getServiceURL(ServerHttpRequest request) {
        return request.getURI().toString().replace(request.getPath().toString(), "").replace(AG_HOST, SERVICES_HOST);
    }

And the respective YML config:

spring:
  cloud:
    gateway:
      routes:
        - id: composite-call-test
          uri: http://localhost:9000
          predicates:
            - Path=/composite/**
          filters:
            - CompositeApiCallFilter

This is about as barebones as I need it for now, as it's just a POC task at this point. However, when I hit localhost:8080/composite/test/one through Postman, I get back an empty map with status code 200, which makes me think the actions in the subscribe() call are not being executed at all, and I don't understand why.

Additionally, I see a bunch of errors show up in the console exactly 5 seconds after the request has been made:

  • Caused by: org.springframework.web.reactive.function.client.WebClientRequestException: Failed to resolve 'http:' after 3 queries ; nested exception is java.net.UnknownHostException: Failed to resolve 'http:' after 3 queries
  • Caused by: java.net.UnknownHostException: Failed to resolve 'http:' after 3 queries
  • Caused by: io.netty.resolver.dns.DnsNameResolverTimeoutException: [/192.168.0.1:53] query via UDP timed out after 5000 milliseconds (no stack trace available)

Additionally, I tried merging the two calls into a new Mono by using the .zip() method, and then transferring the Mono contents into a new map like so:

    public GatewayFilter apply(final Config config) {
        final ModifyResponseBodyGatewayFilterFactory.Config modifyResponseBodyFilterFactoryConfig = new ModifyResponseBodyGatewayFilterFactory.Config();
        modifyResponseBodyFilterFactoryConfig.setRewriteFunction(Map.class, Map.class, (exchange, firstCall) -> {
            ServerHttpRequest request = exchange.getRequest();
            String serviceURL = getServiceURL(request);
            WebClient client = WebClient.create();

            Mono<Map<String, Object>> firstCallDTOMono = Mono.just(firstCall);

            Mono<SecondCallDTO> secondCallDTOMono = client.get()
                    .uri(uriBuilder -> uriBuilder.path(serviceURL + "/composite/test/two").build())
                    .retrieve()
                    .bodyToMono(SecondCallDTO.class);

            Mono<MergedCallDTO> mergedCallDTOMono = Mono
                    .zip(firstCallDTOMono, secondCallDTOMono)
                    .map(mergedMono -> {
                        MergedCallDTO mergedCallDTO = new MergedCallDTO();
                        mergedCallDTO.setResponse1(Map.of("response-1", mergedMono.getT1().get("response-1")));
                        mergedCallDTO.setResponse2(mergedMono.getT2().getResponse());
                        return mergedCallDTO;
                    });

            Map<String, Object> output = new HashMap<>();

            mergedCallDTOMono.map(response -> {
                output.put("1", response.getResponse1().get("response-1"));
                output.put("2", response.getResponse2().get("response-2"));
                return output;
            });

            return Mono.just(output);
        });
        return modifyResponseBodyFilterFactory.apply(modifyResponseBodyFilterFactoryConfig);
    }

With both FirstCallDTO and SecondCallDTO being identical with ApiCallDTO that I mentioned above, and MergedCallDTO looking like this:

@Getter
@Setter
public class MergedCallDTO {
    private Map<String, Object> response1;
    private Map<String, Object> response2;
}

But this doesn't seem to work either, as I still get back an empty map with status code 200, but this time without seeing any errors or exceptions being thrown.

I'm quite lost at this point, I've tried at least 5 different implementations, and none of them have worked. Could anyone please point out the flaw in my approach? Thanks!



Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source