diff --git a/CHANGELOG.md b/CHANGELOG.md index 087c3b159..bc3e8cfb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,3 +8,4 @@ - [feat: support enable/disable cloud location provider via configuration.](https://github.com/Tencent/spring-cloud-tencent/pull/1799) - [feat: refactor Feign eager load, add LoadBalancer warm-up, and fix gateway trailing slash compatibility](https://github.com/Tencent/spring-cloud-tencent/pull/1800) - [test: add unit tests for ConfigurationModifier and fix TsfConsul report logic](https://github.com/Tencent/spring-cloud-tencent/pull/1802) +- [fix: prepend context-path to contract reporter API paths](https://github.com/Tencent/spring-cloud-tencent/pull/1803) diff --git a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/PolarisContractReporter.java b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/PolarisContractReporter.java index 417cf5517..da480554f 100644 --- a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/PolarisContractReporter.java +++ b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/PolarisContractReporter.java @@ -70,16 +70,20 @@ public class PolarisContractReporter implements ApplicationListener interfaceDescriptorList = getInterfaceDescriptorFromSwagger(openAPI); request.setInterfaceDescriptors(interfaceDescriptorList); + if (StringUtils.isNotBlank(contextPath) && openAPI.getPaths() != null) { + Paths newPaths = new Paths(); + for (Map.Entry entry : openAPI.getPaths().entrySet()) { + newPaths.addPathItem(contextPath + entry.getKey(), entry.getValue()); + } + openAPI.setPaths(newPaths); + } String jsonValue; if (springdocObjectMapperProvider != null && springdocObjectMapperProvider.jsonMapper() != null) { jsonValue = springdocObjectMapperProvider.jsonMapper().writeValueAsString(openAPI); @@ -143,6 +154,9 @@ public class PolarisContractReporter implements ApplicationListener getInterfaceDescriptorFromSwagger(OpenAPI openAPI) { List interfaceDescriptorList = new ArrayList<>(); Paths paths = openAPI.getPaths(); + if (paths == null) { + return interfaceDescriptorList; + } for (Map.Entry p : paths.entrySet()) { PathItem path = p.getValue(); Map operationMap = getOperationMapFromPath(path); @@ -151,7 +165,12 @@ public class PolarisContractReporter implements ApplicationListener o : operationMap.entrySet()) { InterfaceDescriptor interfaceDescriptor = new InterfaceDescriptor(); - interfaceDescriptor.setPath(p.getKey()); + if (StringUtils.isNotBlank(contextPath)) { + interfaceDescriptor.setPath(contextPath + p.getKey()); + } + else { + interfaceDescriptor.setPath(p.getKey()); + } interfaceDescriptor.setMethod(o.getKey()); try { String jsonValue; diff --git a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/config/PolarisSwaggerAutoConfiguration.java b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/config/PolarisSwaggerAutoConfiguration.java index 94449d967..eed0946d2 100644 --- a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/config/PolarisSwaggerAutoConfiguration.java +++ b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/config/PolarisSwaggerAutoConfiguration.java @@ -41,6 +41,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplicat import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import org.springframework.core.env.Environment; import org.springframework.lang.Nullable; import org.springframework.util.StringUtils; @@ -100,9 +101,18 @@ public class PolarisSwaggerAutoConfiguration { @Nullable MultipleOpenApiWebMvcResource multipleOpenApiWebMvcResource, @Nullable MultipleOpenApiWebFluxResource multipleOpenApiWebFluxResource, PolarisContractProperties polarisContractProperties, PolarisSDKContextManager polarisSDKContextManager, - PolarisDiscoveryProperties polarisDiscoveryProperties, ObjectMapperProvider springdocObjectMapperProvider) { + PolarisDiscoveryProperties polarisDiscoveryProperties, ObjectMapperProvider springdocObjectMapperProvider, + Environment environment) { + String contextPath = environment.getProperty("server.servlet.context-path", ""); + if (!StringUtils.hasText(contextPath)) { + contextPath = environment.getProperty("spring.webflux.base-path", ""); + } + if (contextPath.endsWith("/")) { + contextPath = contextPath.substring(0, contextPath.length() - 1); + } return new PolarisContractReporter(multipleOpenApiWebMvcResource, multipleOpenApiWebFluxResource, - polarisContractProperties, polarisSDKContextManager.getProviderAPI(), polarisDiscoveryProperties, springdocObjectMapperProvider); + polarisContractProperties, polarisSDKContextManager.getProviderAPI(), polarisDiscoveryProperties, + springdocObjectMapperProvider, contextPath); } @Bean diff --git a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfApiMetadataGrapher.java b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfApiMetadataGrapher.java index 9e9f6b5af..bdcb91ae4 100644 --- a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfApiMetadataGrapher.java +++ b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfApiMetadataGrapher.java @@ -17,12 +17,15 @@ package com.tencent.cloud.polaris.contract.tsf; +import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.ObjectMapper; import com.tencent.cloud.common.util.GzipUtil; import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springdoc.api.AbstractOpenApiResource; @@ -45,15 +48,18 @@ public class TsfApiMetadataGrapher implements SmartLifecycle { private final ObjectMapperProvider springdocObjectMapperProvider; private final ApplicationContext applicationContext; private final String groupName; + private final String contextPath; public TsfApiMetadataGrapher(org.springdoc.webmvc.api.MultipleOpenApiResource multipleOpenApiWebMvcResource, org.springdoc.webflux.api.MultipleOpenApiResource multipleOpenApiWebFluxResource, - String groupName, ApplicationContext applicationContext, ObjectMapperProvider springdocObjectMapperProvider) { + String groupName, ApplicationContext applicationContext, ObjectMapperProvider springdocObjectMapperProvider, + String contextPath) { this.applicationContext = applicationContext; this.multipleOpenApiWebMvcResource = multipleOpenApiWebMvcResource; this.multipleOpenApiWebFluxResource = multipleOpenApiWebFluxResource; this.groupName = groupName; this.springdocObjectMapperProvider = springdocObjectMapperProvider; + this.contextPath = contextPath; } @Override @@ -84,6 +90,14 @@ public class TsfApiMetadataGrapher implements SmartLifecycle { if (openApiResource != null) { openAPI = AbstractOpenApiResourceUtil.getOpenApi(openApiResource); } + if (openAPI != null && contextPath != null && !contextPath.isEmpty() + && openAPI.getPaths() != null) { + Paths newPaths = new Paths(); + for (Map.Entry entry : openAPI.getPaths().entrySet()) { + newPaths.addPathItem(contextPath + entry.getKey(), entry.getValue()); + } + openAPI.setPaths(newPaths); + } String jsonValue; if (springdocObjectMapperProvider != null && springdocObjectMapperProvider.jsonMapper() != null) { jsonValue = springdocObjectMapperProvider.jsonMapper().writeValueAsString(openAPI); diff --git a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfSwaggerAutoConfiguration.java b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfSwaggerAutoConfiguration.java index f9fa53fd4..defd3208d 100644 --- a/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfSwaggerAutoConfiguration.java +++ b/spring-cloud-starter-tencent-polaris-contract/src/main/java/com/tencent/cloud/polaris/contract/tsf/TsfSwaggerAutoConfiguration.java @@ -28,7 +28,9 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.lang.Nullable; +import org.springframework.util.StringUtils; @Configuration @ConditionalOnTsfConsulEnabled @@ -39,8 +41,16 @@ public class TsfSwaggerAutoConfiguration { @ConditionalOnBean(OpenAPI.class) public TsfApiMetadataGrapher tsfApiMetadataGrapher(@Nullable MultipleOpenApiWebMvcResource multipleOpenApiWebMvcResource, @Nullable MultipleOpenApiWebFluxResource multipleOpenApiWebFluxResource, ApplicationContext context, - PolarisContractProperties polarisContractProperties, ObjectMapperProvider springdocObjectMapperProvider) { + PolarisContractProperties polarisContractProperties, ObjectMapperProvider springdocObjectMapperProvider, + Environment environment) { + String contextPath = environment.getProperty("server.servlet.context-path", ""); + if (!StringUtils.hasText(contextPath)) { + contextPath = environment.getProperty("spring.webflux.base-path", ""); + } + if (contextPath.endsWith("/")) { + contextPath = contextPath.substring(0, contextPath.length() - 1); + } return new TsfApiMetadataGrapher(multipleOpenApiWebMvcResource, multipleOpenApiWebFluxResource, - polarisContractProperties.getGroup(), context, springdocObjectMapperProvider); + polarisContractProperties.getGroup(), context, springdocObjectMapperProvider, contextPath); } } diff --git a/spring-cloud-starter-tencent-polaris-contract/src/test/java/com/tencent/cloud/polaris/contract/PolarisContractReporterTest.java b/spring-cloud-starter-tencent-polaris-contract/src/test/java/com/tencent/cloud/polaris/contract/PolarisContractReporterTest.java new file mode 100644 index 000000000..d0d27ea09 --- /dev/null +++ b/spring-cloud-starter-tencent-polaris-contract/src/test/java/com/tencent/cloud/polaris/contract/PolarisContractReporterTest.java @@ -0,0 +1,186 @@ +/* + * Tencent is pleased to support the open source community by making spring-cloud-tencent available. + * + * Copyright (C) 2021 Tencent. All rights reserved. + * + * Licensed under the BSD 3-Clause License (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://opensource.org/licenses/BSD-3-Clause + * + * 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 com.tencent.cloud.polaris.contract; + +import java.lang.reflect.Method; +import java.util.List; + +import com.tencent.cloud.polaris.PolarisDiscoveryProperties; +import com.tencent.cloud.polaris.contract.config.PolarisContractProperties; +import com.tencent.polaris.api.core.ProviderAPI; +import com.tencent.polaris.api.plugin.server.InterfaceDescriptor; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springdoc.core.providers.ObjectMapperProvider; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Test for {@link PolarisContractReporter}. + * + * @author Haotian Zhang + */ +@DisplayName("PolarisContractReporter") +@ExtendWith(MockitoExtension.class) +class PolarisContractReporterTest { + + @Mock + private PolarisContractProperties polarisContractProperties; + + @Mock + private ProviderAPI providerAPI; + + @Mock + private PolarisDiscoveryProperties polarisDiscoveryProperties; + + @Mock + private ObjectMapperProvider springdocObjectMapperProvider; + + private OpenAPI createTestOpenAPI() { + OpenAPI openAPI = new OpenAPI(); + Paths paths = new Paths(); + + PathItem sumPath = new PathItem(); + Operation sumGet = new Operation(); + sumGet.setSummary("sum"); + sumPath.setGet(sumGet); + paths.addPathItem("/quickstart/callee/sum", sumPath); + + PathItem infoPath = new PathItem(); + Operation infoGet = new Operation(); + infoGet.setSummary("info"); + infoPath.setGet(infoGet); + paths.addPathItem("/quickstart/callee/info", infoPath); + + openAPI.setPaths(paths); + return openAPI; + } + + @SuppressWarnings("unchecked") + private List invokeGetInterfaceDescriptorFromSwagger( + PolarisContractReporter reporter, OpenAPI openAPI) throws Exception { + Method method = PolarisContractReporter.class.getDeclaredMethod( + "getInterfaceDescriptorFromSwagger", OpenAPI.class); + method.setAccessible(true); + return (List) method.invoke(reporter, openAPI); + } + + /** + * Test that context-path is prepended to InterfaceDescriptor paths. + */ + @DisplayName("context-path is prepended to interface descriptor paths") + @Test + void testContextPathPrependedToInterfaceDescriptorPaths() throws Exception { + // Arrange + String contextPath = "/callee-service"; + PolarisContractReporter reporter = new PolarisContractReporter( + null, null, polarisContractProperties, providerAPI, + polarisDiscoveryProperties, springdocObjectMapperProvider, contextPath); + OpenAPI openAPI = createTestOpenAPI(); + + // Act + List descriptors = invokeGetInterfaceDescriptorFromSwagger(reporter, openAPI); + + // Assert + assertThat(descriptors).hasSize(2); + assertThat(descriptors) + .extracting(InterfaceDescriptor::getPath) + .containsExactlyInAnyOrder( + "/callee-service/quickstart/callee/sum", + "/callee-service/quickstart/callee/info"); + } + + /** + * Test that empty context-path leaves paths unchanged. + */ + @DisplayName("empty context-path leaves paths unchanged") + @Test + void testEmptyContextPathLeavesPathsUnchanged() throws Exception { + // Arrange + String contextPath = ""; + PolarisContractReporter reporter = new PolarisContractReporter( + null, null, polarisContractProperties, providerAPI, + polarisDiscoveryProperties, springdocObjectMapperProvider, contextPath); + OpenAPI openAPI = createTestOpenAPI(); + + // Act + List descriptors = invokeGetInterfaceDescriptorFromSwagger(reporter, openAPI); + + // Assert + assertThat(descriptors).hasSize(2); + assertThat(descriptors) + .extracting(InterfaceDescriptor::getPath) + .containsExactlyInAnyOrder( + "/quickstart/callee/sum", + "/quickstart/callee/info"); + } + + /** + * Test that trailing slash on context-path is handled correctly via auto-config normalization. + * The auto-config strips trailing slashes before passing to the constructor, + * so we verify the reporter works correctly when contextPath has no trailing slash. + */ + @DisplayName("context-path without trailing slash works correctly") + @Test + void testContextPathWithoutTrailingSlash() throws Exception { + // Arrange + String contextPath = "/api"; + PolarisContractReporter reporter = new PolarisContractReporter( + null, null, polarisContractProperties, providerAPI, + polarisDiscoveryProperties, springdocObjectMapperProvider, contextPath); + OpenAPI openAPI = createTestOpenAPI(); + + // Act + List descriptors = invokeGetInterfaceDescriptorFromSwagger(reporter, openAPI); + + // Assert + assertThat(descriptors).hasSize(2); + assertThat(descriptors) + .extracting(InterfaceDescriptor::getPath) + .allMatch(path -> path.startsWith("/api/")); + } + + /** + * Test that null paths in OpenAPI returns an empty descriptor list without NPE. + * When OpenAPI.getPaths() returns null, the method should gracefully return + * an empty list instead of throwing NullPointerException. + */ + @DisplayName("null paths in OpenAPI returns empty descriptor list") + @Test + void testNullPathsReturnsEmptyList() throws Exception { + // Arrange + PolarisContractReporter reporter = new PolarisContractReporter( + null, null, polarisContractProperties, providerAPI, + polarisDiscoveryProperties, springdocObjectMapperProvider, "/context"); + OpenAPI openAPI = new OpenAPI(); + // Do not call openAPI.setPaths(), so getPaths() returns null + + // Act + List descriptors = invokeGetInterfaceDescriptorFromSwagger(reporter, openAPI); + + // Assert + assertThat(descriptors).isNotNull().isEmpty(); + } +}