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端登录态校验通过"); + } +}