From e465bea6eff06f48fa693851e7abe8aff69db391 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Tue, 12 May 2026 11:14:48 +0800 Subject: [PATCH 1/9] feat(api-doc): support request parameter parsing for RPC types (Dubbo/SOFA/TARS/gRPC) --- .../admin/service/impl/ApiServiceImpl.java | 14 +- .../shenyu/admin/service/ApiServiceTest.java | 54 ++ shenyu-client/shenyu-client-core/pom.xml | 41 + ...AbstractContextRefreshedEventListener.java | 14 +- .../registrar/AbstractApiDocRegistrar.java | 12 +- .../registrar/ApiDocRegistrarImpl.java | 13 +- .../client/core/utils/OpenApiUtils.java | 791 ++++++++++++++++-- .../registrar/ApiDocRegistrarImplTest.java | 263 ++++++ .../registrar/NoHttpApiDocRegistrarTest.java | 276 +++++- .../client/core/utils/OpenApiUtilsTest.java | 502 +++++++++++ .../src/test/proto/test.proto | 26 + .../client/sofa/SofaServiceEventListener.java | 77 +- .../sofa/SofaServiceEventListenerTest.java | 3 +- .../impl/DubboProtobufServiceImpl.java | 6 + .../xml/impl/DubboProtobufServiceImpl.java | 6 + .../impl/DubboProtobufServiceImpl.java | 6 + 16 files changed, 1926 insertions(+), 178 deletions(-) create mode 100644 shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImplTest.java create mode 100644 shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java create mode 100644 shenyu-client/shenyu-client-core/src/test/proto/test.proto diff --git a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ApiServiceImpl.java b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ApiServiceImpl.java index 979e6d7e1ab9..ad05dbc78855 100644 --- a/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ApiServiceImpl.java +++ b/shenyu-admin/src/main/java/org/apache/shenyu/admin/service/impl/ApiServiceImpl.java @@ -21,6 +21,7 @@ import java.util.ArrayList; import org.apache.commons.collections4.CollectionUtils; import org.apache.commons.lang3.StringUtils; +import java.util.Objects; import org.apache.shenyu.admin.disruptor.RegisterClientServerDisruptorPublisher; import org.apache.shenyu.admin.mapper.ApiMapper; import org.apache.shenyu.admin.mapper.TagMapper; @@ -40,7 +41,6 @@ import org.apache.shenyu.admin.model.vo.RuleVO; import org.apache.shenyu.admin.model.vo.TagVO; import org.apache.shenyu.admin.service.ApiService; -import org.apache.shenyu.common.enums.ApiSourceEnum; import org.apache.shenyu.common.enums.PluginEnum; import org.apache.shenyu.common.utils.JsonUtils; import org.apache.shenyu.common.utils.ListUtil; @@ -243,12 +243,14 @@ public ApiVO findById(final String id) { tagVOs = tagDOS.stream().map(TagVO::buildTagVO).collect(Collectors.toList()); } ApiVO apiVO = ApiVO.buildApiVO(item, tagVOs); - if (apiVO.getApiSource().equals(ApiSourceEnum.SWAGGER.getValue())) { + if (StringUtils.isNotBlank(apiVO.getDocument())) { DocItem docItem = JsonUtils.jsonToObject(apiVO.getDocument(), DocItem.class); - apiVO.setRequestHeaders(docItem.getRequestHeaders()); - apiVO.setRequestParameters(docItem.getRequestParameters()); - apiVO.setResponseParameters(docItem.getResponseParameters()); - apiVO.setBizCustomCodeList(docItem.getBizCodeList()); + if (Objects.nonNull(docItem)) { + apiVO.setRequestHeaders(docItem.getRequestHeaders()); + apiVO.setRequestParameters(docItem.getRequestParameters()); + apiVO.setResponseParameters(docItem.getResponseParameters()); + apiVO.setBizCustomCodeList(docItem.getBizCodeList()); + } } return apiVO; diff --git a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ApiServiceTest.java b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ApiServiceTest.java index dd7b719e769b..7ffc2c87b057 100644 --- a/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ApiServiceTest.java +++ b/shenyu-admin/src/test/java/org/apache/shenyu/admin/service/ApiServiceTest.java @@ -108,6 +108,60 @@ public void testFindById() { assertNotNull(byId); } + @Test + public void testFindByIdWithDocumentNotBlank() { + String id = "456"; + ApiDTO apiDTO = new ApiDTO(); + apiDTO.setId(id); + apiDTO.setContextPath("string"); + apiDTO.setApiPath("string"); + apiDTO.setHttpMethod(0); + apiDTO.setConsume("string"); + apiDTO.setProduce("string"); + apiDTO.setVersion("string"); + apiDTO.setRpcType("string"); + apiDTO.setState(0); + apiDTO.setApiOwner("string"); + apiDTO.setApiDesc("string"); + apiDTO.setApiSource(0); + apiDTO.setDocument("{\"module\":\"test-module\",\"requestParameters\":[],\"responseParameters\":[]}"); + ApiDO apiDO = ApiDO.buildApiDO(apiDTO); + Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + apiDO.setDateCreated(now); + apiDO.setDateUpdated(now); + given(this.apiMapper.selectByPrimaryKey(eq(id))).willReturn(apiDO); + ApiVO byId = this.apiService.findById(id); + assertNotNull(byId); + assertNotNull(byId.getRequestParameters()); + assertNotNull(byId.getResponseParameters()); + } + + @Test + public void testFindByIdWithBlankDocument() { + String id = "789"; + ApiDTO apiDTO = new ApiDTO(); + apiDTO.setId(id); + apiDTO.setContextPath("string"); + apiDTO.setApiPath("string"); + apiDTO.setHttpMethod(0); + apiDTO.setConsume("string"); + apiDTO.setProduce("string"); + apiDTO.setVersion("string"); + apiDTO.setRpcType("string"); + apiDTO.setState(0); + apiDTO.setApiOwner("string"); + apiDTO.setApiDesc("string"); + apiDTO.setApiSource(0); + apiDTO.setDocument(""); + ApiDO apiDO = ApiDO.buildApiDO(apiDTO); + Timestamp now = Timestamp.valueOf(LocalDateTime.now()); + apiDO.setDateCreated(now); + apiDO.setDateUpdated(now); + given(this.apiMapper.selectByPrimaryKey(eq(id))).willReturn(apiDO); + ApiVO byId = this.apiService.findById(id); + assertNotNull(byId); + } + @Test public void testListByPage() { PageParameter pageParameter = new PageParameter(); diff --git a/shenyu-client/shenyu-client-core/pom.xml b/shenyu-client/shenyu-client-core/pom.xml index 683afdcc7ce4..b46341a266bb 100644 --- a/shenyu-client/shenyu-client-core/pom.xml +++ b/shenyu-client/shenyu-client-core/pom.xml @@ -81,5 +81,46 @@ spring-boot-starter-tomcat test + + com.google.protobuf + protobuf-java + test + + + io.grpc + grpc-stub + test + + + + + + kr.motd.maven + os-maven-plugin + 1.6.2 + + + + + org.xolstice.maven.plugins + protobuf-maven-plugin + 0.6.1 + true + + com.google.protobuf:protoc:${protobuf-java.version}:exe:${os.detected.classifier} + ${project.basedir}/src/test/proto + ${project.build.directory}/generated-test-sources/protobuf/java + false + + + + + test-compile + + + + + + diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/client/AbstractContextRefreshedEventListener.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/client/AbstractContextRefreshedEventListener.java index 32ef07fcda57..1aef7389b781 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/client/AbstractContextRefreshedEventListener.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/client/AbstractContextRefreshedEventListener.java @@ -17,7 +17,6 @@ package org.apache.shenyu.client.core.client; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import org.apache.commons.collections4.MapUtils; import org.apache.commons.lang3.StringUtils; @@ -213,7 +212,7 @@ private List buildApiDocDTO(final Object bean, final Method m String apiPath = pathJoin(contextPath, superPath, value); ApiHttpMethodEnum[] value3 = sextet.getValue3(); for (ApiHttpMethodEnum apiHttpMethodEnum : value3) { - String documentJson = buildDocumentJson(pairs.getRight(), apiPath, method); + String documentJson = buildDocumentJson(pairs.getRight(), apiPath, method, sextet.getValue4()); String extJson = buildExtJson(method); ApiDocRegisterDTO build = ApiDocRegisterDTO.builder() .consume(sextet.getValue1()) @@ -258,15 +257,8 @@ protected ApiDocRegisterDTO.ApiExt customApiDocExt(final ApiDocRegisterDTO.ApiEx return ext; } - private String buildDocumentJson(final List tags, final String path, final Method method) { - Map documentMap = ImmutableMap.builder() - .put("tags", tags) - .put("operationId", path) - .put("parameters", OpenApiUtils.generateDocumentParameters(path, method)) - .put("responses", OpenApiUtils.generateDocumentResponse(path)) - .put("responseType", Collections.singletonList(OpenApiUtils.parseReturnType(method))) - .build(); - return GsonUtils.getInstance().toJson(documentMap); + private String buildDocumentJson(final List tags, final String path, final Method method, final RpcTypeEnum rpcTypeEnum) { + return OpenApiUtils.buildDocumentJson(tags, path, method, rpcTypeEnum); } protected abstract Sextet buildApiDocSextet(Method method, Annotation annotation, Map beans); diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/AbstractApiDocRegistrar.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/AbstractApiDocRegistrar.java index 6d2e5358b360..30ddb8828638 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/AbstractApiDocRegistrar.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/AbstractApiDocRegistrar.java @@ -17,7 +17,6 @@ package org.apache.shenyu.client.core.register.registrar; -import com.google.common.collect.ImmutableMap; import com.google.common.collect.Lists; import org.apache.commons.lang3.StringUtils; import org.apache.shenyu.client.apidocs.annotations.ApiDoc; @@ -42,9 +41,7 @@ import java.lang.reflect.Method; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; -import java.util.Map; public abstract class AbstractApiDocRegistrar extends AbstractApiRegistrar { @@ -128,14 +125,7 @@ protected List parse(final ApiBean.ApiDefinition apiDefinitio } private String buildDocumentJson(final List tags, final String path, final Method method) { - Map documentMap = ImmutableMap.builder() - .put("tags", tags) - .put("operationId", path) - .put("parameters", OpenApiUtils.generateDocumentParameters(path, method)) - .put("responses", OpenApiUtils.generateDocumentResponse(path)) - .put("responseType", Collections.singletonList(OpenApiUtils.parseReturnType(method))) - .build(); - return GsonUtils.getInstance().toJson(documentMap); + return OpenApiUtils.buildDocumentJson(tags, path, method, rpcTypeEnum); } private String buildExtJson(final ApiBean.ApiDefinition apiDefinition) { diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImpl.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImpl.java index 91b842f97f5d..10d07877a4b2 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImpl.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImpl.java @@ -17,7 +17,6 @@ package org.apache.shenyu.client.core.register.registrar; -import com.google.common.collect.ImmutableMap; import org.apache.commons.lang3.StringUtils; import org.apache.shenyu.client.core.constant.ShenyuClientConstants; import org.apache.shenyu.client.core.disruptor.ShenyuClientRegisterEventPublisher; @@ -38,7 +37,6 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -132,14 +130,9 @@ private String getDocument(final ApiBean.ApiDefinition api) { return document; } final String path = getPath(api); - final Map documentMap = ImmutableMap.builder() - .put("tags", buildTags(api)) - .put("operationId", path) - .put("parameters", OpenApiUtils.generateDocumentParameters(path, api.getApiMethod())) - .put("responses", OpenApiUtils.generateDocumentResponse(path)) - .put("responseType", Collections.singletonList(OpenApiUtils.parseReturnType(api.getApiMethod()))) - .build(); - return GsonUtils.getInstance().toJson(documentMap); + final String rpcType = getRpcType(api); + RpcTypeEnum rpcTypeEnum = RpcTypeEnum.acquireByName(rpcType); + return OpenApiUtils.buildDocumentJson(buildTags(api), path, api.getApiMethod(), rpcTypeEnum); } private String getRpcType(final ApiBean.ApiDefinition api) { diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java index 5affbce77a91..6a9671b3d704 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java @@ -21,6 +21,8 @@ import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.tuple.Pair; import org.apache.shenyu.client.core.constant.ShenyuClientConstants; +import org.apache.shenyu.common.enums.RpcTypeEnum; +import org.apache.shenyu.common.utils.GsonUtils; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestPart; @@ -37,6 +39,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Date; @@ -58,16 +61,73 @@ public class OpenApiUtils { private static final String[] QUERY_CLASSES = new String[]{"org.springframework.web.bind.annotation.RequestParam", "org.springframework.web.bind.annotation.RequestPart"}; + /** + * Check if the given RPC type uses Spring MVC parameter parsing. + * HTTP, WebSocket and Spring Cloud types use Spring MVC annotations for parameter resolution, + * while other RPC types (Dubbo, gRPC, etc.) parse parameters from Java method signatures directly. + * + * @param rpcTypeEnum the RPC type enum + * @return true if Spring MVC parameter parsing should be used + */ + public static boolean useSpringMvcParamParsing(final RpcTypeEnum rpcTypeEnum) { + return rpcTypeEnum == RpcTypeEnum.HTTP + || rpcTypeEnum == RpcTypeEnum.WEB_SOCKET + || rpcTypeEnum == RpcTypeEnum.SPRING_CLOUD; + } /** - * generateDocumentParameters. + * Build document JSON string for the given API method. + * Dispatches to the appropriate parameter/response generation based on RPC type. + * + * @param tags the API tags + * @param path the API path + * @param method the Java method + * @param rpcTypeEnum the RPC type + * @return document JSON string + */ + public static String buildDocumentJson(final List tags, final String path, + final Method method, final RpcTypeEnum rpcTypeEnum) { + boolean useSpringMvcParamParsing = useSpringMvcParamParsing(rpcTypeEnum); + Map documentMap; + if (useSpringMvcParamParsing) { + documentMap = ImmutableMap.builder() + .put("tags", tags) + .put("operationId", path) + .put("requestParameters", generateRequestDocParameters(path, method)) + .put("responseParameters", Collections.singletonList(parseReturnType(method))) + .put("responses", generateDocumentResponse(path)) + .build(); + } else if (rpcTypeEnum == RpcTypeEnum.GRPC) { + documentMap = ImmutableMap.builder() + .put("tags", tags) + .put("operationId", path) + .put("requestParameters", generateGrpcRequestDocParameters(method)) + .put("responseParameters", Collections.singletonList(parseGrpcReturnType(method))) + .put("responses", generateGrpcDocumentResponse(path, method)) + .build(); + } else { + documentMap = ImmutableMap.builder() + .put("tags", tags) + .put("operationId", path) + .put("requestParameters", generateRpcRequestDocParameters(method)) + .put("responseParameters", Collections.singletonList(parseReturnType(method))) + .put("responses", generateRpcDocumentResponse(path, method)) + .build(); + } + return GsonUtils.getInstance().toJson(documentMap); + } + + + /** + * Generate request parameters for HTTP methods. + * This produces OpenAPI-style Parameter objects with in and schema fields. * * @param path the api path * @param method the method - * @return documentParameters + * @return request parameters */ - public static List generateDocumentParameters(final String path, final Method method) { - ArrayList list = new ArrayList<>(); + public static List generateRequestDocParameters(final String path, final Method method) { + List list = new ArrayList<>(); Pair query = isQuery(method); if (query.getLeft()) { for (Annotation[] annotations : query.getRight()) { @@ -89,7 +149,7 @@ public static List generateDocumentParameters(final String path, fina parameter.setIn("query"); parameter.setRequired(required); parameter.setName(name); - parameter.setSchema(new Schema("string", null)); + parameter.setType("string"); list.add(parameter); } } @@ -102,7 +162,7 @@ public static List generateDocumentParameters(final String path, fina parameter.setIn("path"); parameter.setName(segment); parameter.setRequired(true); - parameter.setSchema(new Schema("string", null)); + parameter.setType("string"); list.add(parameter); } if (segment.startsWith(LEFT_ANGLE_BRACKETS) && segment.endsWith(RIGHT_ANGLE_BRACKETS)) { @@ -111,7 +171,7 @@ public static List generateDocumentParameters(final String path, fina parameter.setIn("path"); parameter.setName(name); parameter.setRequired(true); - parameter.setSchema(new Schema("string", null)); + parameter.setType("string"); list.add(parameter); } } @@ -119,6 +179,155 @@ public static List generateDocumentParameters(final String path, fina return list; } + /** + * Generate request parameters for RPC methods. + * Unlike HTTP methods that use Spring annotations, RPC method parameters + * are parsed from Java method parameter types directly. + + * @param method the method + * @return request parameters + */ + public static List generateRpcRequestDocParameters(final Method method) { + List list = new ArrayList<>(); + java.lang.reflect.Parameter[] methodParams = method.getParameters(); + for (java.lang.reflect.Parameter methodParam : methodParams) { + Class paramType = methodParam.getType(); + Schema schema = parseSchema(paramType, 0, new HashMap<>(16)); + Parameter parameter = convertSchemaToParameter(methodParam.getName(), schema); + parameter.setRequired(true); + list.add(parameter); + } + return list; + } + + /** + * Generate request parameters for gRPC methods. + * gRPC method signatures differ from other RPC types: + * - Unary/ServerStreaming: void method(Request req, StreamObserver{Response} observer) + * - ClientStreaming/BidiStreaming: StreamObserver{Request} method(StreamObserver{Response} observer) + * StreamObserver parameters are excluded from request parameters. + * + * @param method the method + * @return request parameters + */ + public static List generateGrpcRequestDocParameters(final Method method) { + List list = new ArrayList<>(); + java.lang.reflect.Parameter[] methodParams = method.getParameters(); + for (java.lang.reflect.Parameter methodParam : methodParams) { + if (isStreamObserver(methodParam.getType())) { + continue; + } + Class paramType = methodParam.getType(); + Schema schema = parseSchema(paramType, 0, new HashMap<>(16)); + Parameter parameter = convertSchemaToParameter(methodParam.getName(), schema); + parameter.setRequired(true); + list.add(parameter); + } + if (list.isEmpty()) { + Type returnType = method.getGenericReturnType(); + Type actualType = extractStreamObserverTypeParam(returnType); + if (Objects.nonNull(actualType)) { + Schema schema = parseSchema(actualType, 0, new HashMap<>(16)); + Parameter parameter = convertSchemaToParameter("request", schema); + parameter.setRequired(true); + list.add(parameter); + } + } + return list; + } + + private static Parameter convertSchemaToParameter(final String name, final Schema schema) { + Parameter parameter = new Parameter(); + parameter.setName(name); + parameter.setType(schema.getType()); + if (Objects.nonNull(schema.getRefs()) && !schema.getRefs().isEmpty()) { + List refs = new ArrayList<>(); + for (Schema ref : schema.getRefs()) { + refs.add(convertSchemaToParameter(ref.getName(), ref)); + } + parameter.setRefs(refs); + } + return parameter; + } + + /** + * Parse return type for gRPC methods. + * - Unary/ServerStreaming: response type is extracted from StreamObserver{Response} parameter + * - ClientStreaming/BidiStreaming: response type is extracted from StreamObserver{Response} parameter + * + * @param method the method + * @return response type + */ + public static ResponseType parseGrpcReturnType(final Method method) { + java.lang.reflect.Parameter[] methodParams = method.getParameters(); + for (java.lang.reflect.Parameter methodParam : methodParams) { + if (isStreamObserver(methodParam.getType())) { + Type paramType = methodParam.getParameterizedType(); + Type actualType = extractStreamObserverTypeParam(paramType); + if (Objects.nonNull(actualType)) { + return parseType("ROOT", actualType, 0, new HashMap<>(16)); + } + } + } + ResponseType voidType = new ResponseType(); + voidType.setName("ROOT"); + voidType.setType("void"); + return voidType; + } + + /** + * Generate document response for gRPC methods. + * + * @param path the api path + * @param method the method + * @return documentResponseMap + */ + public static Map generateGrpcDocumentResponse(final String path, final Method method) { + String returnTypeStr = "void"; + java.lang.reflect.Parameter[] methodParams = method.getParameters(); + for (java.lang.reflect.Parameter methodParam : methodParams) { + if (isStreamObserver(methodParam.getType())) { + Type paramType = methodParam.getParameterizedType(); + Type actualType = extractStreamObserverTypeParam(paramType); + if (Objects.nonNull(actualType)) { + returnTypeStr = resolveTypeName(actualType); + } + break; + } + } + ImmutableMap contentMap = ImmutableMap.builder() + .put(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE, ImmutableMap.of("schema", ImmutableMap.of("type", returnTypeStr))) + .build(); + ImmutableMap successMap = ImmutableMap.builder() + .put("description", path) + .put("content", contentMap).build(); + ImmutableMap notFoundMap = ImmutableMap.builder() + .put("description", StringUtils.join("the path [", path, "] not found")) + .put("content", ImmutableMap.of(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE, + ImmutableMap.of("schema", ImmutableMap.of("type", "string")))).build(); + return ImmutableMap.builder() + .put("200", successMap) + .put("404", notFoundMap) + .build(); + } + + private static boolean isStreamObserver(final Class clazz) { + return "io.grpc.stub.StreamObserver".equals(clazz.getName()); + } + + private static Type extractStreamObserverTypeParam(final Type type) { + if (type instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType) type; + if ("io.grpc.stub.StreamObserver".equals(((Class) pt.getRawType()).getName())) { + Type[] actualTypes = pt.getActualTypeArguments(); + if (actualTypes.length > 0) { + return actualTypes[0]; + } + } + } + return null; + } + private static Pair isQuery(final Method method) { Annotation[][] parameterAnnotations = method.getParameterAnnotations(); for (Annotation[] parameterAnnotation : parameterAnnotations) { @@ -139,6 +348,248 @@ private static boolean isQueryName(final String name, final String[] names) { return false; } + /** + * Generate document response for RPC methods with actual return type info. + * + * @param path the api path + * @param method the method + * @return documentResponseMap + */ + public static Map generateRpcDocumentResponse(final String path, final Method method) { + Type returnType = method.getGenericReturnType(); + String returnTypeStr = "void".equals(returnType.getTypeName()) ? "void" : resolveTypeName(returnType); + ImmutableMap contentMap = ImmutableMap.builder() + .put(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE, ImmutableMap.of("schema", ImmutableMap.of("type", returnTypeStr))) + .build(); + ImmutableMap successMap = ImmutableMap.builder() + .put("description", path) + .put("content", contentMap).build(); + ImmutableMap notFoundMap = ImmutableMap.builder() + .put("description", StringUtils.join("the path [", path, "] not found")) + .put("content", ImmutableMap.of(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE, + ImmutableMap.of("schema", ImmutableMap.of("type", "string")))).build(); + return ImmutableMap.builder() + .put("200", successMap) + .put("404", notFoundMap) + .build(); + } + + private static String resolveTypeName(final Type type) { + if (type instanceof Class) { + return resolveTypeName((Class) type); + } + if (type instanceof ParameterizedType) { + Class rawType = (Class) ((ParameterizedType) type).getRawType(); + if (Collection.class.isAssignableFrom(rawType)) { + return "array"; + } + return "object"; + } + return "object"; + } + + private static String resolveTypeName(final Class clazz) { + if (isBooleanType(clazz)) { + return "boolean"; + } else if (isIntegerType(clazz)) { + return "integer"; + } else if (isNumberType(clazz)) { + return "number"; + } else if (isStringType(clazz)) { + return "string"; + } else if (isDateType(clazz)) { + return "string"; + } else if (clazz.isArray() || Collection.class.isAssignableFrom(clazz)) { + return "array"; + } else if (clazz.isEnum()) { + return "string"; + } else if (isProtobufMessage(clazz)) { + return "object"; + } else if (Map.class.isAssignableFrom(clazz)) { + return "object"; + } else { + return "object"; + } + } + + /** + * Check if the class is a protobuf-generated message class. + * Uses reflection to avoid direct protobuf dependency. + * + * @param clazz the class to check + * @return true if it's a protobuf message class + */ + static boolean isProtobufMessage(final Class clazz) { + if (Objects.isNull(clazz)) { + return false; + } + Class current = clazz; + while (Objects.nonNull(current) && current != Object.class) { + String className = current.getName(); + if ("com.google.protobuf.GeneratedMessageV3".equals(className) + || "com.google.protobuf.GeneratedMessage".equals(className) + || "com.google.protobuf.GeneratedMessageLite".equals(className)) { + return true; + } + current = current.getSuperclass(); + } + return false; + } + + /** + * Check if the class is com.google.protobuf.Empty. + * + * @param clazz the class to check + * @return true if it's protobuf Empty + */ + private static boolean isProtobufEmpty(final Class clazz) { + return Objects.nonNull(clazz) && "com.google.protobuf.Empty".equals(clazz.getName()); + } + + /** + * Parse protobuf message fields using reflection on getDescriptor(). + * Protobuf descriptor types are mapped to OpenAPI types. + * + * @param responseType the response type to populate + * @param clazz the protobuf message class + * @param depth current recursion depth + * @param typeVariableMap type variable map + * @return populated ResponseType + */ + private static ResponseType parseProtobufClass(final ResponseType responseType, final Class clazz, + final int depth, final Map, Type> typeVariableMap) { + if (isProtobufEmpty(clazz)) { + responseType.setType("object"); + return responseType; + } + try { + java.lang.reflect.Method getDescriptorMethod = clazz.getMethod("getDescriptor"); + Object descriptor = getDescriptorMethod.invoke(null); + java.lang.reflect.Method getFieldsMethod = descriptor.getClass().getMethod("getFields"); + @SuppressWarnings("unchecked") + List fields = (List) getFieldsMethod.invoke(descriptor); + List refs = parseProtobufFields(fields, clazz, depth, typeVariableMap); + responseType.setType("object"); + responseType.setRefs(refs); + } catch (Exception e) { + responseType.setType("object"); + } + return responseType; + } + + private static List parseProtobufFields(final List fields, final Class clazz, + final int depth, final Map, Type> typeVariableMap) throws Exception { + List refs = new ArrayList<>(); + java.lang.reflect.Method getNameMethod = null; + java.lang.reflect.Method getTypeMethod = null; + java.lang.reflect.Method isRepeatedMethod = null; + java.lang.reflect.Method getMessageTypeMethod = null; + for (Object field : fields) { + if (Objects.isNull(getNameMethod)) { + getNameMethod = field.getClass().getMethod("getName"); + getTypeMethod = field.getClass().getMethod("getType"); + isRepeatedMethod = field.getClass().getMethod("isRepeated"); + getMessageTypeMethod = field.getClass().getMethod("getMessageType"); + } + String fieldName = (String) getNameMethod.invoke(field); + Object fieldType = getTypeMethod.invoke(field); + boolean isRepeated = (boolean) isRepeatedMethod.invoke(field); + refs.add(parseProtobufField(fieldName, fieldType, isRepeated, + getMessageTypeMethod, field, clazz, depth, typeVariableMap)); + } + return refs; + } + + private static ResponseType parseProtobufField(final String fieldName, final Object fieldType, final boolean isRepeated, + final java.lang.reflect.Method getMessageTypeMethod, final Object field, + final Class clazz, final int depth, + final Map, Type> typeVariableMap) throws Exception { + if (isRepeated) { + return parseRepeatedProtobufField(fieldName, fieldType, getMessageTypeMethod, field); + } + String fieldTypeName = fieldType.toString(); + ResponseType fieldResponse = new ResponseType(); + fieldResponse.setName(fieldName); + if ("MESSAGE".equals(fieldTypeName)) { + resolveProtobufMessageField(fieldResponse, getMessageTypeMethod, field, clazz, depth, typeVariableMap); + } else if ("ENUM".equals(fieldTypeName)) { + fieldResponse.setType("string"); + } else { + fieldResponse.setType(mapProtobufTypeToOpenApi(fieldTypeName)); + } + return fieldResponse; + } + + private static ResponseType parseRepeatedProtobufField(final String fieldName, final Object fieldType, + final java.lang.reflect.Method getMessageTypeMethod, + final Object field) throws Exception { + ResponseType arrayType = new ResponseType(); + arrayType.setName(fieldName); + arrayType.setType("array"); + String fieldTypeName = fieldType.toString(); + ResponseType elementType = new ResponseType(); + elementType.setName("ITEMS"); + if ("MESSAGE".equals(fieldTypeName)) { + Object msgDescriptor = getMessageTypeMethod.invoke(field); + java.lang.reflect.Method getFullNameMethod = msgDescriptor.getClass().getMethod("getFullName"); + String fullMsgName = (String) getFullNameMethod.invoke(msgDescriptor); + elementType.setType("object"); + elementType.setDescription(fullMsgName); + } else { + elementType.setType(mapProtobufTypeToOpenApi(fieldTypeName)); + } + arrayType.setRefs(Collections.singletonList(elementType)); + return arrayType; + } + + private static void resolveProtobufMessageField(final ResponseType fieldResponse, + final java.lang.reflect.Method getMessageTypeMethod, + final Object field, final Class clazz, + final int depth, + final Map, Type> typeVariableMap) throws Exception { + Object msgDescriptor = getMessageTypeMethod.invoke(field); + java.lang.reflect.Method getFullNameMethod = msgDescriptor.getClass().getMethod("getFullName"); + String fullMsgName = (String) getFullNameMethod.invoke(msgDescriptor); + fieldResponse.setType("object"); + fieldResponse.setDescription(fullMsgName); + if (depth < 5) { + try { + Class nestedClass = Class.forName(toJavaClassName(clazz, fullMsgName)); + List nestedRefs = parseProtobufClass(new ResponseType(), nestedClass, depth + 1, typeVariableMap).getRefs(); + if (Objects.nonNull(nestedRefs) && !nestedRefs.isEmpty()) { + fieldResponse.setRefs(nestedRefs); + } + } catch (ClassNotFoundException ignored) { + } + } + } + + private static String mapProtobufTypeToOpenApi(final String protobufType) { + switch (protobufType) { + case "INT32": + case "INT64": + case "UINT32": + case "UINT64": + case "SINT32": + case "SINT64": + case "FIXED32": + case "FIXED64": + case "SFIXED32": + case "SFIXED64": + return "integer"; + case "FLOAT": + case "DOUBLE": + return "number"; + case "BOOL": + return "boolean"; + case "STRING": + case "BYTES": + return "string"; + default: + return "string"; + } + } + /** * generateDocumentResponse. * @@ -236,6 +687,8 @@ private static ResponseType parseClass(final ResponseType responseType, final Cl } else if (isDateType(clazz)) { responseType.setType("date"); return responseType; + } else if (isProtobufMessage(clazz)) { + return parseProtobufClass(responseType, clazz, depth, typeVariableMap); } else { List refs = new ArrayList<>(); for (Field field : clazz.getDeclaredFields()) { @@ -296,6 +749,219 @@ private static ResponseType parseGenericArrayType(final ResponseType responseTyp return responseType; } + private static Schema parseSchema(final Type type, final int depth, final Map, Type> typeVariableMap) { + if (depth > 5) { + return new Schema("object", null); + } + if (type instanceof Class) { + return parseClassSchema((Class) type, depth, typeVariableMap); + } else if (type instanceof ParameterizedType) { + return parseParameterizedTypeSchema((ParameterizedType) type, depth, typeVariableMap); + } else if (type instanceof GenericArrayType) { + Schema elementSchema = parseSchema(((GenericArrayType) type).getGenericComponentType(), depth + 1, typeVariableMap); + Schema schema = new Schema("array", null); + schema.setRefs(Collections.singletonList(elementSchema)); + return schema; + } else if (type instanceof TypeVariable) { + Type actualType = typeVariableMap.get(type); + if (Objects.nonNull(actualType)) { + return parseSchema(actualType, depth, typeVariableMap); + } else if (((TypeVariable) type).getBounds().length > 0) { + return parseSchema(((TypeVariable) type).getBounds()[0], depth, typeVariableMap); + } else { + return new Schema("object", null); + } + } else { + return new Schema("object", null); + } + } + + private static Schema parseClassSchema(final Class clazz, final int depth, final Map, Type> typeVariableMap) { + if (clazz.isArray()) { + Schema elementSchema = parseSchema(clazz.getComponentType(), depth + 1, typeVariableMap); + Schema schema = new Schema("array", null); + schema.setRefs(Collections.singletonList(elementSchema)); + return schema; + } else if (clazz.isEnum()) { + return new Schema("string", null); + } else if (isBooleanType(clazz)) { + return new Schema("boolean", null); + } else if (isIntegerType(clazz)) { + return new Schema("integer", null); + } else if (isNumberType(clazz)) { + return new Schema("number", null); + } else if (isStringType(clazz)) { + return new Schema("string", null); + } else if (isDateType(clazz)) { + return new Schema("string", "date"); + } else if (isProtobufMessage(clazz)) { + return parseProtobufClassSchema(clazz, depth, typeVariableMap); + } else { + List refs = new ArrayList<>(); + for (Field field : clazz.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + Schema fieldSchema = parseSchema(field.getGenericType(), depth + 1, typeVariableMap); + fieldSchema.setName(field.getName()); + refs.add(fieldSchema); + } + Schema schema = new Schema("object", null); + schema.setRefs(refs); + return schema; + } + } + + private static Schema parseParameterizedTypeSchema(final ParameterizedType type, final int depth, final Map, Type> typeVariableMap) { + Class rawType = (Class) type.getRawType(); + Type[] actualTypeArguments = type.getActualTypeArguments(); + TypeVariable[] typeVariables = rawType.getTypeParameters(); + Map, Type> newTypeVariableMap = new HashMap<>(typeVariableMap); + for (int i = 0; i < typeVariables.length; i++) { + newTypeVariableMap.put(typeVariables[i], actualTypeArguments[i]); + } + if (Collection.class.isAssignableFrom(rawType)) { + Schema elementSchema = parseSchema(actualTypeArguments[0], depth + 1, newTypeVariableMap); + Schema schema = new Schema("array", null); + schema.setRefs(Collections.singletonList(elementSchema)); + return schema; + } else if (Map.class.isAssignableFrom(rawType)) { + Schema keySchema = parseSchema(actualTypeArguments[0], depth + 1, newTypeVariableMap); + Schema valueSchema = parseSchema(actualTypeArguments[1], depth + 1, newTypeVariableMap); + Schema schema = new Schema("object", null); + schema.setRefs(Arrays.asList(keySchema, valueSchema)); + return schema; + } else { + List refs = new ArrayList<>(); + for (Field field : rawType.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + Schema fieldSchema = parseSchema(field.getGenericType(), depth + 1, newTypeVariableMap); + fieldSchema.setName(field.getName()); + refs.add(fieldSchema); + } + Schema schema = new Schema("object", null); + schema.setRefs(refs); + return schema; + } + } + + private static Schema parseProtobufClassSchema(final Class clazz, final int depth, final Map, Type> typeVariableMap) { + if (isProtobufEmpty(clazz)) { + return new Schema("object", null); + } + try { + java.lang.reflect.Method getDescriptorMethod = clazz.getMethod("getDescriptor"); + Object descriptor = getDescriptorMethod.invoke(null); + java.lang.reflect.Method getFieldsMethod = descriptor.getClass().getMethod("getFields"); + @SuppressWarnings("unchecked") + List fields = (List) getFieldsMethod.invoke(descriptor); + List refs = parseProtobufFieldsSchema(fields, clazz, depth, typeVariableMap); + Schema schema = new Schema("object", null); + schema.setRefs(refs); + return schema; + } catch (Exception e) { + return new Schema("object", null); + } + } + + private static List parseProtobufFieldsSchema(final List fields, final Class clazz, + final int depth, final Map, Type> typeVariableMap) throws Exception { + List refs = new ArrayList<>(); + java.lang.reflect.Method getNameMethod = null; + java.lang.reflect.Method getTypeMethod = null; + java.lang.reflect.Method isRepeatedMethod = null; + java.lang.reflect.Method getMessageTypeMethod = null; + for (Object field : fields) { + if (Objects.isNull(getNameMethod)) { + getNameMethod = field.getClass().getMethod("getName"); + getTypeMethod = field.getClass().getMethod("getType"); + isRepeatedMethod = field.getClass().getMethod("isRepeated"); + getMessageTypeMethod = field.getClass().getMethod("getMessageType"); + } + String fieldName = (String) getNameMethod.invoke(field); + Object fieldType = getTypeMethod.invoke(field); + boolean isRepeated = (boolean) isRepeatedMethod.invoke(field); + refs.add(parseProtobufFieldSchema(fieldName, fieldType, isRepeated, + getMessageTypeMethod, field, clazz, depth, typeVariableMap)); + } + return refs; + } + + private static Schema parseProtobufFieldSchema(final String fieldName, final Object fieldType, final boolean isRepeated, + final java.lang.reflect.Method getMessageTypeMethod, final Object field, + final Class clazz, final int depth, + final Map, Type> typeVariableMap) throws Exception { + if (isRepeated) { + return parseRepeatedProtobufFieldSchema(fieldName, fieldType, getMessageTypeMethod, field); + } + String fieldTypeName = fieldType.toString(); + Schema schema; + if ("MESSAGE".equals(fieldTypeName)) { + schema = resolveProtobufMessageFieldSchema(getMessageTypeMethod, field, clazz, depth, typeVariableMap); + } else if ("ENUM".equals(fieldTypeName)) { + schema = new Schema("string", null); + } else { + schema = new Schema(mapProtobufTypeToOpenApi(fieldTypeName), null); + } + schema.setName(fieldName); + return schema; + } + + private static Schema parseRepeatedProtobufFieldSchema(final String fieldName, final Object fieldType, + final java.lang.reflect.Method getMessageTypeMethod, + final Object field) throws Exception { + String fieldTypeName = fieldType.toString(); + Schema elementSchema; + if ("MESSAGE".equals(fieldTypeName)) { + Object msgDescriptor = getMessageTypeMethod.invoke(field); + java.lang.reflect.Method getFullNameMethod = msgDescriptor.getClass().getMethod("getFullName"); + String fullMsgName = (String) getFullNameMethod.invoke(msgDescriptor); + elementSchema = new Schema("object", null); + } else { + elementSchema = new Schema(mapProtobufTypeToOpenApi(fieldTypeName), null); + } + Schema schema = new Schema("array", null); + schema.setName(fieldName); + schema.setRefs(Collections.singletonList(elementSchema)); + return schema; + } + + private static Schema resolveProtobufMessageFieldSchema(final java.lang.reflect.Method getMessageTypeMethod, + final Object field, final Class clazz, + final int depth, + final Map, Type> typeVariableMap) throws Exception { + Object msgDescriptor = getMessageTypeMethod.invoke(field); + java.lang.reflect.Method getFullNameMethod = msgDescriptor.getClass().getMethod("getFullName"); + String fullMsgName = (String) getFullNameMethod.invoke(msgDescriptor); + Schema schema = new Schema("object", null); + if (depth < 5) { + try { + Class nestedClass = Class.forName(toJavaClassName(clazz, fullMsgName)); + List nestedRefs = parseProtobufClassSchema(nestedClass, depth + 1, typeVariableMap).getRefs(); + if (Objects.nonNull(nestedRefs) && !nestedRefs.isEmpty()) { + schema.setRefs(nestedRefs); + } + } catch (ClassNotFoundException ignored) { + } + } + return schema; + } + + private static String toJavaClassName(final Class contextClass, final String protobufFullName) { + String packageName = contextClass.getPackage().getName(); + if (protobufFullName.startsWith(packageName + ".")) { + String relativeName = protobufFullName.substring(packageName.length() + 1).replace('.', '$'); + Class declaringClass = contextClass.getDeclaringClass(); + if (Objects.nonNull(declaringClass)) { + return declaringClass.getName() + "$" + relativeName; + } + return packageName + "." + relativeName; + } + return protobufFullName.replace('.', '$'); + } + private static boolean isDateType(final Class clazz) { return clazz == Date.class || clazz == LocalDate.class || clazz == LocalDateTime.class || clazz == LocalTime.class; @@ -368,7 +1034,6 @@ public void setRefs(final List refs) { } } - public static class Parameter { private String name; @@ -379,145 +1044,105 @@ public static class Parameter { private boolean required; - private Schema schema; + private String type; + + private List refs; - /** - * get name. - * - * @return name - */ public String getName() { return name; } - /** - * set name. - * - * @param name name - */ public void setName(final String name) { this.name = name; } - /** - * get in. - * - * @return in - */ public String getIn() { return in; } - /** - * set in. - * - * @param in in - */ public void setIn(final String in) { this.in = in; } - /** - * get description. - * - * @return description - */ public String getDescription() { return description; } - /** - * set description. - * - * @param description description - */ public void setDescription(final String description) { this.description = description; } - /** - * get required. - * - * @return required - */ public boolean isRequired() { return required; } - /** - * set required. - * - * @param required required - */ public void setRequired(final boolean required) { this.required = required; } - /** - * get schema. - * - * @return schema - */ - public Schema getSchema() { - return schema; + public String getType() { + return type; } - /** - * set schema. - * - * @param schema schema - */ - public void setSchema(final Schema schema) { - this.schema = schema; + public void setType(final String type) { + this.type = type; + } + + public List getRefs() { + return refs; + } + + public void setRefs(final List refs) { + this.refs = refs; } } public static class Schema { + private String name; + private String type; private String format; + private List refs; + public Schema(final String type, final String format) { this.type = type; this.format = format; } - /** - * get type. - * - * @return type - */ + public String getName() { + return name; + } + + public void setName(final String name) { + this.name = name; + } + public String getType() { return type; } - /** - * set type. - * - * @param type type - */ public void setType(final String type) { this.type = type; } - /** - * get format. - * - * @return format - */ public String getFormat() { return format; } - /** - * set format. - * - * @param format format - */ public void setFormat(final String format) { this.format = format; } + + public List getRefs() { + return refs; + } + + public void setRefs(final List refs) { + this.refs = refs; + } } } diff --git a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImplTest.java b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImplTest.java new file mode 100644 index 000000000000..d3b3857de000 --- /dev/null +++ b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/ApiDocRegistrarImplTest.java @@ -0,0 +1,263 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.shenyu.client.core.register.registrar; + +import org.apache.shenyu.client.core.register.ApiBean; +import org.apache.shenyu.client.core.register.ClientRegisterConfig; +import org.apache.shenyu.common.enums.RpcTypeEnum; +import org.apache.shenyu.common.utils.GsonUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +public class ApiDocRegistrarImplTest { + + private ApiDocRegistrarImpl dubboRegistrar; + + private ApiDocRegistrarImpl httpRegistrar; + + private ApiDocRegistrarImpl grpcRegistrar; + + @BeforeEach + public void init() { + dubboRegistrar = new ApiDocRegistrarImpl(new DubboClientRegisterConfig()); + httpRegistrar = new ApiDocRegistrarImpl(new HttpClientRegisterConfig()); + grpcRegistrar = new ApiDocRegistrarImpl(new GrpcClientRegisterConfig()); + } + + @Test + void testGetDocumentForRpcType() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), + TestDubboService.class.getName(), + TestDubboService.class.getDeclaredConstructor().newInstance(), + "dubboTestService"); + + apiBean.addApiDefinition(TestDubboService.class.getMethod("findById", String.class), "/findById"); + + String document = invokeGetDocument(dubboRegistrar, apiBean.getApiDefinitions().get(0)); + + // RPC document should use requestParameters/responseParameters (not parameters/responseType) + Map docMap = GsonUtils.getInstance().toObjectMap(document); + assertThat("RPC document should contain requestParameters", docMap.containsKey("requestParameters"), is(true)); + assertThat("RPC document should contain responseParameters", docMap.containsKey("responseParameters"), is(true)); + assertThat("RPC document should NOT contain parameters (old field)", docMap.containsKey("parameters"), is(false)); + assertThat("RPC document should NOT contain responseType (old field)", docMap.containsKey("responseType"), is(false)); + } + + @Test + void testGetDocumentForHttpType() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.HTTP.getName(), + TestHttpService.class.getName(), + TestHttpService.class.getDeclaredConstructor().newInstance(), + "httpTestService"); + + apiBean.addApiDefinition(TestHttpService.class.getMethod("findById", String.class), "/findById"); + + String document = invokeGetDocument(httpRegistrar, apiBean.getApiDefinitions().get(0)); + + // HTTP document should also use requestParameters/responseParameters + Map docMap = GsonUtils.getInstance().toObjectMap(document); + assertThat("HTTP document should contain requestParameters", docMap.containsKey("requestParameters"), is(true)); + assertThat("HTTP document should contain responseParameters", docMap.containsKey("responseParameters"), is(true)); + assertThat("HTTP document should NOT contain parameters (old field)", docMap.containsKey("parameters"), is(false)); + assertThat("HTTP document should NOT contain responseType (old field)", docMap.containsKey("responseType"), is(false)); + } + + @Test + void testGetDocumentForGrpcType() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.GRPC.getName(), + TestGrpcService.class.getName(), + TestGrpcService.class.getDeclaredConstructor().newInstance(), + "grpcTestService"); + + apiBean.addApiDefinition(TestGrpcService.class.getMethod("findById", String.class), "/findById"); + + String document = invokeGetDocument(grpcRegistrar, apiBean.getApiDefinitions().get(0)); + + // gRPC document should use requestParameters/responseParameters + Map docMap = GsonUtils.getInstance().toObjectMap(document); + assertThat("gRPC document should contain requestParameters", docMap.containsKey("requestParameters"), is(true)); + assertThat("gRPC document should contain responseParameters", docMap.containsKey("responseParameters"), is(true)); + assertThat("gRPC document should NOT contain parameters (old field)", docMap.containsKey("parameters"), is(false)); + assertThat("gRPC document should NOT contain responseType (old field)", docMap.containsKey("responseType"), is(false)); + } + + @Test + void testGetDocumentWithCustomDocument() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), + TestDubboService.class.getName(), + TestDubboService.class.getDeclaredConstructor().newInstance(), + "dubboTestService"); + + apiBean.addApiDefinition(TestDubboService.class.getMethod("findById", String.class), "/findById"); + + // Set custom document via properties + String customDoc = "{\"tags\":[\"custom\"],\"operationId\":\"/custom\"}"; + apiBean.getApiDefinitions().get(0).addProperties("document", customDoc); + + String document = invokeGetDocument(dubboRegistrar, apiBean.getApiDefinitions().get(0)); + + // Should return the custom document as-is + Map docMap = GsonUtils.getInstance().toObjectMap(document); + assertThat("Should use custom document", docMap.containsKey("operationId"), is(true)); + assertThat(docMap.get("operationId"), is("/custom")); + } + + @SuppressWarnings("unchecked") + private String invokeGetDocument(final ApiDocRegistrarImpl registrar, final ApiBean.ApiDefinition api) throws Exception { + Method getDocumentMethod = ApiDocRegistrarImpl.class.getDeclaredMethod("getDocument", ApiBean.ApiDefinition.class); + getDocumentMethod.setAccessible(true); + return (String) getDocumentMethod.invoke(registrar, api); + } + + // --- Inner types (must be after all methods per checkstyle InnerTypeLast) --- + + public static class TestDubboService { + public Object findById(final String id) { + return null; + } + } + + public static class TestHttpService { + public Object findById(final String id) { + return null; + } + } + + public static class TestGrpcService { + public Object findById(final String id) { + return null; + } + } + + static class DubboClientRegisterConfig implements ClientRegisterConfig { + @Override + public Integer getPort() { + return 20880; + } + + @Override + public String getHost() { + return "127.0.0.1"; + } + + @Override + public String getAppName() { + return "test-dubbo"; + } + + @Override + public String getContextPath() { + return "/dubbo"; + } + + @Override + public String getIpAndPort() { + return "127.0.0.1:20880"; + } + + @Override + public Boolean getAddPrefixed() { + return false; + } + + @Override + public RpcTypeEnum getRpcTypeEnum() { + return RpcTypeEnum.DUBBO; + } + } + + static class HttpClientRegisterConfig implements ClientRegisterConfig { + @Override + public Integer getPort() { + return 8080; + } + + @Override + public String getHost() { + return "127.0.0.1"; + } + + @Override + public String getAppName() { + return "test-http"; + } + + @Override + public String getContextPath() { + return "/http"; + } + + @Override + public String getIpAndPort() { + return "127.0.0.1:8080"; + } + + @Override + public Boolean getAddPrefixed() { + return false; + } + + @Override + public RpcTypeEnum getRpcTypeEnum() { + return RpcTypeEnum.HTTP; + } + } + + static class GrpcClientRegisterConfig implements ClientRegisterConfig { + @Override + public Integer getPort() { + return 9090; + } + + @Override + public String getHost() { + return "127.0.0.1"; + } + + @Override + public String getAppName() { + return "test-grpc"; + } + + @Override + public String getContextPath() { + return "/grpc"; + } + + @Override + public String getIpAndPort() { + return "127.0.0.1:9090"; + } + + @Override + public Boolean getAddPrefixed() { + return false; + } + + @Override + public RpcTypeEnum getRpcTypeEnum() { + return RpcTypeEnum.GRPC; + } + } +} diff --git a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/NoHttpApiDocRegistrarTest.java b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/NoHttpApiDocRegistrarTest.java index 835c57a6357f..e9760c6dc938 100644 --- a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/NoHttpApiDocRegistrarTest.java +++ b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/register/registrar/NoHttpApiDocRegistrarTest.java @@ -20,41 +20,295 @@ import org.apache.shenyu.client.apidocs.annotations.ApiDoc; import org.apache.shenyu.client.apidocs.annotations.ApiModule; import org.apache.shenyu.client.core.constant.ShenyuClientConstants; +import org.apache.shenyu.client.core.disruptor.ShenyuClientRegisterEventPublisher; import org.apache.shenyu.client.core.register.ApiBean; +import org.apache.shenyu.client.core.register.ClientRegisterConfig; import org.apache.shenyu.common.enums.ApiHttpMethodEnum; import org.apache.shenyu.common.enums.RpcTypeEnum; +import org.apache.shenyu.register.client.api.ShenyuClientRegisterRepository; +import org.apache.shenyu.register.common.dto.ApiDocRegisterDTO; +import org.apache.shenyu.register.common.type.DataTypeParent; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; public class NoHttpApiDocRegistrarTest { - private final NoHttpApiDocRegistrar noHttpApiDocRegistrar = - new NoHttpApiDocRegistrar(null, new TestClientRegisterConfig()); - + + private TestShenyuClientRegisterEventPublisher testPublisher; + + private NoHttpApiDocRegistrar noHttpApiDocRegistrar; + + private NoHttpApiDocRegistrar grpcApiDocRegistrar; + + @BeforeEach + public void init() { + testPublisher = new TestShenyuClientRegisterEventPublisher(); + noHttpApiDocRegistrar = new NoHttpApiDocRegistrar(testPublisher, new DubboTestClientRegisterConfig()); + grpcApiDocRegistrar = new NoHttpApiDocRegistrar(testPublisher, new GrpcTestClientRegisterConfig()); + } + @Test public void testDoParse() { final TestApiBeanAnnotatedClassAndMethod bean = new TestApiBeanAnnotatedClassAndMethod(); - - ApiBean apiBean = new ApiBean(RpcTypeEnum.HTTP.getName(), "bean", bean); - + + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), "bean", bean); + apiBean.addApiDefinition(null, null); - + AbstractApiDocRegistrar.HttpApiSpecificInfo httpApiSpecificInfo = noHttpApiDocRegistrar.doParse(apiBean.getApiDefinitions().get(0)); - + assertThat(httpApiSpecificInfo.getApiHttpMethodEnums().get(0), is(ApiHttpMethodEnum.NOT_HTTP)); - + assertThat(httpApiSpecificInfo.getConsume(), is(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE)); - + assertThat(httpApiSpecificInfo.getProduce(), is(ShenyuClientConstants.MEDIA_TYPE_ALL_VALUE)); } - + + @Test + public void testRpcDocumentGenerationWithParameters() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), + DubboTestServiceImpl.class.getName(), + DubboTestServiceImpl.class.getDeclaredConstructor().newInstance(), + "dubboTestService"); + + apiBean.addApiDefinition(DubboTestServiceImpl.class.getMethod("findById", String.class), "/findById"); + + noHttpApiDocRegistrar.register(apiBean); + + ApiDocRegisterDTO dto = testPublisher.metaData; + assertThat(dto, notNullValue()); + + // Verify document contains requestParameters (ResponseType format for RPC) + assertThat(dto.getDocument(), notNullValue()); + assertThat("Document should contain requestParameters field", dto.getDocument().contains("\"requestParameters\""), is(true)); + assertThat("Parameters should have string type", dto.getDocument().contains("\"type\":\"string\""), is(true)); + } + + @Test + public void testRpcDocumentGenerationWithComplexParameter() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), + DubboTestServiceImpl.class.getName(), + DubboTestServiceImpl.class.getDeclaredConstructor().newInstance(), + "dubboTestService"); + + apiBean.addApiDefinition(DubboTestServiceImpl.class.getMethod("insert", DubboTest.class), "/insert"); + + noHttpApiDocRegistrar.register(apiBean); + + ApiDocRegisterDTO dto = testPublisher.metaData; + assertThat(dto, notNullValue()); + + // Verify document contains object-type parameter with nested refs + assertThat(dto.getDocument(), notNullValue()); + assertThat("Document should contain requestParameters with object type", + dto.getDocument().contains("\"type\":\"object\""), is(true)); + assertThat("Complex parameter should have nested refs", + dto.getDocument().contains("\"refs\""), is(true)); + } + + @Test + public void testRpcDocumentGenerationResponseType() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.DUBBO.getName(), + DubboTestServiceImpl.class.getName(), + DubboTestServiceImpl.class.getDeclaredConstructor().newInstance(), + "dubboTestService"); + + apiBean.addApiDefinition(DubboTestServiceImpl.class.getMethod("findById", String.class), "/findById"); + + noHttpApiDocRegistrar.register(apiBean); + + ApiDocRegisterDTO dto = testPublisher.metaData; + assertThat(dto, notNullValue()); + + // Verify responseParameters contains object with id and name fields + assertThat(dto.getDocument(), notNullValue()); + assertThat("responseParameters should contain object type", + dto.getDocument().contains("\"responseParameters\""), is(true)); + } + + @Test + public void testGrpcDocumentGenerationWithParameters() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.GRPC.getName(), + GrpcTestServiceImpl.class.getName(), + GrpcTestServiceImpl.class.getDeclaredConstructor().newInstance(), + "grpcTestService"); + + apiBean.addApiDefinition(GrpcTestServiceImpl.class.getMethod("unaryCall", String.class, io.grpc.stub.StreamObserver.class), "/unaryCall"); + + grpcApiDocRegistrar.register(apiBean); + + ApiDocRegisterDTO dto = testPublisher.metaData; + assertThat(dto, notNullValue()); + assertThat(dto.getDocument(), notNullValue()); + assertThat("gRPC document should contain requestParameters field", dto.getDocument().contains("\"requestParameters\""), is(true)); + assertThat("gRPC document should contain responseParameters field", dto.getDocument().contains("\"responseParameters\""), is(true)); + } + + @Test + public void testGrpcDocumentGenerationWithVoidReturn() throws Exception { + ApiBean apiBean = new ApiBean(RpcTypeEnum.GRPC.getName(), + GrpcTestServiceImpl.class.getName(), + GrpcTestServiceImpl.class.getDeclaredConstructor().newInstance(), + "grpcTestService"); + + apiBean.addApiDefinition(GrpcTestServiceImpl.class.getMethod("noStreamObserver", String.class), "/noStream"); + + grpcApiDocRegistrar.register(apiBean); + + ApiDocRegisterDTO dto = testPublisher.metaData; + assertThat(dto, notNullValue()); + assertThat(dto.getDocument(), notNullValue()); + assertThat("gRPC document should contain requestParameters field", dto.getDocument().contains("\"requestParameters\""), is(true)); + assertThat("gRPC document should contain responseParameters field", dto.getDocument().contains("\"responseParameters\""), is(true)); + } + + // --- Inner types (must be after all methods per checkstyle InnerTypeLast) --- + + public static class DubboTest { + + private String id; + + private String name; + + public DubboTest() { + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + } + + @ApiModule("dubboTestService") + public static class DubboTestServiceImpl { + + @ApiDoc(desc = "findById") + public DubboTest findById(final String id) { + return null; + } + + @ApiDoc(desc = "insert") + public DubboTest insert(final DubboTest dubboTest) { + return null; + } + } + + @ApiModule("grpcTestService") + public static class GrpcTestServiceImpl { + + @ApiDoc(desc = "unaryCall") + public void unaryCall(final String request, final io.grpc.stub.StreamObserver responseObserver) { + } + + @ApiDoc(desc = "noStreamObserver") + public String noStreamObserver(final String request) { + return null; + } + } + @ApiModule("testClass") static class TestApiBeanAnnotatedClassAndMethod { + @ApiDoc(desc = "testMethod") public String testMethod() { return ""; } } + + static class DubboTestClientRegisterConfig implements ClientRegisterConfig { + + @Override + public Integer getPort() { + return 20880; + } + + @Override + public String getHost() { + return "127.0.0.1"; + } + + @Override + public String getAppName() { + return "test-dubbo"; + } + + @Override + public String getContextPath() { + return "/dubbo"; + } + + @Override + public String getIpAndPort() { + return "127.0.0.1:20880"; + } + + @Override + public Boolean getAddPrefixed() { + return false; + } + + @Override + public RpcTypeEnum getRpcTypeEnum() { + return RpcTypeEnum.DUBBO; + } + } + + static class GrpcTestClientRegisterConfig implements ClientRegisterConfig { + + @Override + public Integer getPort() { + return 9090; + } + + @Override + public String getHost() { + return "127.0.0.1"; + } + + @Override + public String getAppName() { + return "test-grpc"; + } + + @Override + public String getContextPath() { + return "/grpc"; + } + + @Override + public String getIpAndPort() { + return "127.0.0.1:9090"; + } + + @Override + public Boolean getAddPrefixed() { + return false; + } + + @Override + public RpcTypeEnum getRpcTypeEnum() { + return RpcTypeEnum.GRPC; + } + } + + static class TestShenyuClientRegisterEventPublisher extends ShenyuClientRegisterEventPublisher { + + private ApiDocRegisterDTO metaData; + + @Override + public void start(final ShenyuClientRegisterRepository shenyuClientRegisterRepository) { + } + + @Override + public void publishEvent(final DataTypeParent data) { + this.metaData = (ApiDocRegisterDTO) data; + } + } } diff --git a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java new file mode 100644 index 000000000000..f2e044a30dc8 --- /dev/null +++ b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java @@ -0,0 +1,502 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.shenyu.client.core.utils; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.protobuf.Empty; +import org.apache.shenyu.client.core.test.Test.Address; +import org.apache.shenyu.client.core.test.Test.TestRequest; +import org.apache.shenyu.client.core.utils.OpenApiUtils.Parameter; +import org.apache.shenyu.client.core.utils.OpenApiUtils.ResponseType; +import org.apache.shenyu.common.enums.RpcTypeEnum; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +public class OpenApiUtilsTest { + + @Test + void testGenerateRpcDocumentResponse() throws Exception { + Method method = DubboTestService.class.getMethod("findById", String.class); + Map response = OpenApiUtils.generateRpcDocumentResponse("/dubbo/findById", method); + + assertThat(response.containsKey("200"), is(true)); + assertThat(response.containsKey("404"), is(true)); + assertThat(response.containsKey("409"), is(false)); + } + + @Test + void testGenerateRpcDocumentResponseVoidReturn() throws Exception { + Method method = RpcComplexParamService.class.getMethod("deleteById", String.class); + Map response = OpenApiUtils.generateRpcDocumentResponse("/rpc/deleteById", method); + assertThat(response.containsKey("200"), is(true)); + assertThat(response.containsKey("404"), is(true)); + assertThat(response.containsKey("409"), is(false)); + Map successResponse = (Map) response.get("200"); + Map content = (Map) successResponse.get("content"); + Map schema = (Map) ((Map) content.get("*/*")).get("schema"); + assertThat(schema.get("type"), is("void")); + } + + @Test + void testParseReturnTypeObject() throws Exception { + Method method = DubboTestService.class.getMethod("findById", String.class); + ResponseType returnType = OpenApiUtils.parseReturnType(method); + + assertThat(returnType.getType(), is("object")); + assertThat(returnType.getRefs(), notNullValue()); + assertThat(returnType.getRefs(), hasSize(2)); + assertThat(returnType.getRefs().get(0).getName(), is("id")); + assertThat(returnType.getRefs().get(0).getType(), is("string")); + assertThat(returnType.getRefs().get(1).getName(), is("name")); + assertThat(returnType.getRefs().get(1).getType(), is("string")); + } + + @Test + void testParseReturnTypeList() throws Exception { + Method method = DubboTestService.class.getMethod("findAll"); + ResponseType returnType = OpenApiUtils.parseReturnType(method); + + assertThat(returnType.getType(), is("array")); + assertThat(returnType.getRefs(), notNullValue()); + assertThat(returnType.getRefs(), hasSize(1)); + assertThat(returnType.getRefs().get(0).getType(), is("object")); + } + + @Test + void testIsProtobufMessageNegative() { + assertThat(OpenApiUtils.isProtobufMessage(DubboTest.class), is(false)); + assertThat(OpenApiUtils.isProtobufMessage(String.class), is(false)); + assertThat(OpenApiUtils.isProtobufMessage(null), is(false)); + assertThat(OpenApiUtils.isProtobufMessage(int.class), is(false)); + } + + @Test + void testIsProtobufMessagePositive() { + assertThat(OpenApiUtils.isProtobufMessage(TestRequest.class), is(true)); + assertThat(OpenApiUtils.isProtobufMessage(Address.class), is(true)); + assertThat(OpenApiUtils.isProtobufMessage(Empty.class), is(true)); + } + + @Test + void testParseReturnTypeProtobufWithAllFieldTypes() throws Exception { + Method method = ProtobufTestService.class.getMethod("getTestRequest"); + ResponseType returnType = OpenApiUtils.parseReturnType(method); + assertThat(returnType.getType(), is("object")); + assertThat(returnType.getRefs(), notNullValue()); + assertThat(returnType.getRefs(), hasSize(7)); + + assertThat(returnType.getRefs().get(0).getName(), is("id")); + assertThat(returnType.getRefs().get(0).getType(), is("string")); + + assertThat(returnType.getRefs().get(1).getName(), is("count")); + assertThat(returnType.getRefs().get(1).getType(), is("integer")); + + assertThat(returnType.getRefs().get(2).getName(), is("enabled")); + assertThat(returnType.getRefs().get(2).getType(), is("boolean")); + + assertThat(returnType.getRefs().get(3).getName(), is("status")); + assertThat(returnType.getRefs().get(3).getType(), is("string")); + + assertThat(returnType.getRefs().get(4).getName(), is("address")); + assertThat(returnType.getRefs().get(4).getType(), is("object")); + assertThat(returnType.getRefs().get(4).getRefs(), notNullValue()); + assertThat(returnType.getRefs().get(4).getRefs(), hasSize(2)); + assertThat(returnType.getRefs().get(4).getRefs().get(0).getName(), is("street")); + assertThat(returnType.getRefs().get(4).getRefs().get(0).getType(), is("string")); + assertThat(returnType.getRefs().get(4).getRefs().get(1).getName(), is("city")); + assertThat(returnType.getRefs().get(4).getRefs().get(1).getType(), is("string")); + + assertThat(returnType.getRefs().get(5).getName(), is("tags")); + assertThat(returnType.getRefs().get(5).getType(), is("array")); + assertThat(returnType.getRefs().get(5).getRefs(), notNullValue()); + assertThat(returnType.getRefs().get(5).getRefs(), hasSize(1)); + assertThat(returnType.getRefs().get(5).getRefs().get(0).getType(), is("string")); + + assertThat(returnType.getRefs().get(6).getName(), is("addresses")); + assertThat(returnType.getRefs().get(6).getType(), is("array")); + assertThat(returnType.getRefs().get(6).getRefs(), notNullValue()); + assertThat(returnType.getRefs().get(6).getRefs(), hasSize(1)); + assertThat(returnType.getRefs().get(6).getRefs().get(0).getType(), is("object")); + } + + @Test + void testParseReturnTypeProtobufEmpty() throws Exception { + Method method = ProtobufTestService.class.getMethod("getEmpty"); + ResponseType returnType = OpenApiUtils.parseReturnType(method); + assertThat(returnType.getType(), is("object")); + assertThat(returnType.getRefs(), nullValue()); + } + + @Test + void testGenerateRpcRequestDocParametersProtobuf() throws Exception { + Method method = ProtobufTestService.class.getMethod("sendTestRequest", TestRequest.class); + List params = OpenApiUtils.generateRpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("request")); + assertThat(params.get(0).getType(), is("object")); + assertThat(params.get(0).getRefs(), notNullValue()); + assertThat(params.get(0).getRefs(), hasSize(7)); + assertThat(params.get(0).getRefs().get(0).getName(), is("id")); + assertThat(params.get(0).getRefs().get(0).getType(), is("string")); + assertThat(params.get(0).getRefs().get(3).getName(), is("status")); + assertThat(params.get(0).getRefs().get(3).getType(), is("string")); + assertThat(params.get(0).getRefs().get(5).getName(), is("tags")); + assertThat(params.get(0).getRefs().get(5).getType(), is("array")); + } + + @Test + void testGenerateDocumentResponseExistingBehavior() { + Map response = OpenApiUtils.generateDocumentResponse("/test/path"); + assertThat(response.containsKey("200"), is(true)); + assertThat(response.containsKey("404"), is(true)); + assertThat(response.containsKey("409"), is(true)); + } + + @Test + void testGenerateRequestDocParametersWithRequestParam() throws Exception { + Method method = SpringMvcController.class.getMethod("query", String.class); + List params = OpenApiUtils.generateRequestDocParameters("/test/query", method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("name")); + assertThat(params.get(0).getType(), is("string")); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateRequestDocParametersWithPathVariable() throws Exception { + Method method = SpringMvcController.class.getMethod("getByPath", String.class); + List params = OpenApiUtils.generateRequestDocParameters("/test/{id}", method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("id")); + assertThat(params.get(0).getType(), is("string")); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateRequestDocParametersNoAnnotations() throws Exception { + Method method = DubboTestService.class.getMethod("findById", String.class); + List params = OpenApiUtils.generateRequestDocParameters("/dubbo/findById", method); + assertThat(params, hasSize(0)); + } + + @Test + void testGenerateRpcRequestDocParametersSimpleType() throws Exception { + Method method = DubboTestService.class.getMethod("findById", String.class); + List params = OpenApiUtils.generateRpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("id")); + assertThat(params.get(0).getType(), is("string")); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateRpcRequestDocParametersComplexType() throws Exception { + Method method = DubboTestService.class.getMethod("insert", DubboTest.class); + List params = OpenApiUtils.generateRpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getType(), is("object")); + assertThat(params.get(0).getRefs(), notNullValue()); + assertThat(params.get(0).getRefs(), hasSize(2)); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateRpcRequestDocParametersWithListParameter() throws Exception { + Method method = RpcComplexParamService.class.getMethod("batchInsert", List.class); + List params = OpenApiUtils.generateRpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("ids")); + assertThat(params.get(0).getType(), is("object")); + } + + @Test + void testGenerateRpcRequestDocParametersWithMapParameter() throws Exception { + Method method = RpcComplexParamService.class.getMethod("searchByMap", Map.class); + List params = OpenApiUtils.generateRpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("params")); + assertThat(params.get(0).getType(), is("object")); + } + + @Test + void testUseSpringMvcParamParsing() { + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.HTTP), is(true)); + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.WEB_SOCKET), is(true)); + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.SPRING_CLOUD), is(true)); + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.DUBBO), is(false)); + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.GRPC), is(false)); + assertThat(OpenApiUtils.useSpringMvcParamParsing(RpcTypeEnum.SOFA), is(false)); + } + + // --- gRPC tests --- + + @Test + void testGenerateGrpcRequestDocParametersSimpleType() throws Exception { + Method method = GrpcTestService.class.getMethod("unaryCall", String.class, io.grpc.stub.StreamObserver.class); + List params = OpenApiUtils.generateGrpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("request")); + assertThat(params.get(0).getType(), is("string")); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateGrpcRequestDocParametersComplexType() throws Exception { + Method method = GrpcTestService.class.getMethod("unaryCallComplex", GrpcTestClass.class, io.grpc.stub.StreamObserver.class); + List params = OpenApiUtils.generateGrpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("request")); + assertThat(params.get(0).getType(), is("object")); + assertThat(params.get(0).getRefs(), notNullValue()); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testGenerateGrpcRequestDocParametersClientStreaming() throws Exception { + Method method = GrpcClientStreamingService.class.getMethod("clientStreaming", io.grpc.stub.StreamObserver.class); + List params = OpenApiUtils.generateGrpcRequestDocParameters(method); + assertThat(params, hasSize(1)); + assertThat(params.get(0).getName(), is("request")); + assertThat(params.get(0).getType(), is("string")); + assertThat(params.get(0).isRequired(), is(true)); + } + + @Test + void testParseGrpcReturnTypeWithStreamObserver() throws Exception { + Method method = GrpcTestService.class.getMethod("unaryCall", String.class, io.grpc.stub.StreamObserver.class); + ResponseType returnType = OpenApiUtils.parseGrpcReturnType(method); + assertThat(returnType.getName(), is("ROOT")); + assertThat(returnType.getType(), is("object")); + assertThat(returnType.getRefs(), notNullValue()); + assertThat(returnType.getRefs(), hasSize(2)); + } + + @Test + void testParseGrpcReturnTypeVoid() throws Exception { + Method method = GrpcTestService.class.getMethod("noStreamObserver", String.class); + ResponseType returnType = OpenApiUtils.parseGrpcReturnType(method); + assertThat(returnType.getName(), is("ROOT")); + assertThat(returnType.getType(), is("void")); + } + + @Test + void testParseGrpcReturnTypeClientStreaming() throws Exception { + Method method = GrpcClientStreamingService.class.getMethod("clientStreaming", io.grpc.stub.StreamObserver.class); + ResponseType returnType = OpenApiUtils.parseGrpcReturnType(method); + assertThat(returnType.getName(), is("ROOT")); + assertThat(returnType.getType(), is("object")); + assertThat(returnType.getRefs(), notNullValue()); + assertThat(returnType.getRefs(), hasSize(2)); + } + + @Test + void testGenerateGrpcDocumentResponseWithStreamObserver() throws Exception { + Method method = GrpcTestService.class.getMethod("unaryCall", String.class, io.grpc.stub.StreamObserver.class); + Map response = OpenApiUtils.generateGrpcDocumentResponse("/grpc/unaryCall", method); + assertThat(response.containsKey("200"), is(true)); + assertThat(response.containsKey("404"), is(true)); + assertThat(response.containsKey("409"), is(false)); + } + + @Test + void testGenerateGrpcDocumentResponseVoid() throws Exception { + Method method = GrpcTestService.class.getMethod("noStreamObserver", String.class); + Map response = OpenApiUtils.generateGrpcDocumentResponse("/grpc/noStream", method); + assertThat(response.containsKey("200"), is(true)); + assertThat(response.containsKey("404"), is(true)); + } + + // --- buildDocumentJson dispatch tests --- + + @Test + void testBuildDocumentJsonHttp() throws Exception { + Method method = SpringMvcController.class.getMethod("query", String.class); + String json = OpenApiUtils.buildDocumentJson(Arrays.asList("tag1"), "/test/query", method, RpcTypeEnum.HTTP); + JsonObject doc = JsonParser.parseString(json).getAsJsonObject(); + assertThat(doc.has("requestParameters"), is(true)); + assertThat(doc.has("responseParameters"), is(true)); + assertThat(doc.has("parameters"), is(false)); + assertThat(doc.has("responseType"), is(false)); + JsonObject responses = doc.getAsJsonObject("responses"); + assertThat(responses.has("200"), is(true)); + assertThat(responses.has("404"), is(true)); + assertThat(responses.has("409"), is(true)); + } + + @Test + void testBuildDocumentJsonGrpc() throws Exception { + Method method = GrpcTestService.class.getMethod("unaryCall", String.class, io.grpc.stub.StreamObserver.class); + String json = OpenApiUtils.buildDocumentJson(Arrays.asList("tag1"), "/grpc/unaryCall", method, RpcTypeEnum.GRPC); + JsonObject doc = JsonParser.parseString(json).getAsJsonObject(); + assertThat(doc.has("requestParameters"), is(true)); + assertThat(doc.has("responseParameters"), is(true)); + assertThat(doc.has("parameters"), is(false)); + assertThat(doc.has("responseType"), is(false)); + JsonObject responses = doc.getAsJsonObject("responses"); + assertThat(responses.has("200"), is(true)); + assertThat(responses.has("404"), is(true)); + assertThat(responses.has("409"), is(false)); + } + + @Test + void testBuildDocumentJsonRpc() throws Exception { + Method method = DubboTestService.class.getMethod("findById", String.class); + String json = OpenApiUtils.buildDocumentJson(Arrays.asList("tag1"), "/dubbo/findById", method, RpcTypeEnum.DUBBO); + JsonObject doc = JsonParser.parseString(json).getAsJsonObject(); + assertThat(doc.has("requestParameters"), is(true)); + assertThat(doc.has("responseParameters"), is(true)); + assertThat(doc.has("parameters"), is(false)); + assertThat(doc.has("responseType"), is(false)); + JsonObject responses = doc.getAsJsonObject("responses"); + assertThat(responses.has("200"), is(true)); + assertThat(responses.has("404"), is(true)); + assertThat(responses.has("409"), is(false)); + } + + // --- Inner types (must be after all methods per checkstyle InnerTypeLast) --- + + public static class DubboTest { + + private String id; + + private String name; + + public DubboTest() { + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + } + + public static class DubboTestService { + + public DubboTest findById(final String id) { + return null; + } + + public DubboTest insert(final DubboTest dubboTest) { + return null; + } + + public List findAll() { + return null; + } + } + + @RestController + @RequestMapping("/test") + public static class SpringMvcController { + + @RequestMapping("/query") + public String query(@RequestParam("name") final String name) { + return ""; + } + + @RequestMapping("/{id}") + public String getByPath(@RequestParam("id") final String id) { + return ""; + } + } + + public static class GrpcTestClass { + + private String id; + + private String name; + + public GrpcTestClass() { + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + } + + public static class GrpcTestService { + + public void unaryCall(final String request, final io.grpc.stub.StreamObserver responseObserver) { + } + + public void unaryCallComplex(final GrpcTestClass request, final io.grpc.stub.StreamObserver responseObserver) { + } + + public String noStreamObserver(final String request) { + return null; + } + } + + public static class GrpcClientStreamingService { + + public io.grpc.stub.StreamObserver clientStreaming( + final io.grpc.stub.StreamObserver responseObserver) { + return null; + } + } + + public static class ProtobufTestService { + + public TestRequest getTestRequest() { + return null; + } + + public TestRequest sendTestRequest(final TestRequest request) { + return null; + } + + public Empty getEmpty() { + return null; + } + } + + public static class RpcComplexParamService { + + public String batchInsert(final List ids) { + return null; + } + + public String searchByMap(final Map params) { + return null; + } + + public void deleteById(final String id) { + } + } +} diff --git a/shenyu-client/shenyu-client-core/src/test/proto/test.proto b/shenyu-client/shenyu-client-core/src/test/proto/test.proto new file mode 100644 index 000000000000..7c4f267ac9c8 --- /dev/null +++ b/shenyu-client/shenyu-client-core/src/test/proto/test.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; + +package org.apache.shenyu.client.core.test; + +option java_package = "org.apache.shenyu.client.core.test"; + +enum Status { + UNKNOWN = 0; + ACTIVE = 1; + INACTIVE = 2; +} + +message Address { + string street = 1; + string city = 2; +} + +message TestRequest { + string id = 1; + int32 count = 2; + bool enabled = 3; + Status status = 4; + Address address = 5; + repeated string tags = 6; + repeated Address addresses = 7; +} diff --git a/shenyu-client/shenyu-client-sofa/src/main/java/org/apache/shenyu/client/sofa/SofaServiceEventListener.java b/shenyu-client/shenyu-client-sofa/src/main/java/org/apache/shenyu/client/sofa/SofaServiceEventListener.java index 167c580da687..bc3d40ac1c6b 100644 --- a/shenyu-client/shenyu-client-sofa/src/main/java/org/apache/shenyu/client/sofa/SofaServiceEventListener.java +++ b/shenyu-client/shenyu-client-sofa/src/main/java/org/apache/shenyu/client/sofa/SofaServiceEventListener.java @@ -37,9 +37,9 @@ import org.slf4j.LoggerFactory; import org.springframework.aop.support.AopUtils; import org.springframework.context.ApplicationContext; -import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.core.annotation.AnnotatedElementUtils; import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; import org.springframework.util.ReflectionUtils; import java.lang.annotation.Annotation; @@ -102,25 +102,41 @@ protected Class getAnnotationType() { return ShenyuSofaClient.class; } + @Override + protected void handleClass(final Class clazz, + final ServiceFactoryBean bean, + @NonNull final ShenyuSofaClient beanShenyuClient, + final String superPath) { + List namespaceIds = super.getNamespace(); + Method[] methods = ReflectionUtils.getDeclaredMethods(clazz); + for (String namespaceId : namespaceIds) { + for (Method method : methods) { + final MetaDataRegisterDTO metaData = buildMetaDataDTO(bean, beanShenyuClient, + buildApiPath(method, superPath, null), clazz, method, namespaceId); + getPublisher().publishEvent(metaData); + getMetaDataMap().put(method, metaData); + } + } + } + @Override protected String buildApiPath(final Method method, final String superPath, - @NonNull final ShenyuSofaClient shenyuSofaClient) { + @Nullable final ShenyuSofaClient shenyuSofaClient) { final String contextPath = this.getContextPath(); return superPath.contains("*") ? pathJoin(contextPath, superPath.replace("*", ""), method.getName()) - : pathJoin(contextPath, superPath, shenyuSofaClient.path()); + : pathJoin(contextPath, superPath, Objects.requireNonNull(shenyuSofaClient).path()); } @Override protected MetaDataRegisterDTO buildMetaDataDTO(final ServiceFactoryBean serviceBean, @NonNull final ShenyuSofaClient shenyuSofaClient, - final String superPath, + final String path, final Class clazz, final Method method, final String namespaceId) { String appName = this.getAppName(); String contextPath = this.getContextPath(); - String path = pathJoin(contextPath, superPath, shenyuSofaClient.path()); String serviceName = serviceBean.getInterfaceClass().getName(); String desc = shenyuSofaClient.desc(); String configRuleName = shenyuSofaClient.ruleName(); @@ -157,11 +173,18 @@ protected MetaDataRegisterDTO buildMetaDataDTO(final ServiceFactoryBean serviceB } @Override - public void onApplicationEvent(final ContextRefreshedEvent contextRefreshedEvent) { - Map serviceBean = contextRefreshedEvent.getApplicationContext().getBeansOfType(ServiceFactoryBean.class); - for (Map.Entry entry : serviceBean.entrySet()) { - handler(entry.getValue()); + protected Class getCorrectedClass(final ServiceFactoryBean bean) { + Object targetProxy; + try { + targetProxy = ((Service) Objects.requireNonNull(bean.getObject())).getTarget(); + } catch (Exception e) { + LOG.error("failed to get sofa target class", e); + return bean.getClass(); } + if (AopUtils.isAopProxy(targetProxy)) { + return AopUtils.getTargetClass(targetProxy); + } + return targetProxy.getClass(); } @Override @@ -179,42 +202,6 @@ protected Sextet clazz; - Object targetProxy; - try { - targetProxy = ((Service) Objects.requireNonNull(serviceBean.getObject())).getTarget(); - clazz = targetProxy.getClass(); - } catch (Exception e) { - LOG.error("failed to get sofa target class", e); - return; - } - if (AopUtils.isAopProxy(targetProxy)) { - clazz = AopUtils.getTargetClass(targetProxy); - } - final ShenyuSofaClient beanSofaClient = AnnotatedElementUtils.findMergedAnnotation(clazz, ShenyuSofaClient.class); - final String superPath = buildApiSuperPath(clazz, beanSofaClient); - List namespaceIds = super.getNamespace(); - if (superPath.contains("*") && Objects.nonNull(beanSofaClient)) { - Method[] declaredMethods = ReflectionUtils.getDeclaredMethods(clazz); - for (String namespaceId : namespaceIds) { - for (Method declaredMethod : declaredMethods) { - getPublisher().publishEvent(buildMetaDataDTO(serviceBean, beanSofaClient, superPath, clazz, declaredMethod, namespaceId)); - } - } - return; - } - Method[] methods = ReflectionUtils.getUniqueDeclaredMethods(clazz); - for (String namespaceId : namespaceIds) { - for (Method method : methods) { - ShenyuSofaClient methodSofaClient = AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSofaClient.class); - if (Objects.nonNull(methodSofaClient)) { - getPublisher().publishEvent(buildMetaDataDTO(serviceBean, methodSofaClient, superPath, clazz, method, namespaceId)); - } - } - } - } - private String buildRpcExt(final ShenyuSofaClient shenyuSofaClient) { SofaRpcExt build = SofaRpcExt.builder() .loadbalance(shenyuSofaClient.loadBalance()) diff --git a/shenyu-client/shenyu-client-sofa/src/test/java/org/apache/shenyu/client/sofa/SofaServiceEventListenerTest.java b/shenyu-client/shenyu-client-sofa/src/test/java/org/apache/shenyu/client/sofa/SofaServiceEventListenerTest.java index a38615c987d7..5e0bc07b6ed9 100644 --- a/shenyu-client/shenyu-client-sofa/src/test/java/org/apache/shenyu/client/sofa/SofaServiceEventListenerTest.java +++ b/shenyu-client/shenyu-client-sofa/src/test/java/org/apache/shenyu/client/sofa/SofaServiceEventListenerTest.java @@ -204,11 +204,12 @@ public void testBuildMetaDataDTO() throws NoSuchMethodException { String expectedPath = "/sofa/findByIdsAndName/path"; String expectedRpcExt = "{\"loadbalance\":\"loadBalance\",\"retries\":0,\"timeout\":0}"; + String apiPath = sofaServiceEventListener.buildApiPath(method, SUPER_PATH_NOT_CONTAINS_STAR, shenyuSofaClient); MetaDataRegisterDTO realMetaDataRegisterDTO = sofaServiceEventListener .buildMetaDataDTO( serviceFactoryBean, shenyuSofaClient, - SUPER_PATH_NOT_CONTAINS_STAR, + apiPath, SofaServiceEventListener.class, method, Constants.SYS_DEFAULT_NAMESPACE_ID); MetaDataRegisterDTO expectedMetaDataRegisterDTO = MetaDataRegisterDTO diff --git a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-annotation/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/annotation/impl/DubboProtobufServiceImpl.java b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-annotation/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/annotation/impl/DubboProtobufServiceImpl.java index e589bd0c5331..8d8a93f345d7 100644 --- a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-annotation/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/annotation/impl/DubboProtobufServiceImpl.java +++ b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-annotation/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/annotation/impl/DubboProtobufServiceImpl.java @@ -19,27 +19,33 @@ import com.google.protobuf.Empty; import org.apache.dubbo.config.annotation.DubboService; +import org.apache.shenyu.client.apidocs.annotations.ApiDoc; +import org.apache.shenyu.client.apidocs.annotations.ApiModule; import org.apache.shenyu.client.dubbo.common.annotation.ShenyuDubboClient; import org.apache.shenyu.examples.dubbo.api.service.DubboProtobufService; import org.apache.shenyu.examples.dubbo.api.service.DubboTestProtobuf; @DubboService(serialization = "protobuf") @ShenyuDubboClient(value = "/protobufSerialization") +@ApiModule(value = "dubboProtobufService") public class DubboProtobufServiceImpl implements DubboProtobufService { @ShenyuDubboClient("/insert") + @ApiDoc(desc = "insert") @Override public DubboTestProtobuf insert(final DubboTestProtobuf request) { return request; } @ShenyuDubboClient("/update") + @ApiDoc(desc = "update") @Override public Empty update(final DubboTestProtobuf request) { return Empty.getDefaultInstance(); } @ShenyuDubboClient("/findOne") + @ApiDoc(desc = "findOne") @Override public DubboTestProtobuf findOne(final Empty request) { return DubboTestProtobuf.newBuilder().setId("1").setName("test1").build(); diff --git a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-xml/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/xml/impl/DubboProtobufServiceImpl.java b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-xml/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/xml/impl/DubboProtobufServiceImpl.java index 6084bc2470f0..c8f5135e14e5 100644 --- a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-xml/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/xml/impl/DubboProtobufServiceImpl.java +++ b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service-xml/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/xml/impl/DubboProtobufServiceImpl.java @@ -18,6 +18,8 @@ package org.apache.shenyu.examples.apache.dubbo.service.xml.impl; import com.google.protobuf.Empty; +import org.apache.shenyu.client.apidocs.annotations.ApiDoc; +import org.apache.shenyu.client.apidocs.annotations.ApiModule; import org.apache.shenyu.client.dubbo.common.annotation.ShenyuDubboClient; import org.apache.shenyu.examples.dubbo.api.service.DubboProtobufService; import org.apache.shenyu.examples.dubbo.api.service.DubboTestProtobuf; @@ -25,21 +27,25 @@ @Service("dubboProtobufService") @ShenyuDubboClient(value = "/protobufSerialization") +@ApiModule(value = "dubboProtobufService") public class DubboProtobufServiceImpl implements DubboProtobufService { @ShenyuDubboClient("/insert") + @ApiDoc(desc = "insert") @Override public DubboTestProtobuf insert(final DubboTestProtobuf request) { return request; } @ShenyuDubboClient("/update") + @ApiDoc(desc = "update") @Override public Empty update(final DubboTestProtobuf request) { return Empty.getDefaultInstance(); } @ShenyuDubboClient("/findOne") + @ApiDoc(desc = "findOne") @Override public DubboTestProtobuf findOne(final Empty request) { return DubboTestProtobuf.newBuilder().setId("1").setName("test1").build(); diff --git a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/impl/DubboProtobufServiceImpl.java b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/impl/DubboProtobufServiceImpl.java index da96e87072c1..d2efd24320a2 100644 --- a/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/impl/DubboProtobufServiceImpl.java +++ b/shenyu-examples/shenyu-examples-dubbo/shenyu-examples-apache-dubbo-service/src/main/java/org/apache/shenyu/examples/apache/dubbo/service/impl/DubboProtobufServiceImpl.java @@ -18,6 +18,8 @@ package org.apache.shenyu.examples.apache.dubbo.service.impl; import com.google.protobuf.Empty; +import org.apache.shenyu.client.apidocs.annotations.ApiDoc; +import org.apache.shenyu.client.apidocs.annotations.ApiModule; import org.apache.shenyu.client.dubbo.common.annotation.ShenyuDubboClient; import org.apache.shenyu.examples.dubbo.api.service.DubboProtobufService; import org.apache.shenyu.examples.dubbo.api.service.DubboTestProtobuf; @@ -25,21 +27,25 @@ @ShenyuDubboClient(value = "/protobufSerialization") @Service("dubboProtobufService") +@ApiModule(value = "dubboProtobufService") public class DubboProtobufServiceImpl implements DubboProtobufService { @ShenyuDubboClient("/insert") + @ApiDoc(desc = "insert") @Override public DubboTestProtobuf insert(final DubboTestProtobuf request) { return request; } @ShenyuDubboClient("/update") + @ApiDoc(desc = "update") @Override public Empty update(final DubboTestProtobuf request) { return Empty.getDefaultInstance(); } @ShenyuDubboClient("/findOne") + @ApiDoc(desc = "findOne") @Override public DubboTestProtobuf findOne(final Empty request) { return DubboTestProtobuf.newBuilder().setId("1").setName("test1").build(); From d63406ff6d7b87e9ba77b1e89e619d6874fd0805 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Tue, 12 May 2026 11:46:00 +0800 Subject: [PATCH 2/9] fix license --- .../shenyu-client-core/src/test/proto/test.proto | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/shenyu-client/shenyu-client-core/src/test/proto/test.proto b/shenyu-client/shenyu-client-core/src/test/proto/test.proto index 7c4f267ac9c8..fc3e0d305d16 100644 --- a/shenyu-client/shenyu-client-core/src/test/proto/test.proto +++ b/shenyu-client/shenyu-client-core/src/test/proto/test.proto @@ -1,3 +1,18 @@ +// Licensed to the Apache Software Foundation (ASF) under one or more +// contributor license agreements. See the NOTICE file distributed with +// this work for additional information regarding copyright ownership. +// The ASF licenses this file to You 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 +// +// http://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. + syntax = "proto3"; package org.apache.shenyu.client.core.test; From 4cc475c041ae084524e3eb2d4e392b46c0c27be7 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Tue, 12 May 2026 12:05:18 +0800 Subject: [PATCH 3/9] fix checkstyle exclude generated source --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index be8c6d80e734..48d7ead0d802 100644 --- a/pom.xml +++ b/pom.xml @@ -728,7 +728,7 @@ /script/shenyu_checkstyle.xml /script/checkstyle-header.txt true - **/transfer/**/* + **/transfer/**/*,**/generated*/**/* From c556e55609d1ff6731b8066c9f5856c3b8938b79 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Tue, 12 May 2026 14:54:36 +0800 Subject: [PATCH 4/9] fix checkstyle --- shenyu-client/shenyu-client-core/pom.xml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/shenyu-client/shenyu-client-core/pom.xml b/shenyu-client/shenyu-client-core/pom.xml index b46341a266bb..e792a70df701 100644 --- a/shenyu-client/shenyu-client-core/pom.xml +++ b/shenyu-client/shenyu-client-core/pom.xml @@ -121,6 +121,15 @@ + + org.apache.maven.plugins + maven-checkstyle-plugin + + + ${project.basedir}/src/test/java + + + From 04dad48938e21d850596deedb18ceca23ee15b2a Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Fri, 15 May 2026 10:20:52 +0800 Subject: [PATCH 5/9] fix review --- .../client/core/utils/OpenApiUtils.java | 53 ++++++++++--------- .../client/core/utils/OpenApiUtilsTest.java | 21 +++++++- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java index 6a9671b3d704..0ddaefdfff51 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java @@ -120,7 +120,7 @@ public static String buildDocumentJson(final List tags, final String pat /** * Generate request parameters for HTTP methods. - * This produces OpenAPI-style Parameter objects with in and schema fields. + * This produces Parameter objects with name, in, type, and refs fields. * * @param path the api path * @param method the method @@ -154,26 +154,25 @@ public static List generateRequestDocParameters(final String path, fi } } } - } else { - List segments = UrlPathUtils.getSegments(path); - for (String segment : segments) { - if (EVERY_PATH.equals(segment)) { - Parameter parameter = new Parameter(); - parameter.setIn("path"); - parameter.setName(segment); - parameter.setRequired(true); - parameter.setType("string"); - list.add(parameter); - } - if (segment.startsWith(LEFT_ANGLE_BRACKETS) && segment.endsWith(RIGHT_ANGLE_BRACKETS)) { - String name = segment.substring(1, segment.length() - 1); - Parameter parameter = new Parameter(); - parameter.setIn("path"); - parameter.setName(name); - parameter.setRequired(true); - parameter.setType("string"); - list.add(parameter); - } + } + List segments = UrlPathUtils.getSegments(path); + for (String segment : segments) { + if (EVERY_PATH.equals(segment)) { + Parameter parameter = new Parameter(); + parameter.setIn("path"); + parameter.setName(segment); + parameter.setRequired(true); + parameter.setType("string"); + list.add(parameter); + } + if (segment.startsWith(LEFT_ANGLE_BRACKETS) && segment.endsWith(RIGHT_ANGLE_BRACKETS)) { + String name = segment.substring(1, segment.length() - 1); + Parameter parameter = new Parameter(); + parameter.setIn("path"); + parameter.setName(name); + parameter.setRequired(true); + parameter.setType("string"); + list.add(parameter); } } return list; @@ -191,7 +190,7 @@ public static List generateRpcRequestDocParameters(final Method metho List list = new ArrayList<>(); java.lang.reflect.Parameter[] methodParams = method.getParameters(); for (java.lang.reflect.Parameter methodParam : methodParams) { - Class paramType = methodParam.getType(); + Type paramType = methodParam.getParameterizedType(); Schema schema = parseSchema(paramType, 0, new HashMap<>(16)); Parameter parameter = convertSchemaToParameter(methodParam.getName(), schema); parameter.setRequired(true); @@ -217,7 +216,7 @@ public static List generateGrpcRequestDocParameters(final Method meth if (isStreamObserver(methodParam.getType())) { continue; } - Class paramType = methodParam.getType(); + Type paramType = methodParam.getParameterizedType(); Schema schema = parseSchema(paramType, 0, new HashMap<>(16)); Parameter parameter = convertSchemaToParameter(methodParam.getName(), schema); parameter.setRequired(true); @@ -334,7 +333,6 @@ private static Pair isQuery(final Method method) { if (parameterAnnotation.length > 0 && isQueryName(parameterAnnotation[0].annotationType().getName(), QUERY_CLASSES)) { return Pair.of(true, parameterAnnotations); } - return Pair.of(false, null); } return Pair.of(false, null); } @@ -794,6 +792,10 @@ private static Schema parseClassSchema(final Class clazz, final int depth, fi return new Schema("string", null); } else if (isDateType(clazz)) { return new Schema("string", "date"); + } else if (Collection.class.isAssignableFrom(clazz)) { + return new Schema("array", null); + } else if (Map.class.isAssignableFrom(clazz)) { + return new Schema("object", null); } else if (isProtobufMessage(clazz)) { return parseProtobufClassSchema(clazz, depth, typeVariableMap); } else { @@ -822,12 +824,15 @@ private static Schema parseParameterizedTypeSchema(final ParameterizedType type, } if (Collection.class.isAssignableFrom(rawType)) { Schema elementSchema = parseSchema(actualTypeArguments[0], depth + 1, newTypeVariableMap); + elementSchema.setName("items"); Schema schema = new Schema("array", null); schema.setRefs(Collections.singletonList(elementSchema)); return schema; } else if (Map.class.isAssignableFrom(rawType)) { Schema keySchema = parseSchema(actualTypeArguments[0], depth + 1, newTypeVariableMap); + keySchema.setName("key"); Schema valueSchema = parseSchema(actualTypeArguments[1], depth + 1, newTypeVariableMap); + valueSchema.setName("value"); Schema schema = new Schema("object", null); schema.setRefs(Arrays.asList(keySchema, valueSchema)); return schema; diff --git a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java index f2e044a30dc8..350969927fe2 100644 --- a/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java +++ b/shenyu-client/shenyu-client-core/src/test/java/org/apache/shenyu/client/core/utils/OpenApiUtilsTest.java @@ -26,6 +26,7 @@ import org.apache.shenyu.client.core.utils.OpenApiUtils.ResponseType; import org.apache.shenyu.common.enums.RpcTypeEnum; import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -201,6 +202,17 @@ void testGenerateRequestDocParametersWithPathVariable() throws Exception { assertThat(params.get(0).isRequired(), is(true)); } + @Test + void testGenerateRequestDocParametersWithQueryAndPath() throws Exception { + Method method = SpringMvcController.class.getMethod("getByPathWithQuery", String.class, String.class); + List params = OpenApiUtils.generateRequestDocParameters("/test/{id}/detail", method); + assertThat(params, hasSize(2)); + assertThat(params.get(0).getName(), is("name")); + assertThat(params.get(0).getIn(), is("query")); + assertThat(params.get(1).getName(), is("id")); + assertThat(params.get(1).getIn(), is("path")); + } + @Test void testGenerateRequestDocParametersNoAnnotations() throws Exception { Method method = DubboTestService.class.getMethod("findById", String.class); @@ -235,7 +247,7 @@ void testGenerateRpcRequestDocParametersWithListParameter() throws Exception { List params = OpenApiUtils.generateRpcRequestDocParameters(method); assertThat(params, hasSize(1)); assertThat(params.get(0).getName(), is("ids")); - assertThat(params.get(0).getType(), is("object")); + assertThat(params.get(0).getType(), is("array")); } @Test @@ -427,7 +439,12 @@ public String query(@RequestParam("name") final String name) { } @RequestMapping("/{id}") - public String getByPath(@RequestParam("id") final String id) { + public String getByPath(@PathVariable("id") final String id) { + return ""; + } + + @RequestMapping("/{id}/detail") + public String getByPathWithQuery(@PathVariable("id") final String id, @RequestParam("name") final String name) { return ""; } } From 522c819ff51c4c12429c95d63d01c341b5fe74e7 Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Sun, 17 May 2026 10:12:44 +0800 Subject: [PATCH 6/9] Fix Protobuf field schema parsing logic --- .../shenyu/client/core/utils/OpenApiUtils.java | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java index 0ddaefdfff51..d74ba59b6915 100644 --- a/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java +++ b/shenyu-client/shenyu-client-core/src/main/java/org/apache/shenyu/client/core/utils/OpenApiUtils.java @@ -899,7 +899,7 @@ private static Schema parseProtobufFieldSchema(final String fieldName, final Obj final Class clazz, final int depth, final Map, Type> typeVariableMap) throws Exception { if (isRepeated) { - return parseRepeatedProtobufFieldSchema(fieldName, fieldType, getMessageTypeMethod, field); + return parseRepeatedProtobufFieldSchema(fieldName, fieldType, getMessageTypeMethod, field, clazz, depth, typeVariableMap); } String fieldTypeName = fieldType.toString(); Schema schema; @@ -916,17 +916,19 @@ private static Schema parseProtobufFieldSchema(final String fieldName, final Obj private static Schema parseRepeatedProtobufFieldSchema(final String fieldName, final Object fieldType, final java.lang.reflect.Method getMessageTypeMethod, - final Object field) throws Exception { + final Object field, + final Class clazz, final int depth, + final Map, Type> typeVariableMap) throws Exception { String fieldTypeName = fieldType.toString(); Schema elementSchema; if ("MESSAGE".equals(fieldTypeName)) { - Object msgDescriptor = getMessageTypeMethod.invoke(field); - java.lang.reflect.Method getFullNameMethod = msgDescriptor.getClass().getMethod("getFullName"); - String fullMsgName = (String) getFullNameMethod.invoke(msgDescriptor); - elementSchema = new Schema("object", null); + elementSchema = resolveProtobufMessageFieldSchema(getMessageTypeMethod, field, clazz, depth, typeVariableMap); + } else if ("ENUM".equals(fieldTypeName)) { + elementSchema = new Schema("string", null); } else { elementSchema = new Schema(mapProtobufTypeToOpenApi(fieldTypeName), null); } + elementSchema.setName("items"); Schema schema = new Schema("array", null); schema.setName(fieldName); schema.setRefs(Collections.singletonList(elementSchema)); From 9ffc42ca85146ec40e5c27e9b611e8b16ff2b29c Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Sat, 6 Jun 2026 22:08:50 +0800 Subject: [PATCH 7/9] fix use common version --- pom.xml | 2 ++ shenyu-client/shenyu-client-core/pom.xml | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/pom.xml b/pom.xml index 48d7ead0d802..f091fe009086 100644 --- a/pom.xml +++ b/pom.xml @@ -103,6 +103,8 @@ 3.5.1 0.40.1 3.5.0 + 0.6.1 + 1.6.2 diff --git a/shenyu-client/shenyu-client-core/pom.xml b/shenyu-client/shenyu-client-core/pom.xml index e792a70df701..c6e70b6d32b9 100644 --- a/shenyu-client/shenyu-client-core/pom.xml +++ b/shenyu-client/shenyu-client-core/pom.xml @@ -98,14 +98,14 @@ kr.motd.maven os-maven-plugin - 1.6.2 + ${os-maven-plugin.version} org.xolstice.maven.plugins protobuf-maven-plugin - 0.6.1 + ${protobuf-maven-plugin.version} true com.google.protobuf:protoc:${protobuf-java.version}:exe:${os.detected.classifier} From 51af4647d78dfee7fbac49cd6d4073ca0511bf9a Mon Sep 17 00:00:00 2001 From: eye-gu <734164350@qq.com> Date: Tue, 9 Jun 2026 09:43:53 +0800 Subject: [PATCH 8/9] chore: trigger CI retest From ecde3e47fa67e46e042d4c9c13cc6f2277a36069 Mon Sep 17 00:00:00 2001 From: liuhy Date: Tue, 9 Jun 2026 13:43:04 +0800 Subject: [PATCH 9/9] Recover RocketMQ e2e bootstrap after delayed admin health The RocketMQ logging e2e script runs three sync modes in sequence. In the zookeeper round, Docker Compose can return before shenyu-admin has recovered from an initial unhealthy state, leaving shenyu-bootstrap created but not started. The script previously ignored both that compose failure and later healthcheck failures, so Maven reported the gateway as unavailable instead of recovering the service stack first. Constraint: CI builds the required latest Docker images before this script runs Rejected: Increase fixed sleeps only | it would hide the dependency failure without ensuring bootstrap starts Confidence: medium Scope-risk: narrow Directive: Keep bootstrap startup gated on admin health for this e2e script Tested: bash -n e2e-logging-rocketmq-compose.sh; e2e RocketMQ test-compile; RocketMQ logging plugin test-compile; docker compose config for zookeeper, RocketMQ, and HTTP example compose files; git diff --check Not-tested: Full Docker Compose e2e locally because CI-built apache/shenyu-admin:latest, apache/shenyu-bootstrap:latest, and shenyu-examples-http:latest images are not present --- .../compose/script/e2e-logging-rocketmq-compose.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/compose/script/e2e-logging-rocketmq-compose.sh b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/compose/script/e2e-logging-rocketmq-compose.sh index 4049b338fd61..1967c16f0d4a 100644 --- a/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/compose/script/e2e-logging-rocketmq-compose.sh +++ b/shenyu-e2e/shenyu-e2e-case/shenyu-e2e-case-logging-rocketmq/compose/script/e2e-logging-rocketmq-compose.sh @@ -31,16 +31,18 @@ SYNC_ARRAY=("websocket" "http" "zookeeper") docker network create -d bridge shenyu for sync in "${SYNC_ARRAY[@]}"; do + sync_compose_file="$SHENYU_TESTCASE_DIR"/compose/sync/shenyu-sync-"${sync}".yml echo -e "------------------\n" echo "[Start ${sync} synchronous] create shenyu-admin-${sync}.yml shenyu-bootstrap-${sync}.yml " - docker compose -f "$SHENYU_TESTCASE_DIR"/compose/sync/shenyu-sync-"${sync}".yml up -d --quiet-pull + docker compose -f "${sync_compose_file}" up -d --quiet-pull || true sleep 30s - sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31095/actuator/health - sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31195/actuator/health + sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31095/actuator/health || exit 1 + docker compose -f "${sync_compose_file}" up -d shenyu-bootstrap + sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31195/actuator/health || exit 1 docker compose -f "${PRGDIR}"/shenyu-rocketmq-compose.yml up -d --quiet-pull docker compose -f "${PRGDIR}"/shenyu-examples-http-compose.yml up -d --quiet-pull sleep 30s - sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31189/actuator/health + sh "$SHENYU_TESTCASE_DIR"/k8s/script/healthcheck.sh http://localhost:31189/actuator/health || exit 1 sleep 10s docker ps -a ## run e2e-test @@ -60,7 +62,7 @@ for sync in "${SYNC_ARRAY[@]}"; do docker compose -f "${PRGDIR}"/shenyu-rocketmq-compose.yml logs exit 1 fi - docker compose -f "$SHENYU_TESTCASE_DIR"/compose/sync/shenyu-sync-"${sync}".yml down + docker compose -f "${sync_compose_file}" down docker compose -f "${PRGDIR}"/shenyu-rocketmq-compose.yml down docker compose -f "${PRGDIR}"/shenyu-examples-http-compose.yml down echo "[Remove ${sync} synchronous] delete shenyu-admin-${sync}.yml shenyu-bootstrap-${sync}.yml "