diff --git a/docs/src/main/asciidoc/spring-cloud-gateway.adoc b/docs/src/main/asciidoc/spring-cloud-gateway.adoc index df165283..2f8c2edb 100644 --- a/docs/src/main/asciidoc/spring-cloud-gateway.adoc +++ b/docs/src/main/asciidoc/spring-cloud-gateway.adoc @@ -534,6 +534,53 @@ spring: ---- ==== +=== The `AddRequestHeadersIfNotPresent` `GatewayFilter` Factory + +The `AddRequestHeadersIfNotPresent` `GatewayFilter` factory takes a collection of `name` and `value` pairs separated by colon. +The following example configures an `AddRequestHeadersIfNotPresent` `GatewayFilter`: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: add_request_headers_route + uri: https://example.org + filters: + - AddRequestHeadersIfNotPresent=X-Request-Color-1:blue,X-Request-Color-2:green +---- +==== + +This listing adds 2 headers `X-Request-Color-1:blue` and `X-Request-Color-2:green` to the downstream request's headers for all matching requests. +This is similar to how `AddRequestHeader` works, but unlike `AddRequestHeader` it will do it only if the header is not already there. +Otherwise, the original value in the client request is sent. + +Additionally, to set a multi-valued header, use the header name multiple times like `AddRequestHeadersIfNotPresent=X-Request-Color-1:blue,X-Request-Color-1:green`. + +`AddRequestHeadersIfNotPresent` also supports URI variables used to match a path or host. +URI variables may be used in the value and are expanded at runtime. +The following example configures an `AddRequestHeadersIfNotPresent` `GatewayFilter` that uses a variable: + +.application.yml +==== +[source,yaml] +---- +spring: + cloud: + gateway: + routes: + - id: add_request_header_route + uri: https://example.org + predicates: + - Path=/red/{segment} + filters: + - AddRequestHeadersIfNotPresent=X-Request-Red:Blue-{segment} +---- +==== + === The `AddRequestParameter` `GatewayFilter` Factory The `AddRequestParameter` `GatewayFilter` Factory takes a `name` and `value` parameter. 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 68e8fe47..3b224a5c 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 @@ -68,6 +68,7 @@ import org.springframework.cloud.gateway.filter.RouteToRequestUrlFilter; import org.springframework.cloud.gateway.filter.WebsocketRoutingFilter; import org.springframework.cloud.gateway.filter.WeightCalculatorWebFilter; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory; @@ -143,6 +144,7 @@ import org.springframework.cloud.gateway.route.RouteLocator; import org.springframework.cloud.gateway.route.RouteRefreshListener; import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; import org.springframework.cloud.gateway.support.ConfigurationService; +import org.springframework.cloud.gateway.support.KeyValueConverter; import org.springframework.cloud.gateway.support.StringToZonedDateTimeConverter; import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ConfigurableApplicationContext; @@ -187,6 +189,11 @@ public class GatewayAutoConfiguration { return new StringToZonedDateTimeConverter(); } + @Bean + public KeyValueConverter keyValueConverter() { + return new KeyValueConverter(); + } + @Bean public RouteLocatorBuilder routeLocatorBuilder(ConfigurableApplicationContext context) { return new RouteLocatorBuilder(context); @@ -473,6 +480,12 @@ public class GatewayAutoConfiguration { return new AddRequestHeaderGatewayFilterFactory(); } + @Bean + @ConditionalOnEnabledFilter + public AddRequestHeadersIfNotPresentGatewayFilterFactory addRequestHeadersIfNotPresentGatewayFilterFactory() { + return new AddRequestHeadersIfNotPresentGatewayFilterFactory(); + } + @Bean @ConditionalOnEnabledFilter public MapRequestHeaderGatewayFilterFactory mapRequestHeaderGatewayFilterFactory() { diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactory.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactory.java new file mode 100644 index 00000000..f912b928 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactory.java @@ -0,0 +1,165 @@ +/* + * Copyright 2013-2022 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.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import reactor.core.publisher.Mono; + +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.support.ServerWebExchangeUtils; +import org.springframework.core.style.ToStringCreator; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ServerWebExchange; + +import static org.springframework.cloud.gateway.support.GatewayToStringStyler.filterToStringCreator; + +/** + * Adds one or more headers to the downstream request’s headers without overriding + * previous values. If the header is are already present, value(s) will not be set. + * + * @author Abel Salgado Romero + */ +public class AddRequestHeadersIfNotPresentGatewayFilterFactory + extends AbstractGatewayFilterFactory { + + @Override + public GatewayFilter apply(KeyValueConfig config) { + return new GatewayFilter() { + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest.Builder requestBuilder = null; + + Map> aggregatedHeaders = new HashMap<>(); + + for (KeyValue keyValue : config.getKeyValues()) { + String key = keyValue.getKey(); + List candidateValue = aggregatedHeaders.get(key); + if (candidateValue == null) { + candidateValue = new ArrayList<>(); + candidateValue.add(keyValue.getValue()); + } + else { + candidateValue.add(keyValue.getValue()); + } + aggregatedHeaders.put(key, candidateValue); + } + + for (Map.Entry> kv : aggregatedHeaders.entrySet()) { + String headerName = kv.getKey(); + + boolean headerIsMissingOrBlank = exchange.getRequest().getHeaders().getOrEmpty(headerName).stream() + .allMatch(h -> !StringUtils.hasText(h)); + + if (headerIsMissingOrBlank) { + if (requestBuilder == null) { + requestBuilder = exchange.getRequest().mutate(); + } + ServerWebExchange finalExchange = exchange; + requestBuilder.headers(httpHeaders -> { + List replacedValues = kv.getValue().stream() + .map(value -> ServerWebExchangeUtils.expand(finalExchange, value)) + .collect(Collectors.toList()); + httpHeaders.addAll(headerName, replacedValues); + }); + } + } + if (requestBuilder != null) { + exchange = exchange.mutate().request(requestBuilder.build()).build(); + } + return chain.filter(exchange); + } + + @Override + public String toString() { + ToStringCreator toStringCreator = filterToStringCreator( + AddRequestHeadersIfNotPresentGatewayFilterFactory.this); + for (KeyValue keyValue : config.getKeyValues()) { + toStringCreator.append(keyValue.getKey(), keyValue.getValue()); + } + return toStringCreator.toString(); + } + }; + } + + public ShortcutType shortcutType() { + return ShortcutType.GATHER_LIST; + } + + @Override + public List shortcutFieldOrder() { + return Collections.singletonList("keyValues"); + } + + @Override + public KeyValueConfig newConfig() { + return new KeyValueConfig(); + } + + @Override + public Class getConfigClass() { + return KeyValueConfig.class; + } + + public static class KeyValueConfig { + + private KeyValue[] keyValues; + + public KeyValue[] getKeyValues() { + return keyValues; + } + + public void setKeyValues(KeyValue[] keyValues) { + this.keyValues = keyValues; + } + + } + + public static class KeyValue { + + private final String key; + + private final String value; + + public KeyValue(String key, String value) { + this.key = key; + this.value = value; + } + + public String getKey() { + return key; + } + + public String getValue() { + return value; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("name", key).append("value", value).toString(); + } + + } + +} 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 c49893b6..8d23d423 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 @@ -19,6 +19,7 @@ package org.springframework.cloud.gateway.route.builder; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Optional; @@ -37,6 +38,8 @@ import org.springframework.cloud.gateway.filter.GatewayFilter; import org.springframework.cloud.gateway.filter.OrderedGatewayFilter; import org.springframework.cloud.gateway.filter.factory.AbstractChangeRequestUriGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddRequestHeaderGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValue; import org.springframework.cloud.gateway.filter.factory.AddRequestParameterGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.AddResponseHeaderGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.CacheRequestBodyGatewayFilterFactory; @@ -165,6 +168,20 @@ public class GatewayFilterSpec extends UriSpec { .apply(c -> c.setName(headerName).setValue(headerValue))); } + /** + * Adds a request header to the request before it is routed by the Gateway. + * @param headers the header name(s) and value(s) as + * 'name-1:value-1,name-2:value-2,...' + * @return a {@link GatewayFilterSpec} that can be used to apply additional filters + */ + public GatewayFilterSpec addRequestHeadersIfNotPresent(String... headers) { + return filter(getBean(AddRequestHeadersIfNotPresentGatewayFilterFactory.class).apply(c -> { + KeyValue[] values = Arrays.stream(headers).map(header -> header.split(":")) + .map(parts -> new KeyValue(parts[0], parts[1])).toArray(size -> new KeyValue[size]); + c.setKeyValues(values); + })); + } + /** * Adds a request parameter to the request before it is routed by the Gateway. * @param param the parameter name diff --git a/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/KeyValueConverter.java b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/KeyValueConverter.java new file mode 100644 index 00000000..572b4190 --- /dev/null +++ b/spring-cloud-gateway-server/src/main/java/org/springframework/cloud/gateway/support/KeyValueConverter.java @@ -0,0 +1,41 @@ +/* + * Copyright 2013-2022 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.support; + +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValue; +import org.springframework.core.convert.converter.Converter; +import org.springframework.util.StringUtils; + +public class KeyValueConverter implements Converter { + + private static final String INVALID_CONFIGURATION_MESSAGE = "Invalid configuration, expected format is: 'key:value'"; + + @Override + public KeyValue convert(String source) throws IllegalArgumentException { + try { + String[] split = source.split(":"); + if (source.contains(":") && StringUtils.hasText(split[0])) { + return new KeyValue(split[0], split.length == 1 ? "" : split[1]); + } + throw new IllegalArgumentException(INVALID_CONFIGURATION_MESSAGE); + } + catch (ArrayIndexOutOfBoundsException e) { + throw new IllegalArgumentException(INVALID_CONFIGURATION_MESSAGE); + } + } + +} 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 9eea24b1..77ddb901 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 @@ -80,6 +80,7 @@ public class DisableBuiltInFiltersTests { @SpringBootTest(classes = Config.class, properties = { "spring.cloud.gateway.filter.add-request-header.enabled=false", "spring.cloud.gateway.filter.map-request-header.enabled=false", + "spring.cloud.gateway.filter.add-request-headers-if-not-present.enabled=false", "spring.cloud.gateway.filter.add-request-parameter.enabled=false", "spring.cloud.gateway.filter.add-response-header.enabled=false", "spring.cloud.gateway.filter.json-to-grpc.enabled=false", diff --git a/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactoryTests.java b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactoryTests.java new file mode 100644 index 00000000..160aa874 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/java/org/springframework/cloud/gateway/filter/factory/AddRequestHeadersIfNotPresentGatewayFilterFactoryTests.java @@ -0,0 +1,145 @@ +/* + * Copyright 2013-2020 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.util.Arrays; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.filter.GatewayFilter; +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValue; +import org.springframework.cloud.gateway.filter.factory.AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValueConfig; +import org.springframework.cloud.gateway.route.RouteLocator; +import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder; +import org.springframework.cloud.gateway.test.BaseWebClientTests; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT; +import static org.springframework.cloud.gateway.test.TestUtils.getMap; + +@SpringBootTest(webEnvironment = RANDOM_PORT) +@DirtiesContext +@ActiveProfiles(profiles = "request-headers-if-not-present-web-filter") +public class AddRequestHeadersIfNotPresentGatewayFilterFactoryTests extends BaseWebClientTests { + + private static final String TEST_HEADER_1 = "X-Request-Example"; + + private static final String TEST_HEADER_2 = "X-Request-Second-Example"; + + private static final String TEST_HOST_HEADER_VALUE = "www.addrequestheaderjava.org"; + + @Test + public void addRequestHeadersIfHeaderPresentFilterDoesNotAddHeaderIfPresent() { + final String initialHeaderValue = "initial-value"; + testClient.get().uri("/headers").header(TEST_HEADER_1, initialHeaderValue).exchange().expectBody(Map.class) + .consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry(TEST_HEADER_1, initialHeaderValue); + }); + } + + @Test + public void addRequestHeadersIfNotPresentFilterWorks() { + testClient.get().uri("/headers").exchange().expectBody(Map.class).consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry(TEST_HEADER_1, "ValueA"); + }); + } + + @Test + public void addRequestHeadersIfNotPresentFilterOnlyWorksFirstPassWhenMultipleValues() { + testClient.get().uri("/multivalueheaders").exchange().expectBody(Map.class).consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry(TEST_HEADER_1, Arrays.asList("ValueA")); + assertThat(headers).containsEntry(TEST_HEADER_2, Arrays.asList("ValueC")); + }); + } + + @Test + public void addRequestHeadersIfNotPresentFilterWorksOnlyMissingValues() { + final String existingValue = "existing-value"; + testClient.get().uri("/multivalueheaders").header(TEST_HEADER_2, existingValue).exchange().expectBody(Map.class) + .consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry(TEST_HEADER_1, Arrays.asList("ValueA")); + assertThat(headers).containsEntry(TEST_HEADER_2, Arrays.asList(existingValue)); + }); + } + + @Test + public void addRequestHeadersIfNotPresentFilterWorksJavaDsl() { + testClient.get().uri("/headers").header("Host", TEST_HOST_HEADER_VALUE).exchange().expectBody(Map.class) + .consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry("X-Request-Acme", "ValueB-www"); + }); + } + + @Test + public void addRequestHeadersIfNotPresentFilterMultipleValuesWorksJavaDsl() { + testClient.get().uri("/multivalueheaders").header("Host", TEST_HOST_HEADER_VALUE).exchange() + .expectBody(Map.class).consumeWith(result -> { + Map headers = getMap(result.getResponseBody(), "headers"); + assertThat(headers).containsEntry("X-Request-Acme", + Arrays.asList("ValueX", "ValueY", "ValueZ", "www")); + }); + } + + @Test + public void toStringFormat() { + KeyValueConfig keyValueConfig = new KeyValueConfig(); + keyValueConfig.setKeyValues(new KeyValue[] { new KeyValue("my-header-name-1", "my-header-value-1"), + new KeyValue("my-header-name-2", "my-header-value-2"), }); + GatewayFilter filter = new AddRequestHeadersIfNotPresentGatewayFilterFactory().apply(keyValueConfig); + assertThat(filter.toString()).startsWith("[AddRequestHeadersIfNotPresent") + .contains("my-header-name-1 = 'my-header-value-1'").contains("my-header-name-2 = 'my-header-value-2'") + .endsWith("]"); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + @Import(DefaultTestConfig.class) + public static class TestConfig { + + @Value("${test.uri}") + String uri; + + @Bean + public RouteLocator testRouteLocator(RouteLocatorBuilder builder) { + return builder.routes().route("add_request_headers_if_not_present_java_test", + r -> r.path("/headers").and().host("{sub}.addrequestheaderjava.org") + .filters(f -> f.addRequestHeadersIfNotPresent("X-Request-Acme:ValueB-{sub}")).uri(uri)) + .route("add_multiple_request_headers_java_test", + r -> r.path("/multivalueheaders").and().host("{sub}.addrequestheaderjava.org") + .filters(f -> f.addRequestHeadersIfNotPresent("X-Request-Acme:ValueX", + "X-Request-Acme:ValueY", "X-Request-Acme:ValueZ", "X-Request-Acme:{sub}")) + .uri(uri)) + .build(); + } + + } + +} diff --git a/spring-cloud-gateway-server/src/test/resources/application-request-headers-if-not-present-web-filter.yml b/spring-cloud-gateway-server/src/test/resources/application-request-headers-if-not-present-web-filter.yml new file mode 100644 index 00000000..f75cf269 --- /dev/null +++ b/spring-cloud-gateway-server/src/test/resources/application-request-headers-if-not-present-web-filter.yml @@ -0,0 +1,17 @@ +spring: + cloud: + gateway: + routes: + - id: add_request_headers_if_not_present_test + uri: ${test.uri} + predicates: + - Path=/headers + filters: + - AddRequestHeadersIfNotPresent=X-Request-Example:ValueA + - id: add_multiple_requests_if_not_present_header_test + uri: ${test.uri} + predicates: + - Path=/multivalueheaders + filters: + - AddRequestHeadersIfNotPresent=X-Request-Example:ValueA,X-Request-Second-Example:ValueC + - AddRequestHeadersIfNotPresent=X-Request-Example:ValueB,X-Request-Second-Example:ValueD