diff --git a/ruoyi-common/ruoyi-common-doc/pom.xml b/ruoyi-common/ruoyi-common-doc/pom.xml
index c6199a17c..77f8d703f 100644
--- a/ruoyi-common/ruoyi-common-doc/pom.xml
+++ b/ruoyi-common/ruoyi-common-doc/pom.xml
@@ -21,6 +21,11 @@
ruoyi-common-core
+
+ org.dromara
+ ruoyi-common-satoken
+
+
org.springdoc
springdoc-openapi-starter-webmvc-api
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java
index 35b6ce9ea..b22d91139 100644
--- a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/config/SpringDocConfig.java
@@ -7,6 +7,8 @@ import io.swagger.v3.oas.models.security.SecurityRequirement;
import lombok.RequiredArgsConstructor;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.doc.config.properties.SpringDocProperties;
+import org.dromara.common.doc.core.enhancer.SaTokenJavadocResolver;
+import org.dromara.common.doc.core.enhancer.SaTokenMetadataResolver;
import org.dromara.common.doc.handler.OpenApiHandler;
import org.springdoc.core.configuration.SpringDocConfiguration;
import org.springdoc.core.customizers.OpenApiBuilderCustomizer;
@@ -84,8 +86,9 @@ public class SpringDocConfig {
SecurityService securityParser,
SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils,
Optional> openApiBuilderCustomisers,
- Optional> serverBaseUrlCustomisers, Optional javadocProvider) {
- return new OpenApiHandler(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider);
+ Optional> serverBaseUrlCustomisers, Optional javadocProvider,
+ SaTokenMetadataResolver saTokenMetadataResolver) {
+ return new OpenApiHandler(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomisers, serverBaseUrlCustomisers, javadocProvider, saTokenMetadataResolver);
}
/**
@@ -112,6 +115,14 @@ public class SpringDocConfig {
};
}
+ /**
+ * 注册JavaDoc权限解析器
+ */
+ @Bean
+ public SaTokenMetadataResolver saTokenJavadocResolver() {
+ return new SaTokenJavadocResolver();
+ }
+
/**
* 单独使用一个类便于判断 解决springdoc路径拼接重复问题
*
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenJavadocResolver.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenJavadocResolver.java
new file mode 100644
index 000000000..426f26a1e
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenJavadocResolver.java
@@ -0,0 +1,200 @@
+package org.dromara.common.doc.core.enhancer;
+
+import cn.dev33.satoken.annotation.SaCheckLogin;
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaCheckRole;
+import cn.dev33.satoken.annotation.SaIgnore;
+import io.swagger.v3.oas.models.Operation;
+import org.dromara.common.doc.core.model.SaTokenSecurityMetadata;
+import org.springframework.web.method.HandlerMethod;
+
+import java.lang.annotation.Annotation;
+
+/**
+ * 基于JavaDoc的SaToken权限解析器
+ *
+ * @author echo
+ */
+public class SaTokenJavadocResolver implements SaTokenMetadataResolver {
+
+ public static final Class SA_CHECK_ROLE_CLASS = SaCheckRole.class;
+ public static final Class SA_CHECK_PERMISSION_CLASS = SaCheckPermission.class;
+ public static final Class SA_IGNORE_CLASS = SaIgnore.class;
+ public static final Class SA_CHECK_LOGIN = SaCheckLogin.class;
+
+ /**
+ * 核心解析方法
+ */
+ @Override
+ public void resolve(HandlerMethod handlerMethod, Operation operation, SaTokenSecurityMetadata metadata) {
+ // 检查是否忽略校验
+ if (isIgnore(handlerMethod)) {
+ metadata.setIgnore(true);
+ return;
+ }
+
+ // 解析权限校验
+ resolvePermissionCheck(handlerMethod, metadata);
+
+ // 解析角色校验
+ resolveRoleCheck(handlerMethod, metadata);
+ }
+
+ /**
+ * 解析器优先级
+ */
+ @Override
+ public int getOrder() {
+ return 100;
+ }
+
+ /**
+ * 判断是否支持当前HandlerMethod
+ */
+ @Override
+ public boolean supports(HandlerMethod handlerMethod) {
+ return hasAnnotation(handlerMethod
+ .getMethodAnnotation(SA_CHECK_PERMISSION_CLASS)) || hasAnnotation(handlerMethod
+ .getMethodAnnotation(SA_CHECK_ROLE_CLASS)) || hasAnnotation(handlerMethod
+ .getMethodAnnotation(SA_IGNORE_CLASS)) || hasAnnotation(handlerMethod
+ .getBeanType()
+ .getAnnotation(SA_CHECK_PERMISSION_CLASS)) || hasAnnotation(handlerMethod
+ .getBeanType()
+ .getAnnotation(SA_CHECK_ROLE_CLASS)) || hasAnnotation(handlerMethod
+ .getBeanType()
+ .getAnnotation(SA_IGNORE_CLASS));
+ }
+
+ @Override
+ public String getName() {
+ return "SaTokenJavadocResolver";
+ }
+
+ /**
+ * 检查是否忽略校验
+ */
+ private boolean isIgnore(HandlerMethod handlerMethod) {
+ // 检查方法上的注解
+ if (hasAnnotation(handlerMethod.getMethodAnnotation(SA_IGNORE_CLASS))) {
+ return true;
+ }
+ // 检查类上的注解
+ return hasAnnotation(handlerMethod.getBeanType().getAnnotation(SA_IGNORE_CLASS));
+ }
+
+ /**
+ * 解析权限校验
+ */
+ private void resolvePermissionCheck(HandlerMethod handlerMethod, SaTokenSecurityMetadata metadata) {
+ // 获取方法上的注解
+ Annotation methodAnnotation = handlerMethod
+ .getMethodAnnotation(SA_CHECK_PERMISSION_CLASS);
+ // 获取类上的注解
+ Annotation classAnnotation = handlerMethod.getBeanType()
+ .getAnnotation(SA_CHECK_PERMISSION_CLASS);
+
+ // 解析权限信息
+ if (hasAnnotation(methodAnnotation)) {
+ resolvePermissionAnnotation(metadata, methodAnnotation);
+ }
+ if (hasAnnotation(classAnnotation)) {
+ resolvePermissionAnnotation(metadata, classAnnotation);
+ }
+ }
+
+ /**
+ * 解析权限注解
+ */
+ private void resolvePermissionAnnotation(SaTokenSecurityMetadata metadata, Annotation annotation) {
+ try {
+ // 反射获取注解属性
+ Object value = getAnnotationValue(annotation, "value");
+ Object mode = getAnnotationValue(annotation, "mode");
+ Object type = getAnnotationValue(annotation, "type");
+ Object orRole = getAnnotationValue(annotation, "orRole");
+
+ String[] values = convertToStringArray(value);
+ String modeStr = mode != null ? mode.toString() : "AND";
+ String typeStr = type != null ? type.toString() : "";
+ String[] orRoles = convertToStringArray(orRole);
+
+ metadata.addPermission(values, modeStr, typeStr, orRoles);
+ } catch (Exception e) {
+ // 忽略解析错误
+ }
+ }
+
+ /**
+ * 解析角色校验
+ */
+ private void resolveRoleCheck(HandlerMethod handlerMethod, SaTokenSecurityMetadata metadata) {
+ // 获取方法上的注解
+ Annotation methodAnnotation = handlerMethod.getMethodAnnotation(SA_CHECK_ROLE_CLASS);
+ // 获取类上的注解
+ Annotation classAnnotation = handlerMethod.getBeanType()
+ .getAnnotation(SA_CHECK_ROLE_CLASS);
+
+ // 解析角色信息
+ if (hasAnnotation(methodAnnotation)) {
+ resolveRoleAnnotation(metadata, methodAnnotation);
+ }
+ if (hasAnnotation(classAnnotation)) {
+ resolveRoleAnnotation(metadata, classAnnotation);
+ }
+ }
+
+ /**
+ * 解析角色注解
+ */
+ private void resolveRoleAnnotation(SaTokenSecurityMetadata metadata, Annotation annotation) {
+ try {
+ // 反射获取注解属性
+ Object value = getAnnotationValue(annotation, "value");
+ Object mode = getAnnotationValue(annotation, "mode");
+ Object type = getAnnotationValue(annotation, "type");
+
+ String[] values = convertToStringArray(value);
+ String modeStr = mode != null ? mode.toString() : "AND";
+ String typeStr = type != null ? type.toString() : "";
+
+ metadata.addRole(values, modeStr, typeStr);
+ } catch (Exception e) {
+ // 忽略解析错误
+ }
+ }
+
+ /**
+ * 检查注解是否存在
+ */
+ private boolean hasAnnotation(Annotation annotation) {
+ return annotation != null;
+ }
+
+ /**
+ * 获取注解属性值
+ */
+ private Object getAnnotationValue(Annotation annotation, String attributeName) {
+ try {
+ return annotation.annotationType().getMethod(attributeName).invoke(annotation);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /**
+ * 转换为字符串数组
+ */
+ private String[] convertToStringArray(Object value) {
+ if (value == null) {
+ return new String[0];
+ }
+ if (value instanceof String[]) {
+ return (String[])value;
+ }
+ if (value instanceof String) {
+ return new String[] {(String)value};
+ }
+ return new String[0];
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenMetadataResolver.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenMetadataResolver.java
new file mode 100644
index 000000000..0b42b23c7
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/enhancer/SaTokenMetadataResolver.java
@@ -0,0 +1,38 @@
+package org.dromara.common.doc.core.enhancer;
+
+import io.swagger.v3.oas.models.Operation;
+import org.dromara.common.doc.core.model.SaTokenSecurityMetadata;
+import org.springframework.web.method.HandlerMethod;
+
+/**
+ * 权限元数据解析器接口
+ *
+ * @author echo
+ */
+public interface SaTokenMetadataResolver {
+
+ /**
+ * 解析权限元数据
+ */
+ void resolve(HandlerMethod handlerMethod, Operation operation, SaTokenSecurityMetadata metadata);
+
+ /**
+ * 获取解析器优先级
+ */
+ int getOrder();
+
+ /**
+ * 判断是否支持当前HandlerMethod
+ */
+ boolean supports(HandlerMethod handlerMethod);
+
+ /**
+ * 获取解析器的名称
+ *
+ * @return 解析器名称
+ */
+ default String getName() {
+ return this.getClass().getSimpleName();
+ }
+
+}
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/model/SaTokenSecurityMetadata.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/model/SaTokenSecurityMetadata.java
new file mode 100644
index 000000000..e0782a20c
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/core/model/SaTokenSecurityMetadata.java
@@ -0,0 +1,175 @@
+package org.dromara.common.doc.core.model;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.annotation.JsonInclude.Include;
+import lombok.Data;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * 存储权限框架注解解析后的权限和角色信息
+ *
+ * @author AprilWind
+ */
+@Data
+@JsonInclude(Include.NON_EMPTY)
+public class SaTokenSecurityMetadata {
+
+ /**
+ * 权限校验信息列表(对应 @SaCheckPermission 注解)
+ */
+ private List permissions = new ArrayList<>();
+
+ /**
+ * 角色校验信息列表(对应 @SaCheckRole 注解)
+ */
+ private List roles = new ArrayList<>();
+
+ /**
+ * 是否忽略校验(对应 @SaIgnore 注解)
+ */
+ private boolean ignore = false;
+
+ /**
+ * 添加权限信息
+ *
+ * @param values 权限值数组
+ * @param mode 校验模式(AND/OR)
+ * @param type 权限类型
+ * @param orRoles 或角色数组
+ */
+ public void addPermission(String[] values, String mode, String type, String[] orRoles) {
+ if (values != null && values.length > 0) {
+ AuthInfo authInfo = new AuthInfo();
+ authInfo.setValues(values);
+ authInfo.setMode(mode);
+ authInfo.setType(type);
+ if (orRoles != null && orRoles.length > 0) {
+ authInfo.setOrValues(orRoles);
+ authInfo.setOrType("role");
+ }
+ this.permissions.add(authInfo);
+ }
+ }
+
+ /**
+ * 添加角色信息
+ *
+ * @param values 角色值数组
+ * @param mode 校验模式(AND/OR)
+ * @param type 角色类型
+ */
+ public void addRole(String[] values, String mode, String type) {
+ if (values != null && values.length > 0) {
+ AuthInfo authInfo = new AuthInfo();
+ authInfo.setValues(values);
+ authInfo.setMode(mode);
+ authInfo.setType(type);
+ this.roles.add(authInfo);
+ }
+ }
+
+ /**
+ * 生成 Markdown 结构的权限说明
+ *
+ * @return Markdown 文本
+ */
+ public String toMarkdownString() {
+ StringBuilder sb = new StringBuilder();
+ sb.append("
访问权限
");
+
+ if (ignore) {
+ sb.append("> **权限策略**:忽略权限检查
");
+ return sb.toString();
+ }
+
+ if (!ignore && permissions.isEmpty() && roles.isEmpty()){
+ sb.append("> **权限策略**:需要登录
");
+ return sb.toString();
+ }
+
+ if (!permissions.isEmpty()) {
+ sb.append("**权限校验:**
");
+
+ permissions.forEach(p -> {
+ String permTags = Arrays.stream(p.getValues())
+ .map(v -> "`" + v + "`")
+ .collect(Collectors.joining(p.getModeSymbol()));
+
+ sb.append("- ").append(permTags).append("
");
+
+ if (p.getOrValues() != null && p.getOrValues().length > 0) {
+ String orTags = Arrays.stream(p.getOrValues())
+ .map(v -> "`" + v + "`")
+ .collect(Collectors.joining(p.getModeSymbol()));
+ sb.append(" - 或角色:").append(orTags).append("
");
+ }
+ });
+
+ sb.append("
");
+ }
+
+ if (!roles.isEmpty()) {
+ sb.append("**角色校验:**
");
+
+ roles.forEach(r -> {
+
+ String roleTags = Arrays.stream(r.getValues())
+ .map(v -> "`" + v + "`")
+ .collect(Collectors.joining(r.getModeSymbol()));
+
+ sb.append("- ").append(roleTags).append("
");
+ });
+ }
+
+ return sb.toString().trim();
+ }
+
+ /**
+ * 认证信息
+ */
+ @Data
+ @JsonInclude(Include.NON_EMPTY)
+ public static class AuthInfo {
+
+ /**
+ * 权限或角色值数组
+ */
+ private String[] values;
+
+ /**
+ * 校验模式(AND/OR)
+ */
+ private String mode;
+
+ /**
+ * 类型说明
+ */
+ private String type;
+
+ /**
+ * 或权限/角色值数组(用于权限校验时的或角色校验)
+ */
+ private String[] orValues;
+
+ /**
+ * 或值的类型(role/permission)
+ */
+ private String orType;
+
+ /**
+ * 重写mode的获取方法,返回符号而非文字
+ * @return AND→&,OR→|,默认→&
+ */
+ public String getModeSymbol() {
+ if (mode == null) {
+ return " & "; // 默认AND,返回&
+ }
+ return "AND".equalsIgnoreCase(mode) ? " & " : " | ";
+ }
+
+ }
+}
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java
index 56b73694d..2add6d290 100644
--- a/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/dromara/common/doc/handler/OpenApiHandler.java
@@ -11,7 +11,10 @@ import io.swagger.v3.oas.models.Paths;
import io.swagger.v3.oas.models.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
+import org.dromara.common.core.utils.ObjectUtils;
import org.dromara.common.core.utils.StreamUtils;
+import org.dromara.common.doc.core.enhancer.SaTokenMetadataResolver;
+import org.dromara.common.doc.core.model.SaTokenSecurityMetadata;
import org.springdoc.core.customizers.OpenApiBuilderCustomizer;
import org.springdoc.core.customizers.ServerBaseUrlCustomizer;
import org.springdoc.core.properties.SpringDocConfigProperties;
@@ -83,6 +86,11 @@ public class OpenApiHandler extends OpenAPIService {
*/
private final PropertyResolverUtils propertyResolverUtils;
+ /**
+ * 权限元数据解析器接口
+ */
+ private final SaTokenMetadataResolver saTokenJavadocResolver;
+
/**
* The javadoc provider.
*/
@@ -123,7 +131,8 @@ public class OpenApiHandler extends OpenAPIService {
SpringDocConfigProperties springDocConfigProperties, PropertyResolverUtils propertyResolverUtils,
Optional> openApiBuilderCustomizers,
Optional> serverBaseUrlCustomizers,
- Optional javadocProvider) {
+ Optional javadocProvider,
+ SaTokenMetadataResolver saTokenJavadocResolver) {
super(openAPI, securityParser, springDocConfigProperties, propertyResolverUtils, openApiBuilderCustomizers, serverBaseUrlCustomizers, javadocProvider);
if (openAPI.isPresent()) {
this.openAPI = openAPI.get();
@@ -140,6 +149,7 @@ public class OpenApiHandler extends OpenAPIService {
this.openApiBuilderCustomisers = openApiBuilderCustomizers;
this.serverBaseUrlCustomizers = serverBaseUrlCustomizers;
this.javadocProvider = javadocProvider;
+ this.saTokenJavadocResolver = saTokenJavadocResolver;
if (springDocConfigProperties.isUseFqn())
TypeNameResolver.std.setUseFqn(true);
}
@@ -220,6 +230,13 @@ public class OpenApiHandler extends OpenAPIService {
securityParser.buildSecurityRequirement(securityRequirements, operation);
}
+ // 调用SaToken解析器提取JavaDoc中的权限信息
+ if (saTokenJavadocResolver.supports(handlerMethod)) {
+ SaTokenSecurityMetadata metadata = new SaTokenSecurityMetadata();
+ saTokenJavadocResolver.resolve(handlerMethod, operation, metadata);
+ operation.setDescription(metadata.toMarkdownString());
+ }
+
return operation;
}
diff --git a/ruoyi-common/ruoyi-common-doc/src/main/java/org/springdoc/api/AbstractOpenApiResource.java b/ruoyi-common/ruoyi-common-doc/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
new file mode 100644
index 000000000..781824efa
--- /dev/null
+++ b/ruoyi-common/ruoyi-common-doc/src/main/java/org/springdoc/api/AbstractOpenApiResource.java
@@ -0,0 +1,1552 @@
+/*
+ *
+ * *
+ * * *
+ * * * *
+ * * * * *
+ * * * * * * Copyright 2019-2025 the original author or authors.
+ * * * * * *
+ * * * * * * Licensed under the Apache License, Version 2.0 (the "License");
+ * * * * * * you may not use this file except in compliance with the License.
+ * * * * * * You may obtain a copy of the License at
+ * * * * * *
+ * * * * * * https://www.apache.org/licenses/LICENSE-2.0
+ * * * * * *
+ * * * * * * Unless required by applicable law or agreed to in writing, software
+ * * * * * * distributed under the License is distributed on an "AS IS" BASIS,
+ * * * * * * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * * * * * * See the License for the specific language governing permissions and
+ * * * * * * limitations under the License.
+ * * * * *
+ * * * *
+ * * *
+ * *
+ *
+ */
+
+package org.springdoc.api;
+
+import java.io.UnsupportedEncodingException;
+import java.lang.reflect.Method;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.concurrent.Executors;
+import java.util.concurrent.locks.Lock;
+import java.util.concurrent.locks.ReentrantLock;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import com.fasterxml.jackson.annotation.JsonView;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
+import com.fasterxml.jackson.dataformat.yaml.YAMLGenerator.Feature;
+import io.swagger.v3.core.filter.SpecFilter;
+import io.swagger.v3.core.util.ReflectionUtils;
+import io.swagger.v3.oas.annotations.Hidden;
+import io.swagger.v3.oas.annotations.Webhook;
+import io.swagger.v3.oas.annotations.Webhooks;
+import io.swagger.v3.oas.annotations.callbacks.Callback;
+import io.swagger.v3.oas.annotations.enums.ParameterIn;
+import io.swagger.v3.oas.models.Components;
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.Operation;
+import io.swagger.v3.oas.models.PathItem;
+import io.swagger.v3.oas.models.PathItem.HttpMethod;
+import io.swagger.v3.oas.models.Paths;
+import io.swagger.v3.oas.models.SpecVersion;
+import io.swagger.v3.oas.models.media.Schema;
+import io.swagger.v3.oas.models.media.StringSchema;
+import io.swagger.v3.oas.models.parameters.Parameter;
+import io.swagger.v3.oas.models.responses.ApiResponses;
+import io.swagger.v3.oas.models.servers.Server;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springdoc.core.annotations.RouterOperations;
+import org.springdoc.core.customizers.DataRestRouterOperationCustomizer;
+import org.springdoc.core.customizers.GlobalOperationComponentsCustomizer;
+import org.springdoc.core.customizers.OpenApiLocaleCustomizer;
+import org.springdoc.core.customizers.OperationCustomizer;
+import org.springdoc.core.customizers.RouterOperationCustomizer;
+import org.springdoc.core.customizers.SpringDocCustomizers;
+import org.springdoc.core.fn.AbstractRouterFunctionVisitor;
+import org.springdoc.core.fn.RouterFunctionData;
+import org.springdoc.core.fn.RouterOperation;
+import org.springdoc.core.models.MethodAttributes;
+import org.springdoc.core.properties.SpringDocConfigProperties;
+import org.springdoc.core.properties.SpringDocConfigProperties.ApiDocs.OpenApiVersion;
+import org.springdoc.core.properties.SpringDocConfigProperties.GroupConfig;
+import org.springdoc.core.providers.ActuatorProvider;
+import org.springdoc.core.providers.CloudFunctionProvider;
+import org.springdoc.core.providers.JavadocProvider;
+import org.springdoc.core.providers.ObjectMapperProvider;
+import org.springdoc.core.providers.SpringDocProviders;
+import org.springdoc.core.service.AbstractRequestService;
+import org.springdoc.core.service.GenericParameterService;
+import org.springdoc.core.service.GenericResponseService;
+import org.springdoc.core.service.OpenAPIService;
+import org.springdoc.core.service.OperationService;
+import org.springdoc.core.utils.PropertyResolverUtils;
+import org.springdoc.core.utils.SpringDocAnnotationsUtils;
+import org.springdoc.core.utils.SpringDocUtils;
+
+import org.springframework.aop.support.AopUtils;
+import org.springframework.beans.factory.ObjectFactory;
+import org.springframework.context.ApplicationContext;
+import org.springframework.core.annotation.AnnotatedElementUtils;
+import org.springframework.core.annotation.AnnotationUtils;
+import org.springframework.core.env.Environment;
+import org.springframework.util.AntPathMatcher;
+import org.springframework.util.ClassUtils;
+import org.springframework.util.CollectionUtils;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.method.HandlerMethod;
+
+import static org.springdoc.core.converters.SchemaPropertyDeprecatingConverter.isDeprecated;
+import static org.springdoc.core.utils.Constants.ACTUATOR_DEFAULT_GROUP;
+import static org.springdoc.core.utils.Constants.DOT;
+import static org.springdoc.core.utils.Constants.OPERATION_ATTRIBUTE;
+import static org.springdoc.core.utils.Constants.SPRING_MVC_SERVLET_PATH;
+import static org.springdoc.core.utils.SpringDocUtils.cloneViaJson;
+import static org.springframework.util.AntPathMatcher.DEFAULT_PATH_SEPARATOR;
+
+/**
+ * The type Abstract open api resource.
+ *
+ * @author bnasslahsen
+ * @author kevinraddatz
+ * @author hyeonisism
+ * @author doljae
+ * @author zdary
+ * @author Haotian Zhang
+ */
+public abstract class AbstractOpenApiResource extends SpecFilter {
+
+ /**
+ * The constant LOGGER.
+ */
+ private static final Logger LOGGER = LoggerFactory.getLogger(AbstractOpenApiResource.class);
+
+ /**
+ * The constant ADDITIONAL_REST_CONTROLLERS.
+ */
+ private static final List> ADDITIONAL_REST_CONTROLLERS = Collections.synchronizedList(new ArrayList<>());
+
+ /**
+ * The constant HIDDEN_REST_CONTROLLERS.
+ */
+ private static final List> HIDDEN_REST_CONTROLLERS = Collections.synchronizedList(new ArrayList<>());
+
+ /**
+ * The constant MODEL_AND_VIEW_CLASS.
+ */
+ private static Class> modelAndViewClass;
+
+ /**
+ * The Spring doc config properties.
+ */
+ protected final SpringDocConfigProperties springDocConfigProperties;
+
+ /**
+ * The Group name.
+ */
+ protected final String groupName;
+
+ /**
+ * The Spring doc providers.
+ */
+ protected final SpringDocProviders springDocProviders;
+
+ /**
+ * The Spring doc customizers.
+ */
+ protected final SpringDocCustomizers springDocCustomizers;
+
+ /**
+ * The open api builder object factory.
+ */
+ private final ObjectFactory openAPIBuilderObjectFactory;
+
+ /**
+ * The Request builder.
+ */
+ private final AbstractRequestService requestBuilder;
+
+ /**
+ * The Response builder.
+ */
+ private final GenericResponseService responseBuilder;
+
+ /**
+ * The Operation parser.
+ */
+ private final OperationService operationParser;
+
+ /**
+ * The Ant path matcher.
+ */
+ private final AntPathMatcher antPathMatcher = new AntPathMatcher();
+
+ /**
+ * The Reentrant lock.
+ */
+ private final Lock reentrantLock = new ReentrantLock();
+
+ /**
+ * The Path pattern.
+ */
+ private final Pattern pathPattern = Pattern.compile("\\{(.*?)}");
+
+ /**
+ * The Open api builder.
+ */
+ protected OpenAPIService openAPIService;
+
+
+ /**
+ * Instantiates a new Abstract open api resource.
+ *
+ * @param groupName the group name
+ * @param openAPIBuilderObjectFactory the open api builder object factory
+ * @param requestBuilder the request builder
+ * @param responseBuilder the response builder
+ * @param operationParser the operation parser
+ * @param springDocConfigProperties the spring doc config properties
+ * @param springDocProviders the spring doc providers
+ * @param springDocCustomizers the spring doc customizers
+ */
+ protected AbstractOpenApiResource(String groupName, ObjectFactory openAPIBuilderObjectFactory,
+ AbstractRequestService requestBuilder,
+ GenericResponseService responseBuilder, OperationService operationParser,
+ SpringDocConfigProperties springDocConfigProperties, SpringDocProviders springDocProviders, SpringDocCustomizers springDocCustomizers) {
+ super();
+ this.groupName = Objects.requireNonNull(groupName, "groupName");
+ this.openAPIBuilderObjectFactory = openAPIBuilderObjectFactory;
+ this.openAPIService = openAPIBuilderObjectFactory.getObject();
+ this.requestBuilder = requestBuilder;
+ this.responseBuilder = responseBuilder;
+ this.operationParser = operationParser;
+ this.springDocProviders = springDocProviders;
+ this.springDocCustomizers = springDocCustomizers;
+ this.springDocConfigProperties = springDocConfigProperties;
+ if (springDocConfigProperties.isPreLoadingEnabled()) {
+ if (CollectionUtils.isEmpty(springDocConfigProperties.getPreLoadingLocales())) {
+ Executors.newSingleThreadExecutor().execute(this::getOpenApi);
+ }
+ else {
+ for (String locale : springDocConfigProperties.getPreLoadingLocales()) {
+ Executors.newSingleThreadExecutor().execute(() -> this.getOpenApi(null, Locale.forLanguageTag(locale)));
+ }
+ }
+ }
+ }
+
+ /**
+ * Add rest controllers.
+ *
+ * @param classes the classes
+ */
+ public static void addRestControllers(Class>... classes) {
+ ADDITIONAL_REST_CONTROLLERS.addAll(Arrays.asList(classes));
+ }
+
+ /**
+ * Add hidden rest controllers.
+ *
+ * @param classes the classes
+ */
+ public static void addHiddenRestControllers(Class>... classes) {
+ HIDDEN_REST_CONTROLLERS.addAll(Arrays.asList(classes));
+ }
+
+ /**
+ * Add hidden rest controllers.
+ *
+ * @param classes the classes
+ */
+ public static void addHiddenRestControllers(String... classes) {
+ Set> hiddenClasses = new HashSet<>();
+ for (String aClass : classes) {
+ try {
+ hiddenClasses.add(Class.forName(aClass));
+ }
+ catch (ClassNotFoundException e) {
+ LOGGER.warn("The following class doesn't exist and cannot be hidden: {}", aClass);
+ }
+ }
+ HIDDEN_REST_CONTROLLERS.addAll(hiddenClasses);
+ }
+
+ /**
+ * Contains response body boolean.
+ *
+ * @param handlerMethod the handler method
+ * @return the boolean
+ */
+ public static boolean containsResponseBody(HandlerMethod handlerMethod) {
+ ResponseBody responseBodyAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getBeanType(), ResponseBody.class);
+ if (responseBodyAnnotation == null)
+ responseBodyAnnotation = AnnotationUtils.findAnnotation(handlerMethod.getMethod(), ResponseBody.class);
+ return responseBodyAnnotation != null;
+ }
+
+ /**
+ * Is hidden rest controllers boolean.
+ *
+ * @param rawClass the raw class
+ * @return the boolean
+ */
+ public static boolean isHiddenRestControllers(Class> rawClass) {
+ return HIDDEN_REST_CONTROLLERS.stream().anyMatch(clazz -> ClassUtils.getUserClass(clazz).isAssignableFrom(rawClass));
+ }
+
+ /**
+ * Sets model and view class.
+ *
+ * @param modelAndViewClass the model and view class
+ */
+ public static void setModelAndViewClass(Class> modelAndViewClass) {
+ AbstractOpenApiResource.modelAndViewClass = modelAndViewClass;
+ }
+
+ /**
+ * Gets open api.
+ */
+ private void getOpenApi() {
+ this.getOpenApi(null, Locale.getDefault());
+ }
+
+ /**
+ * Gets open api.
+ *
+ * @param locale the locale
+ * @return the open api
+ */
+ protected OpenAPI getOpenApi(Locale locale) {
+ return this.getOpenApi(null, locale);
+ }
+
+ /**
+ * Gets open api.
+ *
+ * @param serverBaseUrl the server base url
+ * @param locale the locale
+ * @return the open api
+ */
+ protected OpenAPI getOpenApi(String serverBaseUrl, Locale locale) {
+ this.reentrantLock.lock();
+ try {
+ final OpenAPI openAPI;
+ final Locale finalLocale = selectLocale(locale);
+ if (openAPIService.getCachedOpenAPI(finalLocale) == null || springDocConfigProperties.isCacheDisabled()) {
+ Instant start = Instant.now();
+ openAPI = openAPIService.build(finalLocale);
+ Map mappingsMap = openAPIService.getMappingsMap().entrySet().stream()
+ .filter(controller -> (AnnotationUtils.findAnnotation(controller.getValue().getClass(),
+ Hidden.class) == null))
+ .filter(controller -> !isHiddenRestControllers(controller.getValue().getClass()))
+ .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a1, a2) -> a1));
+
+ Map findControllerAdvice = openAPIService.getControllerAdviceMap();
+ if (OpenApiVersion.OPENAPI_3_1 == springDocConfigProperties.getApiDocs().getVersion()) {
+ openAPI.openapi(OpenApiVersion.OPENAPI_3_1.getVersion());
+ openAPI.specVersion(SpecVersion.V31);
+ calculateWebhooks(openAPI, locale);
+ }
+ if (springDocConfigProperties.isDefaultOverrideWithGenericResponse()) {
+ if (!CollectionUtils.isEmpty(mappingsMap))
+ findControllerAdvice.putAll(mappingsMap);
+ responseBuilder.buildGenericResponse(openAPI.getComponents(), findControllerAdvice, finalLocale);
+ }
+ getPaths(mappingsMap, finalLocale, openAPI);
+
+ if (springDocConfigProperties.isTrimKotlinIndent())
+ this.trimIndent(openAPI);
+
+ Optional cloudFunctionProviderOptional = springDocProviders.getSpringCloudFunctionProvider();
+ cloudFunctionProviderOptional.ifPresent(cloudFunctionProvider -> {
+ List routerOperationList = cloudFunctionProvider.getRouterOperations(openAPI);
+ if (!CollectionUtils.isEmpty(routerOperationList))
+ this.calculatePath(routerOperationList, locale, openAPI);
+ }
+ );
+ if (!CollectionUtils.isEmpty(openAPI.getServers()))
+ openAPIService.setServersPresent(true);
+ else
+ openAPIService.setServersPresent(false);
+ openAPIService.updateServers(serverBaseUrl, openAPI);
+
+ if (springDocConfigProperties.isRemoveBrokenReferenceDefinitions())
+ this.removeBrokenReferenceDefinitions(openAPI);
+
+ // run the optional customizers
+ List servers = openAPI.getServers();
+ List serversCopy = cloneViaJson(servers, new TypeReference>() {}, springDocProviders.jsonMapper());
+
+ openAPIService.getContext().getBeansOfType(OpenApiLocaleCustomizer.class).values().forEach(openApiLocaleCustomizer -> openApiLocaleCustomizer.customise(openAPI, finalLocale));
+ springDocCustomizers.getOpenApiCustomizers().ifPresent(apiCustomizers -> apiCustomizers.forEach(openApiCustomizer -> openApiCustomizer.customise(openAPI)));
+ if (!CollectionUtils.isEmpty(openAPI.getServers()) && !openAPI.getServers().equals(serversCopy))
+ openAPIService.setServersPresent(true);
+
+ openAPIService.setCachedOpenAPI(openAPI, finalLocale);
+
+ LOGGER.info("Init duration for springdoc-openapi is: {} ms",
+ Duration.between(start, Instant.now()).toMillis());
+ }
+ else {
+ LOGGER.debug("Fetching openApi document from cache");
+ openAPI = openAPIService.getCachedOpenAPI(finalLocale);
+ openAPIService.updateServers(serverBaseUrl, openAPI);
+ }
+ return openAPI;
+ }
+ finally {
+ SpringDocAnnotationsUtils.clearCache(operationParser.getJavadocProvider());
+ this.reentrantLock.unlock();
+ }
+ }
+
+ private Locale selectLocale(Locale inputLocale) {
+ List allowedLocales = springDocConfigProperties.getAllowedLocales();
+ if (!CollectionUtils.isEmpty(allowedLocales)) {
+ Locale bestMatchingAllowedLocale = Locale.lookup(
+ Locale.LanguageRange.parse(inputLocale.toLanguageTag()),
+ allowedLocales.stream().map(Locale::forLanguageTag).toList()
+ );
+
+ return bestMatchingAllowedLocale == null ? Locale.forLanguageTag(allowedLocales.get(0)) : bestMatchingAllowedLocale;
+ }
+
+ return inputLocale == null ? Locale.getDefault() : inputLocale;
+ }
+
+ /**
+ * Indents are removed for properties that are mainly used as “explanations” using Open API.
+ *
+ * @param openAPI the open api
+ */
+ private void trimIndent(OpenAPI openAPI) {
+ trimComponents(openAPI);
+ trimPaths(openAPI);
+ }
+
+ /**
+ * Trim the indent for descriptions in the 'components' of open api.
+ *
+ * @param openAPI the open api
+ */
+ private void trimComponents(OpenAPI openAPI) {
+ final PropertyResolverUtils propertyResolverUtils = operationParser.getPropertyResolverUtils();
+ if (openAPI.getComponents() == null || openAPI.getComponents().getSchemas() == null) {
+ return;
+ }
+ for (Schema> schema : openAPI.getComponents().getSchemas().values()) {
+ schema.description(propertyResolverUtils.trimIndent(schema.getDescription()));
+ if (schema.getProperties() == null) {
+ continue;
+ }
+ for (Object prop : schema.getProperties().values()) {
+ if (prop instanceof Schema> schemaProp) {
+ schemaProp.setDescription(propertyResolverUtils.trimIndent(schemaProp.getDescription()));
+ }
+ }
+ }
+ }
+
+ /**
+ * Trim the indent for descriptions in the 'paths' of open api.
+ *
+ * @param openAPI the open api
+ */
+ private void trimPaths(OpenAPI openAPI) {
+ final PropertyResolverUtils propertyResolverUtils = operationParser.getPropertyResolverUtils();
+ if (openAPI.getPaths() == null) {
+ return;
+ }
+ for (PathItem value : openAPI.getPaths().values()) {
+ value.setDescription(propertyResolverUtils.trimIndent(value.getDescription()));
+ trimIndentOperation(value.getGet());
+ trimIndentOperation(value.getPut());
+ trimIndentOperation(value.getPost());
+ trimIndentOperation(value.getDelete());
+ trimIndentOperation(value.getOptions());
+ trimIndentOperation(value.getHead());
+ trimIndentOperation(value.getPatch());
+ trimIndentOperation(value.getTrace());
+ }
+ }
+
+ /**
+ * Trim the indent for 'operation'
+ *
+ * @param operation the operation
+ */
+ private void trimIndentOperation(Operation operation) {
+ final PropertyResolverUtils propertyResolverUtils = operationParser.getPropertyResolverUtils();
+ if (operation == null) {
+ return;
+ }
+ operation.setSummary(propertyResolverUtils.trimIndent(operation.getSummary()));
+ operation.setDescription(propertyResolverUtils.trimIndent(operation.getDescription()));
+ }
+
+ /**
+ * Gets paths.
+ *
+ * @param findRestControllers the find rest controllers
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected abstract void getPaths(Map findRestControllers, Locale locale, OpenAPI openAPI);
+
+
+ /**
+ * Calculate webhooks.
+ *
+ * @param calculatedOpenAPI the calculated open api
+ * @param locale the locale
+ */
+ protected void calculateWebhooks(OpenAPI calculatedOpenAPI, Locale locale) {
+ Class>[] classes = openAPIService.getWebhooksClasses();
+ Class>[] refinedClasses = Arrays.stream(classes)
+ .filter(clazz -> isPackageToScan(clazz.getPackage()))
+ .toArray(Class>[]::new);
+ Webhooks[] webhooksAttr = openAPIService.getWebhooks(refinedClasses);
+ if (ArrayUtils.isEmpty(webhooksAttr))
+ return;
+ var webhooks = Arrays.stream(webhooksAttr).map(Webhooks::value).flatMap(Arrays::stream).toArray(Webhook[]::new);
+ Arrays.stream(webhooks).forEach(webhook -> {
+ io.swagger.v3.oas.annotations.Operation apiOperation = webhook.operation();
+ Operation operation = new Operation();
+ MethodAttributes methodAttributes = new MethodAttributes(springDocConfigProperties.getDefaultConsumesMediaType(),
+ springDocConfigProperties.getDefaultProducesMediaType(), locale);
+ operationParser.parse(apiOperation, operation, calculatedOpenAPI, methodAttributes);
+ PathItem pathItem = new PathItem().post(operation);
+ calculatedOpenAPI.addWebhooks(webhook.name(), pathItem);
+ });
+ }
+
+ /**
+ * Calculate path.
+ *
+ * @param handlerMethod the handler method
+ * @param routerOperation the router operation
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected void calculatePath(HandlerMethod handlerMethod, RouterOperation routerOperation, Locale locale, OpenAPI openAPI) {
+ routerOperation = customizeRouterOperation(routerOperation, handlerMethod);
+
+ String operationPath = routerOperation.getPath();
+ Set requestMethods = new TreeSet<>(Arrays.asList(routerOperation.getMethods()));
+ io.swagger.v3.oas.annotations.Operation apiOperation = routerOperation.getOperation();
+ String[] methodConsumes = routerOperation.getConsumes();
+ String[] methodProduces = routerOperation.getProduces();
+ String[] headers = routerOperation.getHeaders();
+ Map queryParams = routerOperation.getQueryParams();
+
+ Components components = openAPI.getComponents();
+ Paths paths = openAPI.getPaths();
+
+ Map operationMap = null;
+ if (paths.containsKey(operationPath)) {
+ PathItem pathItem = paths.get(operationPath);
+ operationMap = pathItem.readOperationsMap();
+ }
+
+ JavadocProvider javadocProvider = operationParser.getJavadocProvider();
+
+ for (RequestMethod requestMethod : requestMethods) {
+ Operation existingOperation = getExistingOperation(operationMap, requestMethod);
+ Method method = handlerMethod.getMethod();
+ // skip hidden operations
+ if (operationParser.isHidden(method))
+ continue;
+
+ RequestMapping reqMappingClass = AnnotatedElementUtils.findMergedAnnotation(handlerMethod.getBeanType(),
+ RequestMapping.class);
+
+ MethodAttributes methodAttributes = new MethodAttributes(springDocConfigProperties.getDefaultConsumesMediaType(), springDocConfigProperties.getDefaultProducesMediaType(), methodConsumes, methodProduces, headers, locale);
+ methodAttributes.setMethodOverloaded(existingOperation != null);
+ //Use the javadoc return if present
+ if (javadocProvider != null) {
+ methodAttributes.setJavadocReturn(javadocProvider.getMethodJavadocReturn(handlerMethod.getMethod()));
+ }
+
+ if (reqMappingClass != null) {
+ methodAttributes.setClassConsumes(reqMappingClass.consumes());
+ methodAttributes.setClassProduces(reqMappingClass.produces());
+ }
+
+ methodAttributes.calculateHeadersForClass(method.getDeclaringClass());
+ methodAttributes.calculateConsumesProduces(method);
+
+ Operation operation = (existingOperation != null) ? existingOperation : new Operation();
+
+ if (isDeprecated(method))
+ operation.setDeprecated(true);
+
+ // Add documentation from operation annotation
+ if (apiOperation == null || StringUtils.isBlank(apiOperation.operationId()))
+ apiOperation = AnnotatedElementUtils.findMergedAnnotation(method,
+ io.swagger.v3.oas.annotations.Operation.class);
+
+ calculateJsonView(apiOperation, methodAttributes, method);
+ if (apiOperation != null)
+ openAPI = operationParser.parse(apiOperation, operation, openAPI, methodAttributes);
+ fillParametersList(operation, queryParams, methodAttributes);
+
+ // compute tags
+ operation = openAPIService.buildTags(handlerMethod, operation, openAPI, locale);
+
+ io.swagger.v3.oas.annotations.parameters.RequestBody requestBodyDoc = AnnotatedElementUtils.findMergedAnnotation(method,
+ io.swagger.v3.oas.annotations.parameters.RequestBody.class);
+
+ // RequestBody in Operation
+ requestBuilder.getRequestBodyBuilder()
+ .buildRequestBodyFromDoc(requestBodyDoc, methodAttributes, components,
+ methodAttributes.getJsonViewAnnotationForRequestBody(), locale)
+ .ifPresent(operation::setRequestBody);
+ // requests
+ operation = requestBuilder.build(handlerMethod, requestMethod, operation, methodAttributes, openAPI);
+
+ // responses
+ ApiResponses apiResponses = responseBuilder.build(components, handlerMethod, operation, methodAttributes);
+ operation.setResponses(apiResponses);
+
+ // get javadoc method description
+ if (javadocProvider != null) {
+ String description = javadocProvider.getMethodJavadocDescription(handlerMethod.getMethod());
+ String summary = javadocProvider.getFirstSentence(description);
+// boolean emptyOverrideDescription = StringUtils.isEmpty(operation.getDescription());
+ boolean emptyOverrideSummary = StringUtils.isEmpty(operation.getSummary());
+ if (!StringUtils.isEmpty(description)) {
+ operation.setDescription(description + operation.getDescription());
+ }
+ // if there is a previously set description
+ // but no summary then it is intentional
+ // we keep it as is
+ if (!StringUtils.isEmpty(summary) && emptyOverrideSummary) {
+ operation.setSummary(javadocProvider.getFirstSentence(description));
+ }
+ }
+
+ Set apiCallbacks = AnnotatedElementUtils.findMergedRepeatableAnnotations(method, io.swagger.v3.oas.annotations.callbacks.Callback.class);
+
+ // callbacks
+ buildCallbacks(openAPI, methodAttributes, operation, apiCallbacks);
+
+ // allow for customisation
+ operation = customizeOperation(operation, components, handlerMethod);
+
+ if (StringUtils.contains(operationPath, "*")) {
+ Matcher matcher = pathPattern.matcher(operationPath);
+ while (matcher.find()) {
+ String pathParam = matcher.group(1);
+ String newPathParam = pathParam.replace("*", "");
+ operationPath = operationPath.replace("{" + pathParam + "}", "{" + newPathParam + "}");
+ }
+ }
+
+ PathItem pathItemObject = buildPathItem(requestMethod, operation, operationPath, paths);
+ paths.addPathItem(operationPath, pathItemObject);
+ }
+ }
+
+ /**
+ * Build callbacks.
+ *
+ * @param openAPI the open api
+ * @param methodAttributes the method attributes
+ * @param operation the operation
+ * @param apiCallbacks the api callbacks
+ */
+ private void buildCallbacks(OpenAPI openAPI, MethodAttributes methodAttributes, Operation operation, Set apiCallbacks) {
+ if (!CollectionUtils.isEmpty(apiCallbacks))
+ operationParser.buildCallbacks(apiCallbacks, openAPI, methodAttributes)
+ .ifPresent(operation::setCallbacks);
+ }
+
+ /**
+ * Calculate path.
+ *
+ * @param routerOperationList the router operation list
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected void calculatePath(List routerOperationList, Locale locale, OpenAPI openAPI) {
+ ApplicationContext applicationContext = openAPIService.getContext();
+ if (!CollectionUtils.isEmpty(routerOperationList)) {
+ Collections.sort(routerOperationList);
+ for (RouterOperation routerOperation : routerOperationList) {
+ if (routerOperation.getBeanClass() != null && !Void.class.equals(routerOperation.getBeanClass())) {
+ Object handlerBean = applicationContext.getBean(routerOperation.getBeanClass());
+ HandlerMethod handlerMethod = null;
+
+ if (StringUtils.isNotBlank(routerOperation.getBeanMethod())) {
+ try {
+ if (ArrayUtils.isEmpty(routerOperation.getParameterTypes())) {
+ Method[] declaredMethods = org.springframework.util.ReflectionUtils.getAllDeclaredMethods(AopUtils.getTargetClass(handlerBean));
+ Optional methodOptional = Arrays.stream(declaredMethods)
+ .filter(method -> routerOperation.getBeanMethod().equals(method.getName()) && method.getParameters().length == 0)
+ .findAny();
+ if (!methodOptional.isPresent())
+ methodOptional = Arrays.stream(declaredMethods)
+ .filter(method1 -> routerOperation.getBeanMethod().equals(method1.getName()))
+ .findAny();
+ if (methodOptional.isPresent())
+ handlerMethod = new HandlerMethod(handlerBean, methodOptional.get());
+ }
+ else
+ handlerMethod = new HandlerMethod(handlerBean, routerOperation.getBeanMethod(), routerOperation.getParameterTypes());
+ }
+ catch (NoSuchMethodException e) {
+ LOGGER.error(e.getMessage());
+ }
+ if (handlerMethod != null && isFilterCondition(handlerMethod, routerOperation.getPath(), routerOperation.getProduces(), routerOperation.getConsumes(), routerOperation.getHeaders()))
+ calculatePath(handlerMethod, routerOperation, locale, openAPI);
+ }
+ }
+ else if (routerOperation.getOperation() != null && StringUtils.isNotBlank(routerOperation.getOperation().operationId()) && isFilterCondition(routerOperation.getPath(), routerOperation.getProduces(), routerOperation.getConsumes(), routerOperation.getHeaders())) {
+ calculatePath(routerOperation, locale, openAPI);
+ }
+ else if (routerOperation.getOperationModel() != null && StringUtils.isNotBlank(routerOperation.getOperationModel().getOperationId()) && isFilterCondition(routerOperation.getPath(), routerOperation.getProduces(), routerOperation.getConsumes(), routerOperation.getHeaders())) {
+ calculatePath(routerOperation, locale, openAPI);
+ }
+ }
+ }
+ }
+
+ /**
+ * Calculate path.
+ *
+ * @param routerOperation the router operation
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected void calculatePath(RouterOperation routerOperation, Locale locale, OpenAPI openAPI) {
+ routerOperation = customizeDataRestRouterOperation(routerOperation);
+
+ String operationPath = routerOperation.getPath();
+ io.swagger.v3.oas.annotations.Operation apiOperation = routerOperation.getOperation();
+ String[] methodConsumes = routerOperation.getConsumes();
+ String[] methodProduces = routerOperation.getProduces();
+ String[] headers = routerOperation.getHeaders();
+ Map queryParams = routerOperation.getQueryParams();
+
+ Paths paths = openAPI.getPaths();
+ Map operationMap = null;
+ if (paths.containsKey(operationPath)) {
+ PathItem pathItem = paths.get(operationPath);
+ operationMap = pathItem.readOperationsMap();
+ }
+ for (RequestMethod requestMethod : routerOperation.getMethods()) {
+ Operation existingOperation = getExistingOperation(operationMap, requestMethod);
+ MethodAttributes methodAttributes = new MethodAttributes(springDocConfigProperties.getDefaultConsumesMediaType(), springDocConfigProperties.getDefaultProducesMediaType(), methodConsumes, methodProduces, headers, locale);
+ methodAttributes.setMethodOverloaded(existingOperation != null);
+ Operation operation = getOperation(routerOperation, existingOperation);
+ if (apiOperation != null)
+ openAPI = operationParser.parse(apiOperation, operation, openAPI, methodAttributes);
+
+ String operationId = operation.getOperationId();
+ operation.setOperationId(operationId);
+
+ fillParametersList(operation, queryParams, methodAttributes);
+ if (!CollectionUtils.isEmpty(operation.getParameters()))
+ operation.getParameters().stream()
+ .filter(parameter -> StringUtils.isEmpty(parameter.get$ref()))
+ .forEach(parameter -> {
+ if (parameter.getSchema() == null)
+ parameter.setSchema(new StringSchema());
+ if (parameter.getIn() == null)
+ parameter.setIn(ParameterIn.QUERY.toString());
+ }
+ );
+
+ PathItem pathItemObject = buildPathItem(requestMethod, operation, operationPath, paths);
+ paths.addPathItem(operationPath, pathItemObject);
+ }
+ }
+
+ /**
+ * Customize data rest router operation.
+ *
+ * @param routerOperation the router operation
+ * @return the router operation
+ */
+ private RouterOperation customizeDataRestRouterOperation(RouterOperation routerOperation) {
+ Optional> optionalDataRestRouterOperationCustomizers = springDocCustomizers.getDataRestRouterOperationCustomizers();
+ if (optionalDataRestRouterOperationCustomizers.isPresent()) {
+ Set dataRestRouterOperationCustomizerList = optionalDataRestRouterOperationCustomizers.get();
+ for (DataRestRouterOperationCustomizer dataRestRouterOperationCustomizer : dataRestRouterOperationCustomizerList) {
+ routerOperation = dataRestRouterOperationCustomizer.customize(routerOperation);
+ }
+ }
+ return routerOperation;
+ }
+
+ /**
+ * Calculate path.
+ *
+ * @param handlerMethod the handler method
+ * @param operationPath the operation path
+ * @param requestMethods the request methods
+ * @param consumes the consumes
+ * @param produces the produces
+ * @param headers the headers
+ * @param params the params
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected void calculatePath(HandlerMethod handlerMethod, String operationPath,
+ Set requestMethods, String[] consumes, String[] produces, String[] headers, String[] params, Locale locale, OpenAPI openAPI) {
+ this.calculatePath(handlerMethod, new RouterOperation(operationPath, requestMethods.toArray(new RequestMethod[requestMethods.size()]), consumes, produces, headers, params), locale, openAPI);
+ }
+
+ /**
+ * Gets router function paths.
+ *
+ * @param beanName the bean name
+ * @param routerFunctionVisitor the router function visitor
+ * @param locale the locale
+ * @param openAPI the open api
+ */
+ protected void getRouterFunctionPaths(String beanName, AbstractRouterFunctionVisitor routerFunctionVisitor,
+ Locale locale, OpenAPI openAPI) {
+ boolean withRouterOperation = routerFunctionVisitor.getRouterFunctionDatas().stream()
+ .anyMatch(routerFunctionData -> routerFunctionData.getAttributes().containsKey(OPERATION_ATTRIBUTE));
+ if (withRouterOperation) {
+ List operationList = routerFunctionVisitor.getRouterFunctionDatas().stream().map(RouterOperation::new).collect(Collectors.toList());
+ calculatePath(operationList, locale, openAPI);
+ }
+ else {
+ List routerOperationList = new ArrayList<>();
+ ApplicationContext applicationContext = openAPIService.getContext();
+ RouterOperations routerOperations = applicationContext.findAnnotationOnBean(beanName, RouterOperations.class);
+ if (routerOperations == null) {
+ org.springdoc.core.annotations.RouterOperation routerOperation = applicationContext.findAnnotationOnBean(beanName, org.springdoc.core.annotations.RouterOperation.class);
+ if (routerOperation != null)
+ routerOperationList.add(routerOperation);
+ }
+ else
+ routerOperationList.addAll(Arrays.asList(routerOperations.value()));
+ if (routerOperationList.size() == 1)
+ calculatePath(routerOperationList.stream().map(routerOperation -> new RouterOperation(routerOperation, routerFunctionVisitor.getRouterFunctionDatas().get(0))).collect(Collectors.toList()), locale, openAPI);
+ else {
+ List operationList = routerOperationList.stream().map(RouterOperation::new).collect(Collectors.toList());
+ mergeRouters(routerFunctionVisitor.getRouterFunctionDatas(), operationList);
+ calculatePath(operationList, locale, openAPI);
+ }
+ }
+ }
+
+ /**
+ * Is filter condition boolean.
+ *
+ * @param handlerMethod the handler method
+ * @param operationPath the operation path
+ * @param produces the produces
+ * @param consumes the consumes
+ * @param headers the headers
+ * @return the boolean
+ */
+ protected boolean isFilterCondition(HandlerMethod handlerMethod, String operationPath, String[] produces, String[] consumes, String[] headers) {
+ return isMethodToFilter(handlerMethod)
+ && isPackageToScan(handlerMethod.getBeanType().getPackage())
+ && isFilterCondition(operationPath, produces, consumes, headers);
+ }
+
+ /**
+ * Is target method suitable for inclusion in current documentation/
+ *
+ * @param handlerMethod the method to check
+ * @return whether the method should be included in the current OpenAPI definition
+ */
+ protected boolean isMethodToFilter(HandlerMethod handlerMethod) {
+ return this.springDocCustomizers.getMethodFilters()
+ .map(Collection::stream)
+ .map(stream -> stream.allMatch(m -> m.isMethodToInclude(handlerMethod.getMethod())))
+ .orElse(true);
+ }
+
+ /**
+ * Is condition to match boolean.
+ *
+ * @param existingConditions the existing conditions
+ * @param conditionType the condition type
+ * @return the boolean
+ */
+ protected boolean isConditionToMatch(String[] existingConditions, ConditionType conditionType) {
+ List conditionsToMatch = getConditionsToMatch(conditionType);
+ if (CollectionUtils.isEmpty(conditionsToMatch)) {
+ Optional optionalGroupConfig = springDocConfigProperties.getGroupConfigs().stream().filter(groupConfig -> this.groupName.equals(groupConfig.getGroup())).findAny();
+ if (optionalGroupConfig.isPresent())
+ conditionsToMatch = getConditionsToMatch(conditionType, optionalGroupConfig.get());
+ }
+ return CollectionUtils.isEmpty(conditionsToMatch)
+ || (!ArrayUtils.isEmpty(existingConditions) && conditionsToMatch.size() == existingConditions.length && conditionsToMatch.containsAll(Arrays.asList(existingConditions)));
+ }
+
+ /**
+ * Is package to scan boolean.
+ *
+ * @param aPackage the a package
+ * @return the boolean
+ */
+ protected boolean isPackageToScan(Package aPackage) {
+ if (aPackage == null)
+ return true;
+ final String packageName = aPackage.getName();
+ List packagesToScan = springDocConfigProperties.getPackagesToScan();
+ List packagesToExclude = springDocConfigProperties.getPackagesToExclude();
+ if (CollectionUtils.isEmpty(packagesToScan)) {
+ Optional optionalGroupConfig = springDocConfigProperties.getGroupConfigs().stream().filter(groupConfig -> this.groupName.equals(groupConfig.getGroup())).findAny();
+ if (optionalGroupConfig.isPresent())
+ packagesToScan = optionalGroupConfig.get().getPackagesToScan();
+ }
+ if (CollectionUtils.isEmpty(packagesToExclude)) {
+ Optional optionalGroupConfig = springDocConfigProperties.getGroupConfigs().stream().filter(groupConfig -> this.groupName.equals(groupConfig.getGroup())).findAny();
+ if (optionalGroupConfig.isPresent())
+ packagesToExclude = optionalGroupConfig.get().getPackagesToExclude();
+ }
+ boolean include = CollectionUtils.isEmpty(packagesToScan)
+ || packagesToScan.stream().anyMatch(pack -> packageName.equals(pack)
+ || packageName.startsWith(pack + DOT));
+ boolean exclude = !CollectionUtils.isEmpty(packagesToExclude)
+ && (packagesToExclude.stream().anyMatch(pack -> packageName.equals(pack)
+ || packageName.startsWith(pack + DOT)));
+
+ return include && !exclude;
+ }
+
+ /**
+ * Is path to match boolean.
+ *
+ * @param operationPath the operation path
+ * @return the boolean
+ */
+ protected boolean isPathToMatch(String operationPath) {
+ List pathsToMatch = springDocConfigProperties.getPathsToMatch();
+ List pathsToExclude = springDocConfigProperties.getPathsToExclude();
+ if (CollectionUtils.isEmpty(pathsToMatch)) {
+ Optional optionalGroupConfig = springDocConfigProperties.getGroupConfigs().stream().filter(groupConfig -> this.groupName.equals(groupConfig.getGroup())).findAny();
+ if (optionalGroupConfig.isPresent())
+ pathsToMatch = optionalGroupConfig.get().getPathsToMatch();
+ }
+ if (CollectionUtils.isEmpty(pathsToExclude)) {
+ Optional optionalGroupConfig = springDocConfigProperties.getGroupConfigs().stream().filter(groupConfig -> this.groupName.equals(groupConfig.getGroup())).findAny();
+ if (optionalGroupConfig.isPresent())
+ pathsToExclude = optionalGroupConfig.get().getPathsToExclude();
+ }
+ boolean include = CollectionUtils.isEmpty(pathsToMatch) || pathsToMatch.stream().anyMatch(pattern -> antPathMatcher.match(pattern, operationPath));
+ boolean exclude = !CollectionUtils.isEmpty(pathsToExclude) && pathsToExclude.stream().anyMatch(pattern -> antPathMatcher.match(pattern, operationPath));
+ return include && !exclude;
+ }
+
+ /**
+ * Decode string.
+ *
+ * @param requestURI the request uri
+ * @return the string
+ */
+ protected String decode(String requestURI) {
+ try {
+ return URLDecoder.decode(requestURI, StandardCharsets.UTF_8.toString());
+ }
+ catch (UnsupportedEncodingException e) {
+ return requestURI;
+ }
+ }
+
+ /**
+ * Is additional rest controller boolean.
+ *
+ * @param rawClass the raw class
+ * @return the boolean
+ */
+ protected boolean isAdditionalRestController(Class> rawClass) {
+ return ADDITIONAL_REST_CONTROLLERS.stream().anyMatch(clazz -> ClassUtils.getUserClass(clazz).isAssignableFrom(rawClass));
+ }
+
+ /**
+ * Is rest controller boolean.
+ *
+ * @param restControllers the rest controllers
+ * @param handlerMethod the handler method
+ * @param operationPath the operation path
+ * @return the boolean
+ */
+ protected boolean isRestController(Map restControllers, HandlerMethod handlerMethod,
+ String operationPath) {
+ boolean hasOperationAnnotation = AnnotatedElementUtils.hasAnnotation(handlerMethod.getMethod(), io.swagger.v3.oas.annotations.Operation.class);
+
+ return ((containsResponseBody(handlerMethod) || hasOperationAnnotation) && restControllers.containsKey(handlerMethod.getBean().toString()) || isAdditionalRestController(handlerMethod.getBeanType()))
+ && operationPath.startsWith(DEFAULT_PATH_SEPARATOR)
+ && (springDocConfigProperties.isModelAndViewAllowed() || modelAndViewClass == null || !modelAndViewClass.isAssignableFrom(handlerMethod.getMethod().getReturnType()));
+ }
+
+ /**
+ * Gets default allowed http methods.
+ *
+ * @return the default allowed http methods
+ */
+ protected Set getDefaultAllowedHttpMethods() {
+ RequestMethod[] allowedRequestMethods = { RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, RequestMethod.DELETE, RequestMethod.OPTIONS, RequestMethod.HEAD };
+ return new HashSet<>(Arrays.asList(allowedRequestMethods));
+ }
+
+ /**
+ * Customise operation.
+ *
+ * @param operation the operation
+ * @param components
+ * @param handlerMethod the handler method
+ * @return the operation
+ */
+ protected Operation customizeOperation(Operation operation, Components components, HandlerMethod handlerMethod) {
+ Optional> optionalOperationCustomizers = springDocCustomizers.getOperationCustomizers();
+ if (optionalOperationCustomizers.isPresent()) {
+ Set operationCustomizerList = optionalOperationCustomizers.get();
+ for (OperationCustomizer operationCustomizer : operationCustomizerList) {
+ if (operationCustomizer instanceof GlobalOperationComponentsCustomizer globalOperationComponentsCustomizer)
+ operation = globalOperationComponentsCustomizer.customize(operation, components, handlerMethod);
+ else
+ operation = operationCustomizer.customize(operation, handlerMethod);
+ }
+ }
+ return operation;
+ }
+
+ /**
+ * Customise router operation
+ *
+ * @param routerOperation the router operation
+ * @param handlerMethod the handler method
+ * @return the router operation
+ */
+ protected RouterOperation customizeRouterOperation(RouterOperation routerOperation, HandlerMethod handlerMethod) {
+ Optional> optionalRouterOperationCustomizers = springDocCustomizers.getRouterOperationCustomizers();
+ if (optionalRouterOperationCustomizers.isPresent()) {
+ Set routerOperationCustomizerList = optionalRouterOperationCustomizers.get();
+ for (RouterOperationCustomizer routerOperationCustomizer : routerOperationCustomizerList) {
+ routerOperation = routerOperationCustomizer.customize(routerOperation, handlerMethod);
+ }
+ }
+ return routerOperation;
+ }
+
+ /**
+ * Merge routers.
+ *
+ * @param routerFunctionDatas the router function datas
+ * @param routerOperationList the router operation list
+ */
+ protected void mergeRouters(List routerFunctionDatas, List routerOperationList) {
+ for (RouterOperation routerOperation : routerOperationList) {
+ if (StringUtils.isNotBlank(routerOperation.getPath())) {
+ // PATH
+ List routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getMethods())) {
+ // PATH + METHOD
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualMethods(routerOperation.getMethods(), routerFunctionData1.getMethods()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getProduces())) {
+ // PATH + METHOD + PRODUCES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualMethods(routerOperation.getMethods(), routerFunctionData1.getMethods())
+ && isEqualArrays(routerFunctionData1.getProduces(), routerOperation.getProduces()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getConsumes())) {
+ // PATH + METHOD + PRODUCES + CONSUMES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualMethods(routerOperation.getMethods(), routerFunctionData1.getMethods())
+ && isEqualArrays(routerFunctionData1.getProduces(), routerOperation.getProduces())
+ && isEqualArrays(routerFunctionData1.getConsumes(), routerOperation.getConsumes()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ }
+ }
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getConsumes())) {
+ // PATH + METHOD + CONSUMES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualMethods(routerOperation.getMethods(), routerFunctionData1.getMethods())
+ && isEqualArrays(routerFunctionData1.getConsumes(), routerOperation.getConsumes()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ }
+ }
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getProduces())) {
+ // PATH + PRODUCES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualArrays(routerFunctionData1.getProduces(), routerOperation.getProduces()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getConsumes())) {
+ // PATH + PRODUCES + CONSUMES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualMethods(routerOperation.getMethods(), routerFunctionData1.getMethods())
+ && isEqualArrays(routerFunctionData1.getConsumes(), routerOperation.getConsumes())
+ && isEqualArrays(routerFunctionData1.getProduces(), routerOperation.getProduces()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ }
+ }
+ else if (routerFunctionDataList.size() > 1 && ArrayUtils.isNotEmpty(routerOperation.getConsumes())) {
+ // PATH + CONSUMES
+ routerFunctionDataList = routerFunctionDatas.stream()
+ .filter(routerFunctionData1 -> routerFunctionData1.getPath().equals(routerOperation.getPath())
+ && isEqualArrays(routerFunctionData1.getConsumes(), routerOperation.getConsumes()))
+ .toList();
+ if (routerFunctionDataList.size() == 1)
+ fillRouterOperation(routerFunctionDataList.get(0), routerOperation);
+ }
+ }
+ }
+ }
+
+ /**
+ * Calculate json view.
+ *
+ * @param apiOperation the api operation
+ * @param methodAttributes the method attributes
+ * @param method the method
+ */
+ private void calculateJsonView(io.swagger.v3.oas.annotations.Operation apiOperation,
+ MethodAttributes methodAttributes, Method method) {
+ JsonView jsonViewAnnotation;
+ JsonView jsonViewAnnotationForRequestBody;
+ if (apiOperation != null && apiOperation.ignoreJsonView()) {
+ jsonViewAnnotation = null;
+ jsonViewAnnotationForRequestBody = null;
+ }
+ else {
+ jsonViewAnnotation = AnnotatedElementUtils.findMergedAnnotation(method, JsonView.class);
+ /*
+ * If one and only one exists, use the @JsonView annotation from the method
+ * parameter annotated with @RequestBody. Otherwise fall back to the @JsonView
+ * annotation for the method itself.
+ */
+ jsonViewAnnotationForRequestBody = (JsonView) Arrays.stream(ReflectionUtils.getParameterAnnotations(method))
+ .filter(arr -> Arrays.stream(arr)
+ .anyMatch(annotation -> (annotation.annotationType()
+ .equals(io.swagger.v3.oas.annotations.parameters.RequestBody.class) || annotation.annotationType().equals(RequestBody.class))))
+ .flatMap(Arrays::stream).filter(annotation -> annotation.annotationType().equals(JsonView.class))
+ .reduce((a, b) -> null).orElse(jsonViewAnnotation);
+ }
+ methodAttributes.setJsonViewAnnotation(jsonViewAnnotation);
+ methodAttributes.setJsonViewAnnotationForRequestBody(jsonViewAnnotationForRequestBody);
+ }
+
+ /**
+ * Is equal arrays boolean.
+ *
+ * @param array1 the array 1
+ * @param array2 the array 2
+ * @return the boolean
+ */
+ private boolean isEqualArrays(String[] array1, String[] array2) {
+ Arrays.sort(array1);
+ Arrays.sort(array2);
+ return Arrays.equals(array1, array2);
+ }
+
+ /**
+ * Is equal methods boolean.
+ *
+ * @param requestMethods1 the request methods 1
+ * @param requestMethods2 the request methods 2
+ * @return the boolean
+ */
+ private boolean isEqualMethods(RequestMethod[] requestMethods1, RequestMethod[] requestMethods2) {
+ Arrays.sort(requestMethods1);
+ Arrays.sort(requestMethods2);
+ return Arrays.equals(requestMethods1, requestMethods2);
+ }
+
+ /**
+ * Fill parameters list.
+ *
+ * @param operation the operation
+ * @param queryParams the query params
+ * @param methodAttributes the method attributes
+ */
+ private void fillParametersList(Operation operation, Map queryParams, MethodAttributes methodAttributes) {
+ List parametersList = operation.getParameters();
+ if (parametersList == null)
+ parametersList = new ArrayList<>();
+ Collection headersMap = AbstractRequestService.getHeaders(methodAttributes, new LinkedHashMap<>());
+ headersMap.forEach(parameter -> {
+ Optional existingParam;
+ if (!CollectionUtils.isEmpty(operation.getParameters())) {
+ existingParam = operation.getParameters().stream().filter(p -> parameter.getName().equals(p.getName())).findAny();
+ if (existingParam.isEmpty())
+ operation.addParametersItem(parameter);
+ }
+ });
+ if (!CollectionUtils.isEmpty(queryParams)) {
+ for (Map.Entry entry : queryParams.entrySet()) {
+ io.swagger.v3.oas.models.parameters.Parameter parameter = new io.swagger.v3.oas.models.parameters.Parameter();
+ parameter.setName(entry.getKey());
+ parameter.setSchema(new StringSchema()._default(entry.getValue()));
+ parameter.setRequired(true);
+ parameter.setIn(ParameterIn.QUERY.toString());
+ GenericParameterService.mergeParameter(parametersList, parameter);
+ }
+ operation.setParameters(parametersList);
+ }
+ }
+
+ /**
+ * Fill router operation.
+ *
+ * @param routerFunctionData the router function data
+ * @param routerOperation the router operation
+ */
+ private void fillRouterOperation(RouterFunctionData routerFunctionData, RouterOperation routerOperation) {
+ if (ArrayUtils.isEmpty(routerOperation.getConsumes()))
+ routerOperation.setConsumes(routerFunctionData.getConsumes());
+ if (ArrayUtils.isEmpty(routerOperation.getProduces()))
+ routerOperation.setProduces(routerFunctionData.getProduces());
+ if (ArrayUtils.isEmpty(routerOperation.getHeaders()))
+ routerOperation.setHeaders(routerFunctionData.getHeaders());
+ if (ArrayUtils.isEmpty(routerOperation.getMethods()))
+ routerOperation.setMethods(routerFunctionData.getMethods());
+ if (CollectionUtils.isEmpty(routerOperation.getQueryParams()))
+ routerOperation.setQueryParams(routerFunctionData.getQueryParams());
+ }
+
+ /**
+ * Build path item.
+ *
+ * @param requestMethod the request method
+ * @param operation the operation
+ * @param operationPath the operation path
+ * @param paths the paths
+ * @return the path item
+ */
+ private PathItem buildPathItem(RequestMethod requestMethod, Operation operation, String operationPath,
+ Paths paths) {
+ PathItem pathItemObject;
+ if (operation != null && !CollectionUtils.isEmpty(operation.getParameters())) {
+ Iterator paramIt = operation.getParameters().iterator();
+ while (paramIt.hasNext()) {
+ Parameter parameter = paramIt.next();
+ if (ParameterIn.PATH.toString().equals(parameter.getIn())) {
+ // check it's present in the path
+ String name = parameter.getName();
+ if (!StringUtils.containsAny(operationPath, "{" + name + "}", "{*" + name + "}"))
+ paramIt.remove();
+ }
+ }
+ }
+ if (paths.containsKey(operationPath))
+ pathItemObject = paths.get(operationPath);
+ else
+ pathItemObject = new PathItem();
+
+ switch (requestMethod) {
+ case POST:
+ pathItemObject.post(operation);
+ break;
+ case GET:
+ pathItemObject.get(operation);
+ break;
+ case DELETE:
+ pathItemObject.delete(operation);
+ break;
+ case PUT:
+ pathItemObject.put(operation);
+ break;
+ case PATCH:
+ pathItemObject.patch(operation);
+ break;
+ case TRACE:
+ pathItemObject.trace(operation);
+ break;
+ case HEAD:
+ pathItemObject.head(operation);
+ break;
+ case OPTIONS:
+ pathItemObject.options(operation);
+ break;
+ default:
+ // Do nothing here
+ break;
+ }
+ return pathItemObject;
+ }
+
+ /**
+ * Gets existing operation.
+ *
+ * @param operationMap the operation map
+ * @param requestMethod the request method
+ * @return the existing operation
+ */
+ private Operation getExistingOperation(Map operationMap, RequestMethod requestMethod) {
+ Operation existingOperation = null;
+ if (!CollectionUtils.isEmpty(operationMap)) {
+ // Get existing operation definition
+ switch (requestMethod) {
+ case GET:
+ existingOperation = operationMap.get(HttpMethod.GET);
+ break;
+ case POST:
+ existingOperation = operationMap.get(HttpMethod.POST);
+ break;
+ case PUT:
+ existingOperation = operationMap.get(HttpMethod.PUT);
+ break;
+ case DELETE:
+ existingOperation = operationMap.get(HttpMethod.DELETE);
+ break;
+ case PATCH:
+ existingOperation = operationMap.get(HttpMethod.PATCH);
+ break;
+ case HEAD:
+ existingOperation = operationMap.get(HttpMethod.HEAD);
+ break;
+ case OPTIONS:
+ existingOperation = operationMap.get(HttpMethod.OPTIONS);
+ break;
+ default:
+ // Do nothing here
+ break;
+ }
+ }
+ return existingOperation;
+ }
+
+ /**
+ * Gets operation.
+ *
+ * @param routerOperation the router operation
+ * @param existingOperation the existing operation
+ * @return the operation
+ */
+ private Operation getOperation(RouterOperation routerOperation, Operation existingOperation) {
+ Operation operationModel = routerOperation.getOperationModel();
+ Operation operation;
+ if (existingOperation != null && operationModel == null) {
+ operation = existingOperation;
+ }
+ else if (existingOperation == null && operationModel != null) {
+ operation = operationModel;
+ }
+ else if (existingOperation != null) {
+ operation = operationParser.mergeOperation(existingOperation, operationModel);
+ }
+ else {
+ operation = new Operation();
+ }
+ return operation;
+ }
+
+ /**
+ * Init open api builder.
+ *
+ * @param locale the locale
+ */
+ protected void initOpenAPIBuilder(Locale locale) {
+ locale = selectLocale(locale);
+ if (openAPIService.getCachedOpenAPI(locale) != null && springDocConfigProperties.isCacheDisabled()) {
+ openAPIService = openAPIBuilderObjectFactory.getObject();
+ }
+ }
+
+ /**
+ * Write yaml value string.
+ *
+ * @param openAPI the open api
+ * @return the string
+ * @throws JsonProcessingException the json processing exception
+ */
+ protected byte[] writeYamlValue(OpenAPI openAPI) throws JsonProcessingException {
+ String result;
+ ObjectMapper objectMapper = springDocProviders.yamlMapper();
+ if (springDocConfigProperties.isWriterWithOrderByKeys())
+ ObjectMapperProvider.sortOutput(objectMapper, springDocConfigProperties);
+ YAMLFactory factory = (YAMLFactory) objectMapper.getFactory();
+ factory.configure(Feature.USE_NATIVE_TYPE_ID, false);
+ if (!springDocConfigProperties.isWriterWithDefaultPrettyPrinter())
+ result = objectMapper.writerFor(OpenAPI.class).writeValueAsString(openAPI);
+ else
+ result = objectMapper.writerWithDefaultPrettyPrinter().forType(OpenAPI.class).writeValueAsString(openAPI);
+ return result.getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Gets actuator uri.
+ *
+ * @param scheme the scheme
+ * @param host the host
+ * @return the actuator uri
+ */
+ protected URI getActuatorURI(String scheme, String host) {
+ final Optional actuatorProviderOptional = springDocProviders.getActuatorProvider();
+ URI uri = null;
+ if (actuatorProviderOptional.isPresent()) {
+ ActuatorProvider actuatorProvider = actuatorProviderOptional.get();
+ int port;
+ String path;
+ if (ACTUATOR_DEFAULT_GROUP.equals(this.groupName)) {
+ port = actuatorProvider.getActuatorPort();
+ path = actuatorProvider.getActuatorPath();
+ }
+ else {
+ port = actuatorProvider.getApplicationPort();
+ path = actuatorProvider.getContextPath();
+ String mvcServletPath = this.openAPIService.getContext().getBean(Environment.class).getProperty(SPRING_MVC_SERVLET_PATH);
+ if (SpringDocUtils.isValidPath(mvcServletPath))
+ path = path + mvcServletPath;
+ }
+ try {
+ uri = new URI(StringUtils.defaultIfEmpty(scheme, "http"), null, StringUtils.defaultIfEmpty(host, "localhost"), port, path, null, null);
+ }
+ catch (URISyntaxException e) {
+ LOGGER.error("Unable to parse the URL: scheme {}, host {}, port {}, path {}", scheme, host, port, path);
+ }
+ }
+ return uri;
+ }
+
+ /**
+ * Is actuator rest controller boolean.
+ *
+ * @param operationPath the operation path
+ * @param handlerMethod the handler method
+ * @return the boolean
+ */
+ protected boolean isActuatorRestController(String operationPath, HandlerMethod handlerMethod) {
+ Optional actuatorProviderOptional = springDocProviders.getActuatorProvider();
+ boolean isActuatorRestController = false;
+ if (actuatorProviderOptional.isPresent())
+ isActuatorRestController = actuatorProviderOptional.get().isRestController(operationPath, handlerMethod);
+ return springDocConfigProperties.isShowActuator() && isActuatorRestController && (modelAndViewClass == null || !modelAndViewClass.isAssignableFrom(handlerMethod.getMethod().getReturnType()));
+ }
+
+ /**
+ * Write json value string.
+ *
+ * @param openAPI the open api
+ * @return the string
+ * @throws JsonProcessingException the json processing exception
+ */
+ protected byte[] writeJsonValue(OpenAPI openAPI) throws JsonProcessingException {
+ String result;
+ ObjectMapper objectMapper = springDocProviders.jsonMapper();
+ if (springDocConfigProperties.isWriterWithOrderByKeys())
+ ObjectMapperProvider.sortOutput(objectMapper, springDocConfigProperties);
+ if (!springDocConfigProperties.isWriterWithDefaultPrettyPrinter())
+ result = objectMapper.writerFor(OpenAPI.class).writeValueAsString(openAPI);
+ else
+ result = objectMapper.writerWithDefaultPrettyPrinter().forType(OpenAPI.class).writeValueAsString(openAPI);
+ return result.getBytes(StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Gets conditions to match.
+ *
+ * @param conditionType the condition type
+ * @param groupConfigs the group configs
+ * @return the conditions to match
+ */
+ private List getConditionsToMatch(ConditionType conditionType, GroupConfig... groupConfigs) {
+ List conditionsToMatch = null;
+ GroupConfig groupConfig = null;
+ if (ArrayUtils.isNotEmpty(groupConfigs))
+ groupConfig = groupConfigs[0];
+ switch (conditionType) {
+ case HEADERS:
+ conditionsToMatch = (groupConfig != null) ? groupConfig.getHeadersToMatch() : springDocConfigProperties.getHeadersToMatch();
+ break;
+ case PRODUCES:
+ conditionsToMatch = (groupConfig != null) ? groupConfig.getProducesToMatch() : springDocConfigProperties.getProducesToMatch();
+ break;
+ case CONSUMES:
+ conditionsToMatch = (groupConfig != null) ? groupConfig.getConsumesToMatch() : springDocConfigProperties.getConsumesToMatch();
+ break;
+ default:
+ break;
+ }
+ return conditionsToMatch;
+ }
+
+ /**
+ * Is filter condition boolean.
+ *
+ * @param operationPath the operation path
+ * @param produces the produces
+ * @param consumes the consumes
+ * @param headers the headers
+ * @return the boolean
+ */
+ private boolean isFilterCondition(String operationPath, String[] produces, String[] consumes, String[] headers) {
+ return isPathToMatch(operationPath)
+ && isConditionToMatch(produces, ConditionType.PRODUCES)
+ && isConditionToMatch(consumes, ConditionType.CONSUMES)
+ && isConditionToMatch(headers, ConditionType.HEADERS);
+ }
+
+ /**
+ * The enum Condition type.
+ */
+ enum ConditionType {
+ /**
+ * Produces condition type.
+ */
+ PRODUCES,
+ /**
+ * Consumes condition type.
+ */
+ CONSUMES,
+ /**
+ * Headers condition type.
+ */
+ HEADERS
+ }
+}
diff --git a/ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/SaTokenTestController.java b/ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/SaTokenTestController.java
new file mode 100644
index 000000000..c9dada111
--- /dev/null
+++ b/ruoyi-modules/ruoyi-demo/src/main/java/org/dromara/demo/controller/SaTokenTestController.java
@@ -0,0 +1,198 @@
+package org.dromara.demo.controller;
+
+import cn.dev33.satoken.annotation.*;
+import lombok.extern.slf4j.Slf4j;
+import org.dromara.common.core.domain.R;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * SaToken 权限测试
+ *
+ * @author AprilWind
+ */
+@Slf4j
+@RestController
+@RequestMapping("/demo/SaToken")
+public class SaTokenTestController {
+
+ // ====================== 基础场景:单一校验规则 ======================
+
+ /**
+ * 场景1:仅登录校验(无角色/权限限制,只需登录态)
+ */
+ @SaCheckLogin
+ @GetMapping("/basic/loginOnly")
+ public R loginOnly() {
+ log.info("【场景1】仅登录校验通过");
+ return R.ok("仅登录校验通过,无需角色/权限");
+ }
+
+ /**
+ * 场景2:单一角色校验(AND模式,默认)
+ */
+ @SaCheckRole("admin")
+ @GetMapping("/basic/singleRole")
+ public R singleRole() {
+ log.info("【场景2】单一角色(admin)校验通过");
+ return R.ok("拥有admin角色,校验通过");
+ }
+
+ /**
+ * 场景3:单一权限校验(AND模式,默认)
+ */
+ @SaCheckPermission("system:user:view")
+ @GetMapping("/basic/singlePermission")
+ public R singlePermission() {
+ log.info("【场景3】单一权限(system:user:view)校验通过");
+ return R.ok("拥有system:user:view权限,校验通过");
+ }
+
+ /**
+ * 场景4:忽略所有权限校验(SaIgnore优先级最高)
+ */
+ @SaIgnore
+ @SaCheckRole("none_exist") // 该注解会被忽略
+ @GetMapping("/basic/ignoreAll")
+ public R ignoreAll() {
+ log.info("【场景4】SaIgnore忽略所有权限校验");
+ return R.ok("SaIgnore生效,所有权限校验被忽略");
+ }
+
+ // ====================== 进阶场景:多条件组合(AND/OR) ======================
+
+ /**
+ * 场景5:多角色AND模式(必须同时拥有所有角色)
+ */
+ @SaCheckRole(value = {"admin", "operator"}, mode = SaMode.AND)
+ @GetMapping("/advance/multiRoleAnd")
+ public R multiRoleAnd() {
+ log.info("【场景5】多角色AND模式(admin+operator)校验通过");
+ return R.ok("同时拥有admin和operator角色,校验通过");
+ }
+
+ /**
+ * 场景6:多角色OR模式(拥有任一角色即可)
+ */
+ @SaCheckRole(value = {"admin", "test"}, mode = SaMode.OR)
+ @GetMapping("/advance/multiRoleOr")
+ public R multiRoleOr() {
+ log.info("【场景6】多角色OR模式(admin|test)校验通过");
+ return R.ok("拥有admin或test角色,校验通过");
+ }
+
+ /**
+ * 场景7:多权限AND模式(必须同时拥有所有权限)
+ */
+ @SaCheckPermission(value = {"system:user:edit", "system:log:view"}, mode = SaMode.AND)
+ @GetMapping("/advance/multiPermAnd")
+ public R multiPermAnd() {
+ log.info("【场景7】多权限AND模式(system:user:edit+system:log:view)校验通过");
+ return R.ok("同时拥有system:user:edit和system:log:view权限,校验通过");
+ }
+
+ /**
+ * 场景8:多权限OR模式(拥有任一权限即可)
+ */
+ @SaCheckPermission(value = {"system:user:add", "system:user:delete"}, mode = SaMode.OR)
+ @GetMapping("/advance/multiPermOr")
+ public R multiPermOr() {
+ log.info("【场景8】多权限OR模式(system:user:add|system:user:delete)校验通过");
+ return R.ok("拥有system:user:add或system:user:delete权限,校验通过");
+ }
+
+ // ====================== 高级场景:通配符/混合组合 ======================
+
+ /**
+ * 场景9:权限通配符匹配(前缀匹配)
+ * 拥有system:user:* 即可匹配所有用户模块权限
+ */
+ @SaCheckPermission("system:user:*")
+ @GetMapping("/advanced/permWildcardPrefix")
+ public R permWildcardPrefix() {
+ log.info("【场景9】权限通配符(system:user:*)校验通过");
+ return R.ok("拥有system:user:*前缀权限,校验通过");
+ }
+
+ /**
+ * 场景10:角色通配符匹配(前缀匹配)
+ * 拥有admin_* 即可匹配所有admin开头的角色
+ */
+ @SaCheckRole("admin_*")
+ @GetMapping("/advanced/roleWildcardPrefix")
+ public R roleWildcardPrefix() {
+ log.info("【场景10】角色通配符(admin_*)校验通过");
+ return R.ok("拥有admin_*前缀角色,校验通过");
+ }
+
+ /**
+ * 场景11:权限+角色混合AND模式(所有条件必须满足)
+ * 需同时满足:拥有admin角色 + 拥有system:user:all权限
+ */
+ @SaCheckRole("admin")
+ @SaCheckPermission("system:user:all")
+ @GetMapping("/advanced/mixRolePermAnd")
+ public R mixRolePermAnd() {
+ log.info("【场景11】角色+权限混合AND(admin+system:user:all)校验通过");
+ return R.ok("拥有admin角色且拥有system:user:all权限,校验通过");
+ }
+
+ /**
+ * 场景12:权限+角色混合OR模式(任一条件满足即可)
+ * 满足任一:拥有super_admin角色 | 拥有system:manage权限
+ */
+ @SaCheckRole(value = {"super_admin"}, mode = SaMode.OR)
+ @SaCheckPermission(value = {"system:manage"}, mode = SaMode.OR)
+ @GetMapping("/advanced/mixRolePermOr")
+ public R mixRolePermOr() {
+ log.info("【场景12】角色+权限混合OR(super_admin|system:manage)校验通过");
+ return R.ok("拥有super_admin角色或system:manage权限,校验通过");
+ }
+
+ /**
+ * 场景13:orRole参数(权限校验失败时,兜底角色校验)
+ * 核心逻辑:无system:user:export权限时,检查是否有admin/operator角色
+ */
+ @SaCheckPermission(value = "system:user:export", orRole = {"admin", "operator"})
+ @GetMapping("/advanced/permWithOrRole")
+ public R permWithOrRole() {
+ log.info("【场景13】权限+orRole兜底校验通过");
+ return R.ok("拥有system:user:export权限,或拥有admin/operator角色,校验通过");
+ }
+
+ // ====================== 特殊场景:临时权限/注解覆盖 ======================
+
+ /**
+ * 场景14:SaIgnore局部覆盖(方法注解覆盖类注解,若有)
+ * 假设类上有@SaCheckLogin,方法上@SaIgnore会覆盖
+ */
+ @SaIgnore
+ @GetMapping("/special/ignoreOverride")
+ public R ignoreOverride() {
+ log.info("【场景14】SaIgnore覆盖类级别权限注解");
+ return R.ok("方法级SaIgnore覆盖类级别权限校验");
+ }
+
+ /**
+ * 场景15:临时权限校验(SaCheckPermission逻辑:临时权限>永久权限)
+ * 注:临时权限需通过SaToken API手动设置,如 SaHolder.getStpLogic().setTempPermission("system:temp:test")
+ */
+ @SaCheckPermission("system:temp:test")
+ @GetMapping("/special/tempPermission")
+ public R tempPermission() {
+ log.info("【场景15】临时权限(system:temp:test)校验通过");
+ return R.ok("临时权限校验通过(需先通过API设置临时权限)");
+ }
+
+ /**
+ * 场景16:登录类型指定(多端登录场景,如PC/APP/小程序)
+ * 注:需配合SaToken多账号体系配置
+ */
+ @SaCheckLogin(type = "PC") // 仅校验PC端的登录态
+ @GetMapping("/special/loginTypeSpecify")
+ public R loginTypeSpecify() {
+ log.info("【场景16】指定登录类型(PC)校验通过");
+ return R.ok("仅PC端登录态校验通过");
+ }
+}