From 10e0270c7d310094b5abefb47cdd82199766e8d4 Mon Sep 17 00:00:00 2001 From: Samuel Gulliksson Date: Fri, 26 Jun 2026 18:21:10 +0200 Subject: [PATCH] Allow query parameters without value in proxied URL. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the behavior of query parameters in MvcUtils with HierarchicalUriComponents from spring-web to allow query parameters without value to be passed along in the proxied request by ProxyExchangeHandlerFunction. This allows custom filter implementations to transform incoming query parameters to be preserved without trailing equal sign such that '?param' in the original request is not proxied as ยด?param='. Fixes gh-3197. Signed-off-by: Samuel Gulliksson --- .../gateway/server/mvc/common/MvcUtils.java | 11 +++- .../server/mvc/ServerMvcIntegrationTests.java | 63 +++++++++++++++++++ .../server/mvc/common/MvcUtilsTests.java | 9 +++ 3 files changed, 80 insertions(+), 3 deletions(-) diff --git a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java index 871d0cbf7..93f834371 100644 --- a/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java +++ b/spring-cloud-gateway-server-webmvc/src/main/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtils.java @@ -342,9 +342,14 @@ public static String stripContextPath(ServerRequest request, String path) { public static MultiValueMap encodeQueryParams(MultiValueMap params) { MultiValueMap encodedQueryParams = new LinkedMultiValueMap<>(params.size()); for (Map.Entry> entry : params.entrySet()) { - for (String value : entry.getValue()) { - encodedQueryParams.add(UriUtils.encode(entry.getKey(), StandardCharsets.UTF_8), - UriUtils.encode(value, StandardCharsets.UTF_8)); + String key = UriUtils.encode(entry.getKey(), StandardCharsets.UTF_8); + if (CollectionUtils.isEmpty(entry.getValue())) { + encodedQueryParams.put(key, entry.getValue()); + } + else { + for (String value : entry.getValue()) { + encodedQueryParams.add(key, UriUtils.encode(value, StandardCharsets.UTF_8)); + } } } return CollectionUtils.unmodifiableMultiValueMap(encodedQueryParams); diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java index 81d4c8c1e..a8d2b269b 100644 --- a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/ServerMvcIntegrationTests.java @@ -27,6 +27,7 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Function; import java.util.function.Predicate; import com.github.benmanes.caffeine.cache.Caffeine; @@ -1023,6 +1024,44 @@ public void queryParamWithSpecialCharactersWorks() { }); } + @Test + public void queryParamWithoutValueWorks() { + restClient.get() + .uri("/queryParamWithoutValue?myparam") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .consumeWith(result -> { + Map responseBody = result.getResponseBody(); + assertThat(responseBody).containsKey("args"); + Map args = getMap(responseBody, "args"); + assertThat(args).containsKey("myparam"); + assertThat(args.get("myparam")).isEqualTo(""); + String url = (String) responseBody.get("url"); + assertThat(url).endsWith("/get?myparam"); + }); + } + + @Test + public void queryParamWithEmptyValueWorks() { + restClient.get() + .uri("/get?myparam=") + .exchange() + .expectStatus() + .isOk() + .expectBody(Map.class) + .consumeWith(result -> { + Map responseBody = result.getResponseBody(); + assertThat(responseBody).containsKey("args"); + Map args = getMap(responseBody, "args"); + assertThat(args).containsKey("myparam"); + assertThat(args.get("myparam")).isEqualTo(""); + String url = (String) responseBody.get("url"); + assertThat(url).endsWith("/get?myparam="); + }); + } + @Test public void clientResponseBodyAttributeWorks() { restClient.get() @@ -1690,6 +1729,30 @@ public RouterFunction gatewayRouterFunctionsReadResponseBody() { // @formatter:on } + @Bean + public RouterFunction gatewayRouterFunctionsEmptyQueryParamFilter() { + Function transformQueryParamWithoutValue = request -> ServerRequest + .from(request) + .params(params -> { + // transform any query parameter with empty string value to not have + // any value at all + for (var param : params.entrySet()) { + if (param.getValue().size() == 1 && param.getValue().get(0).isEmpty()) { + params.put(param.getKey(), Collections.emptyList()); + } + } + }) + .build(); + // @formatter:off + return route("testQueryParamWithoutValue") + .GET("/queryParamWithoutValue", http()) + .before(transformQueryParamWithoutValue) + .filter(new HttpbinUriResolver()) + .filter(setPath("/get")) + .build(); + // @formatter:on + } + @Bean public FilterRegistrationBean myFilter() { FilterRegistrationBean reg = new FilterRegistrationBean<>(new MyFilter()); diff --git a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtilsTests.java b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtilsTests.java index 9db79ae06..563abd212 100644 --- a/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtilsTests.java +++ b/spring-cloud-gateway-server-webmvc/src/test/java/org/springframework/cloud/gateway/server/mvc/common/MvcUtilsTests.java @@ -17,11 +17,13 @@ package org.springframework.cloud.gateway.server.mvc.common; import java.util.Collections; +import java.util.Map; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.MultiValueMap; import org.springframework.web.servlet.function.ServerRequest; import static org.assertj.core.api.Assertions.assertThat; @@ -62,4 +64,11 @@ void stripContextPathNoOpWithoutContextPath() { assertThat(MvcUtils.stripContextPath(request, "/path")).isEqualTo("/path"); } + @Test + void encodeQueryParamsKeepsParamWithoutValue() { + var queryParams = MultiValueMap.fromMultiValue(Map.of("test", Collections.emptyList())); + var encoded = MvcUtils.encodeQueryParams(queryParams); + assertThat(encoded).isEqualTo(queryParams); + } + }