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);
+    }
 }


Reply via email to