Search code examples
javaspring-bootswaggerspringdocspringdoc-openapi-ui

How can a Spring Cloud Gateway expose its (dynamic) routes through a Swagger UI?


How can a Spring Cloud Gateway expose its routes through a Swagger UI? Here's a minimalistic static Gateway:

package com.example.gatewaydemo.routing;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;

@Configuration
public class MyRoutingConfig {
    @Bean
    public RouteLocator routeLocator(RouteLocatorBuilder routeLocatorBuilder) {
        return routeLocatorBuilder.routes()
                .route(predicateSpec -> predicateSpec
                        .path("/uuid")
                        .and()
                        .method(HttpMethod.GET)
                        .uri("https://httpbin.org")
                ).build();
    }
}

In a regular Spring web app, adding Swagger dependencies is enough

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-boot-starter</artifactId>
            <version>3.0.0</version>
        </dependency>

        <dependency>
            <groupId>io.springfox</groupId>
            <artifactId>springfox-swagger-ui</artifactId>
            <version>3.0.0</version>
        </dependency>

It's not the case with Gateway

My Gateway uses Spring Boot 3 and Java 17 so I included this dependency instead

<dependency>
   <groupId>org.springdoc</groupId>
   <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
   <version>2.0.2</version>
</dependency>

However, it's clear I have to provide some configuration because if I simply include that springdoc dependency and include the @OpenApiDefinition annotation

package by.afinny.apigateway;

import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.info.Info;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@OpenAPIDefinition(info = @Info(title = "API Gateway", version = "1.0", description = "Documentation API Gateway v1.0"))
public class ApiGatewayV2Application {
    public static void main(String[] args) {
        SpringApplication.run(ApiGatewayV2Application.class, args);
    }
}

I get this response when making a request to /swagger-ui.html (which gets redirected to /webjars/swagger-ui/index.html). Sorry, it's ui after all so I have to include a screenshot

Failed to load remote configuration

enter image description here

Please note that I retrieve services and their docs dynamically so static solutions like

springdoc:
  enable-native-support: true
  api-docs:
    enabled: true
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    config-url: /v3/api-docs/swagger-config
    urls:
      - url: /v3/api-docs
        name: API Gateway Service
        primaryName: API Gateway Service
      - url: /product-service/v3/api-docs
        name: Product Service
        primaryName: Product Service
      - url: /price-service/v3/api-docs
        name: Price Service
        primaryName: Price Service

won't fit. That is, at startup my Gateway doesn't know whether there are product-service or price-service running

In a sense, I guess, my question boils down to this: how do I write Java config for Swagger UI in a Spring Cloud Gateway application?

UPD

Here's what I tried

springdoc:
  api-docs:
    enabled: false
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    config-url: /swagger-ui-config
package by.afinny.apigateway.controller;

import by.afinny.apigateway.model.uiConfig.SwaggerUiConfig;
import by.afinny.apigateway.service.SwaggerUiConfigProvider;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;

@RestController
@RequiredArgsConstructor
public class SwaggerUiConfigController {
    private final SwaggerUiConfigProvider configProvider;
    @GetMapping("/swagger-ui-config")
    public Mono<SwaggerUiConfig> getConfig() {
        return configProvider.getSwaggerUiConfig();
    }
}
package by.afinny.apigateway.model.uiConfig;

import by.afinny.apigateway.model.documentedApplication.SwaggerApplication;
import by.afinny.apigateway.service.SwaggerApplicationSerializer;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.Collection;

@NoArgsConstructor
@Getter
public class SwaggerUiConfig {
    @JsonProperty("urls")
    @JsonSerialize(contentUsing = SwaggerApplicationSerializer.class) // because SwaggerApplication contains a ton of other properties
    private Collection<SwaggerApplication> swaggerApplications;

    public SwaggerUiConfig(Collection<SwaggerApplication> swaggerApplications) {
        this.swaggerApplications = swaggerApplications;
    }

    public static SwaggerUiConfig from(Collection<SwaggerApplication> swaggerApplications) {
        return new SwaggerUiConfig(swaggerApplications);
    }
}
package by.afinny.apigateway.service;

import by.afinny.apigateway.model.documentedApplication.SwaggerApplication;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import lombok.SneakyThrows;

import java.io.IOException;

