From 950bba9e78773dbffb2f92fab12a51bb1b37f1f3 Mon Sep 17 00:00:00 2001 From: wy471x Date: Fri, 22 May 2026 13:49:23 +0800 Subject: [PATCH 1/8] feat(client):add many path register for spring mvc client. --- .../init/SpringMvcClientEventListener.java | 53 +++++++++++++++---- 1 file changed, 44 insertions(+), 9 deletions(-) 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 d2f89f8816d8..0a81871a622d 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 @@ -44,6 +44,7 @@ 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,54 @@ protected String getClientName() { 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 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 paths = buildApiSuperPaths(clazz, beanShenyuClient); + return paths.isEmpty() ? formatPath(buildRootPath()) : paths.get(0); + } + + protected List buildApiSuperPaths(final Class clazz, @Nullable final ShenyuSpringMvcClient beanShenyuClient) { + final String rootPath = buildRootPath(); + if (Objects.nonNull(beanShenyuClient) && ArrayUtils.isNotEmpty(beanShenyuClient.path())) { + return Arrays.stream(beanShenyuClient.path()) + .filter(StringUtils::isNotBlank) + .map(p -> formatPath(String.format("%s/%s", rootPath, p))) + .collect(Collectors.toList()); } 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 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 From 6d959477bfa1a75bc343c9c3d63cc62e647fd836 Mon Sep 17 00:00:00 2001 From: wy471x Date: Sat, 23 May 2026 00:04:53 +0800 Subject: [PATCH 2/8] feat(client):improve code coverage for SpringMvcClientEventListener. --- .../SpringMvcClientEventListenerTest.java | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) 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 a9e4ba8c49e2..757703d77903 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 @@ -21,8 +21,11 @@ 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.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; @@ -44,10 +47,15 @@ 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; @@ -192,6 +200,57 @@ public void testOnBuildApiSuperPath() { registerUtilsMockedStatic.close(); } + @Test + public void testBuildApiDocSextetDefaultProducesConsumes() throws NoSuchMethodException { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("getDefault"); + Sextet 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()); + registerUtilsMockedStatic.close(); + } + + @Test + public void testBuildApiDocSextetExplicitProducesConsumesAndMethod() throws NoSuchMethodException { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("postExplicit", String.class); + Sextet 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()); + registerUtilsMockedStatic.close(); + } + + @Test + public void testBuildApiDocSextetMultipleMethodsProducesConsumes() throws NoSuchMethodException { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("multi", String.class); + Sextet 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 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()); + registerUtilsMockedStatic.close(); + } + @RestController @RequestMapping("/order") @ShenyuSpringMvcClient(path = "/order") @@ -238,4 +297,29 @@ public String test(final String hello) { } } + @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; + } + } + } From bda0005b9c1d9c91e259504a1d95d0bd3edf694d Mon Sep 17 00:00:00 2001 From: wy471x Date: Sat, 23 May 2026 19:36:32 +0800 Subject: [PATCH 3/8] feat(client):add many path register for spring mvc client. --- .../init/SpringMvcClientEventListener.java | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 788327271d88..ca4aeaec945f 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 @@ -194,10 +194,13 @@ protected String buildApiSuperPath(final Class clazz, @Nullable final ShenyuS protected List buildApiSuperPaths(final Class clazz, @Nullable final ShenyuSpringMvcClient beanShenyuClient) { final String rootPath = buildRootPath(); if (Objects.nonNull(beanShenyuClient) && ArrayUtils.isNotEmpty(beanShenyuClient.path())) { - return Arrays.stream(beanShenyuClient.path()) + List 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); if (Objects.nonNull(requestMapping) && ArrayUtils.isNotEmpty(requestMapping.path())) { @@ -247,8 +250,13 @@ protected void handleMethod(final Object bean, final Class clazz, 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]); + // Skip if any annotation path is already captured in superPath (class annotation used as method fallback) + final String annotationPath = methodShenyuClient.path()[0]; + boolean alreadyInSuperPath = Arrays.stream(methodShenyuClient.path()) + .filter(StringUtils::isNotBlank) + .anyMatch(p -> superPath.endsWith(formatPath(p))); + if (StringUtils.isNotBlank(annotationPath) && !alreadyInSuperPath) { + return pathJoin(contextPath, superPath, annotationPath); } final String path = getPathByMethod(method); if (StringUtils.isNotBlank(path)) { From 42eee2b5511ef3a2541376ed8900535ea818de20 Mon Sep 17 00:00:00 2001 From: wy471x Date: Sat, 23 May 2026 19:38:04 +0800 Subject: [PATCH 4/8] feat(client):add integrated test cases for SpringMvcClientEventListener. --- .../SpringMvcMappingPathControllerTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) 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 3b1debec6bf7..ea4d63281fd5 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 @@ -24,23 +24,49 @@ import java.io.IOException; -public class SpringMvcMappingPathControllerTest extends AbstractTest { +class SpringMvcMappingPathControllerTest extends AbstractTest { + + private static final String MULTI_PATH_SUFFIX = "I'm Shenyu-Gateway System. Welcome!"; @Test - public void testHello() throws IOException { + void testHello() throws IOException { String res = HttpHelper.INSTANCE.postGateway("/http/hello", java.lang.String.class); assertEquals("hello! I'm Shenyu-Gateway System. Welcome!", res); } @Test - public void testHi()throws IOException { + void testHi() throws IOException { String res = HttpHelper.INSTANCE.postGateway("/http/hi?name=tom", java.lang.String.class); assertEquals("hi! tom! I'm Shenyu-Gateway System. Welcome!", res); } @Test - public void testPost()throws IOException { + void testPost() throws IOException { String res = HttpHelper.INSTANCE.postGateway("/http/post/hi?name=tom", java.lang.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", java.lang.String.class); + assertEquals("hello from multipath! " + MULTI_PATH_SUFFIX, res); + } + + @Test + void testMultiPathV2Greet() throws IOException { + String res = HttpHelper.INSTANCE.postGateway("/http/multipath/v2/greet", java.lang.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", java.lang.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", java.lang.String.class); + assertEquals("echo: shenyu! " + MULTI_PATH_SUFFIX, res); + } } From eb5655ca35edb9710989f7509561f65342be62d9 Mon Sep 17 00:00:00 2001 From: wy471x Date: Sat, 23 May 2026 20:00:03 +0800 Subject: [PATCH 5/8] feat(client):add integrated test cases for SpringMvcClientEventListener. --- .../SpringMvcMappingPathControllerTest.java | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) 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 ea4d63281fd5..bfc372e08a30 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,56 +17,75 @@ 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; + +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)); + } + } + @Test void testHello() throws IOException { - String res = HttpHelper.INSTANCE.postGateway("/http/hello", java.lang.String.class); + String res = HttpHelper.INSTANCE.postGateway("/http/hello", String.class); assertEquals("hello! I'm Shenyu-Gateway System. Welcome!", res); } @Test void testHi() throws IOException { - String res = HttpHelper.INSTANCE.postGateway("/http/hi?name=tom", java.lang.String.class); + String res = HttpHelper.INSTANCE.postGateway("/http/hi?name=tom", String.class); assertEquals("hi! tom! I'm Shenyu-Gateway System. Welcome!", res); } @Test void testPost() throws IOException { - String res = HttpHelper.INSTANCE.postGateway("/http/post/hi?name=tom", java.lang.String.class); + 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", java.lang.String.class); + 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", java.lang.String.class); + 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", java.lang.String.class); + 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", java.lang.String.class); + String res = HttpHelper.INSTANCE.postGateway("/http/multipath/v2/echo?name=shenyu", String.class); assertEquals("echo: shenyu! " + MULTI_PATH_SUFFIX, res); } } From 1fd46e7392c4fbb1c2256dbf8e94544cf1ca07e8 Mon Sep 17 00:00:00 2001 From: wy471x Date: Mon, 25 May 2026 22:16:09 +0800 Subject: [PATCH 6/8] feat(client):add multipath register test controller. --- .../SpringMvcMultiPathController.java | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 shenyu-examples/shenyu-examples-http/src/main/java/org/apache/shenyu/examples/http/controller/SpringMvcMultiPathController.java 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 000000000000..a95494e1c5eb --- /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; + } +} From 63adcaee242116a5a6d86d1222e9c4f21f64631f Mon Sep 17 00:00:00 2001 From: wy471x Date: Tue, 14 Jul 2026 14:52:05 +0800 Subject: [PATCH 7/8] 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 --- ...AbstractContextRefreshedEventListener.java | 61 ++++++++++--------- .../init/SpringMvcClientEventListener.java | 6 +- .../SpringMvcClientEventListenerTest.java | 20 ++++++ .../SpringMvcMappingPathControllerTest.java | 1 + 4 files changed, 58 insertions(+), 30 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 b19ae4e61dc3..e329ef09b524 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 @@ private List buildApiDocDTO(final Object bean, final Method m 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 superPaths = buildApiSuperPaths(clazz, AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType())); Annotation annotation = AnnotatedElementUtils.findMergedAnnotation(clazz, getAnnotationType()); if (Objects.isNull(annotation)) { return Lists.newArrayList(); @@ -208,30 +205,33 @@ private List buildApiDocDTO(final Object bean, final Method m String contextPath = getContextPath(); String[] value0 = sextet.getValue0(); List 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 @@ protected Class getCorrectedClass(final T bean) { protected abstract String buildApiSuperPath(Class clazz, @Nullable A beanShenyuClient); + protected List 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 ca4aeaec945f..6c0086248761 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 @@ -191,6 +191,7 @@ protected String buildApiSuperPath(final Class clazz, @Nullable final ShenyuS return paths.isEmpty() ? formatPath(buildRootPath()) : paths.get(0); } + @Override protected List buildApiSuperPaths(final Class clazz, @Nullable final ShenyuSpringMvcClient beanShenyuClient) { final String rootPath = buildRootPath(); if (Objects.nonNull(beanShenyuClient) && ArrayUtils.isNotEmpty(beanShenyuClient.path())) { @@ -251,8 +252,9 @@ protected String buildApiPath(final Method method, final String superPath, @NonNull final ShenyuSpringMvcClient methodShenyuClient) { String contextPath = getContextPath(); // Skip if any annotation path is already captured in superPath (class annotation used as method fallback) - final String annotationPath = methodShenyuClient.path()[0]; - boolean alreadyInSuperPath = Arrays.stream(methodShenyuClient.path()) + final String[] annotationPaths = methodShenyuClient.path(); + final String annotationPath = ArrayUtils.isNotEmpty(annotationPaths) ? annotationPaths[0] : ""; + boolean alreadyInSuperPath = Arrays.stream(annotationPaths) .filter(StringUtils::isNotBlank) .anyMatch(p -> superPath.endsWith(formatPath(p))); if (StringUtils.isNotBlank(annotationPath) && !alreadyInSuperPath) { 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 a2abaf07c251..8a99ea867ac7 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 @@ -218,6 +218,18 @@ public void testOnBuildApiSuperPath() { registerUtilsMockedStatic.close(); } + @Test + public void testBuildApiSuperPathsMultiPath() { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + List 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 testBuildApiDocSextetDefaultProducesConsumes() throws NoSuchMethodException { SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); @@ -315,6 +327,14 @@ public String test(final String hello) { } } + @RestController + @ShenyuSpringMvcClient(path = {"/multi-a", "/multi-b"}) + static class SpringMvcMultiPathTestBean { + public String test() { + return "ok"; + } + } + @RestController static class ApiDocTestBean { 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 bfc372e08a30..0de6fef39391 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 @@ -45,6 +45,7 @@ static void waitForMultiPathRoutes() throws InterruptedException { } Thread.sleep(TimeUnit.SECONDS.toMillis(2)); } + throw new AssertionError("Multi-path routes not ready after 60s: /http/multipath/v1/greet"); } @Test From 765056c1752d8beb029b065fb81314a7b1a293c7 Mon Sep 17 00:00:00 2001 From: wy471x Date: Wed, 15 Jul 2026 00:11:16 +0800 Subject: [PATCH 8/8] fix: remove fragile endsWith heuristic from buildApiPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../init/SpringMvcClientEventListener.java | 23 +- .../SpringMvcClientEventListenerTest.java | 264 +++++++++++++++--- 2 files changed, 241 insertions(+), 46 deletions(-) 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 6c0086248761..78e7489fff2c 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 @@ -233,14 +233,18 @@ protected void handleMethod(final Object bean, final Class clazz, 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 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); } @@ -251,13 +255,9 @@ protected void handleMethod(final Object bean, final Class clazz, protected String buildApiPath(final Method method, final String superPath, @NonNull final ShenyuSpringMvcClient methodShenyuClient) { String contextPath = getContextPath(); - // Skip if any annotation path is already captured in superPath (class annotation used as method fallback) final String[] annotationPaths = methodShenyuClient.path(); final String annotationPath = ArrayUtils.isNotEmpty(annotationPaths) ? annotationPaths[0] : ""; - boolean alreadyInSuperPath = Arrays.stream(annotationPaths) - .filter(StringUtils::isNotBlank) - .anyMatch(p -> superPath.endsWith(formatPath(p))); - if (StringUtils.isNotBlank(annotationPath) && !alreadyInSuperPath) { + if (StringUtils.isNotBlank(annotationPath)) { return pathJoin(contextPath, superPath, annotationPath); } final String path = getPathByMethod(method); @@ -267,6 +267,15 @@ protected String buildApiPath(final Method method, final String superPath, 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 8a99ea867ac7..638ed1575a13 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,6 +17,8 @@ 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; @@ -26,6 +28,9 @@ 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; @@ -36,6 +41,7 @@ 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; @@ -45,6 +51,7 @@ 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; @@ -60,8 +67,11 @@ 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; @@ -230,55 +240,206 @@ public void testBuildApiSuperPathsMultiPath() { 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 controllerBeans = new LinkedHashMap<>(); + controllerBeans.put("multiPathApiDocBean", multiPathBean); + when(applicationContext.getBeansWithAnnotation(eq(Controller.class))).thenReturn(controllerBeans); + + Map 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 publisherMock = mockStatic(ShenyuClientRegisterEventPublisher.class); + MockedStatic 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 captor = ArgumentCaptor.forClass(DataTypeParent.class); + verify(publisher, atLeastOnce()).publishEvent(captor.capture()); + + List events = captor.getAllValues(); + + // Verify MetaDataRegisterDTO: 2 paths × 1 method = 2 entries + List 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 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 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 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 { - SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); - Method method = ApiDocTestBean.class.getDeclaredMethod("getDefault"); - Sextet 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()); - registerUtilsMockedStatic.close(); + try { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("getDefault"); + Sextet 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 { - SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); - Method method = ApiDocTestBean.class.getDeclaredMethod("postExplicit", String.class); - Sextet 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()); - registerUtilsMockedStatic.close(); + try { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("postExplicit", String.class); + Sextet 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 { - SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); - Method method = ApiDocTestBean.class.getDeclaredMethod("multi", String.class); - Sextet 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 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()); - registerUtilsMockedStatic.close(); + try { + SpringMvcClientEventListener listener = buildSpringMvcClientEventListener(false, false); + Method method = ApiDocTestBean.class.getDeclaredMethod("multi", String.class); + Sextet 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 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 @@ -335,6 +496,31 @@ public String test() { } } + @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 {