This is an automated email from the ASF dual-hosted git repository.
funky-eyes pushed a commit to branch 2.x
in repository https://gitbox.apache.org/repos/asf/incubator-seata.git
The following commit(s) were added to refs/heads/2.x by this push:
new 30f602eea3 optimize: removed the OkHttp3 dependency from the
NamingServer. (#8077)
30f602eea3 is described below
commit 30f602eea37b5acf147c2b9903ffca50f0f6abeb
Author: funkye <[email protected]>
AuthorDate: Tue Apr 28 20:16:57 2026 +0800
optimize: removed the OkHttp3 dependency from the NamingServer. (#8077)
---
.gitignore | 5 +
changes/en-us/2.x.md | 2 +-
changes/zh-cn/2.x.md | 2 +-
console/pom.xml | 10 +
.../seata/console/config/RestClientConfig.java | 38 +++
.../mcp/service/impl/ConsoleRemoteServiceImpl.java | 37 ++-
.../service/impl/ConsoleRemoteServiceImplTest.java | 166 ++++++++++
namingserver/pom.xml | 4 -
.../seata/namingserver/config/WebConfig.java | 36 +--
.../namingserver/filter/ConsoleRemotingFilter.java | 125 +++++---
.../seata/namingserver/manager/NamingManager.java | 54 ++--
.../service/ConsoleLocalServiceImpl.java | 33 +-
.../seata/namingserver/NamingManagerTest.java | 104 ++++---
.../filter/ConsoleRemotingFilterTest.java | 344 ++++++++++++++++-----
.../service/ConsoleLocalServiceImplTest.java | 140 +++++++++
15 files changed, 877 insertions(+), 223 deletions(-)
diff --git a/.gitignore b/.gitignore
index 2eb9470ffe..f4ce63bf64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -73,6 +73,11 @@ dependency-reduced-pom.xml
.DS_Store
Thumbs.db
*.orig
+.github/agents/**
+.github/instructions/**
+.github/prompts/**
+.github/skills/**
+.github/copilot-instructions.md
#h2
*.db
diff --git a/changes/en-us/2.x.md b/changes/en-us/2.x.md
index 50415b22f4..fa0f8f5519 100644
--- a/changes/en-us/2.x.md
+++ b/changes/en-us/2.x.md
@@ -64,7 +64,7 @@ Add changes here for all PR submitted to the 2.x branch.
- [[#8064](https://github.com/apache/incubator-seata/pull/8064)] bump
org.apache.kafka:kafka-clients from 3.6.1 to 3.9.2
- [[#8069](https://github.com/apache/incubator-seata/pull/8069)] bump
org.assertj:assertj-core from 3.12.2 to 3.27.7
- [[#8075](https://github.com/apache/incubator-seata/pull/8075)] bump
at.yawk.lz4:lz4-java from 1.9.0 to 1.10.1
-
+- [[#8077](https://github.com/apache/incubator-seata/pull/8077)] removed the
OkHttp3 dependency from the NamingServer
### security:
diff --git a/changes/zh-cn/2.x.md b/changes/zh-cn/2.x.md
index 1f0eff234e..a939ad3293 100644
--- a/changes/zh-cn/2.x.md
+++ b/changes/zh-cn/2.x.md
@@ -65,7 +65,7 @@
- [[#8064](https://github.com/apache/incubator-seata/pull/8064)] 将
org.apache.kafka:kafka-clients 从 3.6.1 版本升级到 3.9.2 版本
- [[#8069](https://github.com/apache/incubator-seata/pull/8069)] 将
org.assertj:assertj-core 从 3.12.2 版本升级到 3.27.7
- [[#8075](https://github.com/apache/incubator-seata/pull/8075)] 将
at.yawk.lz4:lz4-java 从 1.9.0 版本升级到 1.10.1
-
+- [[#8077](https://github.com/apache/incubator-seata/pull/8077)] namingserver
移除 okhttp3 依赖
### security:
diff --git a/console/pom.xml b/console/pom.xml
index 1fd057f32e..9cb1ffa640 100644
--- a/console/pom.xml
+++ b/console/pom.xml
@@ -134,6 +134,16 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.springframework</groupId>
+ <artifactId>spring-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.hamcrest</groupId>
+ <artifactId>hamcrest</artifactId>
+ <scope>test</scope>
+ </dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
diff --git
a/console/src/main/java/org/apache/seata/console/config/RestClientConfig.java
b/console/src/main/java/org/apache/seata/console/config/RestClientConfig.java
new file mode 100644
index 0000000000..2804244755
--- /dev/null
+++
b/console/src/main/java/org/apache/seata/console/config/RestClientConfig.java
@@ -0,0 +1,38 @@
+/*
+ * 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.seata.console.config;
+
+import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
+
+import java.net.http.HttpClient;
+
+@Configuration(proxyBeanMethods = false)
+@SuppressWarnings("null")
+public class RestClientConfig {
+
+ @Bean("consoleRestClient")
+ @ConditionalOnMissingBean(name = "consoleLocalServiceImpl")
+ public RestClient consoleRestClient(RestClient.Builder restClientBuilder) {
+ HttpClient httpClient = HttpClient.newHttpClient();
+ JdkClientHttpRequestFactory requestFactory = new
JdkClientHttpRequestFactory(httpClient);
+ return restClientBuilder.requestFactory(requestFactory).build();
+ }
+}
diff --git
a/console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java
b/console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java
index 4dfb152b1c..8586acb3f5 100644
---
a/console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java
+++
b/console/src/main/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImpl.java
@@ -27,6 +27,7 @@ import org.apache.seata.mcp.exception.ServiceCallException;
import org.apache.seata.mcp.service.ConsoleApiService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Qualifier;
import
org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@@ -35,23 +36,28 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
-import org.springframework.web.client.RestTemplate;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
import java.util.Map;
+import java.util.Objects;
import static org.apache.seata.mcp.core.utils.UrlUtils.buildUrl;
import static org.apache.seata.mcp.core.utils.UrlUtils.objectToQueryParamMap;
@ConditionalOnMissingBean(name = "consoleLocalServiceImpl")
@Service
+@SuppressWarnings("null")
public class ConsoleRemoteServiceImpl implements ConsoleApiService {
private static final Logger LOGGER =
LoggerFactory.getLogger(ConsoleRemoteServiceImpl.class);
private final JwtTokenUtils jwtTokenUtils;
- private final RestTemplate restTemplate;
+ private final RestClient restClient;
private final ObjectMapper objectMapper;
@@ -59,11 +65,11 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
public ConsoleRemoteServiceImpl(
JwtTokenUtils jwtTokenUtils,
- RestTemplate restTemplate,
+ @Qualifier("consoleRestClient") RestClient restClient,
ObjectMapper objectMapper,
NamingServerProperties namingServerProperties) {
this.jwtTokenUtils = jwtTokenUtils;
- this.restTemplate = restTemplate;
+ this.restClient = restClient;
this.objectMapper = objectMapper;
this.namingServerProperties = namingServerProperties;
LOGGER.info("ConsoleRemoteServiceImpl initialized.");
@@ -103,7 +109,7 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
HttpEntity<String> entity = new HttpEntity<>(headers);
String responseBody;
try {
- ResponseEntity<String> response = restTemplate.exchange(url,
HttpMethod.GET, entity, String.class);
+ ResponseEntity<String> response = executeRequest(url,
HttpMethod.GET, entity);
responseBody = response.getBody();
@@ -143,7 +149,7 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
HttpEntity<String> entity = new HttpEntity<>(headers);
String responseBody;
try {
- ResponseEntity<String> response = restTemplate.exchange(url,
HttpMethod.GET, entity, String.class);
+ ResponseEntity<String> response = executeRequest(url,
HttpMethod.GET, entity);
responseBody = response.getBody();
@@ -183,7 +189,7 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
HttpEntity<String> entity = new HttpEntity<>(headers);
String responseBody;
try {
- ResponseEntity<String> response = restTemplate.exchange(url,
HttpMethod.DELETE, entity, String.class);
+ ResponseEntity<String> response = executeRequest(url,
HttpMethod.DELETE, entity);
responseBody = response.getBody();
@@ -223,7 +229,7 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
HttpEntity<String> entity = new HttpEntity<>(headers);
String responseBody;
try {
- ResponseEntity<String> response = restTemplate.exchange(url,
HttpMethod.PUT, entity, String.class);
+ ResponseEntity<String> response = executeRequest(url,
HttpMethod.PUT, entity);
responseBody = response.getBody();
@@ -241,4 +247,19 @@ public class ConsoleRemoteServiceImpl implements
ConsoleApiService {
throw new ServiceCallException(errorMsg);
}
}
+
+ private ResponseEntity<String> executeRequest(String url, HttpMethod
httpMethod, HttpEntity<String> entity)
+ throws RestClientException {
+ return restClient
+ .method(Objects.requireNonNull(httpMethod))
+ .uri(Objects.requireNonNull(url))
+ .headers(headers -> headers.addAll(entity.getHeaders()))
+ .exchange((request, response) -> new ResponseEntity<>(
+ readResponseBody(response), response.getHeaders(),
response.getStatusCode()));
+ }
+
+ private String
readResponseBody(RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse
response)
+ throws IOException {
+ return response.getBody() == null ? "" :
StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
+ }
}
diff --git
a/console/src/test/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImplTest.java
b/console/src/test/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImplTest.java
new file mode 100644
index 0000000000..821dc8e418
--- /dev/null
+++
b/console/src/test/java/org/apache/seata/mcp/service/impl/ConsoleRemoteServiceImplTest.java
@@ -0,0 +1,166 @@
+/*
+ * 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.seata.mcp.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.seata.console.config.WebSecurityConfig;
+import org.apache.seata.console.utils.JwtTokenUtils;
+import org.apache.seata.mcp.core.props.NameSpaceDetail;
+import org.apache.seata.mcp.core.props.NamingServerProperties;
+import org.apache.seata.mcp.exception.ServiceCallException;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import
org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+
+import java.util.HashMap;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.ExpectedCount.once;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+@SuppressWarnings("null")
+class ConsoleRemoteServiceImplTest {
+
+ private JwtTokenUtils jwtTokenUtils;
+
+ private MockRestServiceServer server;
+
+ private ConsoleRemoteServiceImpl consoleRemoteService;
+
+ @BeforeEach
+ void setUp() {
+ jwtTokenUtils = mock(JwtTokenUtils.class);
+ when(jwtTokenUtils.validateToken("jwt-token")).thenReturn(true);
+
+ NamingServerProperties namingServerProperties = new
NamingServerProperties();
+ namingServerProperties.setProtocol("http");
+ namingServerProperties.setAddr(List.of("127.0.0.1:8081"));
+
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ server = MockRestServiceServer.bindTo(restClientBuilder).build();
+ RestClient restClient = restClientBuilder.build();
+ consoleRemoteService =
+ new ConsoleRemoteServiceImpl(jwtTokenUtils, restClient, new
ObjectMapper(), namingServerProperties);
+
+ SecurityContextHolder.getContext()
+ .setAuthentication(new
UsernamePasswordAuthenticationToken("user", "jwt-token", List.of()));
+ }
+
+ @AfterEach
+ void tearDown() {
+ SecurityContextHolder.clearContext();
+ }
+
+ @Test
+ void getCallTCShouldSendAuthorizationAndNamespaceHeaders() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setCluster("default");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("x-custom", "value");
+ HashMap<String, String> queryParams = new HashMap<>();
+ queryParams.put("page", "1");
+
+ server.expect(once(),
requestTo("http://127.0.0.1:8081/api/v1/console/sessions?page=1"))
+ .andExpect(method(HttpMethod.GET))
+ .andExpect(header(WebSecurityConfig.AUTHORIZATION_HEADER,
WebSecurityConfig.TOKEN_PREFIX + "jwt-token"))
+ .andExpect(header("x-seata-namespace", "public"))
+ .andExpect(header("x-seata-cluster", "default"))
+ .andExpect(header("x-custom", "value"))
+ .andRespond(withSuccess("remote-ok",
org.springframework.http.MediaType.TEXT_PLAIN));
+
+ String response =
+ consoleRemoteService.getCallTC(nameSpaceDetail,
"/api/v1/console/sessions", null, queryParams, headers);
+
+ server.verify();
+ assertEquals("remote-ok", response);
+ }
+
+ @Test
+ void putCallTCShouldEncodeVgroupAsQueryParameter() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setvGroup("vgroup-a");
+
+ server.expect(once(),
requestTo("http://127.0.0.1:8081/api/v1/console/sessions?vGroup=vgroup-a"))
+ .andExpect(method(HttpMethod.PUT))
+ .andExpect(header(WebSecurityConfig.AUTHORIZATION_HEADER,
WebSecurityConfig.TOKEN_PREFIX + "jwt-token"))
+ .andExpect(header("x-seata-namespace", "public"))
+ .andExpect(headerDoesNotExist("x-seata-cluster"))
+ .andRespond(withSuccess("updated",
org.springframework.http.MediaType.TEXT_PLAIN));
+
+ String response = consoleRemoteService.putCallTC(
+ nameSpaceDetail, "/api/v1/console/sessions", null, new
HashMap<>(), new HttpHeaders());
+
+ server.verify();
+ assertEquals("updated", response);
+ }
+
+ @Test
+ void deleteCallTCShouldSendHeadersAndReturnResponse() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setCluster("default");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("x-custom", "value");
+ HashMap<String, String> queryParams = new HashMap<>();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:8081/api/v1/console/sessions"))
+ .andExpect(method(HttpMethod.DELETE))
+ .andExpect(header(WebSecurityConfig.AUTHORIZATION_HEADER,
WebSecurityConfig.TOKEN_PREFIX + "jwt-token"))
+ .andExpect(header("x-seata-namespace", "public"))
+ .andExpect(header("x-seata-cluster", "default"))
+ .andExpect(header("x-custom", "value"))
+ .andRespond(withSuccess("deleted-ok",
org.springframework.http.MediaType.TEXT_PLAIN));
+
+ String response = consoleRemoteService.deleteCallTC(
+ nameSpaceDetail, "/api/v1/console/sessions", null,
queryParams, headers);
+
+ server.verify();
+ assertEquals("deleted-ok", response);
+ }
+
+ @Test
+ void getCallNameSpaceShouldThrowWhenUpstreamReturnsNon2xx() {
+ server.expect(once(),
requestTo("http://127.0.0.1:8081/api/v1/console/namespaces"))
+ .andExpect(method(HttpMethod.GET))
+ .andExpect(header(WebSecurityConfig.AUTHORIZATION_HEADER,
WebSecurityConfig.TOKEN_PREFIX + "jwt-token"))
+
.andRespond(withStatus(HttpStatus.INTERNAL_SERVER_ERROR).body("boom"));
+
+ assertThrows(
+ ServiceCallException.class, () ->
consoleRemoteService.getCallNameSpace("/api/v1/console/namespaces"));
+ server.verify();
+ }
+}
diff --git a/namingserver/pom.xml b/namingserver/pom.xml
index 03bfb3c5a6..2314aa16bd 100644
--- a/namingserver/pom.xml
+++ b/namingserver/pom.xml
@@ -169,10 +169,6 @@
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
- <dependency>
- <groupId>com.squareup.okhttp3</groupId>
- <artifactId>okhttp</artifactId>
- </dependency>
</dependencies>
<build>
diff --git
a/namingserver/src/main/java/org/apache/seata/namingserver/config/WebConfig.java
b/namingserver/src/main/java/org/apache/seata/namingserver/config/WebConfig.java
index f7d0a79f08..78afca5151 100644
---
a/namingserver/src/main/java/org/apache/seata/namingserver/config/WebConfig.java
+++
b/namingserver/src/main/java/org/apache/seata/namingserver/config/WebConfig.java
@@ -17,21 +17,18 @@
package org.apache.seata.namingserver.config;
import jakarta.servlet.Filter;
-import okhttp3.Dispatcher;
-import okhttp3.OkHttpClient;
import org.apache.seata.namingserver.filter.ConsoleRemotingFilter;
import org.apache.seata.namingserver.manager.NamingManager;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
-import org.springframework.http.client.OkHttp3ClientHttpRequestFactory;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.http.client.JdkClientHttpRequestFactory;
+import org.springframework.web.client.RestClient;
-import java.util.concurrent.TimeUnit;
+import java.net.http.HttpClient;
+import java.time.Duration;
-import static
org.apache.seata.namingserver.contants.NamingConstant.DEFAULT_CONNECTION_MAX_PER_ROUTE;
-import static
org.apache.seata.namingserver.contants.NamingConstant.DEFAULT_CONNECTION_MAX_TOTAL;
import static
org.apache.seata.namingserver.contants.NamingConstant.DEFAULT_REQUEST_TIMEOUT;
import static
org.apache.seata.namingserver.contants.NamingConstant.DEFAULT_WRITE_TIMEOUT;
@@ -39,26 +36,19 @@ import static
org.apache.seata.namingserver.contants.NamingConstant.DEFAULT_WRIT
public class WebConfig {
@Bean
- public RestTemplate restTemplate() {
- Dispatcher dispatcher = new Dispatcher();
- dispatcher.setMaxRequests(DEFAULT_CONNECTION_MAX_TOTAL);
- dispatcher.setMaxRequestsPerHost(DEFAULT_CONNECTION_MAX_PER_ROUTE);
-
- OkHttpClient client = new OkHttpClient.Builder()
- .dispatcher(dispatcher)
- .connectTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
- .readTimeout(DEFAULT_REQUEST_TIMEOUT, TimeUnit.MILLISECONDS)
- .writeTimeout(DEFAULT_WRITE_TIMEOUT, TimeUnit.MILLISECONDS)
+ public RestClient restClient(RestClient.Builder restClientBuilder) {
+ HttpClient httpClient = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofMillis(DEFAULT_REQUEST_TIMEOUT))
+ .version(HttpClient.Version.HTTP_1_1)
.build();
-
- // Create and return a RestTemplate with the custom request factory
- return new RestTemplate(new OkHttp3ClientHttpRequestFactory(client));
+ JdkClientHttpRequestFactory requestFactory = new
JdkClientHttpRequestFactory(httpClient);
+
requestFactory.setReadTimeout(Duration.ofMillis(Math.max(DEFAULT_REQUEST_TIMEOUT,
DEFAULT_WRITE_TIMEOUT)));
+ return restClientBuilder.requestFactory(requestFactory).build();
}
@Bean
- public FilterRegistrationBean<Filter> consoleRemotingFilter(
- NamingManager namingManager, RestTemplate restTemplate) {
- ConsoleRemotingFilter consoleRemotingFilter = new
ConsoleRemotingFilter(namingManager, restTemplate);
+ public FilterRegistrationBean<Filter> consoleRemotingFilter(NamingManager
namingManager, RestClient restClient) {
+ ConsoleRemotingFilter consoleRemotingFilter = new
ConsoleRemotingFilter(namingManager, restClient);
FilterRegistrationBean<Filter> registration = new
FilterRegistrationBean<>();
registration.setFilter(consoleRemotingFilter);
registration.addUrlPatterns("/*");
diff --git
a/namingserver/src/main/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilter.java
b/namingserver/src/main/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilter.java
index b4c85a62fc..56a41c23b1 100644
---
a/namingserver/src/main/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilter.java
+++
b/namingserver/src/main/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilter.java
@@ -36,14 +36,17 @@ import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.client.RestClient;
import java.io.IOException;
+import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
+import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import java.util.regex.Pattern;
@@ -54,15 +57,15 @@ public class ConsoleRemotingFilter implements Filter {
private final NamingManager namingManager;
- private final RestTemplate restTemplate;
+ private final RestClient restClient;
private final Pattern urlPattern = Pattern.compile(CONSOLE_PATTERN);
private static final Logger LOGGER =
LoggerFactory.getLogger(ConsoleRemotingFilter.class);
- public ConsoleRemotingFilter(NamingManager namingManager, RestTemplate
restTemplate) {
+ public ConsoleRemotingFilter(NamingManager namingManager, RestClient
restClient) {
this.namingManager = namingManager;
- this.restTemplate = restTemplate;
+ this.restClient = restClient;
}
/**
@@ -96,24 +99,25 @@ public class ConsoleRemotingFilter implements Filter {
}
int i = 0;
// Skip optional UTF-8 BOM (0xEF, 0xBB, 0xBF)
- if (body.length >= 3
- && (body[0] & 0xFF) == 0xEF
- && (body[1] & 0xFF) == 0xBB
- && (body[2] & 0xFF) == 0xBF) {
+ if (body.length >= 3 && (body[0] & 0xFF) == 0xEF && (body[1] & 0xFF)
== 0xBB && (body[2] & 0xFF) == 0xBF) {
i = 3;
}
// skip leading whitespace (including Unicode NBSP / BOM that survived
as whitespace)
- while (i < body.length && (body[i] == ' ' || body[i] == '\t'
- || body[i] == '\r' || body[i] == '\n')) {
+ while (i < body.length && (body[i] == ' ' || body[i] == '\t' ||
body[i] == '\r' || body[i] == '\n')) {
i++;
}
if (i >= body.length) {
return true;
}
byte first = body[i];
- return first == '{' || first == '[' || first == '"'
- || first == 't' || first == 'f' || first == 'n'
- || (first >= '0' && first <= '9') || first == '-';
+ return first == '{'
+ || first == '['
+ || first == '"'
+ || first == 't'
+ || first == 'f'
+ || first == 'n'
+ || (first >= '0' && first <= '9')
+ || first == '-';
}
@Override
@@ -131,7 +135,7 @@ public class ConsoleRemotingFilter implements Filter {
String vgroup = request.getParameter("vgroup");
if (StringUtils.isNotBlank(namespace)
&& (StringUtils.isNotBlank(cluster) ||
StringUtils.isNotBlank(vgroup))) {
- List<NamingServerNode> list = null;
+ List<NamingServerNode> list = Collections.emptyList();
if (StringUtils.isNotBlank(vgroup)) {
list = namingManager.getInstancesByVgroupAndNamespace(
namespace,
@@ -158,24 +162,29 @@ public class ConsoleRemotingFilter implements Filter {
if (node.getRole() == ClusterRole.LEADER) {
headers.add(RAFT_GROUP_HEADER, node.getUnit());
}
- Collections.list(request.getHeaderNames())
- .forEach(headerName -> {
- if
(!HttpHeaders.HOST.equalsIgnoreCase(headerName)
- &&
!HttpHeaders.CONNECTION.equalsIgnoreCase(headerName)
- &&
!"Keep-Alive".equalsIgnoreCase(headerName)
- &&
!HttpHeaders.PROXY_AUTHENTICATE.equalsIgnoreCase(headerName)
- &&
!HttpHeaders.PROXY_AUTHORIZATION.equalsIgnoreCase(headerName)
- &&
!HttpHeaders.TE.equalsIgnoreCase(headerName)
- &&
!HttpHeaders.TRAILER.equalsIgnoreCase(headerName)
- &&
!HttpHeaders.UPGRADE.equalsIgnoreCase(headerName)) {
- headers.add(headerName,
request.getHeader(headerName));
- }
- });
+
Collections.list(request.getHeaderNames()).forEach(headerName -> {
+ if
(!HttpHeaders.HOST.equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.CONNECTION.equalsIgnoreCase(headerName)
+ &&
!"Keep-Alive".equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.PROXY_AUTHENTICATE.equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.PROXY_AUTHORIZATION.equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.TE.equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.TRAILER.equalsIgnoreCase(headerName)
+ &&
!HttpHeaders.UPGRADE.equalsIgnoreCase(headerName)) {
+ String headerValue =
request.getHeader(headerName);
+ // headerName comes from the Servlet API
Enumeration, which JDT treats as @Nullable.
+ // The Servlet specification guarantees
header names are never null;
+ // this defensive null guard is added
solely to suppress the type-safety warning.
+ if (headerName != null && headerValue !=
null) {
+ headers.add(headerName, headerValue);
+ }
+ }
+ });
// Create the HttpEntity with headers and body
HttpMethod httpMethod;
try {
- httpMethod =
HttpMethod.valueOf(request.getMethod());
+ httpMethod =
HttpMethod.valueOf(Objects.requireNonNull(request.getMethod()));
} catch (IllegalArgumentException ex) {
LOGGER.error("Unsupported HTTP method: {}",
request.getMethod(), ex);
response.setStatus(HttpServletResponse.SC_METHOD_NOT_ALLOWED);
@@ -197,7 +206,8 @@ public class ConsoleRemotingFilter implements Filter {
// headers-only for empty body
httpEntity = new HttpEntity<>(headers);
} else {
- // Remove potentially stale
length/transfer headers and let the client recompute them
+ // Remove potentially stale
length/transfer headers and let the client recompute
+ // them
headers.remove(HttpHeaders.CONTENT_LENGTH);
headers.remove(HttpHeaders.TRANSFER_ENCODING);
httpEntity = new HttpEntity<>(body,
headers);
@@ -205,8 +215,10 @@ public class ConsoleRemotingFilter implements Filter {
}
try {
- ResponseEntity<byte[]> responseEntity =
restTemplate.exchange(URI.create(targetUrl), httpMethod, httpEntity,
byte[].class);
- //Copy headers from proxied response, skipping
hop-by-hop and headers we manage ourselves to mitigate
+ ResponseEntity<byte[]> responseEntity =
+
executeProxyRequest(URI.create(targetUrl), httpMethod, httpEntity);
+ // Copy headers from proxied response,
skipping hop-by-hop and headers we manage
+ // ourselves to mitigate
// security risks from Content-Type
manipulation
responseEntity.getHeaders().forEach((key,
value) -> {
if
(!HttpHeaders.CONTENT_TYPE.equalsIgnoreCase(key)
@@ -225,25 +237,30 @@ public class ConsoleRemotingFilter implements Filter {
});
// Force a safe Content-Type: reject HTML/XML
types that could
// execute scripts; fall back to
application/json
- String proxiedContentType =
responseEntity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
+ String proxiedContentType =
+
responseEntity.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE);
String safeContentType;
- if (isSafeContentType(proxiedContentType)) {
+ if (proxiedContentType != null &&
isSafeContentType(proxiedContentType)) {
safeContentType = proxiedContentType;
} else {
safeContentType =
"application/json;charset=UTF-8";
}
response.setContentType(safeContentType);
response.setHeader("X-Content-Type-Options",
"nosniff");
-
response.setStatus(responseEntity.getStatusCode().value());
+ response.setStatus(
+
responseEntity.getStatusCode().value());
byte[] responseBody = responseEntity.getBody();
// HEAD responses must not include a message
body (RFC 7231 §4.3.2)
if (!HttpMethod.HEAD.equals(httpMethod)
- && responseBody != null &&
responseBody.length > 0) {
+ && responseBody != null
+ && responseBody.length > 0) {
// For JSON content type, validate that
the body actually looks
// like JSON to prevent XSS via crafted
upstream responses
if
(safeContentType.toLowerCase(Locale.ROOT).contains("application/json")
&& !looksLikeJson(responseBody)) {
- LOGGER.warn("Upstream returned
non-JSON body for Content-Type {}, replacing with error response",
safeContentType);
+ LOGGER.warn(
+ "Upstream returned non-JSON
body for Content-Type {}, replacing with error response",
+ safeContentType);
response.setStatus(HttpServletResponse.SC_BAD_GATEWAY);
response.setContentType("application/json;charset=UTF-8");
responseBody = "{\"error\":\"Upstream
returned invalid response body\"}"
@@ -256,7 +273,10 @@ public class ConsoleRemotingFilter implements Filter {
// Client likely disconnected (broken
pipe); log at debug
// level and do NOT attempt sendError
– the response may
// already be committed.
- LOGGER.debug("Failed to write proxy
response body (client disconnect?): {}", e.getMessage(), e);
+ LOGGER.debug(
+ "Failed to write proxy
response body (client disconnect?): {}",
+ e.getMessage(),
+ e);
}
}
} catch (Exception ex) {
@@ -271,4 +291,35 @@ public class ConsoleRemotingFilter implements Filter {
}
filterChain.doFilter(servletRequest, servletResponse);
}
+
+ private ResponseEntity<byte[]> executeProxyRequest(
+ URI targetUrl, HttpMethod httpMethod, HttpEntity<byte[]>
httpEntity) {
+ RestClient.RequestBodySpec requestSpec = restClient
+ .method(Objects.requireNonNull(httpMethod))
+ .uri(Objects.requireNonNull(targetUrl))
+ .headers(headers -> headers.addAll(httpEntity.getHeaders()));
+ RestClient.RequestHeadersSpec<?> exchangeSpec = requestSpec;
+ byte[] requestBody = httpEntity.getBody();
+ if (requestBody != null
+ && requestBody.length > 0
+ && !HttpMethod.GET.equals(httpMethod)
+ && !HttpMethod.HEAD.equals(httpMethod)) {
+ exchangeSpec = requestSpec.body(requestBody);
+ }
+ return exchangeSpec.exchange((req, response) -> {
+ byte[] bodyBytes;
+ if (HttpMethod.HEAD.equals(httpMethod)) {
+ // HEAD responses must not contain a message body per RFC 7231
§4.3.2.
+ bodyBytes = null;
+ } else {
+ // For 204/304 and similar no-body responses, some HTTP client
implementations
+ // (e.g. JDK HTTP Client) may return null from getBody().
+ // Treat a null stream as an empty byte array defensively,
+ // consistent with the null guards in ConsoleLocalServiceImpl
and ConsoleRemoteServiceImpl.
+ InputStream bodyStream = response.getBody();
+ bodyBytes = (bodyStream != null) ?
StreamUtils.copyToByteArray(bodyStream) : new byte[0];
+ }
+ return new ResponseEntity<>(bodyBytes, response.getHeaders(),
response.getStatusCode());
+ });
+ }
}
diff --git
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
index a2f13a79d8..263e26bb87 100644
---
a/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
+++
b/namingserver/src/main/java/org/apache/seata/namingserver/manager/NamingManager.java
@@ -21,7 +21,6 @@ import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.github.benmanes.caffeine.cache.RemovalListener;
import jakarta.annotation.PostConstruct;
-import okhttp3.Response;
import org.apache.seata.common.NamingServerConstants;
import org.apache.seata.common.metadata.Cluster;
import org.apache.seata.common.metadata.ClusterRole;
@@ -30,7 +29,6 @@ import
org.apache.seata.common.metadata.namingserver.NamingServerNode;
import org.apache.seata.common.metadata.namingserver.Unit;
import org.apache.seata.common.result.Result;
import org.apache.seata.common.result.SingleResult;
-import org.apache.seata.common.util.HttpClientUtil;
import org.apache.seata.common.util.StringUtils;
import org.apache.seata.namingserver.entity.bo.ClusterBO;
import org.apache.seata.namingserver.entity.bo.NamespaceBO;
@@ -50,15 +48,19 @@ import org.springframework.context.ApplicationContext;
import org.springframework.scheduling.concurrent.CustomizableThreadFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
+import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.util.UriComponentsBuilder;
-import java.io.IOException;
import java.net.InetSocketAddress;
+import java.net.URI;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
@@ -94,6 +96,9 @@ public class NamingManager {
@Autowired
private NamingServerMetricsManager metricsManager;
+ @Autowired
+ private RestClient restClient;
+
public NamingManager() {
this.instanceLiveTable = new ConcurrentHashMap<>();
this.namespaceClusterDataMap = new ConcurrentHashMap<>();
@@ -221,21 +226,17 @@ public class NamingManager {
HashMap<String, String> params = new HashMap<>();
params.put(CONSTANT_GROUP, vGroup);
params.put(NamingServerConstants.CONSTANT_UNIT, actualUnitName);
- Map<String, String> header = new HashMap<>();
- header.put("Content-Type", "application/x-www-form-urlencoded");
-
- try (Response httpResponse = HttpClientUtil.doGet(httpUrl, params,
header, 3000)) {
- if (httpResponse == null || httpResponse.code() != 200) {
- return new Result<>(
- String.valueOf(httpResponse != null ?
httpResponse.code() : 500),
- "add vGroup in new cluster failed");
+ try {
+ int statusCode = executeControlRequest(httpUrl, params);
+ if (statusCode != 200) {
+ return new Result<>(String.valueOf(statusCode), "add
vGroup in new cluster failed");
}
LOGGER.info(
"namespace: {} add vGroup: {} in new cluster: {}
successfully!",
namespace,
vGroup,
clusterName);
- } catch (IOException e) {
+ } catch (RestClientException e) {
LOGGER.error("add vGroup in new cluster failed:{}",
e.getMessage(), e);
return new Result<>("500", "add vGroup in new cluster failed");
}
@@ -254,13 +255,12 @@ public class NamingManager {
HashMap<String, String> params = new HashMap<>();
params.put(CONSTANT_GROUP, vGroup);
params.put(NamingServerConstants.CONSTANT_UNIT, unitName);
- Map<String, String> header = new HashMap<>();
- header.put("Content-Type", "application/x-www-form-urlencoded");
- try (Response httpResponse = HttpClientUtil.doGet(httpUrl, params,
header, 3000)) {
- if (httpResponse == null || httpResponse.code() != 200) {
+ try {
+ int statusCode = executeControlRequest(httpUrl, params);
+ if (statusCode != 200) {
LOGGER.warn("remove vGroup in old cluster failed");
return new Result<>(
- String.valueOf(httpResponse != null ?
httpResponse.code() : 500),
+ String.valueOf(statusCode),
"removing vGroup " + vGroup + " in old cluster " +
clusterName + " failed");
}
LOGGER.info(
@@ -268,7 +268,7 @@ public class NamingManager {
namespace,
vGroup,
clusterName);
- } catch (IOException e) {
+ } catch (RestClientException e) {
LOGGER.error("handle removing vGroup in old cluster
failed:{}", e.getMessage(), e);
return new Result<>(
"500", "handle removing vGroup " + vGroup + " in old
cluster " + clusterName + " failed");
@@ -277,6 +277,24 @@ public class NamingManager {
return new Result<>("200", "remove group in old cluster
successfully!");
}
+ private int executeControlRequest(String httpUrl, Map<String, String>
params) {
+ URI targetUri = buildControlUri(httpUrl, params);
+ Integer statusCode = restClient
+ .get()
+ .uri(targetUri)
+ .header("Content-Type", "application/x-www-form-urlencoded")
+ .exchange((request, response) ->
response.getStatusCode().value());
+ return Objects.requireNonNull(statusCode);
+ }
+
+ private URI buildControlUri(String httpUrl, Map<String, String> params) {
+ String rawUrl = Objects.requireNonNull(httpUrl);
+ String baseUrl = rawUrl.endsWith("?") ? rawUrl.substring(0,
rawUrl.length() - 1) : rawUrl;
+ UriComponentsBuilder builder =
UriComponentsBuilder.fromUriString(baseUrl);
+ params.forEach(builder::queryParam);
+ return Objects.requireNonNull(builder.build().encode().toUri());
+ }
+
public boolean addGroup(String namespace, String clusterName, String
unitName, String vGroup) {
try {
ClusterBO clusterBO = vGroupMap
diff --git
a/namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java
b/namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java
index 782e2c3019..3dcbc94331 100644
---
a/namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java
+++
b/namingserver/src/main/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImpl.java
@@ -36,11 +36,16 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
+import org.springframework.util.StreamUtils;
+import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
-import org.springframework.web.client.RestTemplate;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.concurrent.ThreadLocalRandom;
import static org.apache.seata.common.Constants.RAFT_GROUP_HEADER;
@@ -56,13 +61,13 @@ public class ConsoleLocalServiceImpl implements
ConsoleApiService {
private final NamingManager namingManager;
- private final RestTemplate restTemplate;
+ private final RestClient restClient;
private final ObjectMapper objectMapper;
- public ConsoleLocalServiceImpl(NamingManager namingManager, RestTemplate
restTemplate, ObjectMapper objectMapper) {
+ public ConsoleLocalServiceImpl(NamingManager namingManager, RestClient
restClient, ObjectMapper objectMapper) {
this.namingManager = namingManager;
- this.restTemplate = restTemplate;
+ this.restClient = restClient;
this.objectMapper = objectMapper;
LOGGER.info("ConsoleLocalServiceImpl initialized.");
}
@@ -78,7 +83,7 @@ public class ConsoleLocalServiceImpl implements
ConsoleApiService {
String cluster = nameSpaceDetail.getCluster();
String vgroup = nameSpaceDetail.getvGroup();
if (StringUtils.isNotBlank(namespace) &&
(StringUtils.isNotBlank(cluster) || StringUtils.isNotBlank(vgroup))) {
- List<NamingServerNode> list = null;
+ List<NamingServerNode> list = Collections.emptyList();
if (StringUtils.isNotBlank(vgroup)) {
list = namingManager.getInstancesByVgroupAndNamespace(
namespace, vgroup, HttpMethod.GET.equals(httpMethod));
@@ -100,8 +105,7 @@ public class ConsoleLocalServiceImpl implements
ConsoleApiService {
HttpEntity<String> entity = new HttpEntity<>(headers);
String responseBody;
try {
- ResponseEntity<String> response =
- restTemplate.exchange(targetUrl, httpMethod,
entity, String.class);
+ ResponseEntity<String> response =
executeRequest(targetUrl, httpMethod, entity);
responseBody = response.getBody();
@@ -125,6 +129,21 @@ public class ConsoleLocalServiceImpl implements
ConsoleApiService {
throw new IllegalArgumentException("Invalid NameSpace Detail");
}
+ private ResponseEntity<String> executeRequest(String targetUrl, HttpMethod
httpMethod, HttpEntity<String> entity)
+ throws RestClientException {
+ return restClient
+ .method(Objects.requireNonNull(httpMethod))
+ .uri(Objects.requireNonNull(targetUrl))
+ .headers(headers -> headers.addAll(entity.getHeaders()))
+ .exchange((request, response) -> new ResponseEntity<>(
+ readResponseBody(response), response.getHeaders(),
response.getStatusCode()));
+ }
+
+ private String
readResponseBody(RestClient.RequestHeadersSpec.ConvertibleClientHttpResponse
response)
+ throws IOException {
+ return response.getBody() == null ? "" :
StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
+ }
+
@Override
public String getCallTC(
NameSpaceDetail nameSpaceDetail,
diff --git
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
index 36eaccca81..074adf80a5 100644
---
a/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
+++
b/namingserver/src/test/java/org/apache/seata/namingserver/NamingManagerTest.java
@@ -16,8 +16,7 @@
*/
package org.apache.seata.namingserver;
-import okhttp3.Response;
-import okhttp3.ResponseBody;
+import org.apache.seata.common.NamingServerConstants;
import org.apache.seata.common.metadata.Cluster;
import org.apache.seata.common.metadata.ClusterRole;
import org.apache.seata.common.metadata.Node;
@@ -25,7 +24,6 @@ import
org.apache.seata.common.metadata.namingserver.NamingServerNode;
import org.apache.seata.common.metadata.namingserver.Unit;
import org.apache.seata.common.result.Result;
import org.apache.seata.common.result.SingleResult;
-import org.apache.seata.common.util.HttpClientUtil;
import org.apache.seata.namingserver.entity.pojo.ClusterData;
import org.apache.seata.namingserver.entity.vo.monitor.ClusterVO;
import org.apache.seata.namingserver.entity.vo.v2.NamespaceVO;
@@ -36,14 +34,17 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.Mock;
-import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import org.springframework.context.ApplicationContext;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
import java.util.ArrayList;
import java.util.Arrays;
@@ -53,11 +54,14 @@ import java.util.Map;
import java.util.UUID;
import static org.apache.seata.common.NamingServerConstants.CONSTANT_GROUP;
-import static org.junit.jupiter.api.Assertions.*;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyInt;
-import static org.mockito.ArgumentMatchers.anyMap;
-import static org.mockito.ArgumentMatchers.anyString;
+import static org.springframework.test.web.client.ExpectedCount.once;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.LENIENT)
@@ -65,31 +69,22 @@ class NamingManagerTest {
private NamingManager namingManager;
- @Mock
private ApplicationContext applicationContext;
- @Mock
- private Response httpResponse;
-
- @Mock
- private ResponseBody responseBody;
-
- private MockedStatic<HttpClientUtil> mockedHttpClientUtil;
+ private MockRestServiceServer server;
@BeforeEach
void setUp() {
namingManager = new NamingManager();
+ applicationContext = Mockito.mock(ApplicationContext.class);
ReflectionTestUtils.setField(namingManager, "applicationContext",
applicationContext);
ReflectionTestUtils.setField(namingManager, "heartbeatTimeThreshold",
500000);
ReflectionTestUtils.setField(namingManager,
"heartbeatCheckTimePeriod", 10000000);
ReflectionTestUtils.setField(namingManager, "metricsManager", new
NoOpNamingMetricsManager());
- Mockito.when(httpResponse.code()).thenReturn(200);
- Mockito.when(httpResponse.body()).thenReturn(responseBody);
- mockedHttpClientUtil = Mockito.mockStatic(HttpClientUtil.class);
- mockedHttpClientUtil
- .when(() -> HttpClientUtil.doGet(anyString(), anyMap(),
anyMap(), anyInt()))
- .thenReturn(httpResponse);
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ server = MockRestServiceServer.bindTo(restClientBuilder).build();
+ ReflectionTestUtils.setField(namingManager, "restClient",
restClientBuilder.build());
namingManager.init();
}
@@ -109,9 +104,7 @@ class NamingManagerTest {
@AfterEach
void tearDown() {
- if (mockedHttpClientUtil != null) {
- mockedHttpClientUtil.close();
- }
+ server.reset();
}
@Test
@@ -235,17 +228,19 @@ class NamingManagerTest {
node.getMetadata().put(CONSTANT_GROUP, vGroups);
namingManager.registerInstance(node, namespace, clusterName, unitName);
- Mockito.when(httpResponse.code()).thenReturn(200);
Result<String> result = namingManager.createGroup(namespace, vGroup,
clusterName, unitName);
assertFalse(result.isSuccess());
- vGroup = "test-vGroup2";
- result = namingManager.createGroup(namespace, vGroup, clusterName,
unitName);
+ String newVGroup = "test-vGroup2";
+ server.expect(
+ once(),
+ request -> assertControlRequest(
+ request, "/vgroup/v1/addVGroup", "127.0.0.1",
9080, newVGroup, unitName))
+ .andRespond(withSuccess("", MediaType.APPLICATION_JSON));
+ result = namingManager.createGroup(namespace, newVGroup, clusterName,
unitName);
assertTrue(result.isSuccess());
assertEquals("200", result.getCode());
assertEquals("add vGroup successfully!", result.getMessage());
-
- mockedHttpClientUtil.verify(
- () -> HttpClientUtil.doGet(anyString(), anyMap(), anyMap(),
anyInt()), Mockito.times(1));
+ server.verify();
}
@Test
@@ -299,21 +294,18 @@ class NamingManagerTest {
nodeList.add(node);
unit.setNamingInstanceList(nodeList);
- Mockito.when(httpResponse.code()).thenReturn(200);
- Mockito.when(httpResponse.body()).thenReturn(responseBody);
-
- mockedHttpClientUtil
- .when(() -> HttpClientUtil.doGet(anyString(), anyMap(),
anyMap(), anyInt()))
- .thenReturn(httpResponse);
+ server.expect(
+ once(),
+ request -> assertControlRequest(
+ request, "/vgroup/v1/removeVGroup",
"127.0.0.1", 9000, vGroup, unitName))
+ .andRespond(withSuccess("", MediaType.APPLICATION_JSON));
Result<String> result = namingManager.removeGroup(unit, vGroup,
clusterName, namespace, unitName);
assertTrue(result.isSuccess());
assertEquals("200", result.getCode());
assertEquals("remove group in old cluster successfully!",
result.getMessage());
-
- mockedHttpClientUtil.verify(
- () -> HttpClientUtil.doGet(anyString(), anyMap(), anyMap(),
anyInt()), Mockito.times(1));
+ server.verify();
}
@Test
@@ -435,4 +427,34 @@ class NamingManagerTest {
ClusterData notFoundNamespace =
namingManager.getClusterData("non-existent-namespace", clusterName);
assertNull(notFoundNamespace);
}
+
+ private void assertControlRequest(
+ org.springframework.http.client.ClientHttpRequest request,
+ String expectedPath,
+ String expectedHost,
+ int expectedPort,
+ String expectedGroup,
+ String expectedUnit) {
+ assertEquals(HttpMethod.GET, request.getMethod());
+ assertEquals(expectedHost, request.getURI().getHost());
+ assertEquals(expectedPort, request.getURI().getPort());
+ assertEquals(expectedPath, request.getURI().getPath());
+ assertEquals(expectedGroup,
extractQueryValue(request.getURI().getRawQuery(), CONSTANT_GROUP));
+ assertEquals(
+ expectedUnit,
extractQueryValue(request.getURI().getRawQuery(),
NamingServerConstants.CONSTANT_UNIT));
+ assertEquals("application/x-www-form-urlencoded",
request.getHeaders().getFirst(HttpHeaders.CONTENT_TYPE));
+ }
+
+ private String extractQueryValue(String query, String key) {
+ if (query == null) {
+ return null;
+ }
+ for (String pair : query.split("&")) {
+ String[] parts = pair.split("=", 2);
+ if (parts.length == 2 && key.equals(parts[0])) {
+ return parts[1];
+ }
+ }
+ return null;
+ }
}
diff --git
a/namingserver/src/test/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilterTest.java
b/namingserver/src/test/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilterTest.java
index 0efa2da41c..77eaf6b2a0 100644
---
a/namingserver/src/test/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilterTest.java
+++
b/namingserver/src/test/java/org/apache/seata/namingserver/filter/ConsoleRemotingFilterTest.java
@@ -22,40 +22,55 @@ import
org.apache.seata.common.metadata.namingserver.NamingServerNode;
import org.apache.seata.namingserver.manager.NamingManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
-import org.mockito.ArgumentCaptor;
-import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
-import org.springframework.http.ResponseEntity;
+import org.springframework.http.client.ClientHttpRequest;
+import org.springframework.http.client.ClientHttpRequestFactory;
+import org.springframework.http.client.ClientHttpResponse;
+import org.springframework.mock.http.client.MockClientHttpRequest;
+import org.springframework.mock.http.client.MockClientHttpResponse;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
-import org.springframework.web.client.RestTemplate;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
+import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.ExpectedCount.once;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.headerDoesNotExist;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
/**
* Unit tests for {@link ConsoleRemotingFilter}.
* <p>
- * Covers the GET/HEAD body-stripping regression (OkHttp
IllegalArgumentException)
+ * Covers the GET/HEAD body-stripping regression on upstream proxy requests
* and other proxy-forwarding behavior.
*/
+// HttpMethod/MediaType static constants and NullBodyClientHttpRequestFactory
inner anonymous class methods
+// are treated as @Nullable by JDT nullness analysis; they are never null at
runtime,
+// so the type-safety warning is suppressed here uniformly.
+@SuppressWarnings("null")
class ConsoleRemotingFilterTest {
private NamingManager namingManager;
- private RestTemplate restTemplate;
+ private RestClient.Builder restClientBuilder;
+ private MockRestServiceServer server;
private ConsoleRemotingFilter filter;
private FilterChain filterChain;
@@ -67,60 +82,40 @@ class ConsoleRemotingFilterTest {
@BeforeEach
void setUp() {
namingManager = mock(NamingManager.class);
- restTemplate = mock(RestTemplate.class);
+ restClientBuilder = RestClient.builder();
+ server = MockRestServiceServer.bindTo(restClientBuilder).build();
filterChain = mock(FilterChain.class);
- filter = new ConsoleRemotingFilter(namingManager, restTemplate);
+ filter = new ConsoleRemotingFilter(namingManager,
restClientBuilder.build());
// Set up a NamingServerNode with a control endpoint
NamingServerNode node = new NamingServerNode();
node.setControl(new Node.Endpoint(TARGET_HOST, TARGET_PORT, "http"));
- when(namingManager.getInstances(NAMESPACE, CLUSTER))
- .thenReturn(Collections.singletonList(node));
+ when(namingManager.getInstances(NAMESPACE,
CLUSTER)).thenReturn(Collections.singletonList(node));
}
/**
* Regression test: a GET request with a non-empty body should NOT forward
- * the body to the upstream server (to avoid OkHttp's
IllegalArgumentException).
+ * the body to the upstream server.
* The body, Content-Length, and Transfer-Encoding headers must be
stripped.
*/
@Test
void getRequestWithBodyShouldStripBody() throws Exception {
- // Prepare a GET request with a body (some clients/frameworks may
attach one)
MockHttpServletRequest request = createConsoleRequest("GET");
request.setContent("{\"key\":\"value\"}".getBytes(StandardCharsets.UTF_8));
request.addHeader(HttpHeaders.CONTENT_LENGTH, "15");
MockHttpServletResponse response = new MockHttpServletResponse();
- // Stub RestTemplate to return a successful JSON response
- HttpHeaders responseHeaders = new HttpHeaders();
- responseHeaders.set(HttpHeaders.CONTENT_TYPE,
"application/json;charset=UTF-8");
- ResponseEntity<byte[]> upstreamResponse = new ResponseEntity<>(
- "{\"result\":\"ok\"}".getBytes(StandardCharsets.UTF_8),
- responseHeaders,
- HttpStatus.OK);
-
- when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET),
any(HttpEntity.class), eq(byte[].class)))
- .thenReturn(upstreamResponse);
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.GET))
+ .andExpect(headerDoesNotExist(HttpHeaders.CONTENT_LENGTH))
+ .andExpect(headerDoesNotExist(HttpHeaders.TRANSFER_ENCODING))
+ .andExpect(this::expectEmptyBody)
+ .andRespond(withSuccess("{\"result\":\"ok\"}",
org.springframework.http.MediaType.APPLICATION_JSON));
filter.doFilter(request, response, filterChain);
-
- // Capture the HttpEntity sent to RestTemplate
- @SuppressWarnings("unchecked")
- ArgumentCaptor<HttpEntity<byte[]>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
- verify(restTemplate).exchange(any(URI.class), eq(HttpMethod.GET),
entityCaptor.capture(), eq(byte[].class));
-
- HttpEntity<byte[]> capturedEntity = entityCaptor.getValue();
- // Body must be null (stripped for GET)
- assertNull(capturedEntity.getBody(), "GET request body should be
stripped (null)");
- // Content-Length and Transfer-Encoding headers must not be forwarded
- assertNull(capturedEntity.getHeaders().get(HttpHeaders.CONTENT_LENGTH),
- "Content-Length header should be removed for GET");
-
assertNull(capturedEntity.getHeaders().get(HttpHeaders.TRANSFER_ENCODING),
- "Transfer-Encoding header should be removed for GET");
-
- // Verify filterChain was NOT invoked (proxied)
+ server.verify();
verify(filterChain, never()).doFilter(any(), any());
assertEquals(200, response.getStatus());
}
@@ -135,21 +130,13 @@ class ConsoleRemotingFilterTest {
MockHttpServletResponse response = new MockHttpServletResponse();
- HttpHeaders responseHeaders = new HttpHeaders();
- responseHeaders.set(HttpHeaders.CONTENT_TYPE, "application/json");
- ResponseEntity<byte[]> upstreamResponse = new ResponseEntity<>(
- null, responseHeaders, HttpStatus.OK);
-
- when(restTemplate.exchange(any(URI.class), eq(HttpMethod.HEAD),
any(HttpEntity.class), eq(byte[].class)))
- .thenReturn(upstreamResponse);
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.HEAD))
+ .andExpect(this::expectEmptyBody)
+
.andRespond(withStatus(HttpStatus.OK).contentType(org.springframework.http.MediaType.APPLICATION_JSON));
filter.doFilter(request, response, filterChain);
-
- @SuppressWarnings("unchecked")
- ArgumentCaptor<HttpEntity<byte[]>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
- verify(restTemplate).exchange(any(URI.class), eq(HttpMethod.HEAD),
entityCaptor.capture(), eq(byte[].class));
-
- assertNull(entityCaptor.getValue().getBody(), "HEAD request body
should be stripped (null)");
+ server.verify();
verify(filterChain, never()).doFilter(any(), any());
}
@@ -164,27 +151,62 @@ class ConsoleRemotingFilterTest {
MockHttpServletResponse response = new MockHttpServletResponse();
- HttpHeaders responseHeaders = new HttpHeaders();
- responseHeaders.set(HttpHeaders.CONTENT_TYPE,
"application/json;charset=UTF-8");
- ResponseEntity<byte[]> upstreamResponse = new ResponseEntity<>(
- "{\"result\":\"created\"}".getBytes(StandardCharsets.UTF_8),
- responseHeaders,
- HttpStatus.OK);
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.POST))
+ .andExpect(clientHttpRequest -> assertEquals(
+ new String(bodyBytes, StandardCharsets.UTF_8),
+ new String(
+ ((MockClientHttpRequest)
clientHttpRequest).getBodyAsBytes(), StandardCharsets.UTF_8),
+ "POST request body should be forwarded as-is"))
+ .andRespond(
+ withSuccess("{\"result\":\"created\"}",
org.springframework.http.MediaType.APPLICATION_JSON));
- when(restTemplate.exchange(any(URI.class), eq(HttpMethod.POST),
any(HttpEntity.class), eq(byte[].class)))
- .thenReturn(upstreamResponse);
+ filter.doFilter(request, response, filterChain);
+ server.verify();
+ }
+
+ @Test
+ void putRequestShouldForwardBody() throws Exception {
+ byte[] bodyBytes =
"{\"data\":\"test-put\"}".getBytes(StandardCharsets.UTF_8);
+ MockHttpServletRequest request = createConsoleRequest("PUT");
+ request.setContent(bodyBytes);
+
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.PUT))
+ .andExpect(clientHttpRequest -> assertEquals(
+ new String(bodyBytes, StandardCharsets.UTF_8),
+ new String(
+ ((MockClientHttpRequest)
clientHttpRequest).getBodyAsBytes(), StandardCharsets.UTF_8),
+ "PUT request body should be forwarded as-is"))
+ .andRespond(
+ withSuccess("{\"result\":\"updated\"}",
org.springframework.http.MediaType.APPLICATION_JSON));
filter.doFilter(request, response, filterChain);
+ server.verify();
+ }
+
+ @Test
+ void deleteRequestShouldForwardBody() throws Exception {
+ byte[] bodyBytes =
"{\"data\":\"test-delete\"}".getBytes(StandardCharsets.UTF_8);
+ MockHttpServletRequest request = createConsoleRequest("DELETE");
+ request.setContent(bodyBytes);
- @SuppressWarnings("unchecked")
- ArgumentCaptor<HttpEntity<byte[]>> entityCaptor =
ArgumentCaptor.forClass(HttpEntity.class);
- verify(restTemplate).exchange(any(URI.class), eq(HttpMethod.POST),
entityCaptor.capture(), eq(byte[].class));
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.DELETE))
+ .andExpect(clientHttpRequest -> assertEquals(
+ new String(bodyBytes, StandardCharsets.UTF_8),
+ new String(
+ ((MockClientHttpRequest)
clientHttpRequest).getBodyAsBytes(), StandardCharsets.UTF_8),
+ "DELETE request body should be forwarded as-is"))
+ .andRespond(
+ withSuccess("{\"result\":\"deleted\"}",
org.springframework.http.MediaType.APPLICATION_JSON));
- byte[] capturedBody = entityCaptor.getValue().getBody();
- assertNotNull(capturedBody, "POST body should not be null");
- assertEquals(new String(bodyBytes, StandardCharsets.UTF_8),
- new String(capturedBody, StandardCharsets.UTF_8),
- "POST request body should be forwarded as-is");
+ filter.doFilter(request, response, filterChain);
+ server.verify();
}
/**
@@ -198,7 +220,6 @@ class ConsoleRemotingFilterTest {
filter.doFilter(request, response, filterChain);
verify(filterChain).doFilter(any(), any());
- verify(restTemplate, never()).exchange(any(URI.class), any(),
any(HttpEntity.class), eq(byte[].class));
}
/**
@@ -211,24 +232,38 @@ class ConsoleRemotingFilterTest {
MockHttpServletResponse response = new MockHttpServletResponse();
- HttpHeaders responseHeaders = new HttpHeaders();
- responseHeaders.set(HttpHeaders.CONTENT_TYPE, "application/json");
- // Upstream sends HTML disguised as JSON
- byte[] htmlBody =
"<html><script>alert('xss')</script></html>".getBytes(StandardCharsets.UTF_8);
- ResponseEntity<byte[]> upstreamResponse = new ResponseEntity<>(
- htmlBody, responseHeaders, HttpStatus.OK);
-
- when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET),
any(HttpEntity.class), eq(byte[].class)))
- .thenReturn(upstreamResponse);
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withSuccess(
+ "<html><script>alert('xss')</script></html>",
+ org.springframework.http.MediaType.APPLICATION_JSON));
filter.doFilter(request, response, filterChain);
+ server.verify();
- assertEquals(502, response.getStatus(),
- "Should return 502 when upstream body is not valid JSON");
+ assertEquals(502, response.getStatus(), "Should return 502 when
upstream body is not valid JSON");
String body = response.getContentAsString();
assertEquals("{\"error\":\"Upstream returned invalid response
body\"}", body);
}
+ @Test
+ void non2xxResponseShouldStillBeProxied() throws Exception {
+ MockHttpServletRequest request = createConsoleRequest("GET");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withStatus(HttpStatus.BAD_GATEWAY)
+
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
+ .body("{\"error\":\"upstream failed\"}"));
+
+ filter.doFilter(request, response, filterChain);
+ server.verify();
+
+ assertEquals(502, response.getStatus(), "Proxy mode should preserve
upstream non-2xx status codes");
+ assertEquals("{\"error\":\"upstream failed\"}",
response.getContentAsString());
+ }
+
/**
* Helper: create a MockHttpServletRequest that matches the console URL
pattern
* and includes the required namespace/cluster headers.
@@ -239,5 +274,148 @@ class ConsoleRemotingFilterTest {
request.addHeader("x-seata-cluster", CLUSTER);
return request;
}
-}
+ private void
expectEmptyBody(org.springframework.http.client.ClientHttpRequest request)
throws IOException {
+ assertEquals(
+ 0,
+ ((MockClientHttpRequest) request).getBodyAsBytes().length,
+ "GET/HEAD request body should be stripped");
+ }
+
+ /**
+ * Regression test: when the upstream returns 204 No Content (no body),
+ * the proxy should pass through 204 rather than returning 500.
+ * <p>
+ * Background: {@code executeProxyRequest} calls
+ * {@code StreamUtils.copyToByteArray(response.getBody())} for non-HEAD
requests.
+ * Some HTTP client implementations (e.g. JDK HTTP Client) may return
{@code null}
+ * from {@code getBody()} for 204/304 no-body responses.
+ * Spring 6.x {@code StreamUtils.copyToByteArray(null)} has an internal
null guard,
+ * but the defensive coding style should remain consistent with
+ * {@code ConsoleLocalServiceImpl} and {@code ConsoleRemoteServiceImpl},
+ * and guard against future Spring version behaviour changes or other HTTP
client differences.
+ */
+ @Test
+ void upstreamNoContentShouldReturn204() throws Exception {
+ MockHttpServletRequest request = createConsoleRequest("DELETE");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.DELETE))
+ .andRespond(withStatus(HttpStatus.NO_CONTENT));
+
+ filter.doFilter(request, response, filterChain);
+ server.verify();
+
+ assertEquals(204, response.getStatus(), "Upstream 204 No Content
should be proxied as 204, not 500");
+ assertEquals(0, response.getContentAsByteArray().length, "204 response
should have no body");
+ }
+
+ /**
+ * Regression test: force {@code getBody()} to return {@code null} via a
custom
+ * {@link ClientHttpRequestFactory} to simulate the real behaviour of JDK
HTTP Client
+ * and similar implementations for no-body responses.
+ * After fix: a null body stream is treated as an empty byte array and 204
is proxied.
+ * Before fix (if {@code StreamUtils} did not handle null): NPE → caught →
500.
+ */
+ @Test
+ void upstreamNullBodyStreamShouldReturn204NotServerError() throws
Exception {
+ // Build a RestClient whose getBody() returns null, simulating extreme
behaviour
+ // of real HTTP clients for 204 responses.
+ RestClient nullBodyRestClient = RestClient.builder()
+ .requestFactory(new
NullBodyClientHttpRequestFactory(HttpStatus.NO_CONTENT))
+ .build();
+ ConsoleRemotingFilter filterWithNullBody = new
ConsoleRemotingFilter(namingManager, nullBodyRestClient);
+
+ MockHttpServletRequest request = createConsoleRequest("GET");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ filterWithNullBody.doFilter(request, response, filterChain);
+
+ assertEquals(204, response.getStatus(), "Upstream 204 with null body
should be proxied as 204, not 500");
+ assertEquals(0, response.getContentAsByteArray().length, "204 null
body response should have no response body");
+ }
+
+ /**
+ * Regression test: when the upstream returns 304 Not Modified (no body),
+ * the proxy should pass through 304.
+ */
+ @Test
+ void upstreamNotModifiedShouldReturn304() throws Exception {
+ MockHttpServletRequest request = createConsoleRequest("GET");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/globalSession/query"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withStatus(HttpStatus.NOT_MODIFIED));
+
+ filter.doFilter(request, response, filterChain);
+ server.verify();
+
+ assertEquals(304, response.getStatus(), "Upstream 304 Not Modified
should be proxied as 304, not 500");
+ assertEquals(0, response.getContentAsByteArray().length, "304 response
should have no body");
+ }
+
+ /**
+ * A {@link ClientHttpRequestFactory} whose responses always return {@code
null}
+ * from {@code getBody()}, simulating the behaviour of JDK HTTP Client and
similar
+ * implementations for no-body responses.
+ */
+ private static final class NullBodyClientHttpRequestFactory implements
ClientHttpRequestFactory {
+
+ private final HttpStatus status;
+
+ NullBodyClientHttpRequestFactory(HttpStatus status) {
+ this.status = status;
+ }
+
+ @Override
+ public ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod)
{
+ return new ClientHttpRequest() {
+ private final HttpHeaders headers = new HttpHeaders();
+
+ @Override
+ public ClientHttpResponse execute() {
+ // Simulate an upstream response with the given status
code where getBody() is null.
+ return new MockClientHttpResponse(new byte[0], status) {
+ @Override
+ public InputStream getBody() {
+ // Force null return to simulate JDK HTTP Client
behaviour for no-body responses.
+ return null;
+ }
+
+ @Override
+ public void close() {
+ // Skip close when getBody() is null to avoid
NullPointerException.
+ }
+ };
+ }
+
+ @Override
+ public HttpHeaders getHeaders() {
+ return headers;
+ }
+
+ @Override
+ public URI getURI() {
+ return uri;
+ }
+
+ @Override
+ public HttpMethod getMethod() {
+ return httpMethod;
+ }
+
+ @Override
+ public Map<String, Object> getAttributes() {
+ return Collections.emptyMap();
+ }
+
+ @Override
+ public OutputStream getBody() throws IOException {
+ return OutputStream.nullOutputStream();
+ }
+ };
+ }
+ }
+}
diff --git
a/namingserver/src/test/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImplTest.java
b/namingserver/src/test/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImplTest.java
new file mode 100644
index 0000000000..4a6d71c847
--- /dev/null
+++
b/namingserver/src/test/java/org/apache/seata/namingserver/service/ConsoleLocalServiceImplTest.java
@@ -0,0 +1,140 @@
+/*
+ * 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.seata.namingserver.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.apache.seata.common.metadata.ClusterRole;
+import org.apache.seata.common.metadata.Node;
+import org.apache.seata.common.metadata.namingserver.NamingServerNode;
+import org.apache.seata.mcp.core.props.NameSpaceDetail;
+import org.apache.seata.mcp.exception.ServiceCallException;
+import org.apache.seata.namingserver.manager.NamingManager;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.test.web.client.MockRestServiceServer;
+import org.springframework.web.client.RestClient;
+
+import java.util.HashMap;
+import java.util.List;
+
+import static org.apache.seata.common.Constants.RAFT_GROUP_HEADER;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static org.springframework.test.web.client.ExpectedCount.once;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.header;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.method;
+import static
org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
+import static
org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
+
+class ConsoleLocalServiceImplTest {
+
+ private NamingManager namingManager;
+
+ private MockRestServiceServer server;
+
+ private ConsoleLocalServiceImpl consoleLocalService;
+
+ @BeforeEach
+ void setUp() {
+ namingManager = mock(NamingManager.class);
+ RestClient.Builder restClientBuilder = RestClient.builder();
+ server = MockRestServiceServer.bindTo(restClientBuilder).build();
+ consoleLocalService = new ConsoleLocalServiceImpl(namingManager,
restClientBuilder.build(), new ObjectMapper());
+ }
+
+ @Test
+ void getCallTCShouldForwardRequestToLeaderNode() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setCluster("default");
+
+ NamingServerNode leaderNode = new NamingServerNode();
+ leaderNode.setRole(ClusterRole.LEADER);
+ leaderNode.setUnit("unit-a");
+ leaderNode.setControl(new Node.Endpoint("127.0.0.1", 7091, "http"));
+ when(namingManager.getInstances("public",
"default")).thenReturn(List.of(leaderNode));
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.add("x-extra", "value");
+ HashMap<String, String> queryParams = new HashMap<>();
+ queryParams.put("page", "1");
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/sessions?page=1"))
+ .andExpect(method(HttpMethod.GET))
+ .andExpect(header("x-extra", "value"))
+ .andExpect(header(RAFT_GROUP_HEADER, "unit-a"))
+ .andRespond(withSuccess("local-ok",
org.springframework.http.MediaType.TEXT_PLAIN));
+
+ String response =
+ consoleLocalService.getCallTC(nameSpaceDetail,
"/api/v1/console/sessions", null, queryParams, headers);
+
+ server.verify();
+ assertEquals("local-ok", response);
+ }
+
+ @Test
+ void getCallTCWithVgroupShouldForwardToNode() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setvGroup("vgroup-local");
+
+ NamingServerNode vgroupNode = new NamingServerNode();
+ vgroupNode.setControl(new Node.Endpoint("127.0.0.1", 8082, "http"));
+ when(namingManager.getInstancesByVgroupAndNamespace("public",
"vgroup-local", true))
+ .thenReturn(List.of(vgroupNode));
+
+ HttpHeaders headers = new HttpHeaders();
+ HashMap<String, String> queryParams = new HashMap<>();
+
+ server.expect(once(),
requestTo("http://127.0.0.1:8082/api/v1/console/sessions"))
+ .andExpect(method(HttpMethod.GET))
+ .andRespond(withSuccess("vgroup-ok",
org.springframework.http.MediaType.TEXT_PLAIN));
+
+ String response =
+ consoleLocalService.getCallTC(nameSpaceDetail,
"/api/v1/console/sessions", null, queryParams, headers);
+
+ server.verify();
+ assertEquals("vgroup-ok", response);
+ }
+
+ @Test
+ void deleteCallTCShouldThrowWhenUpstreamReturnsNon2xx() {
+ NameSpaceDetail nameSpaceDetail = new NameSpaceDetail();
+ nameSpaceDetail.setNamespace("public");
+ nameSpaceDetail.setCluster("default");
+
+ NamingServerNode node = new NamingServerNode();
+ node.setControl(new Node.Endpoint("127.0.0.1", 7091, "http"));
+ when(namingManager.getInstances("public",
"default")).thenReturn(List.of(node));
+
+ server.expect(once(),
requestTo("http://127.0.0.1:7091/api/v1/console/sessions"))
+ .andExpect(method(HttpMethod.DELETE))
+ .andRespond(withStatus(HttpStatus.BAD_GATEWAY).body("failed"));
+
+ assertThrows(
+ ServiceCallException.class,
+ () -> consoleLocalService.deleteCallTC(
+ nameSpaceDetail, "/api/v1/console/sessions", null, new
HashMap<>(), new HttpHeaders()));
+ server.verify();
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]