This is an automated email from the ASF dual-hosted git repository.
Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git
The following commit(s) were added to refs/heads/master by this push:
new d39e623b57 feat(client):add many path register for spring mvc client.
(#6348)
d39e623b57 is described below
commit d39e623b57677160d3b38a144a566a2ad798162b
Author: wy471x <[email protected]>
AuthorDate: Wed Jul 15 15:36:09 2026 +0800
feat(client):add many path register for spring mvc client. (#6348)
* feat(client):add many path register for spring mvc client.
* feat(client):improve code coverage for SpringMvcClientEventListener.
* feat(client):add many path register for spring mvc client.
* feat(client):add integrated test cases for SpringMvcClientEventListener.
* feat(client):add integrated test cases for SpringMvcClientEventListener.
* feat(client):add multipath register test controller.
* fix: generate API docs for all super-paths on multi-path controllers
Previously buildApiDocDTO called buildApiSuperPath (singular), so only the
first class-level prefix was used in generated API docs. Now iterates over
buildApiSuperPaths to produce ApiDocRegisterDTO for every prefix. Also fixes
a potential NPE in buildApiPath when a method annotation has an empty path
array.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
* fix: remove fragile endsWith heuristic from buildApiPath
Replace the superPath.endsWith(formatPath(p)) deduplication check with
explicit annotation-provenance routing in handleMethod. When the method
has its own @ShenyuSpringMvcClient, the annotation path is used directly.
When falling back to the class-level annotation, @RequestMapping path is
used instead — avoiding both path duplication and false-positive suffix
matches. Also adds buildApiPathFromRequestMapping for the fallback path
and 5 new unit tests covering buildApiPath edge cases.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
---------
Co-authored-by: aias00 <[email protected]>
Co-authored-by: Claude Opus 4.7 <[email protected]>
---
.../AbstractContextRefreshedEventListener.java | 61 +++--
.../init/SpringMvcClientEventListener.java | 80 +++++-
.../init/SpringMvcClientEventListenerTest.java | 290 +++++++++++++++++++++
.../controller/SpringMvcMultiPathController.java | 60 +++++
.../http/SpringMvcMappingPathControllerTest.java | 66 ++++-
5 files changed, 506 insertions(+), 51 deletions(-)
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 b19ae4e61d..e329ef09b5 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
@@ -193,10 +193,7 @@ public abstract class
AbstractContextRefreshedEventListener<T, A extends Annotat
return Collections.emptyList();
}
Class<?> clazz = AopUtils.isAopProxy(bean) ?
AopUtils.getTargetClass(bean) : bean.getClass();
- String superPath = buildApiSuperPath(clazz,
AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType()));
- if (superPath.contains("*")) {
- superPath = superPath.substring(0, superPath.lastIndexOf("/"));
- }
+ List<String> superPaths = buildApiSuperPaths(clazz,
AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType()));
Annotation annotation =
AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType());
if (Objects.isNull(annotation)) {
return Lists.newArrayList();
@@ -208,30 +205,33 @@ public abstract class
AbstractContextRefreshedEventListener<T, A extends Annotat
String contextPath = getContextPath();
String[] value0 = sextet.getValue0();
List<ApiDocRegisterDTO> list = Lists.newArrayList();
- for (String value : value0) {
- String apiPath = pathJoin(contextPath, superPath, value);
- ApiHttpMethodEnum[] value3 = sextet.getValue3();
- for (ApiHttpMethodEnum apiHttpMethodEnum : value3) {
- String documentJson = buildDocumentJson(pairs.getRight(),
apiPath, method, sextet.getValue4());
- String extJson = buildExtJson(method);
- ApiDocRegisterDTO build = ApiDocRegisterDTO.builder()
- .consume(sextet.getValue1())
- .produce(sextet.getValue2())
- .httpMethod(apiHttpMethodEnum.getValue())
- .contextPath(contextPath)
- .ext(extJson)
- .document(documentJson)
- .rpcType(sextet.getValue4().getName())
- .version(sextet.getValue5())
- .apiDesc(pairs.getLeft())
- .tags(pairs.getRight())
- .apiPath(apiPath)
-
.apiSource(ApiSourceEnum.ANNOTATION_GENERATION.getValue())
- .state(ApiStateEnum.UNPUBLISHED.getState())
- .apiOwner("admin")
- .eventType(EventType.REGISTER)
- .build();
- list.add(build);
+ for (String rawPath : superPaths) {
+ String superPath = rawPath.contains("*") ? rawPath.substring(0,
rawPath.lastIndexOf("/")) : rawPath;
+ for (String value : value0) {
+ String apiPath = pathJoin(contextPath, superPath, value);
+ ApiHttpMethodEnum[] value3 = sextet.getValue3();
+ for (ApiHttpMethodEnum apiHttpMethodEnum : value3) {
+ String documentJson = buildDocumentJson(pairs.getRight(),
apiPath, method, sextet.getValue4());
+ String extJson = buildExtJson(method);
+ ApiDocRegisterDTO build = ApiDocRegisterDTO.builder()
+ .consume(sextet.getValue1())
+ .produce(sextet.getValue2())
+ .httpMethod(apiHttpMethodEnum.getValue())
+ .contextPath(contextPath)
+ .ext(extJson)
+ .document(documentJson)
+ .rpcType(sextet.getValue4().getName())
+ .version(sextet.getValue5())
+ .apiDesc(pairs.getLeft())
+ .tags(pairs.getRight())
+ .apiPath(apiPath)
+
.apiSource(ApiSourceEnum.ANNOTATION_GENERATION.getValue())
+ .state(ApiStateEnum.UNPUBLISHED.getState())
+ .apiOwner("admin")
+ .eventType(EventType.REGISTER)
+ .build();
+ list.add(build);
+ }
}
}
return list;
@@ -298,6 +298,11 @@ public abstract class
AbstractContextRefreshedEventListener<T, A extends Annotat
protected abstract String buildApiSuperPath(Class<?> clazz,
@Nullable A beanShenyuClient);
+ protected List<String> buildApiSuperPaths(final Class<?> clazz,
+ @Nullable final A
beanShenyuClient) {
+ return Collections.singletonList(buildApiSuperPath(clazz,
beanShenyuClient));
+ }
+
protected void handleClass(final Class<?> clazz,
final T bean,
@NonNull final A beanShenyuClient,
diff --git
a/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/main/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListener.java
b/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/main/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListener.java
index 17e8f3145c..78e7489fff 100644
---
a/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/main/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListener.java
+++
b/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/main/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListener.java
@@ -43,6 +43,7 @@ import org.springframework.core.env.Environment;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Controller;
+import org.springframework.util.ReflectionUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.util.UriComponentsBuilder;
@@ -167,20 +168,58 @@ public class SpringMvcClientEventListener extends
AbstractContextRefreshedEventL
return RpcTypeEnum.HTTP.getName();
}
+ @Override
+ protected void handle(final String beanName, final Object bean) {
+ Class<?> clazz = getCorrectedClass(bean);
+ final ShenyuSpringMvcClient beanShenyuClient =
AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType());
+ final List<String> superPaths = buildApiSuperPaths(clazz,
beanShenyuClient);
+ final Method[] methods =
ReflectionUtils.getUniqueDeclaredMethods(clazz);
+ for (String superPath : superPaths) {
+ if (Objects.nonNull(beanShenyuClient) && superPath.contains("*")) {
+ handleClass(clazz, bean, beanShenyuClient, superPath);
+ continue;
+ }
+ for (Method method : methods) {
+ handleMethod(bean, clazz, beanShenyuClient, method, superPath);
+ }
+ }
+ }
+
@Override
protected String buildApiSuperPath(final Class<?> clazz, @Nullable final
ShenyuSpringMvcClient beanShenyuClient) {
- final String servletPath =
StringUtils.defaultString(this.env.getProperty("spring.mvc.servlet.path"), "");
- final String servletContextPath =
StringUtils.defaultString(this.env.getProperty("server.servlet.context-path"),
"");
- final String rootPath = String.format("/%s/%s/", servletContextPath,
servletPath);
- if (Objects.nonNull(beanShenyuClient) &&
StringUtils.isNotBlank(beanShenyuClient.path()[0])) {
- return formatPath(String.format("%s/%s", rootPath,
beanShenyuClient.path()[0]));
+ List<String> paths = buildApiSuperPaths(clazz, beanShenyuClient);
+ return paths.isEmpty() ? formatPath(buildRootPath()) : paths.get(0);
+ }
+
+ @Override
+ protected List<String> buildApiSuperPaths(final Class<?> clazz, @Nullable
final ShenyuSpringMvcClient beanShenyuClient) {
+ final String rootPath = buildRootPath();
+ if (Objects.nonNull(beanShenyuClient) &&
ArrayUtils.isNotEmpty(beanShenyuClient.path())) {
+ List<String> paths = Arrays.stream(beanShenyuClient.path())
+ .filter(StringUtils::isNotBlank)
+ .map(p -> formatPath(String.format("%s/%s", rootPath, p)))
+ .collect(Collectors.toList());
+ if (!paths.isEmpty()) {
+ return paths;
+ }
}
RequestMapping requestMapping = AnnotationUtils.findAnnotation(clazz,
RequestMapping.class);
- // Only the first path is supported temporarily
- if (Objects.nonNull(requestMapping) &&
ArrayUtils.isNotEmpty(requestMapping.path()) &&
StringUtils.isNotBlank(requestMapping.path()[0])) {
- return formatPath(String.format("%s/%s", rootPath,
requestMapping.path()[0]));
+ if (Objects.nonNull(requestMapping) &&
ArrayUtils.isNotEmpty(requestMapping.path())) {
+ List<String> paths = Arrays.stream(requestMapping.path())
+ .filter(StringUtils::isNotBlank)
+ .map(p -> formatPath(String.format("%s/%s", rootPath, p)))
+ .collect(Collectors.toList());
+ if (!paths.isEmpty()) {
+ return paths;
+ }
}
- return formatPath(rootPath);
+ return Collections.singletonList(formatPath(rootPath));
+ }
+
+ private String buildRootPath() {
+ final String servletPath =
Optional.ofNullable(this.env.getProperty("spring.mvc.servlet.path")).orElse("");
+ final String servletContextPath =
Optional.ofNullable(this.env.getProperty("server.servlet.context-path")).orElse("");
+ return String.format("/%s/%s/", servletContextPath, servletPath);
}
@Override
@@ -194,14 +233,18 @@ public class SpringMvcClientEventListener extends
AbstractContextRefreshedEventL
final Method method, final String superPath) {
final RequestMapping requestMapping =
AnnotatedElementUtils.findMergedAnnotation(method, RequestMapping.class);
ShenyuSpringMvcClient methodShenyuClient =
AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSpringMvcClient.class);
- methodShenyuClient = Objects.isNull(methodShenyuClient) ?
beanShenyuClient : methodShenyuClient;
+ final boolean hasMethodAnnotation =
Objects.nonNull(methodShenyuClient);
+ methodShenyuClient = hasMethodAnnotation ? methodShenyuClient :
beanShenyuClient;
// the result of ReflectionUtils#getUniqueDeclaredMethods contains
method such as hashCode, wait, toSting
// add Objects.nonNull(requestMapping) to make sure not register wrong
method
if (Objects.nonNull(methodShenyuClient) &&
Objects.nonNull(requestMapping)) {
List<String> namespaceIds = super.getNamespace();
for (String namespaceId : namespaceIds) {
+ final String apiPath = hasMethodAnnotation
+ ? buildApiPath(method, superPath, methodShenyuClient)
+ : buildApiPathFromRequestMapping(method, superPath);
final MetaDataRegisterDTO metaData = buildMetaDataDTO(bean,
methodShenyuClient,
- buildApiPath(method, superPath, methodShenyuClient),
clazz, method, namespaceId);
+ apiPath, clazz, method, namespaceId);
getPublisher().publishEvent(metaData);
getMetaDataMap().put(method, metaData);
}
@@ -212,8 +255,10 @@ public class SpringMvcClientEventListener extends
AbstractContextRefreshedEventL
protected String buildApiPath(final Method method, final String superPath,
@NonNull final ShenyuSpringMvcClient
methodShenyuClient) {
String contextPath = getContextPath();
- if (StringUtils.isNotBlank(methodShenyuClient.path()[0])) {
- return pathJoin(contextPath, superPath,
methodShenyuClient.path()[0]);
+ final String[] annotationPaths = methodShenyuClient.path();
+ final String annotationPath = ArrayUtils.isNotEmpty(annotationPaths) ?
annotationPaths[0] : "";
+ if (StringUtils.isNotBlank(annotationPath)) {
+ return pathJoin(contextPath, superPath, annotationPath);
}
final String path = getPathByMethod(method);
if (StringUtils.isNotBlank(path)) {
@@ -222,6 +267,15 @@ public class SpringMvcClientEventListener extends
AbstractContextRefreshedEventL
return pathJoin(contextPath, superPath);
}
+ String buildApiPathFromRequestMapping(final Method method, final String
superPath) {
+ String contextPath = getContextPath();
+ final String path = getPathByMethod(method);
+ if (StringUtils.isNotBlank(path)) {
+ return pathJoin(contextPath, superPath, path);
+ }
+ return pathJoin(contextPath, superPath);
+ }
+
private String formatPath(final String path) {
return path.replaceAll("/+", "/").replaceFirst("/$", "");
}
diff --git
a/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/test/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListenerTest.java
b/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/test/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListenerTest.java
index bfb90dd7e7..638ed1575a 100644
---
a/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/test/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListenerTest.java
+++
b/shenyu-client/shenyu-client-http/shenyu-client-springmvc/src/test/java/org/apache/shenyu/client/springmvc/init/SpringMvcClientEventListenerTest.java
@@ -17,13 +17,21 @@
package org.apache.shenyu.client.springmvc.init;
+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.exception.ShenyuClientIllegalArgumentException;
import
org.apache.shenyu.client.core.register.ShenyuClientRegisterRepositoryFactory;
import org.apache.shenyu.client.springmvc.annotation.ShenyuSpringMvcClient;
+import org.apache.shenyu.common.enums.ApiHttpMethodEnum;
+import org.apache.shenyu.common.enums.RpcTypeEnum;
import org.apache.shenyu.common.exception.ShenyuException;
import org.apache.shenyu.client.core.utils.PortUtils;
+import org.apache.shenyu.register.common.dto.ApiDocRegisterDTO;
+import org.apache.shenyu.register.common.dto.MetaDataRegisterDTO;
+import org.apache.shenyu.register.common.type.DataTypeParent;
+import org.javatuples.Sextet;
import org.apache.shenyu.register.client.api.ShenyuClientRegisterRepository;
import org.apache.shenyu.register.client.http.utils.RegisterUtils;
import org.apache.shenyu.register.common.config.ShenyuClientConfig;
@@ -33,6 +41,7 @@ import org.junit.Assert;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -42,18 +51,27 @@ import org.springframework.context.ApplicationContext;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.core.annotation.AnnotatedElementUtils;
import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
+import java.lang.reflect.Method;
+import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
+import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Properties;
+import java.util.stream.Collectors;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
@@ -210,6 +228,220 @@ public class SpringMvcClientEventListenerTest {
registerUtilsMockedStatic.close();
}
+ @Test
+ public void testBuildApiSuperPathsMultiPath() {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ List<String> paths = listener.buildApiSuperPaths(
+ SpringMvcMultiPathTestBean.class,
+
AnnotatedElementUtils.findMergedAnnotation(SpringMvcMultiPathTestBean.class,
ShenyuSpringMvcClient.class));
+ Assertions.assertEquals(2, paths.size());
+ Assertions.assertTrue(paths.stream().anyMatch(p ->
p.endsWith("/multi-a")));
+ Assertions.assertTrue(paths.stream().anyMatch(p ->
p.endsWith("/multi-b")));
+ registerUtilsMockedStatic.close();
+ }
+
+ @Test
+ public void testBuildApiPathMethodAnnotationTakesPrecedence() throws
Exception {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
SpringMvcClientTestBean.class.getDeclaredMethod("hello", String.class);
+ ShenyuSpringMvcClient methodAnnotation =
AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSpringMvcClient.class);
+ // Method has path="/hello", so it should be used regardless of
superPath
+ String apiPath = listener.buildApiPath(method, "/order",
methodAnnotation);
+ Assertions.assertEquals("/mvc/order/hello", apiPath);
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void
testBuildApiPathEmptyMethodAnnotationFallsThroughToRequestMapping() throws
Exception {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
SpringMvcClientTestBean.class.getDeclaredMethod("hello2", String.class);
+ ShenyuSpringMvcClient methodAnnotation =
AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSpringMvcClient.class);
+ // Method has path="" (empty), so it should fall through to
@GetMapping("/hello2")
+ String apiPath = listener.buildApiPath(method, "/order",
methodAnnotation);
+ Assertions.assertEquals("/mvc/order/hello2", apiPath);
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiPathSuffixOverlapDoesNotCauseFalseMatch() throws
Exception {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ // @ShenyuSpringMvcClient(path = "/order") on method, superPath =
"/prefix/order"
+ // The old endsWith heuristic would falsely skip the annotation
path here
+ Method method =
SpringMvcSuffixOverlapTestBean.class.getDeclaredMethod("greet");
+ ShenyuSpringMvcClient methodAnnotation =
AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSpringMvcClient.class);
+ String apiPath = listener.buildApiPath(method, "/prefix/order",
methodAnnotation);
+ Assertions.assertEquals("/mvc/prefix/order/order", apiPath);
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiPathClassLevelFallbackUsesRequestMapping() throws
Exception {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
SpringMvcClientTestBean.class.getDeclaredMethod("hello3", String.class);
+ // Method has no @ShenyuSpringMvcClient → class-level annotation
path="/order" should be skipped
+ // @GetMapping("") → should just return superPath
+ String apiPath = listener.buildApiPathFromRequestMapping(method,
"/order");
+ Assertions.assertEquals("/mvc/order", apiPath);
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiPathMultiPathMethodAnnotation() throws Exception {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
SpringMvcMultiPathApiDocTestBean.class.getDeclaredMethod("greet");
+ ShenyuSpringMvcClient methodAnnotation =
AnnotatedElementUtils.findMergedAnnotation(method, ShenyuSpringMvcClient.class);
+ // Method has path="/greet" with class multi-path {"/multi-a",
"/multi-b"}
+ // superPath="/multi-a" should NOT suppress "/greet" (different
path)
+ String apiPath = listener.buildApiPath(method, "/multi-a",
methodAnnotation);
+ Assertions.assertEquals("/mvc/multi-a/greet", apiPath);
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testMultiPathControllerPublishesCorrectMetaDataAndApiDoc() {
+ try {
+ SpringMvcMultiPathApiDocTestBean multiPathBean = new
SpringMvcMultiPathApiDocTestBean();
+
+ Map<String, Object> controllerBeans = new LinkedHashMap<>();
+ controllerBeans.put("multiPathApiDocBean", multiPathBean);
+
when(applicationContext.getBeansWithAnnotation(eq(Controller.class))).thenReturn(controllerBeans);
+
+ Map<String, Object> apiModuleBeans = new LinkedHashMap<>();
+ apiModuleBeans.put("multiPathApiDocBean", multiPathBean);
+
when(applicationContext.getBeansWithAnnotation(eq(ApiModule.class))).thenReturn(apiModuleBeans);
+
+ when(applicationContext.getEnvironment()).thenReturn(env);
+ when(env.getProperty("shenyu.discovery.type",
ShenyuClientConstants.DISCOVERY_LOCAL_MODE)).thenReturn("local");
+
when(applicationContext.getAutowireCapableBeanFactory()).thenReturn(beanFactory);
+
+ try (MockedStatic<ShenyuClientRegisterEventPublisher>
publisherMock = mockStatic(ShenyuClientRegisterEventPublisher.class);
+ MockedStatic<PortUtils> portUtilsMock =
mockStatic(PortUtils.class)) {
+
+ ShenyuClientRegisterEventPublisher publisher =
mock(ShenyuClientRegisterEventPublisher.class);
+
publisherMock.when(ShenyuClientRegisterEventPublisher::getInstance).thenReturn(publisher);
+ portUtilsMock.when(() ->
PortUtils.findPort(beanFactory)).thenReturn(8080);
+
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ ContextRefreshedEvent event = new
ContextRefreshedEvent(applicationContext);
+ listener.onApplicationEvent(event);
+
+ ArgumentCaptor<DataTypeParent> captor =
ArgumentCaptor.forClass(DataTypeParent.class);
+ verify(publisher,
atLeastOnce()).publishEvent(captor.capture());
+
+ List<DataTypeParent> events = captor.getAllValues();
+
+ // Verify MetaDataRegisterDTO: 2 paths × 1 method = 2 entries
+ List<MetaDataRegisterDTO> metaDatas = events.stream()
+ .filter(e -> e instanceof MetaDataRegisterDTO)
+ .map(e -> (MetaDataRegisterDTO) e)
+ .collect(Collectors.toList());
+ Assertions.assertEquals(2, metaDatas.size(),
+ "Expected 2 metadata entries for 2 class-level paths ×
1 method");
+ List<String> metaPaths = metaDatas.stream()
+ .map(MetaDataRegisterDTO::getPath)
+ .sorted()
+ .collect(Collectors.toList());
+
Assertions.assertTrue(metaPaths.get(0).endsWith("/multi-a/greet"),
+ "Expected path ending with /multi-a/greet but got: " +
metaPaths.get(0));
+
Assertions.assertTrue(metaPaths.get(1).endsWith("/multi-b/greet"),
+ "Expected path ending with /multi-b/greet but got: " +
metaPaths.get(1));
+
+ // Verify ApiDocRegisterDTO: 2 paths × 1 path-value × 1 HTTP
method = 2 entries
+ List<ApiDocRegisterDTO> apiDocs = events.stream()
+ .filter(e -> e instanceof ApiDocRegisterDTO)
+ .map(e -> (ApiDocRegisterDTO) e)
+ .collect(Collectors.toList());
+ Assertions.assertEquals(2, apiDocs.size(),
+ "Expected 2 API doc entries for 2 class-level paths ×
1 method");
+ List<String> apiPaths = apiDocs.stream()
+ .map(ApiDocRegisterDTO::getApiPath)
+ .sorted()
+ .collect(Collectors.toList());
+
Assertions.assertTrue(apiPaths.get(0).endsWith("/multi-a/greet"),
+ "Expected apiPath ending with /multi-a/greet but got:
" + apiPaths.get(0));
+
Assertions.assertTrue(apiPaths.get(1).endsWith("/multi-b/greet"),
+ "Expected apiPath ending with /multi-b/greet but got:
" + apiPaths.get(1));
+ }
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiDocSextetDefaultProducesConsumes() throws
NoSuchMethodException {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
ApiDocTestBean.class.getDeclaredMethod("getDefault");
+ Sextet<String[], String, String, ApiHttpMethodEnum[], RpcTypeEnum,
String> result =
+ listener.buildApiDocSextet(method, null,
Collections.emptyMap());
+
+ Assertions.assertArrayEquals(new String[]{"/get-default"},
result.getValue0());
+ Assertions.assertEquals("*/*", result.getValue1());
+ Assertions.assertEquals("*/*", result.getValue2());
+ Assertions.assertArrayEquals(new
ApiHttpMethodEnum[]{ApiHttpMethodEnum.GET}, result.getValue3());
+ Assertions.assertEquals(RpcTypeEnum.HTTP, result.getValue4());
+ Assertions.assertEquals("v0.01", result.getValue5());
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiDocSextetExplicitProducesConsumesAndMethod()
throws NoSuchMethodException {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method =
ApiDocTestBean.class.getDeclaredMethod("postExplicit", String.class);
+ Sextet<String[], String, String, ApiHttpMethodEnum[], RpcTypeEnum,
String> result =
+ listener.buildApiDocSextet(method, null,
Collections.emptyMap());
+
+ Assertions.assertArrayEquals(new String[]{"/post-explicit"},
result.getValue0());
+ Assertions.assertEquals("application/json", result.getValue1());
+ Assertions.assertEquals("application/json", result.getValue2());
+ Assertions.assertArrayEquals(new
ApiHttpMethodEnum[]{ApiHttpMethodEnum.POST}, result.getValue3());
+ Assertions.assertEquals(RpcTypeEnum.HTTP, result.getValue4());
+ Assertions.assertEquals("v0.01", result.getValue5());
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
+ @Test
+ public void testBuildApiDocSextetMultipleMethodsProducesConsumes() throws
NoSuchMethodException {
+ try {
+ SpringMvcClientEventListener listener =
buildSpringMvcClientEventListener(false, false);
+ Method method = ApiDocTestBean.class.getDeclaredMethod("multi",
String.class);
+ Sextet<String[], String, String, ApiHttpMethodEnum[], RpcTypeEnum,
String> result =
+ listener.buildApiDocSextet(method, null,
Collections.emptyMap());
+
+ Assertions.assertArrayEquals(new String[]{"/multi"},
result.getValue0());
+ Assertions.assertEquals("application/json,application/xml",
result.getValue1());
+ Assertions.assertEquals("application/json,application/xml",
result.getValue2());
+ List<ApiHttpMethodEnum> methods =
Arrays.asList(result.getValue3());
+ Assertions.assertTrue(methods.contains(ApiHttpMethodEnum.GET));
+ Assertions.assertTrue(methods.contains(ApiHttpMethodEnum.POST));
+ Assertions.assertEquals(2, methods.size());
+ Assertions.assertEquals(RpcTypeEnum.HTTP, result.getValue4());
+ Assertions.assertEquals("v0.01", result.getValue5());
+ } finally {
+ registerUtilsMockedStatic.close();
+ }
+ }
+
@RestController
@RequestMapping("/order")
@ShenyuSpringMvcClient(path = "/order")
@@ -256,4 +488,62 @@ public class SpringMvcClientEventListenerTest {
}
}
+ @RestController
+ @ShenyuSpringMvcClient(path = {"/multi-a", "/multi-b"})
+ static class SpringMvcMultiPathTestBean {
+ public String test() {
+ return "ok";
+ }
+ }
+
+ @RestController
+ @RequestMapping({"/multi-a", "/multi-b"})
+ @ShenyuSpringMvcClient(path = {"/multi-a", "/multi-b"})
+ @ApiModule(value = "multiPathApiDoc")
+ static class SpringMvcMultiPathApiDocTestBean {
+
+ @GetMapping("/greet")
+ @ShenyuSpringMvcClient(path = "/greet")
+ @ApiDoc(desc = "greet")
+ public String greet() {
+ return "hello from multipath";
+ }
+ }
+
+ @RestController
+ @ShenyuSpringMvcClient(path = "/prefix/order")
+ static class SpringMvcSuffixOverlapTestBean {
+
+ @GetMapping("/test")
+ @ShenyuSpringMvcClient(path = "/order")
+ public String greet() {
+ return "ok";
+ }
+ }
+
+ @RestController
+ static class ApiDocTestBean {
+
+ @GetMapping(value = "/get-default")
+ public String getDefault() {
+ return "ok";
+ }
+
+ @RequestMapping(value = "/post-explicit",
+ method = RequestMethod.POST,
+ produces = "application/json",
+ consumes = "application/json")
+ public String postExplicit(@RequestBody final String input) {
+ return input;
+ }
+
+ @RequestMapping(value = "/multi",
+ method = {RequestMethod.GET, RequestMethod.POST},
+ produces = {"application/json", "application/xml"},
+ consumes = {"application/json", "application/xml"})
+ public String multi(@RequestBody final String input) {
+ return input;
+ }
+ }
+
}
diff --git
a/shenyu-examples/shenyu-examples-http/src/main/java/org/apache/shenyu/examples/http/controller/SpringMvcMultiPathController.java
b/shenyu-examples/shenyu-examples-http/src/main/java/org/apache/shenyu/examples/http/controller/SpringMvcMultiPathController.java
new file mode 100644
index 0000000000..a95494e1c5
--- /dev/null
+++
b/shenyu-examples/shenyu-examples-http/src/main/java/org/apache/shenyu/examples/http/controller/SpringMvcMultiPathController.java
@@ -0,0 +1,60 @@
+/*
+ * 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.examples.http.controller;
+
+import org.apache.shenyu.client.apidocs.annotations.ApiDoc;
+import org.apache.shenyu.client.apidocs.annotations.ApiModule;
+import org.apache.shenyu.client.springmvc.annotation.ShenyuSpringMvcClient;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * SpringMvcMultiPathController — verifies multi-path class-level registration.
+ * Both /multipath/v1 and /multipath/v2 prefixes are registered via a single
annotation.
+ */
+@RestController
+@RequestMapping({"/multipath/v1", "/multipath/v2"})
+@ShenyuSpringMvcClient(path = {"/multipath/v1", "/multipath/v2"}, desc =
"multi path register")
+@ApiModule(value = "springMvcMultiPathController")
+public class SpringMvcMultiPathController {
+
+ private static final String SUFFIX = "I'm Shenyu-Gateway System. Welcome!";
+
+ /**
+ * greet.
+ *
+ * @return result
+ */
+ @RequestMapping("/greet")
+ @ApiDoc(desc = "greet")
+ public String greet() {
+ return "hello from multipath! " + SUFFIX;
+ }
+
+ /**
+ * echo.
+ *
+ * @param name name
+ * @return result
+ */
+ @RequestMapping("/echo")
+ @ApiDoc(desc = "echo")
+ public String echo(final String name) {
+ return "echo: " + name + "! " + SUFFIX;
+ }
+}
diff --git
a/shenyu-integrated-test/shenyu-integrated-test-http/src/test/java/org/apache/shenyu/integrated/test/http/SpringMvcMappingPathControllerTest.java
b/shenyu-integrated-test/shenyu-integrated-test-http/src/test/java/org/apache/shenyu/integrated/test/http/SpringMvcMappingPathControllerTest.java
index 3b1debec6b..0de6fef393 100644
---
a/shenyu-integrated-test/shenyu-integrated-test-http/src/test/java/org/apache/shenyu/integrated/test/http/SpringMvcMappingPathControllerTest.java
+++
b/shenyu-integrated-test/shenyu-integrated-test-http/src/test/java/org/apache/shenyu/integrated/test/http/SpringMvcMappingPathControllerTest.java
@@ -17,30 +17,76 @@
package org.apache.shenyu.integrated.test.http;
-import org.junit.jupiter.api.Test;
-import org.apache.shenyu.integratedtest.common.helper.HttpHelper;
import org.apache.shenyu.integratedtest.common.AbstractTest;
-import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.apache.shenyu.integratedtest.common.helper.HttpHelper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
import java.io.IOException;
+import java.util.concurrent.TimeUnit;
-public class SpringMvcMappingPathControllerTest extends AbstractTest {
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class SpringMvcMappingPathControllerTest extends AbstractTest {
+
+ private static final String MULTI_PATH_SUFFIX = "I'm Shenyu-Gateway
System. Welcome!";
+
+ @BeforeAll
+ static void waitForMultiPathRoutes() throws InterruptedException {
+ // Multi-path routes are registered asynchronously; poll until
available
+ for (int i = 0; i < 30; i++) {
+ try {
+ String res =
HttpHelper.INSTANCE.postGateway("/http/multipath/v1/greet", String.class);
+ if (("hello from multipath! " +
MULTI_PATH_SUFFIX).equals(res)) {
+ return;
+ }
+ } catch (IOException e) {
+ // route not ready yet, keep waiting
+ }
+ Thread.sleep(TimeUnit.SECONDS.toMillis(2));
+ }
+ throw new AssertionError("Multi-path routes not ready after 60s:
/http/multipath/v1/greet");
+ }
@Test
- public void testHello() throws IOException {
- String res = HttpHelper.INSTANCE.postGateway("/http/hello",
java.lang.String.class);
+ void testHello() throws IOException {
+ String res = HttpHelper.INSTANCE.postGateway("/http/hello",
String.class);
assertEquals("hello! I'm Shenyu-Gateway System. Welcome!", res);
}
@Test
- public void testHi()throws IOException {
- String res = HttpHelper.INSTANCE.postGateway("/http/hi?name=tom",
java.lang.String.class);
+ void testHi() throws IOException {
+ String res = HttpHelper.INSTANCE.postGateway("/http/hi?name=tom",
String.class);
assertEquals("hi! tom! I'm Shenyu-Gateway System. Welcome!", res);
}
@Test
- public void testPost()throws IOException {
- String res = HttpHelper.INSTANCE.postGateway("/http/post/hi?name=tom",
java.lang.String.class);
+ void testPost() throws IOException {
+ String res = HttpHelper.INSTANCE.postGateway("/http/post/hi?name=tom",
String.class);
assertEquals("[post method result]:hi! tom! I'm Shenyu-Gateway System.
Welcome!", res);
}
+
+ @Test
+ void testMultiPathV1Greet() throws IOException {
+ String res =
HttpHelper.INSTANCE.postGateway("/http/multipath/v1/greet", String.class);
+ assertEquals("hello from multipath! " + MULTI_PATH_SUFFIX, res);
+ }
+
+ @Test
+ void testMultiPathV2Greet() throws IOException {
+ String res =
HttpHelper.INSTANCE.postGateway("/http/multipath/v2/greet", String.class);
+ assertEquals("hello from multipath! " + MULTI_PATH_SUFFIX, res);
+ }
+
+ @Test
+ void testMultiPathV1Echo() throws IOException {
+ String res =
HttpHelper.INSTANCE.postGateway("/http/multipath/v1/echo?name=shenyu",
String.class);
+ assertEquals("echo: shenyu! " + MULTI_PATH_SUFFIX, res);
+ }
+
+ @Test
+ void testMultiPathV2Echo() throws IOException {
+ String res =
HttpHelper.INSTANCE.postGateway("/http/multipath/v2/echo?name=shenyu",
String.class);
+ assertEquals("echo: shenyu! " + MULTI_PATH_SUFFIX, res);
+ }
}