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 17e8f3145c05..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 @@ -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,58 @@ 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); + } + + @Override + 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 @@ -194,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); } @@ -212,8 +255,10 @@ 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]); + 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 @@ 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 bfb90dd7e7e6..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,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.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.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 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 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 { + 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 { + 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 { + 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 @RequestMapping("/order") @ShenyuSpringMvcClient(path = "/order") @@ -256,4 +488,62 @@ public String test(final String hello) { } } + @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 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..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 @@ -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); + } }