package com.light.cloud.common.web.openapi.config;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import com.light.cloud.common.core.constant.PlatformConstant;
import io.swagger.v3.oas.models.parameters.HeaderParameter;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.collections4.MapUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springdoc.core.customizers.OpenApiCustomizer;
import org.springdoc.core.customizers.GlobalOpenApiCustomizer;
import org.springdoc.core.customizers.GlobalOperationCustomizer;
import org.springdoc.core.models.GroupedOpenApi;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementPortType;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.servlet.WebMvcEndpointHandlerMapping;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.web.method.HandlerMethod;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.TreeTraversingParser;
import com.fasterxml.jackson.databind.type.MapType;
import com.light.cloud.common.core.constant.BaseConstant;
import com.light.cloud.common.core.enums.ResponseEnum;
import com.light.cloud.common.core.tool.StringTool;
import com.light.cloud.common.web.openapi.properties.OpenapiProperties;
import com.light.cloud.common.web.openapi.properties.RouteDefinition;
import io.swagger.v3.core.util.Json;
import io.swagger.v3.core.util.Yaml;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.media.Schema;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.Operation;
import io.swagger.v3.oas.models.parameters.Parameter;
import io.swagger.v3.oas.models.PathItem;
import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.servers.Server;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
/**
* SpringDoc配置类
*
* @author Hui Liu
* @date 2022/7/29
*/
@Slf4j
@Configuration
public class SpringDocConfig {
@Value(("${server.servlet.context-path:}"))
private String contextPath;
public static final String BASE_PACKAGE = "com.light.cloud.common.web.endpoints";
@Resource
private Environment environment;
@Resource
private OpenapiProperties openapiProperties;
@Resource
private List<Parameter> openapiParameters;
private CustomOperationCustomizer customOperationCustomizer = new CustomOperationCustomizer();
private CustomOpenApiCustomizer customOpenApiCustomizer = new CustomOpenApiCustomizer();
/**
* 添加分组
*
* @return
*/
@Bean("platformApi")
public GroupedOpenApi platformApi() {
boolean isOpen = true;
String profileStr = openapiProperties.getProfiles();
if (StringUtils.isNotBlank(profileStr)) {
List<String> profileList = Arrays.stream(profileStr.split(BaseConstant.COMMA))
.map(String::trim).collect(Collectors.toList());
Profiles profiles = Profiles.of(profileList.toArray(new String[0]));
isOpen = environment.acceptsProfiles(profiles);
}
return GroupedOpenApi.builder()
.group(openapiProperties.getBaseGroup())
// 扫描该包下的所有需要在Swagger中展示的API,@Hidden 注解标注的除外
.packagesToScan(BASE_PACKAGE)
// 添加全局参数
.addOperationCustomizer(customOperationCustomizer)
// 添加统一的上下文地址 contextPath
// .addOpenApiCustomizer(customOpenApiCustomizer)
.build();
}
@Bean("serviceApi")
public GroupedOpenApi serviceApi() {
boolean isOpen = true;
String profileStr = openapiProperties.getProfiles();
if (StringUtils.isNotBlank(profileStr)) {
List<String> profileList = Arrays.stream(profileStr.split(BaseConstant.COMMA))
.map(String::trim).collect(Collectors.toList());
Profiles profiles = Profiles.of(profileList.toArray(new String[0]));
isOpen = environment.acceptsProfiles(profiles);
}
return GroupedOpenApi.builder()
.group(openapiProperties.getScanGroup())
// 扫描该包下的所有需要在Swagger中展示的API, @Hidden 注解标注的除外
.packagesToScan(openapiProperties.getScanPackages())
// 添加全局参数
.addOperationCustomizer(customOperationCustomizer)
// 添加统一的上下文地址 contextPath
// .addOpenApiCustomizer(customOpenApiCustomizer)
.build();
}
// region 动态注册Swagger 文档配置
// /**
// * 动态注册Swagger文档配置
// */
// @Bean
// public OpenapiAdditionalBeanRegistry openapiAdditionalBeanRegistry() {
// return new OpenapiAdditionalBeanRegistry();
// }
/**
* 注册项目模块内部的接口文档
*/
public void registerInternalOpenApis(ConfigurableListableBeanFactory beanFactory) {
Map<String, GroupedOpenApi> openApis = internalOpenApis();
for (Map.Entry<String, GroupedOpenApi> entry : openApis.entrySet()) {
String docName = entry.getKey();
GroupedOpenApi openApi = entry.getValue();
beanFactory.registerSingleton(docName + "InternalApi", openApi);
}
}
/**
* 注册项目模块外部的接口文档
*
* @deprecated 此方式只能实现接口的展示,不能跨服务调用,使用 knife4j aggregation 代替
*/
public void registerExternalOpenApis(ConfigurableListableBeanFactory beanFactory) {
Map<String, GroupedOpenApi> openApis = externalOpenApis();
for (Map.Entry<String, GroupedOpenApi> entry : openApis.entrySet()) {
String docName = entry.getKey();
GroupedOpenApi openApi = entry.getValue();
beanFactory.registerSingleton(docName + "ExternalApi", openApi);
}
}
/**
* 解析项目模块内部的接口文档
*
* @return key-组名称 value-Api文档对象
*/
public Map<String, GroupedOpenApi> internalOpenApis() {
Map<String, RouteDefinition> internals = openapiProperties.getInternals();
if (MapUtils.isEmpty(internals)) {
return Collections.emptyMap();
}
Map<String, GroupedOpenApi> openApiMap = new HashMap<>(internals.size());
for (Map.Entry<String, RouteDefinition> entry : internals.entrySet()) {
String entityKey = entry.getKey();
RouteDefinition route = entry.getValue();
String groupName = StringUtils.isBlank(route.getGroupName()) ? parseGroupName(entityKey) : route.getGroupName();
String scanPackage = route.getLocation();
GroupedOpenApi openApi = GroupedOpenApi.builder()
.group(groupName)
// 扫描该包下的所有需要在Swagger中展示的API,@Hidden 注解标注的除外
.packagesToScan(scanPackage)
// 添加全局参数
.addOperationCustomizer(customOperationCustomizer)
// 添加统一的上下文地址 contextPath
// .addOpenApiCustomizer(customOpenApiCustomizer)
.build();
openApiMap.put(entityKey, openApi);
}
return openApiMap;
}
/**
* 解析项目模块外部的接口文档
*
* @return key-组名称 value-Api文档对象
* @deprecated 此方式只能实现接口的展示,不能跨服务调用,使用 knife4j aggregation 代替
*/
public Map<String, GroupedOpenApi> externalOpenApis() {
Map<String, RouteDefinition> externals = openapiProperties.getExternals();
if (MapUtils.isEmpty(externals)) {
return Collections.emptyMap();
}
Map<String, GroupedOpenApi> openApiMap = new HashMap<>(externals.size());
for (Map.Entry<String, RouteDefinition> entry : externals.entrySet()) {
String entityKey = entry.getKey();
RouteDefinition route = entry.getValue();
String groupName = StringUtils.isBlank(route.getGroupName()) ? parseGroupName(entityKey) : route.getGroupName();
String resourcePath = buildUrl(route.getSchema().toLowerCase(), route.getUri(), route.getLocation());
Map<String, Object> apiSpecMap = getResourceAsMap(resourcePath);
GroupedOpenApi openApi = GroupedOpenApi.builder()
.group(groupName)
// 扫描该包下的所有 需要在Swagger中展示的API,@Hidden 注解标注的除外
// .packagesToScan(scanPackage)
// 添加全局参数
.addOperationCustomizer(customOperationCustomizer)
// 添加统一的上下文地址 contextPath
.addOpenApiCustomizer(new FileOpenApiCustomizer(apiSpecMap, route.getServerUrl()))
.build();
openApiMap.put(entityKey, openApi);
}
return openApiMap;
}
private String parseGroupName(String entityKey) {
String[] strings = StringUtils.splitByCharacterTypeCamelCase(entityKey);
return Arrays.stream(strings).map(s -> StringTool.toUpperCamelCase(s, null))
.collect(Collectors.joining(BaseConstant.SPACE));
}
private String buildUrl(String schema, String uri, String path) {
if (StringUtils.equalsAny(schema, "http", "https")) {
return schema + "://" + uri + "/" + path;
} else if (StringUtils.equalsAny(schema, "ftp", "sftp")) {
return schema + "://" + uri + "/" + path;
} else if (StringUtils.equalsAny(schema, "classpath")) {
return path;
} else if (StringUtils.equalsAny(schema, "file")) {
String osName = System.getProperty("os.name").toLowerCase();
if (osName.startsWith("windows")) {
return schema + ":///" + uri + "/" + path;
} else if (osName.startsWith("linux")) {
return schema + ":/" + uri + "/" + path;
}
}
throw new UnsupportedOperationException("Unsupported schema: " + schema);
}
private Map<String, Object> getResourceAsMap(String resourcePath) {
try {
// 支持 classpath: file:// http: ftp:// 等协议
org.springframework.core.io.Resource resource = new PathMatchingResourcePatternResolver()
.getResource(resourcePath);
byte[] bytes = resource.getContentAsByteArray();
if (ArrayUtils.isNotEmpty(bytes)) {
String content = new String(bytes, StandardCharsets.UTF_8);
Map<String, Object> apiSpecMap = null;
if (resourcePath.endsWith(BaseConstant.DOT_YAML) || resourcePath.endsWith(BaseConstant.DOT_YML)) {
apiSpecMap = yamlToMap(content, Map.class);
} else {
apiSpecMap = jsonToMap(content, Map.class, String.class, Object.class);
}
return apiSpecMap;
}
} catch (Exception ex) {
log.error(ex.getMessage());
}
return null;
}
// endregion
// region 可解决Spring 6.x 与Swagger 3.0.0 不兼容问题
/**
* 增加如下配置可解决Spring 6.x 与Swagger 3.0.0 不兼容问题
**/
@Bean
public WebMvcEndpointHandlerMapping webEndpointServletHandlerMapping(WebEndpointsSupplier webEndpointsSupplier,
ServletEndpointsSupplier servletEndpointsSupplier,
ControllerEndpointsSupplier controllerEndpointsSupplier,
EndpointMediaTypes endpointMediaTypes,
CorsEndpointProperties corsProperties,
WebEndpointProperties webEndpointProperties,
Environment environment) {
List<ExposableEndpoint<?>> allEndpoints = new ArrayList();
Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
allEndpoints.addAll(webEndpoints);
allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
String basePath = webEndpointProperties.getBasePath();
EndpointMapping endpointMapping = new EndpointMapping(basePath);
boolean shouldRegisterLinksMapping = this.shouldRegisterLinksMapping(webEndpointProperties, environment, basePath);
return new WebMvcEndpointHandlerMapping(endpointMapping, webEndpoints,
endpointMediaTypes, corsProperties.toCorsConfiguration(),
new EndpointLinksResolver(allEndpoints, basePath), shouldRegisterLinksMapping);
}
private boolean shouldRegisterLinksMapping(WebEndpointProperties webEndpointProperties,
Environment environment,
String basePath) {
return webEndpointProperties.getDiscovery().isEnabled()
&& (org.springframework.util.StringUtils.hasText(basePath)
|| ManagementPortType.get(environment).equals(ManagementPortType.DIFFERENT));
}
// endregion
// region 全局参数及上下文处理
/**
* 添加全局的请求头参数,并为所有接口添加context-path
*/
// @Bean
public OpenApiCustomizer customerGlobalOpenApiCustomizer() {
return openApi -> {
Paths paths = openApi.getPaths();
String[] pathSet = paths.keySet().toArray(new String[0]);
for (String path : pathSet) {
PathItem pathItem = paths.get(path);
// 添加全局的请求头参数
pathItem.readOperations().forEach(operation -> {
// 引用在 OpenAPI 中定义的 components
operation.addParametersItem(new HeaderParameter().$ref("#/components/parameters/" + PlatformConstant.HEADER_CLIENT_ID))
.addParametersItem(new HeaderParameter().$ref("#/components/parameters/" + PlatformConstant.HEADER_AUTHORIZATION));
});
// 为接口添加 context-path
paths.put(contextPath + path, pathItem);
paths.remove(path);
}
};
}
public class CustomOperationCustomizer implements GlobalOperationCustomizer {
@Override
public Operation customize(Operation operation, HandlerMethod handlerMethod) {
for (Parameter parameter : openapiParameters) {
operation.addParametersItem(parameter);
}
return operation;
}
}
public class CustomOpenApiCustomizer implements GlobalOpenApiCustomizer {
@Override
public void customise(OpenAPI openApi) {
if (StringUtils.isBlank(contextPath)) {
return;
}
Paths paths = openApi.getPaths();
// To avoid ConcurrentModificationException
String[] keySet = paths.keySet().toArray(new String[0]);
for (String key : keySet) {
if (key.startsWith(contextPath)) {
continue;
}
PathItem pathItem = paths.get(key);
// add contextPath to the individual operation
paths.put(contextPath + key, pathItem);
paths.remove(key);
}
// Server server = openApi.getServers().get(0);
// // Add the basePath to the server entry
// server.setUrl(server.getUrl());
}
}
/**
* @see <a href="https://stackoverflow.com/questions/71156280/how-can-i-add-custom-json-object-to-openapi-spec-generated-by-springboot-springd">How can I add custom json object to Openapi spec generated by SpringBoot SpringDoc?</a>
* @see <a href="https://github.com/springdoc/springdoc-openapi/issues/705">Programatically schemas added are not showed in the generated openapi </a>
* @see <a href="https://github.com/springdoc/springdoc-openapi/issues/1703">How to refresh the API</a>
*/
public class FileOpenApiCustomizer implements GlobalOpenApiCustomizer {
private Map<String, Object> apiSpecMap;
private List<String> serverUrls;
public FileOpenApiCustomizer(Map<String, Object> apiSpecMap, String serverUrls) {
this.apiSpecMap = apiSpecMap;
if (StringUtils.isNotBlank(serverUrls)) {
this.serverUrls = Arrays.stream(serverUrls.split(",")).toList();
}
}
@Override
public void customise(OpenAPI openApi) {
if (StringUtils.isBlank(contextPath)) {
contextPath = "";
}
// add external info to api
Map<String, Object> extensions = new HashMap<>();
extensions.put("external", "https://lorchr.github.io/light-docusaurus");
openApi.setExtensions(extensions);
// 添加server信息,发送请求是 curl脚本会生成新的url
if (CollectionUtils.isNotEmpty(serverUrls)) {
List<Server> servers = serverUrls.stream().map(url -> {
Server server = new Server();
server.setUrl(url);
return server;
}).collect(Collectors.toList());
openApi.setServers(servers);
}
Map<String, Object> pathMap = (Map<String, Object>) apiSpecMap.get("paths");
Paths paths = openApi.getPaths();
// Note: 如果此处不清空,其他接口的数据也会显示到当前的接口集中
paths.clear();
for (Map.Entry<String, Object> entry : pathMap.entrySet()) {
String key = entry.getKey();
Map<String, Object> valueMap = (Map<String, Object>) entry.getValue();
PathItem pathItem = jsonToBean(beanToJson(valueMap), PathItem.class);
// for (Map.Entry<String, Object> innerEntry : valueMap.entrySet()) {
// String innerKey = innerEntry.getKey();
// Object innerValue = innerEntry.getValue();
// Operation operation = jsonToBean(JsonTool.beanToJson(innerValue), Operation.class);
// if (PathItem.HttpMethod.POST.name().equalsIgnoreCase(innerKey)) {
// pathItem.setPost(operation);
// } else if (PathItem.HttpMethod.GET.name().equalsIgnoreCase(innerKey)) {
// pathItem.setGet(operation);
// } else if (PathItem.HttpMethod.PUT.name().equalsIgnoreCase(innerKey)) {
// pathItem.setPut(operation);
// } else if (PathItem.HttpMethod.PATCH.name().equalsIgnoreCase(innerKey)) {
// pathItem.setPatch(operation);
// } else if (PathItem.HttpMethod.DELETE.name().equalsIgnoreCase(innerKey)) {
// pathItem.setDelete(operation);
// } else if (PathItem.HttpMethod.HEAD.name().equalsIgnoreCase(innerKey)) {
// pathItem.setHead(operation);
// } else if (PathItem.HttpMethod.OPTIONS.name().equalsIgnoreCase(innerKey)) {
// pathItem.setOptions(operation);
// } else if (PathItem.HttpMethod.TRACE.name().equalsIgnoreCase(innerKey)) {
// pathItem.setTrace(operation);
// }
// }
paths.addPathItem(key, pathItem);
}
// String openapi = (String) apiSpecMap.get("openapi");
// List<Object> info = (List<Object>) apiSpecMap.get("info");
// Map<String, Object> externalDocs = (Map<String, Object>) apiSpecMap.get("externalDocs");
// List<Object> servers = (List<Object>) apiSpecMap.get("servers");
// List<Object> tags = (List<Object>) apiSpecMap.get("tags");
// Map<String, Object> paths = (Map<String, Object>) apiSpecMap.get("paths");
// List<Object> security = (List<Object>) apiSpecMap.get("security");
// Map<String, Object> components = (Map<String, Object>) apiSpecMap.get("components");
Components components = openApi.getComponents();
Map<String, Object> componentMap = (Map<String, Object>) apiSpecMap.get("components");
Map<String, Object> schemaMap = (Map<String, Object>) componentMap.get("schemas");
for (Map.Entry<String, Object> entry : schemaMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
Schema schema = jsonToBean(beanToJson(value), Schema.class);
components.addSchemas(key, schema);
}
}
}
// endregion
/**
* 使用Swagger时,最好使用自带的 {@link io.swagger.v3.core.util.Json} {@link io.swagger.v3.core.util.Yaml}解析类
*
* @param content json字符串
* @param clazz 目标对象类型
* @return clazz 类型对象
*/
public <T> T jsonToBean(String content, Class<T> clazz) {
try {
return Json.mapper().readValue(content, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public Map jsonToMap(String content, Class<? extends Map> mapType,
Class<String> keyType, Class<Object> valueType) {
try {
ObjectMapper mapper = Json.mapper();
MapType type = mapper.getTypeFactory()
.constructMapType(mapType, keyType, valueType);
return mapper.readValue(content, type);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public String beanToJson(Object bean) {
try {
return Json.mapper().writeValueAsString(bean);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
public <T> T yamlToBean(String content, Class<T> clazz) {
try {
return Yaml.mapper().readValue(content, clazz);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
/**
* Jackson yaml不支持带锚点的yaml文档解析:
*
* @see <a href="https://stackoverflow.com/questions/40074700/jackson-yaml-support-for-anchors-and-references">Jackson YAML: support for anchors and references</a>
* @see <a href="https://github.com/FasterXML/jackson-dataformats-text/issues/98">Jackson Yaml anchors/references Support</a>
*/
public static <K, V> Map<K, V> yamlToMap(String content, Class<? extends Map> mapType,
Class<K> keyType, Class<V> valueType) {
try {
ObjectMapper mapper = Yaml.mapper();
final JsonNode rootNode = mapper.readTree(content);
MapType type = mapper.getTypeFactory()
.constructMapType(mapType, keyType, valueType);
TreeTraversingParser treeTraversingParser = new TreeTraversingParser(rootNode);
return mapper.readValue(treeTraversingParser, type);
} catch (Exception e) {
log.error(ResponseEnum.YAML_PARSE_ERROR.getDesc(), e);
}
return null;
}
/**
* 使用 snakeyaml 可以支持锚点
*/
public Map yamlToMap(String content, Class<? extends Map> mapType) {
org.yaml.snakeyaml.Yaml yaml = new org.yaml.snakeyaml.Yaml();
return yaml.loadAs(content, mapType);
}
public String beanToYaml(Object bean) {
try {
return Yaml.mapper().writeValueAsString(bean);
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
}
}
}