Merge branch 'add-AddRequestHeadersIfNotPresentGatewayFilterFactory-2359'

This commit is contained in:
spencergibb
2022-10-05 11:38:11 -04:00
8 changed files with 446 additions and 0 deletions

View File

@@ -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.

View File

@@ -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() {

View File

@@ -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 requests 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<AddRequestHeadersIfNotPresentGatewayFilterFactory.KeyValueConfig> {
@Override
public GatewayFilter apply(KeyValueConfig config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest.Builder requestBuilder = null;
Map<String, List<String>> aggregatedHeaders = new HashMap<>();
for (KeyValue keyValue : config.getKeyValues()) {
String key = keyValue.getKey();
List<String> 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<String, List<String>> 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<String> 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<String> shortcutFieldOrder() {
return Collections.singletonList("keyValues");
}
@Override
public KeyValueConfig newConfig() {
return new KeyValueConfig();
}
@Override
public Class<KeyValueConfig> 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();
}
}
}

View File

@@ -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

View File

@@ -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<String, KeyValue> {
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);
}
}
}

View File

@@ -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",

View File

@@ -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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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();
}
}
}

View File

@@ -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