Wick Technology Blog

Distributed API Documentation - How to Aggregate Swagger

April 29, 2020

Publishing API documentation across a whole system of microservices can be difficult. The services are small and split up, they may be behind an API gateway which changes their path, and the serving of documentation needs to be secure.

We have a lot of microservices at Mettle, and they need to be documented and discoverable for the APIs to be consumed. Sometimes APIs needed by frontend developers already exist, but they don’t know about it. Having a single place for all API documentation reduces the chance of miscommunication and friction between teams.

I had a few requirements for our API documentation:

  • It should all be in one place, in one UI - each service should just serve Swagger JSON or YAML and not have a Swagger UI
  • The Documentation UI should be behind single sign on, and the documentation endpoints should be secure
  • The UI and endpoints to request the Swagger docs should be on the same host so there’s no need for setting up CORS in every service
  • Even when using an API gateway which changes the path, the path of the request in the docs should be correct, so you can try the request out

To do this, I used Spring Cloud Gateway to dynamically generate routes based on configuration. Each service has a url where the API docs are served from, and a gateway prefix - which is what the API gateway prepends onto the path for all API requests. For example in application.yaml:

api-docs.endpoints:
  service-name:
    gateway-prefix: /api
    service-url: http://service.namespace
    service-path: /resource/api-docs

These properties are provided throughout the app as a @ConfigurationProperties class, ApiDocEndpointsConfiguration.

Configuring the Swagger UI to serve multiple API documents

The list of configured services is used to generate a response to /swagger-config.json which is an endpoint the UI requests to get its config. In this we provide a list of urls which will proxy requests to the documentation on each of the microservices.

@RestController
@Hidden
public class SwaggerConfigController {

    @Autowired
    private ApiDocEndpointsConfiguration apiDocEndpoints;

    @GetMapping("/swagger-config.json")
    public SwaggerUrlsConfig swaggerConfig() {
        List<SwaggerUrlsConfig.SwaggerUrl> urls = apiDocEndpoints.getEndpoints()
            .entrySet().stream().map(routeEntry -> {
                String name = routeEntry.getKey();
                return SwaggerUrlsConfig.SwaggerUrl.builder()
                        .url(routeEntry.getValue().getGatewayPrefix() 
                            + routeEntry.getValue().getServicePath())
                        .name(name).build();
            }).collect(Collectors.toList());
        return new SwaggerUrlsConfig(urls);
    }
    
    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    public static class SwaggerUrlsConfig {
    
        private List<SwaggerUrl> urls;
    
        @Data
        @Builder
        @NoArgsConstructor
        @AllArgsConstructor
        public static class SwaggerUrl {
            private String url;
            private String name;
        }
    }

}

We use Springfox to serve the Swagger UI, which just requires a dependency:

implementation 'org.springdoc:springdoc-openapi-webflux-ui:1.2.33'

and some config:

springdoc:
  swagger-ui:
    path: /
    configUrl: /swagger-config.json

This meant the UI would pick up our custom config served by the SwaggerConfigController and would show a list of microservices with API documentation.

Configuring each microservice

Each service serves its own Swagger JSON or YAML and this is unauthenticated to allow the documentation gateway to retrieve them for the UI. Only requests from within the cluster are allowed, so it’s not reachable through the API gateway.

Setting up the documentation gateway to proxy requests

The documentation gateway proxies requests for Swagger docs to the services in the cluster and then displays it on the UI it is also providing. Spring Cloud Gateway allows you to set up routes in code, not just in properties. This is done by providing a RouteLocator as a @Bean (this should be placed inside a class annotated with @Configuration for Spring Boot to pick it up):

@Autowired
private ApiDocEndpointsConfiguration apiDocEndpoints;

@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
    RouteLocatorBuilder.Builder routes = builder.routes();
    apiDocEndpoints.getEndpoints().forEach((key, endpoint) -> {
        routes.route(this.routeForJsonResponses(endpoint));
    });
    return routes.build();
}

private Function<PredicateSpec, Route.AsyncBuilder> routeForJsonResponses(
    ApiDocEndpointsConfiguration.Endpoint endpoint) {
    return r -> r.path(new PathBuilder(endpoint.getGatewayPrefix(), 
                    endpoint.getServicePath()).getPath())
            .filters(f -> f.setPath(endpoint.getServicePath()))
            .uri(endpoint.getServiceUrl());
}

The above code shows building the routes for Spring Cloud Gateway. For each service with API documentation it configures a route, which matches on the path we set in the /swagger-config.json response, sets the new path as the path to the API docs on the service and changes the url to be the internal Kubernetes url of the service.

Making sure the path is correct

If you use an API gateway it is usually changing the path of the request. To make sure the paths in the API documentation are correct we need to modify the response. Spring Cloud Gateway allows us to do that with a response modification filter.

private Function<PredicateSpec, Route.AsyncBuilder> routeForJsonResponses(
    ApiDocEndpointsConfiguration.Endpoint endpoint) {
    return r -> r.path(new PathBuilder(endpoint.getGatewayPrefix(), 
                        endpoint.getServicePath()).getPath())
            .filters(f -> f.setPath(endpoint.getServicePath())
            # Adding response modification filter
                    .modifyResponseBody(Map.class, Map.class, 
                        rewriteServersWithGatewayUrl(endpoint, namespace)))
            .uri(endpoint.getServiceUrl());
}

private RewriteFunction<Map, Map> rewriteServersWithGatewayUrl(
    ApiDocEndpointsConfiguration.Endpoint endpoint) {
    return (serverWebExchange, openAPI) -> {
        Server server = new Server();
        server.setUrl("https://api.domain.com" + endpoint.getGatewayPrefix());
        openAPI.put("servers", Collections.singletonList(server));
        return Mono.just(openAPI);
    };
}

In the above I’ve added a response modification filter which deserializes the JSON to a Map. This allows me to add the API gateway url as a OpenAPI server in the response with the configured prefix (if the API gateway changes the path at all).

Securing the Docs

Lastly, put your Swagger UI behind a single sign on provider (for example, following Steve Wade’s way of doing this in Kubernetes) and make sure your Swagger JSON cannot be accessed via your API gateway (because it’s unauthenticated at the microservice level to allow Spring Cloud Gateway to proxy requests to it).

Conclusion

I thought this was a neat way of using the gateway pattern to bring together microservice documentation. As ever there are things I’d like to improve, for example, making the documentation endpoints automatically discoverable rather than statically by configuration. Also, it may be preferable to combine the documentation into one big file and then group the requests by tags rather than “leaking” what services serve what resources to consumers.


Phil Hardwick

Written by Phil Hardwick