diff --git a/docs/src/main/asciidoc/_configprops.adoc b/docs/src/main/asciidoc/_configprops.adoc index 0eef0582..2856b060 100644 --- a/docs/src/main/asciidoc/_configprops.adoc +++ b/docs/src/main/asciidoc/_configprops.adoc @@ -43,6 +43,7 @@ |spring.cloud.gateway.filter.rewrite-location-response-header.enabled | `+++true+++` | Enables the rewrite-location-response-header filter. |spring.cloud.gateway.filter.rewrite-location.enabled | `+++true+++` | Enables the rewrite-location filter. |spring.cloud.gateway.filter.rewrite-path.enabled | `+++true+++` | Enables the rewrite-path filter. +|spring.cloud.gateway.filter.rewrite-request-parameter.enabled | `+++true+++` | Enables the rewrite-request-parameter filter. |spring.cloud.gateway.filter.rewrite-response-header.enabled | `+++true+++` | Enables the rewrite-response-header filter. |spring.cloud.gateway.filter.save-session.enabled | `+++true+++` | Enables the save-session filter. |spring.cloud.gateway.filter.secure-headers.content-security-policy | `+++default-src 'self' https:; font-src 'self' https: data:; img-src 'self' https: data:; object-src 'none'; script-src https:; style-src 'self' https: 'unsafe-inline'+++` | diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index 6b383185..f7ce13cc 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -1598,6 +1598,34 @@ spring: For a request path of `/red/blue`, this sets the path to `/blue` before making the downstream request. Note that the `$` should be replaced with `$\` because of the YAML specification. +=== The `RewriteRequestParameter` `GatewayFilter` Factory + +The `RewriteRequestParameter` `GatewayFilter` factory takes a `name` parameter and a `replacement` parameter. +It will rewrite the value of the request parameter of the given `name`. +If multiple request parameters with the same `name` are set, they will be replaced with a single value. +If no request parameter is found, no changes will be made. +The following listing configures a `RewriteRequestParameter` `GatewayFilter`: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: rewriterequestparameter_route + uri: https://example.org + predicates: + - Path=/products + filters: + - RewriteRequestParameter=campaign,fall2023 +---- +==== + +For a request to `/products?campaign=old`, this sets the request parameter to `campaign=fall2023`. + + === The `RewriteResponseHeader` `GatewayFilter` Factory The `RewriteResponseHeader` `GatewayFilter` factory takes `name`, `regexp`, and `replacement` parameters. diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index ca2b7a14..77a7706f 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -97,6 +97,7 @@ import org.springframework.cloud.gateway.filter.factory.RequestSizeGatewayFilter import org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewriteLocationResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RewriteRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewriteResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SaveSessionGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory; @@ -703,6 +704,12 @@ public class GatewayAutoConfiguration { return new RequestHeaderSizeGatewayFilterFactory(); } + @Bean + @ConditionalOnEnabledFilter + public RewriteRequestParameterGatewayFilterFactory rewriteRequestParameterGatewayFilterFactory() { + return new RewriteRequestParameterGatewayFilterFactory(); + } + @Bean public GzipMessageBodyResolver gzipMessageBodyResolver() { return new GzipMessageBodyResolver(); diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java new file mode 100644 index 00000000..9a5a3d6b --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactory.java @@ -0,0 +1,96 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.gateway.filter.factory; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; + +/** + * @author Fredrich Ombico + */ +public class RewriteRequestParameterGatewayFilterFactory + extends AbstractGatewayFilterFactory { + + /** + * Replacement key. + */ + public static final String REPLACEMENT_KEY = "replacement"; + + public RewriteRequestParameterGatewayFilterFactory() { + super(Config.class); + } + + @Override + public List shortcutFieldOrder() { + return Arrays.asList(NAME_KEY, REPLACEMENT_KEY); + } + + @Override + public GatewayFilter apply(Config config) { + return new GatewayFilter() { + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest req = exchange.getRequest(); + + UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(req.getURI()); + if (req.getQueryParams().containsKey(config.getName())) { + uriComponentsBuilder.replaceQueryParam(config.getName(), config.getReplacement()); + } + + URI uri = uriComponentsBuilder.build().toUri(); + ServerHttpRequest request = req.mutate().uri(uri).build(); + + return chain.filter(exchange.mutate().request(request).build()); + } + + @Override + public String toString() { + return filterToStringCreator(RewriteRequestParameterGatewayFilterFactory.this) + .append(config.getName(), config.replacement).toString(); + } + }; + } + + public static class Config extends AbstractGatewayFilterFactory.NameConfig { + + private String replacement; + + public String getReplacement() { + return replacement; + } + + public Config setReplacement(String replacement) { + Assert.notNull(replacement, "replacement must not be null"); + this.replacement = replacement; + return this; + } + + } + +} diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java index d9c2a6af..dfa3ca4d 100644 --- a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/route/builder/GatewayFilterSpec.java @@ -64,6 +64,7 @@ import org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactor import org.springframework.cloud.gateway.filter.factory.RewriteLocationResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewriteLocationResponseHeaderGatewayFilterFactory.StripVersion; import org.springframework.cloud.gateway.filter.factory.RewritePathGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RewriteRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewriteResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SaveSessionGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.SecureHeadersGatewayFilterFactory; @@ -686,6 +687,17 @@ public class GatewayFilterSpec extends UriSpec { .setHostValue(hostValue).setProtocols(protocolsRegex))); } + /** + * A filter that rewrites the value of a request parameter + * @param name The name of the request parameter to replace + * @param replacement The new value for the request parameter + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec rewriteRequestParameter(String name, String replacement) { + return filter(getBean(RewriteRequestParameterGatewayFilterFactory.class) + .apply(c -> c.setReplacement(replacement).setName(name))); + } + /** * A filter that sets the status on the response before it is returned to the client * by the Gateway. diff --git a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 753c5efa..79d80429 100644 --- a/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-cloud-gateway-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -167,6 +167,12 @@ "description": "Enables the rewrite-location filter.", "defaultValue": "true" }, + { + "name": "spring.cloud.gateway.filter.rewrite-request-parameter.enabled", + "type": "java.lang.Boolean", + "description": "Enables the rewrite-request-parameter filter.", + "defaultValue": "true" + }, { "name": "spring.cloud.gateway.filter.set-status.enabled", "type": "java.lang.Boolean", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java index 7892ab6e..23eba91d 100644 --- a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/config/conditional/DisableBuiltInFiltersTests.java @@ -103,6 +103,7 @@ public class DisableBuiltInFiltersTests { "spring.cloud.gateway.filter.rewrite-response-header.enabled=false", "spring.cloud.gateway.filter.rewrite-location-response-header.enabled=false", "spring.cloud.gateway.filter.rewrite-location.enabled=false", + "spring.cloud.gateway.filter.rewrite-request-parameter.enabled=false", "spring.cloud.gateway.filter.set-status.enabled=false", "spring.cloud.gateway.filter.save-session.enabled=false", "spring.cloud.gateway.filter.strip-prefix.enabled=false", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryIntegrationTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryIntegrationTests.java new file mode 100644 index 00000000..26d49797 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryIntegrationTests.java @@ -0,0 +1,50 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.gateway.filter.factory; + +import org.junit.jupiter.api.Test; + +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; + +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; + +/** + * @author Fredrich Ombico + */ +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +class RewriteRequestParameterGatewayFilterFactoryIntegrationTests extends BaseWebClientTests { + + @Test + void rewriteRequestParameterFilterWorks() { + testClient.get().uri("/get?campaign=old").header("Host", "www.rewriterequestparameter.org").exchange() + .expectStatus().isOk().expectBody().jsonPath("$.args.size", "fall2023"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java new file mode 100644 index 00000000..9efd0924 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/RewriteRequestParameterGatewayFilterFactoryTests.java @@ -0,0 +1,102 @@ +/* + * Copyright 2013-2023 the original author or authors. + * + * Licensed 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 + * + * https://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.springframework.cloud.gateway.filter.factory; + +import java.net.URI; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.MultiValueMap; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.util.UriComponentsBuilder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.cloud.gateway.filter.factory.RewriteRequestParameterGatewayFilterFactory.Config; + +/** + * @author Fredrich Ombico + */ +class RewriteRequestParameterGatewayFilterFactoryTests { + + @Test + void toStringFormat() { + Config config = new Config(); + config.setName("campaign"); + config.setReplacement("fall2023"); + GatewayFilter filter = new RewriteRequestParameterGatewayFilterFactory().apply(config); + assertThat(filter.toString()).contains("campaign").contains("fall2023"); + } + + @Test + void rewriteRequestParameterFilterWorks() { + testRewriteRequestParameterFilter("campaign", "fall2023", "size=small&campaign=old", + Map.of("size", List.of("small"), "campaign", List.of("fall2023"))); + } + + @Test + void rewriteRequestParameterFilterRewritesMultipleParamsWithSameName() { + testRewriteRequestParameterFilter("campaign", "fall2023", "campaign=fall&size=small&campaign=old", + Map.of("size", List.of("small"), "campaign", List.of("fall2023"))); + } + + @Test + void rewriteRequestParameterFilterDoesNotAddParamIfNameNotFound() { + testRewriteRequestParameterFilter("campaign", "winter2023", "color=green&sort=popular", + Map.of("color", List.of("green"), "sort", List.of("popular"))); + } + + @Test + void rewriteRequestParameterFilterWorksWithSpecialCharacters() { + testRewriteRequestParameterFilter("campaign", "black friday~(1.A-B_C!)", "campaign=old&color=green", + Map.of("campaign", List.of("black friday~(1.A-B_C!)"), "color", List.of("green"))); + } + + private void testRewriteRequestParameterFilter(String name, String replacement, String query, + Map> expectedQueryParams) { + GatewayFilter filter = new RewriteRequestParameterGatewayFilterFactory() + .apply(config -> config.setReplacement(replacement).setName(name)); + + URI url = UriComponentsBuilder.fromUriString("http://localhost/get").query(query).build(true).toUri(); + MockServerHttpRequest request = MockServerHttpRequest.method(HttpMethod.GET, url).build(); + + ServerWebExchange exchange = MockServerWebExchange.from(request); + + GatewayFilterChain filterChain = mock(GatewayFilterChain.class); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ServerWebExchange.class); + when(filterChain.filter(captor.capture())).thenReturn(Mono.empty()); + + filter.filter(exchange, filterChain); + + ServerWebExchange webExchange = captor.getValue(); + + MultiValueMap actualQueryParams = webExchange.getRequest().getQueryParams(); + assertThat(actualQueryParams).containsExactlyInAnyOrderEntriesOf(expectedQueryParams); + } + +} diff --git a/spring-cloud-gateway-server/src/test/resources/application.yml b/spring-cloud-gateway-server/src/test/resources/application.yml index 9ca3a774..2e5a0571 100644 --- a/spring-cloud-gateway-server/src/test/resources/application.yml +++ b/spring-cloud-gateway-server/src/test/resources/application.yml @@ -341,6 +341,14 @@ spring: - RewriteLocationResponseHeader - AddResponseHeader=Location, https://backend.org:443/v1/some/object/id + # ===================================== + - id: rewrite_request_parameter_test + uri: ${test.uri} + predicates: + - Host=**.rewriterequestparameter.org + filters: + - RewriteRequestParameter=campaign,fall2023 + # ===================================== - id: rewrite_response_header_test uri: ${test.uri}