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 17e8f3145c05..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 @@ -43,6 +43,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,57 @@ 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())) { + 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); - // 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 @@ -212,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)) { 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 33ec3f106664..d788184c87cd 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 @@ -22,8 +22,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; @@ -45,10 +48,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; @@ -208,6 +216,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") @@ -254,4 +313,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; + } + } + } 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; + } +} 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..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,30 +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; -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)); + } + } @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); + } }