This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new e36b06053c fix(agent-gateway): stop duplicating HTTP tool query 
arguments in the upstream URI (#7926)
e36b06053c is described below

commit e36b06053c738a5edca9ed8f5cac5fdafa0d775c
Author: hanzhe-one <[email protected]>
AuthorDate: Thu Oct 22 11:04:47 2026 +0800

    fix(agent-gateway): stop duplicating HTTP tool query arguments in the 
upstream URI (#7926)
    
    AgentHttpToolExecutor stored the route together with its encoded query in
    ShenyuContext.realUrl while the child request URI already carried the same
    query. RequestUrlUtils.buildRequestUri appends the request query to realUrl,
    so every configured GET tool reached its upstream as
    /orders/status?id=A?id=A.
    
    Keep only the route path in realUrl (raw path, matching the plugin
    contract) and let the child request URI carry the encoded arguments.
    Build the child URI once and reuse it for the context fields.
    
    Verified: the new regression test fails on the previous code with
    'Invalid character = for QUERY_PARAM in "A%26B?id=A%26B"' and passes with
    the fix; POST body arguments and a queryless route stay unchanged.
---
 shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml  |   6 +
 .../agent/gateway/tool/AgentHttpToolExecutor.java  |   9 +-
 .../gateway/tool/AgentHttpToolExecutorTest.java    | 132 ++++++++++++++++++++-
 3 files changed, 143 insertions(+), 4 deletions(-)

diff --git a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml 
b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
index fe1ad2e805..63feff34bc 100644
--- a/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
+++ b/shenyu-plugin/shenyu-plugin-agent-gateway/pom.xml
@@ -48,6 +48,12 @@
             <version>${project.version}</version>
             <scope>test</scope>
         </dependency>
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-plugin-context-path</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
         <dependency>
             <groupId>com.networknt</groupId>
             <artifactId>json-schema-validator</artifactId>
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
index f96986e497..b3bfbe3a59 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/main/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutor.java
@@ -120,7 +120,8 @@ public final class AgentHttpToolExecutor implements 
AgentHttpToolInvoker {
                     return Flux.empty();
                 }
             };
-            ServerHttpRequest base = 
seed.mutate().uri(URI.create("http://localhost"; + path)).contextPath("")
+            final URI target = URI.create("http://localhost"; + path);
+            ServerHttpRequest base = seed.mutate().uri(target).contextPath("")
                     
.method(HttpMethod.valueOf(tool.getMethod())).headers(headers -> {
                         headers.clear();
                         headers.setAccept(List.of(MediaType.APPLICATION_JSON));
@@ -133,8 +134,10 @@ public final class AgentHttpToolExecutor implements 
AgentHttpToolInvoker {
             ShenyuContext context = new ShenyuContext();
             context.setRpcType("http");
             context.setHttpMethod(tool.getMethod());
-            context.setPath(URI.create(path).getPath());
-            context.setRealUrl(path);
+            context.setPath(target.getRawPath());
+            // Keep only the route path. RequestUrlUtils appends the request 
query to realUrl, and the child
+            // request URI already carries the encoded arguments, so a query 
here would be forwarded twice.
+            context.setRealUrl(target.getRawPath());
             context.setStartDateTime(LocalDateTime.now());
             child.getAttributes().put(Constants.CONTEXT, context);
             
child.getAttributes().put(AgentGatewayConstants.HTTP_TOOL_CALL_ATTRIBUTE, true);
diff --git 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
index 17c1f91d71..db434f89e9 100644
--- 
a/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
+++ 
b/shenyu-plugin/shenyu-plugin-agent-gateway/src/test/java/org/apache/shenyu/plugin/agent/gateway/tool/AgentHttpToolExecutorTest.java
@@ -19,12 +19,29 @@ package org.apache.shenyu.plugin.agent.gateway.tool;
 
 import com.google.gson.JsonObject;
 import com.google.gson.JsonParser;
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.constant.Constants;
 import org.apache.shenyu.common.dto.AgentGatewayHttpToolConfig;
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.impl.ContextMappingRuleHandle;
+import org.apache.shenyu.common.enums.SelectorTypeEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
 import org.apache.shenyu.plugin.agent.gateway.AgentGatewayConstants;
 import org.apache.shenyu.plugin.api.ShenyuPluginChain;
+import org.apache.shenyu.plugin.api.context.ShenyuContext;
+import org.apache.shenyu.plugin.api.utils.RequestUrlUtils;
+import org.apache.shenyu.plugin.api.utils.SpringBeanUtils;
+import org.apache.shenyu.plugin.base.cache.BaseDataCache;
+import org.apache.shenyu.plugin.context.path.ContextPathPlugin;
+import 
org.apache.shenyu.plugin.context.path.handler.ContextPathPluginDataHandler;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.ValueSource;
+import org.springframework.context.ConfigurableApplicationContext;
 import org.springframework.core.io.buffer.DataBufferLimitException;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.HttpCookie;
@@ -48,12 +65,81 @@ import java.util.function.Function;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 
 /**
  * Fresh cursors, independent exchanges/responses, bounded capture and 
downstream cancellation.
  */
 class AgentHttpToolExecutorTest {
 
+    @BeforeEach
+    void preparePluginCaches() {
+        ConfigurableApplicationContext context = 
mock(ConfigurableApplicationContext.class);
+        ShenyuConfig config = new ShenyuConfig();
+        config.getSelectorMatchCache().getCache().setEnabled(false);
+        config.getRuleMatchCache().getCache().setEnabled(false);
+        when(context.getBean(ShenyuConfig.class)).thenReturn(config);
+        SpringBeanUtils.getInstance().setApplicationContext(context);
+    }
+
+    @AfterEach
+    void cleanPluginCaches() {
+        BaseDataCache.getInstance().cleanPluginData();
+        BaseDataCache.getInstance().cleanSelectorData();
+        BaseDataCache.getInstance().cleanRuleData();
+        ContextPathPluginDataHandler.CACHED_HANDLE.get().getAllCache().clear();
+    }
+
+    @Test
+    void forwardsEncodedQueryArgumentsExactlyOnceThroughTheOrdinaryHttpRoute() 
{
+        // The route the executor hands to the ordinary chain is the same 
input RequestUrlUtils receives in
+        // production, so building the upstream URI here proves what the 
gateway would actually send.
+        RecordingChain chain = new RecordingChain(child -> {
+            assertEquals("http://127.0.0.1:18080/orders/status?id=A%26B";,
+                    RequestUrlUtils.buildRequestUri(child, 
"http://127.0.0.1:18080";).toString());
+            return write(child, "{}");
+        });
+        StepVerifier.create(executor(origin(), chain, 
4096).invoke(tool("GET"), 
invocation("A&B"))).expectNextCount(1).verifyComplete();
+        assertEquals(1, chain.forks.get());
+    }
+
+    @Test
+    void forwardsABodyArgumentWithoutInventingAQuery() {
+        RecordingChain chain = new RecordingChain(child -> {
+            assertEquals("http://127.0.0.1:18080/orders/status";,
+                    RequestUrlUtils.buildRequestUri(child, 
"http://127.0.0.1:18080";).toString());
+            return write(child, "{}");
+        });
+        StepVerifier.create(executor(origin(), chain, 
4096).invoke(tool("POST"), 
invocation("A&B"))).expectNextCount(1).verifyComplete();
+    }
+
+    @Test
+    void forwardsAQuerylessRouteWithoutAddingAQuestionMark() {
+        RecordingChain chain = new RecordingChain(child -> {
+            assertEquals("http://127.0.0.1:18080/orders/status";,
+                    RequestUrlUtils.buildRequestUri(child, 
"http://127.0.0.1:18080";).toString());
+            return write(child, "{}");
+        });
+        StepVerifier.create(executor(origin(), chain, 
4096).invoke(toolWithoutArguments(), invocation(new JsonObject())))
+                .expectNextCount(1).verifyComplete();
+    }
+
+    @Test
+    void keepsTheContextPathRewriteWithoutDuplicatingTheQuery() {
+        cacheContextPathRule("/api", "context-rule");
+        final ContextPathPlugin contextPath = new ContextPathPlugin();
+        RecordingChain chain = new RecordingChain(child -> 
contextPath.execute(child, routed -> {
+            // The context path plugin only rewrites realUrl; the encoded 
argument must survive untouched.
+            assertEquals("/orders/status", ((ShenyuContext) 
routed.getAttribute(Constants.CONTEXT)).getRealUrl());
+            assertEquals("http://127.0.0.1:18080/orders/status?id=A%26B";,
+                    RequestUrlUtils.buildRequestUri(routed, 
"http://127.0.0.1:18080";).toString());
+            return write(routed, "{}");
+        }));
+        StepVerifier.create(executor(origin(), chain, 4096).invoke(tool("GET", 
"/api/orders/status"), invocation("A&B")))
+                .expectNextCount(1).verifyComplete();
+    }
+
     @Test
     void doesNotReuseClientBodyHeadersCookiesAttributesOrResponse() {
         final MockServerWebExchange origin = origin();
@@ -225,17 +311,61 @@ class AgentHttpToolExecutorTest {
     }
 
     private AgentGatewayHttpToolConfig.Tool tool(final String method) {
+        return tool(method, "/orders/status");
+    }
+
+    private AgentGatewayHttpToolConfig.Tool tool(final String method, final 
String url) {
         JsonObject plans = JsonParser.parseString("""
                 {"lookup":{"description":"Look up a 
record","inputSchema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]},
                 
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{"id":"query"}}}}
                 """).getAsJsonObject();
         
plans.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("requestTemplate").addProperty("method",
 method);
+        
plans.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("requestTemplate").addProperty("url",
 url);
         
plans.getAsJsonObject("lookup").getAsJsonObject("requestConfig").getAsJsonObject("argsPosition").addProperty("id",
 "POST".equals(method) ? "body" : "query");
         return AgentGatewayHttpToolConfig.parse(plans, 
Set.of("lookup")).getTools().get("lookup");
     }
 
+    private AgentGatewayHttpToolConfig.Tool toolWithoutArguments() {
+        JsonObject plans = JsonParser.parseString("""
+                {"lookup":{"description":"Look up a 
record","inputSchema":{"type":"object","properties":{}},
+                
"requestConfig":{"requestTemplate":{"method":"GET","url":"/orders/status"},"argsPosition":{}}}}
+                """).getAsJsonObject();
+        return AgentGatewayHttpToolConfig.parse(plans, 
Set.of("lookup")).getTools().get("lookup");
+    }
+
+    private void cacheContextPathRule(final String contextPath, final String 
ruleId) {
+        PluginData plugin = new PluginData();
+        plugin.setName(new ContextPathPlugin().named());
+        plugin.setEnabled(true);
+        BaseDataCache.getInstance().cachePluginData(plugin);
+        SelectorData selector = new SelectorData();
+        selector.setId("context-selector");
+        selector.setPluginName(plugin.getName());
+        selector.setType(SelectorTypeEnum.FULL_FLOW.getCode());
+        selector.setContinued(true);
+        selector.setEnabled(true);
+        selector.setLogged(false);
+        selector.setSort(0);
+        BaseDataCache.getInstance().cacheSelectData(selector);
+        RuleData rule = new RuleData();
+        rule.setId(ruleId);
+        rule.setSelectorId(selector.getId());
+        rule.setEnabled(true);
+        rule.setLogged(false);
+        rule.setSort(0);
+        ContextMappingRuleHandle handle = new ContextMappingRuleHandle();
+        handle.setContextPath(contextPath);
+        rule.setHandle(GsonUtils.getInstance().toJson(handle));
+        new ContextPathPluginDataHandler().handlerRule(rule);
+        BaseDataCache.getInstance().cacheRuleData(rule);
+    }
+
     private AgentToolInvocation invocation(final String id) {
-        return new AgentToolInvocation("same-id", "subject", arguments(id), 
"rule", 1, Instant.now().plusSeconds(10));
+        return invocation(arguments(id));
+    }
+
+    private AgentToolInvocation invocation(final JsonObject value) {
+        return new AgentToolInvocation("same-id", "subject", value, "rule", 1, 
Instant.now().plusSeconds(10));
     }
 
     private JsonObject arguments(final String id) {

Reply via email to