public class SwaggerApplicationSerializer extends JsonSerializer<SwaggerApplication> {
    @Override
    @SneakyThrows
    public void serialize(SwaggerApplication swaggerApplication, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
        jsonGenerator.writeStartObject();
        jsonGenerator.writeStringField("url", swaggerApplication.getUrl());
        jsonGenerator.writeStringField("name", swaggerApplication.getName());
        jsonGenerator.writeEndObject();
    }
}

The returned JSON looks correct

{
  "urls": [
    {
      "url": "/HELLOWORLD/v3/api-docs",
      "name": "HELLOWORLD"
    }
  ]
}

enter image description here

However, I still get the same redirect and the same "failed to load configuration" message, nothing changed. What is my mistake?


Solution

  • Your Gateway should serve the docs too

    package com.example.dynamicgateway.controller;
    
    import com.example.dynamicgateway.model.uiConfig.SwaggerUiConfig;
    import com.example.dynamicgateway.service.swaggerUiSupport.SwaggerUiSupport;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RestController;
    import reactor.core.publisher.Mono;
    
    @RestController
    @RequiredArgsConstructor
    public class SwaggerUiConfigController {
        private final SwaggerUiSupport uiSupport;
    
        @GetMapping("/swagger-ui-config")
        public Mono<SwaggerUiConfig> getConfig() {
            return uiSupport.getSwaggerUiConfig();
        }
    }
    
    package com.example.dynamicgateway.controller;
    
    import com.example.dynamicgateway.service.swaggerUiSupport.SwaggerUiSupport;
    import io.swagger.v3.oas.models.OpenAPI;
    import lombok.RequiredArgsConstructor;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PathVariable;
    import org.springframework.web.bind.annotation.RestController;
    import reactor.core.publisher.Mono;
    
    @RestController
    @RequiredArgsConstructor
    public class SwaggerDocController {
        private final SwaggerUiSupport uiSupport;
    
        @GetMapping("{application-name}/doc")
        public Mono<OpenAPI> getSwaggerAppDoc(@PathVariable("application-name") String applicationName) {
            // return cached OpenAPI object right away
            return uiSupport.getSwaggerAppDoc(applicationName);
        }
    }
    
    package com.example.dynamicgateway.model.uiConfig;
    
    import com.example.dynamicgateway.model.documentedApplication.SwaggerApplication;
    import com.example.dynamicgateway.service.swaggerUiSupport.SwaggerUiConfigSerializer;
    import com.fasterxml.jackson.annotation.JsonProperty;
    import com.fasterxml.jackson.databind.annotation.JsonSerialize;
    import lombok.Getter;
    import lombok.NoArgsConstructor;
    
    import java.util.Collection;
    
    @NoArgsConstructor
    @Getter
    public class SwaggerUiConfig {
        @JsonProperty("urls")
        @JsonSerialize(contentUsing = SwaggerUiConfigSerializer.class)
        private Collection<SwaggerApplication> swaggerApplications;
    
        public SwaggerUiConfig(Collection<SwaggerApplication> swaggerApplications) {
            this.swaggerApplications = swaggerApplications;
        }
    
        public static SwaggerUiConfig from(Collection<SwaggerApplication> swaggerApplications) {
            return new SwaggerUiConfig(swaggerApplications);
        }
    }
    
    package com.example.dynamicgateway.service.swaggerUiSupport;
    
    import com.example.dynamicgateway.model.documentedApplication.SwaggerApplication;
    import com.fasterxml.jackson.core.JsonGenerator;
    import com.fasterxml.jackson.databind.JsonSerializer;
    import com.fasterxml.jackson.databind.SerializerProvider;
    import lombok.SneakyThrows;
    
    import java.text.MessageFormat;
    
    public class SwaggerUiConfigSerializer extends JsonSerializer<SwaggerApplication> {
        @Override
        @SneakyThrows
        public void serialize(SwaggerApplication swaggerApplication, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) {
            jsonGenerator.writeStartObject();
            jsonGenerator.writeStringField("url", MessageFormat.format("/{0}/doc", swaggerApplication.getName()));
            jsonGenerator.writeStringField("name", swaggerApplication.getName());
            jsonGenerator.writeEndObject();
        }
    }
    
    gateway:
      servers:
        - url: https://localhost:8080
          description: Api-Gateway-V2
      v1Prefix: /api/v1
    
    springdoc:
      swagger-ui:
        enabled: true
        path: /swagger-ui.html
        config-url: /swagger-ui-config
    

    I won't include the entire code, but here's my SwaggerUiSupport. EndpointCollector is an interface that represent an endpoint cache (all endpoints collected from Eureka applications, assuming they have a Swagger/OpenApi/Springdoc dependency)

    package com.example.dynamicgateway.service.swaggerUiSupport;
    
    import com.example.dynamicgateway.model.documentedApplication.DocumentedApplication;
    import com.example.dynamicgateway.model.documentedApplication.SwaggerApplication;
    import com.example.dynamicgateway.model.documentedEndpoint.DocumentedEndpoint;
    import com.example.dynamicgateway.model.documentedEndpoint.SwaggerEndpoint;
    import com.example.dynamicgateway.model.gatewayMeta.GatewayMeta;
    import com.example.dynamicgateway.model.uiConfig.SwaggerUiConfig;
    import com.example.dynamicgateway.service.endpointCollector.EndpointCollector;
    import io.swagger.v3.oas.models.OpenAPI;
    import io.swagger.v3.oas.models.PathItem;
    import io.swagger.v3.oas.models.Paths;
    import io.swagger.v3.parser.core.models.SwaggerParseResult;
    import lombok.RequiredArgsConstructor;
    import lombok.SneakyThrows;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.stereotype.Component;
    import reactor.core.publisher.Mono;
    
    import java.text.MessageFormat;
    import java.util.Map;
    import java.util.NoSuchElementException;
    import java.util.Set;
    import java.util.stream.Collectors;
    
    @Component
    @Slf4j
    @RequiredArgsConstructor
    public class BasicSwaggerUiSupport implements SwaggerUiSupport {
        private final EndpointCollector<SwaggerEndpoint> endpointCollector;
        private final GatewayMeta gatewayMeta;
    
        @Override
        public Mono<SwaggerUiConfig> getSwaggerUiConfig() {
            Set<SwaggerApplication> swaggerApps = endpointCollector.getKnownEndpoints().stream()
                    .map(DocumentedEndpoint::getDeclaringApp)
                    .collect(Collectors.toSet());
            return Mono.just(SwaggerUiConfig.from(swaggerApps));
        }
    
        @Override
        @SneakyThrows
        public Mono<OpenAPI> getSwaggerAppDoc(String appName) {
            return Mono.just(
                    endpointCollector.getKnownEndpoints().stream()
                            .map(DocumentedEndpoint::getDeclaringApp)
                            .filter(documentedApplication -> documentedApplication.getName().equals(appName))
                            .map(DocumentedApplication::getNativeDoc)
                            .map(SwaggerParseResult::getOpenAPI)
                            .peek(this::setGatewayPrefixes)
                            .peek(this::setGatewayServers)
                            .findFirst()
                            .orElseThrow(() -> new IllegalArgumentException(MessageFormat.format(
                                    "No service with name {0} is known to this Gateway", appName
                            )))
            );
        }
    
        private void setGatewayPrefixes(OpenAPI openAPI) {
            Paths newPaths = new Paths();
            for (Map.Entry<String, PathItem> pathItemEntry : openAPI.getPaths().entrySet()) {
                String servicePath = pathItemEntry.getKey();
                PathItem pathItem = pathItemEntry.getValue();
    
                String prefixedPath = servicePath;
                if (servicePath != null && !servicePath.startsWith(gatewayMeta.v1Prefix())) {
                    String nonprefixedPath = endpointCollector.getKnownEndpoints().stream()
                            .filter(documentedEndpoint -> documentedEndpoint.getDetails().getPath().equals(servicePath))
                            .map(documentedEndpoint -> documentedEndpoint.getDetails().getNonPrefixedPath())
                            .findFirst()
                            .orElseThrow(() -> new NoSuchElementException(MessageFormat.format(
                                    "No endpoint found. Requested path: {0}", servicePath
                            )));
    
                    prefixedPath = gatewayMeta.v1Prefix() + nonprefixedPath;
                }
                newPaths.put(prefixedPath, pathItem);
            }
            openAPI.setPaths(newPaths);
        }
    
        private void setGatewayServers(OpenAPI openAPI) {
            openAPI.setServers(gatewayMeta.servers());
        }
    }