Merge branch '2.2.x'
This commit is contained in:
@@ -888,18 +888,23 @@ It requires the use of the `spring-boot-starter-data-redis-reactive` Spring Boot
|
||||
|
||||
The algorithm used is the https://en.wikipedia.org/wiki/Token_bucket[Token Bucket Algorithm].
|
||||
|
||||
The `redis-rate-limiter.replenishRate` is how many requests per second you want a user to be allowed to do, without any dropped requests.
|
||||
The `redis-rate-limiter.replenishRate` property is how many requests per second you want a user to be allowed to do, without any dropped requests.
|
||||
This is the rate at which the token bucket is filled.
|
||||
|
||||
The `redis-rate-limiter.burstCapacity` is the maximum number of requests a user is allowed to do in a single second.
|
||||
The `redis-rate-limiter.burstCapacity` property is the maximum number of requests a user is allowed to do in a single second.
|
||||
This is the number of tokens the token bucket can hold.
|
||||
Setting this value to zero blocks all requests.
|
||||
|
||||
The `redis-rate-limiter.requestedTokens` property is how many tokens a request costs.
|
||||
This is the number of tokens taken from the bucket for each request and defaults to `1`.
|
||||
|
||||
A steady rate is accomplished by setting the same value in `replenishRate` and `burstCapacity`.
|
||||
Temporary bursts can be allowed by setting `burstCapacity` higher than `replenishRate`.
|
||||
In this case, the rate limiter needs to be allowed some time between bursts (according to `replenishRate`), as two consecutive bursts will result in dropped requests (`HTTP 429 - Too Many Requests`).
|
||||
The following listing configures a `redis-rate-limiter`:
|
||||
|
||||
Rate limits bellow `1 request/s` are accomplished by setting `replenishRate` to the wanted number of requests, `requestedTokens` to the timespan in seconds and `burstCapacity` to the product of `replenishRate` and `requestedTokens`, e.g. setting `replenishRate=1`, `requestedTokens=60` and `burstCapacity=60` will result in a limit of `1 request/min`.
|
||||
|
||||
.application.yml
|
||||
====
|
||||
[source,yaml]
|
||||
@@ -915,6 +920,7 @@ spring:
|
||||
args:
|
||||
redis-rate-limiter.replenishRate: 10
|
||||
redis-rate-limiter.burstCapacity: 20
|
||||
redis-rate-limiter.requestedTokens: 1
|
||||
|
||||
----
|
||||
====
|
||||
|
||||
@@ -605,6 +605,11 @@ public class GatewayAutoConfiguration {
|
||||
spec.maxHeaderSize(
|
||||
(int) properties.getMaxHeaderSize().toBytes());
|
||||
}
|
||||
if (properties.getMaxInitialLineLength() != null) {
|
||||
// cast to int is ok, since @Max is Integer.MAX_VALUE
|
||||
spec.maxInitialLineLength(
|
||||
(int) properties.getMaxInitialLineLength().toBytes());
|
||||
}
|
||||
return spec;
|
||||
}).tcpConfiguration(tcpClient -> {
|
||||
|
||||
|
||||
@@ -42,11 +42,13 @@ import org.springframework.boot.web.server.WebServerException;
|
||||
import org.springframework.core.style.ToStringCreator;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import org.springframework.util.unit.DataSize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
/**
|
||||
* Configuration properties for the Netty {@link reactor.netty.http.client.HttpClient}.
|
||||
*/
|
||||
@ConfigurationProperties("spring.cloud.gateway.httpclient")
|
||||
@Validated
|
||||
public class HttpClientProperties {
|
||||
|
||||
/** The connect timeout in millis, the default is 45s. */
|
||||
@@ -58,6 +60,9 @@ public class HttpClientProperties {
|
||||
/** The max response header size. */
|
||||
private DataSize maxHeaderSize;
|
||||
|
||||
/** The max initial line length. */
|
||||
private DataSize maxInitialLineLength;
|
||||
|
||||
/** Pool configuration for Netty HttpClient. */
|
||||
private Pool pool = new Pool();
|
||||
|
||||
@@ -98,6 +103,15 @@ public class HttpClientProperties {
|
||||
this.maxHeaderSize = maxHeaderSize;
|
||||
}
|
||||
|
||||
@Max(Integer.MAX_VALUE)
|
||||
public DataSize getMaxInitialLineLength() {
|
||||
return maxInitialLineLength;
|
||||
}
|
||||
|
||||
public void setMaxInitialLineLength(DataSize maxInitialLineLength) {
|
||||
this.maxInitialLineLength = maxInitialLineLength;
|
||||
}
|
||||
|
||||
public Pool getPool() {
|
||||
return pool;
|
||||
}
|
||||
@@ -145,6 +159,7 @@ public class HttpClientProperties {
|
||||
.append("connectTimeout", connectTimeout)
|
||||
.append("responseTimeout", responseTimeout)
|
||||
.append("maxHeaderSize", maxHeaderSize)
|
||||
.append("maxInitialLineLength", maxInitialLineLength)
|
||||
.append("pool", pool)
|
||||
.append("proxy", proxy)
|
||||
.append("ssl", ssl)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* 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.config;
|
||||
|
||||
import javax.validation.ConstraintValidator;
|
||||
import javax.validation.ConstraintValidatorContext;
|
||||
import javax.validation.constraints.Max;
|
||||
|
||||
import org.springframework.util.unit.DataSize;
|
||||
|
||||
// https://in.relation.to/2017/03/02/adding-custom-constraint-definitions-via-the-java-service-loader/
|
||||
public class MaxDataSizeValidator implements ConstraintValidator<Max, DataSize> {
|
||||
|
||||
private long maxValue;
|
||||
|
||||
@Override
|
||||
public boolean isValid(DataSize value, ConstraintValidatorContext context) {
|
||||
// null values are valid
|
||||
if (value == null) {
|
||||
return true;
|
||||
}
|
||||
return value.toBytes() <= maxValue;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void initialize(Max maxValue) {
|
||||
this.maxValue = maxValue.value();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
package org.springframework.cloud.gateway.discovery;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Predicate;
|
||||
@@ -121,12 +122,8 @@ public class DiscoveryClientRouteDefinitionLocator implements RouteDefinitionLoc
|
||||
return serviceInstances.filter(instances -> !instances.isEmpty())
|
||||
.map(instances -> instances.get(0)).filter(includePredicate)
|
||||
.map(instance -> {
|
||||
String serviceId = instance.getServiceId();
|
||||
|
||||
RouteDefinition routeDefinition = new RouteDefinition();
|
||||
routeDefinition.setId(this.routeIdPrefix + serviceId);
|
||||
String uri = urlExpr.getValue(evalCtxt, instance, String.class);
|
||||
routeDefinition.setUri(URI.create(uri));
|
||||
RouteDefinition routeDefinition = buildRouteDefinition(urlExpr,
|
||||
instance);
|
||||
|
||||
final ServiceInstance instanceForEval = new DelegatingServiceInstance(
|
||||
instance, properties);
|
||||
@@ -159,6 +156,18 @@ public class DiscoveryClientRouteDefinitionLocator implements RouteDefinitionLoc
|
||||
});
|
||||
}
|
||||
|
||||
protected RouteDefinition buildRouteDefinition(Expression urlExpr,
|
||||
ServiceInstance serviceInstance) {
|
||||
String serviceId = serviceInstance.getServiceId();
|
||||
RouteDefinition routeDefinition = new RouteDefinition();
|
||||
routeDefinition.setId(this.routeIdPrefix + serviceId);
|
||||
String uri = urlExpr.getValue(this.evalCtxt, serviceInstance, String.class);
|
||||
routeDefinition.setUri(URI.create(uri));
|
||||
// add instance metadata
|
||||
routeDefinition.setMetadata(new LinkedHashMap<>(serviceInstance.getMetadata()));
|
||||
return routeDefinition;
|
||||
}
|
||||
|
||||
String getValueFromExpr(SimpleEvaluationContext evalCtxt, SpelExpressionParser parser,
|
||||
ServiceInstance instance, Map.Entry<String, String> entry) {
|
||||
try {
|
||||
|
||||
@@ -121,7 +121,10 @@ public class RetryGatewayFilterFactory
|
||||
Retry<ServerWebExchange> exceptionRetry = null;
|
||||
if (!retryConfig.getExceptions().isEmpty()) {
|
||||
Predicate<RetryContext<ServerWebExchange>> retryContextPredicate = context -> {
|
||||
if (exceedsMaxIterations(context.applicationContext(), retryConfig)) {
|
||||
|
||||
ServerWebExchange exchange = context.applicationContext();
|
||||
|
||||
if (exceedsMaxIterations(exchange, retryConfig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -133,7 +136,14 @@ public class RetryGatewayFilterFactory
|
||||
trace("exception or its cause is retryable %s, configured exceptions %s",
|
||||
() -> getExceptionNameWithCause(exception),
|
||||
retryConfig::getExceptions);
|
||||
return true;
|
||||
|
||||
HttpMethod httpMethod = exchange.getRequest().getMethod();
|
||||
boolean retryableMethod = retryConfig.getMethods()
|
||||
.contains(httpMethod);
|
||||
trace("retryableMethod: %b, httpMethod %s, configured methods %s",
|
||||
() -> retryableMethod, () -> httpMethod,
|
||||
retryConfig::getMethods);
|
||||
return retryableMethod;
|
||||
}
|
||||
}
|
||||
trace("exception or its cause is not retryable %s, configured exceptions %s",
|
||||
|
||||
@@ -38,6 +38,7 @@ import org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator;
|
||||
import org.springframework.cloud.gateway.support.ConfigurationService;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.core.style.ToStringCreator;
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
import org.springframework.data.redis.core.script.RedisScript;
|
||||
import org.springframework.validation.Validator;
|
||||
@@ -49,6 +50,7 @@ import org.springframework.validation.annotation.Validated;
|
||||
*
|
||||
* @author Spencer Gibb
|
||||
* @author Ronny Bräunlich
|
||||
* @author Denis Cutic
|
||||
*/
|
||||
@ConfigurationProperties("spring.cloud.gateway.redis-rate-limiter")
|
||||
public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Config>
|
||||
@@ -87,10 +89,15 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
public static final String REPLENISH_RATE_HEADER = "X-RateLimit-Replenish-Rate";
|
||||
|
||||
/**
|
||||
* Burst Capacity Header name.
|
||||
* Burst Capacity header name.
|
||||
*/
|
||||
public static final String BURST_CAPACITY_HEADER = "X-RateLimit-Burst-Capacity";
|
||||
|
||||
/**
|
||||
* Requested Tokens header name.
|
||||
*/
|
||||
public static final String REQUESTED_TOKENS_HEADER = "X-RateLimit-Requested-Tokens";
|
||||
|
||||
private Log log = LogFactory.getLog(getClass());
|
||||
|
||||
private ReactiveStringRedisTemplate redisTemplate;
|
||||
@@ -120,6 +127,9 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
/** The name of the header that returns the burst capacity configuration. */
|
||||
private String burstCapacityHeader = BURST_CAPACITY_HEADER;
|
||||
|
||||
/** The name of the header that returns the requested tokens configuration. */
|
||||
private String requestedTokensHeader = REQUESTED_TOKENS_HEADER;
|
||||
|
||||
public RedisRateLimiter(ReactiveStringRedisTemplate redisTemplate,
|
||||
RedisScript<List<Long>> script, ConfigurationService configurationService) {
|
||||
super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService);
|
||||
@@ -141,7 +151,7 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
* This creates an instance with default static configuration, useful in Java DSL.
|
||||
* @param defaultReplenishRate how many tokens per second in token-bucket algorithm.
|
||||
* @param defaultBurstCapacity how many tokens the bucket can hold in token-bucket
|
||||
* alogritm.
|
||||
* algorithm.
|
||||
*/
|
||||
public RedisRateLimiter(int defaultReplenishRate, int defaultBurstCapacity) {
|
||||
super(Config.class, CONFIGURATION_PROPERTY_NAME, (ConfigurationService) null);
|
||||
@@ -149,6 +159,19 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
.setBurstCapacity(defaultBurstCapacity);
|
||||
}
|
||||
|
||||
/**
|
||||
* This creates an instance with default static configuration, useful in Java DSL.
|
||||
* @param defaultReplenishRate how many tokens per second in token-bucket algorithm.
|
||||
* @param defaultBurstCapacity how many tokens the bucket can hold in token-bucket
|
||||
* algorithm.
|
||||
* @param defaultRequestedTokens how many tokens are requested per request.
|
||||
*/
|
||||
public RedisRateLimiter(int defaultReplenishRate, int defaultBurstCapacity,
|
||||
int defaultRequestedTokens) {
|
||||
this(defaultReplenishRate, defaultBurstCapacity);
|
||||
this.defaultConfig.setRequestedTokens(defaultRequestedTokens);
|
||||
}
|
||||
|
||||
static List<String> getKeys(String id) {
|
||||
// use `{}` around keys to use Redis Key hash tags
|
||||
// this allows for using redis cluster
|
||||
@@ -194,6 +217,14 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
this.burstCapacityHeader = burstCapacityHeader;
|
||||
}
|
||||
|
||||
public String getRequestedTokensHeader() {
|
||||
return requestedTokensHeader;
|
||||
}
|
||||
|
||||
public void setRequestedTokensHeader(String requestedTokensHeader) {
|
||||
this.requestedTokensHeader = requestedTokensHeader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used when setting default configuration in constructor.
|
||||
* @param context the ApplicationContext object to be used by this object
|
||||
@@ -237,12 +268,16 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
// How much bursting do you want to allow?
|
||||
int burstCapacity = routeConfig.getBurstCapacity();
|
||||
|
||||
// How many tokens are requested per request?
|
||||
int requestedTokens = routeConfig.getRequestedTokens();
|
||||
|
||||
try {
|
||||
List<String> keys = getKeys(id);
|
||||
|
||||
// The arguments to the LUA script. time() returns unixtime in seconds.
|
||||
List<String> scriptArgs = Arrays.asList(replenishRate + "",
|
||||
burstCapacity + "", Instant.now().getEpochSecond() + "", "1");
|
||||
burstCapacity + "", Instant.now().getEpochSecond() + "",
|
||||
requestedTokens + "");
|
||||
// allowed, tokens_left = redis.eval(SCRIPT, keys, args)
|
||||
Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys,
|
||||
scriptArgs);
|
||||
@@ -298,6 +333,8 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
String.valueOf(config.getReplenishRate()));
|
||||
headers.put(this.burstCapacityHeader,
|
||||
String.valueOf(config.getBurstCapacity()));
|
||||
headers.put(this.requestedTokensHeader,
|
||||
String.valueOf(config.getRequestedTokens()));
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
@@ -311,6 +348,9 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
@Min(1)
|
||||
private int burstCapacity = 1;
|
||||
|
||||
@Min(1)
|
||||
private int requestedTokens = 1;
|
||||
|
||||
public int getReplenishRate() {
|
||||
return replenishRate;
|
||||
}
|
||||
@@ -329,10 +369,21 @@ public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Confi
|
||||
return this;
|
||||
}
|
||||
|
||||
public int getRequestedTokens() {
|
||||
return requestedTokens;
|
||||
}
|
||||
|
||||
public Config setRequestedTokens(int requestedTokens) {
|
||||
this.requestedTokens = requestedTokens;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Config{" + "replenishRate=" + replenishRate + ", burstCapacity="
|
||||
+ burstCapacity + '}';
|
||||
return new ToStringCreator(this).append("replenishRate", replenishRate)
|
||||
.append("burstCapacity", burstCapacity)
|
||||
.append("requestedTokens", requestedTokens).toString();
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -91,11 +91,17 @@ public class GatewayAutoConfigurationTests {
|
||||
"spring.cloud.gateway.httpclient.connect-timeout=10",
|
||||
"spring.cloud.gateway.httpclient.response-timeout=10s",
|
||||
"spring.cloud.gateway.httpclient.pool.type=fixed",
|
||||
// greather than integer max value
|
||||
"spring.cloud.gateway.httpclient.max-initial-line-length=2147483647",
|
||||
"spring.cloud.gateway.httpclient.proxy.host=myhost",
|
||||
"spring.cloud.gateway.httpclient.websocket.max-frame-payload-length=1024")
|
||||
.run(context -> {
|
||||
assertThat(context).hasSingleBean(HttpClient.class);
|
||||
HttpClient httpClient = context.getBean(HttpClient.class);
|
||||
HttpClientProperties properties = context
|
||||
.getBean(HttpClientProperties.class);
|
||||
assertThat(properties.getMaxInitialLineLength().toBytes())
|
||||
.isLessThanOrEqualTo(Integer.MAX_VALUE);
|
||||
/*
|
||||
* FIXME: 2.1.0 HttpClientOptions options = httpClient.options();
|
||||
*
|
||||
|
||||
@@ -75,6 +75,7 @@ public class DiscoveryClientRouteDefinitionLocatorTests {
|
||||
RouteDefinition definition = definitions.get(0);
|
||||
assertThat(definition.getId()).isEqualTo("testedge_SERVICE1");
|
||||
assertThat(definition.getUri()).hasScheme("lb").hasHost("SERVICE1");
|
||||
assertThat(definition.getMetadata()).containsEntry("edge", "true");
|
||||
|
||||
assertThat(definition.getPredicates()).hasSize(1);
|
||||
PredicateDefinition predicate = definition.getPredicates().get(0);
|
||||
|
||||
@@ -147,6 +147,27 @@ public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTest
|
||||
assertThat(TestConfig.map.get("sleepyRequest")).isNotNull().hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldNotRetryWhenSleepyRequestPost() throws Exception {
|
||||
testClient.mutate().responseTimeout(Duration.ofSeconds(10)).build().post()
|
||||
.uri("/sleep?key=notRetriesSleepyRequestPost&millis=3000")
|
||||
.header(HttpHeaders.HOST, "www.retry-only-get.org").exchange()
|
||||
.expectStatus().isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
|
||||
|
||||
assertThat(TestConfig.map.get("notRetriesSleepyRequestPost")).isNotNull()
|
||||
.hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldRetryWhenSleepyRequestGet() throws Exception {
|
||||
testClient.mutate().responseTimeout(Duration.ofSeconds(10)).build().get()
|
||||
.uri("/sleep?key=sleepyRequestGet&millis=3000")
|
||||
.header(HttpHeaders.HOST, "www.retry-only-get.org").exchange()
|
||||
.expectStatus().isEqualTo(HttpStatus.GATEWAY_TIMEOUT);
|
||||
|
||||
assertThat(TestConfig.map.get("sleepyRequestGet")).isNotNull().hasValue(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
public void retryFilterLoadBalancedWithMultipleServers() {
|
||||
@@ -253,7 +274,11 @@ public class RetryGatewayFilterFactoryIntegrationTests extends BaseWebClientTest
|
||||
.retry(config -> config.setRetries(2)
|
||||
.setMethods(HttpMethod.POST, HttpMethod.GET)))
|
||||
.uri(uri))
|
||||
|
||||
.route("retry_only_get", r -> r.host("**.retry-only-get.org")
|
||||
.filters(f -> f.prefixPath("/httpbin")
|
||||
.retry(config -> config.setRetries(2)
|
||||
.setMethods(HttpMethod.GET)))
|
||||
.uri(uri))
|
||||
.route("retry_with_backoff", r -> r.host("**.retrywithbackoff.org")
|
||||
.filters(f -> f.prefixPath("/httpbin").retry(config -> {
|
||||
config.setRetries(2).setBackoff(Duration.ofMillis(100),
|
||||
|
||||
@@ -51,27 +51,32 @@ public class RedisRateLimiterConfigTests {
|
||||
|
||||
@Before
|
||||
public void init() {
|
||||
routeLocator.getRoutes().collectList().block(); // prime routes since getRoutes()
|
||||
// no longer blocks
|
||||
// prime routes since getRoutes() no longer blocks
|
||||
routeLocator.getRoutes().collectList().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateConfiguredFromEnvironment() {
|
||||
assertFilter("redis_rate_limiter_config_test", 10, 20, false);
|
||||
assertFilter("redis_rate_limiter_config_test", 10, 20, 1, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateConfiguredFromEnvironmentMinimal() {
|
||||
assertFilter("redis_rate_limiter_minimal_config_test", 2, 1, 1, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateConfiguredFromJavaAPI() {
|
||||
assertFilter("custom_redis_rate_limiter", 20, 40, false);
|
||||
assertFilter("custom_redis_rate_limiter", 20, 40, 10, false);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateConfiguredFromJavaAPIDirectBean() {
|
||||
assertFilter("alt_custom_redis_rate_limiter", 30, 60, true);
|
||||
assertFilter("alt_custom_redis_rate_limiter", 30, 60, 20, true);
|
||||
}
|
||||
|
||||
private void assertFilter(String key, int replenishRate, int burstCapacity,
|
||||
boolean useDefaultConfig) {
|
||||
int requestedTokens, boolean useDefaultConfig) {
|
||||
RedisRateLimiter.Config config;
|
||||
|
||||
if (useDefaultConfig) {
|
||||
@@ -84,6 +89,7 @@ public class RedisRateLimiterConfigTests {
|
||||
assertThat(config).isNotNull();
|
||||
assertThat(config.getReplenishRate()).isEqualTo(replenishRate);
|
||||
assertThat(config.getBurstCapacity()).isEqualTo(burstCapacity);
|
||||
assertThat(config.getRequestedTokens()).isEqualTo(requestedTokens);
|
||||
|
||||
Route route = routeLocator.getRoutes().filter(r -> r.getId().equals(key)).next()
|
||||
.block();
|
||||
@@ -100,7 +106,8 @@ public class RedisRateLimiterConfigTests {
|
||||
return builder.routes().route("custom_redis_rate_limiter",
|
||||
r -> r.path("/custom").filters(f -> f.requestRateLimiter()
|
||||
.rateLimiter(RedisRateLimiter.class,
|
||||
rl -> rl.setBurstCapacity(40).setReplenishRate(20))
|
||||
rl -> rl.setBurstCapacity(40).setReplenishRate(20)
|
||||
.setRequestedTokens(10))
|
||||
.and()).uri("http://localhost"))
|
||||
.route("alt_custom_redis_rate_limiter",
|
||||
r -> r.path("/custom")
|
||||
@@ -113,7 +120,7 @@ public class RedisRateLimiterConfigTests {
|
||||
|
||||
@Bean
|
||||
public RedisRateLimiter myRateLimiter() {
|
||||
return new RedisRateLimiter(30, 60);
|
||||
return new RedisRateLimiter(30, 60, 20);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* @author Spencer Gibb
|
||||
* @author Denis Cutic
|
||||
*/
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest
|
||||
@@ -49,22 +50,23 @@ public class RedisRateLimiterDefaultFilterConfigTests {
|
||||
|
||||
@Before
|
||||
public void init() {
|
||||
routeLocator.getRoutes().collectList().block(); // prime routes since getRoutes()
|
||||
// no longer blocks
|
||||
// prime routes since getRoutes() no longer blocks
|
||||
routeLocator.getRoutes().collectList().block();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateConfiguredFromEnvironmentDefaultFilters() {
|
||||
String routeId = "redis_rate_limiter_config_default_test";
|
||||
RedisRateLimiter.Config config = rateLimiter.loadConfiguration(routeId);
|
||||
assertConfigAndRoute(routeId, 70, 80, config);
|
||||
assertConfigAndRoute(routeId, 70, 80, 10, config);
|
||||
}
|
||||
|
||||
private void assertConfigAndRoute(String key, int replenishRate, int burstCapacity,
|
||||
RedisRateLimiter.Config config) {
|
||||
int requestedTokens, RedisRateLimiter.Config config) {
|
||||
assertThat(config).isNotNull();
|
||||
assertThat(config.getReplenishRate()).isEqualTo(replenishRate);
|
||||
assertThat(config.getBurstCapacity()).isEqualTo(burstCapacity);
|
||||
assertThat(config.getRequestedTokens()).isEqualTo(requestedTokens);
|
||||
|
||||
Route route = routeLocator.getRoutes().filter(r -> r.getId().equals(key)).next()
|
||||
.block();
|
||||
|
||||
@@ -18,6 +18,8 @@ package org.springframework.cloud.gateway.filter.ratelimit;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
@@ -45,6 +47,7 @@ import static org.springframework.boot.test.context.SpringBootTest.WebEnvironmen
|
||||
*
|
||||
* @author Spencer Gibb
|
||||
* @author Ronny Bräunlich
|
||||
* @author Denis Cutic
|
||||
*/
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = RANDOM_PORT)
|
||||
@@ -57,52 +60,48 @@ public class RedisRateLimiterTests extends BaseWebClientTests {
|
||||
@Autowired
|
||||
private RedisRateLimiter rateLimiter;
|
||||
|
||||
@Before
|
||||
public void setUp() throws Exception {
|
||||
assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue()));
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() throws Exception {
|
||||
rateLimiter.setIncludeHeaders(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redisRateLimiterWorks() throws Exception {
|
||||
assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue()));
|
||||
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
int replenishRate = 10;
|
||||
int burstCapacity = 2 * replenishRate;
|
||||
int requestedTokens = 1;
|
||||
|
||||
String routeId = "myroute";
|
||||
rateLimiter.getConfig().put(routeId, new RedisRateLimiter.Config()
|
||||
.setBurstCapacity(burstCapacity).setReplenishRate(replenishRate));
|
||||
rateLimiter.getConfig().put(routeId,
|
||||
new RedisRateLimiter.Config().setBurstCapacity(burstCapacity)
|
||||
.setReplenishRate(replenishRate)
|
||||
.setRequestedTokens(requestedTokens));
|
||||
|
||||
// Bursts work
|
||||
for (int i = 0; i < burstCapacity; i++) {
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("Burst # %s is allowed", i).isTrue();
|
||||
assertThat(response.getHeaders())
|
||||
.containsKey(RedisRateLimiter.REMAINING_HEADER);
|
||||
assertThat(response.getHeaders()).containsEntry(
|
||||
RedisRateLimiter.REPLENISH_RATE_HEADER,
|
||||
String.valueOf(replenishRate));
|
||||
assertThat(response.getHeaders()).containsEntry(
|
||||
RedisRateLimiter.BURST_CAPACITY_HEADER,
|
||||
String.valueOf(burstCapacity));
|
||||
}
|
||||
checkLimitEnforced(id, replenishRate, burstCapacity, requestedTokens, routeId);
|
||||
}
|
||||
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
if (response.isAllowed()) { // TODO: sometimes there is an off by one error
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
}
|
||||
assertThat(response.isAllowed()).as("Burst # %s is not allowed", burstCapacity)
|
||||
.isFalse();
|
||||
@Test
|
||||
public void redisRateLimiterWorksForLowRates() throws Exception {
|
||||
String id = UUID.randomUUID().toString();
|
||||
|
||||
Thread.sleep(1000);
|
||||
int replenishRate = 1;
|
||||
int burstCapacity = 3;
|
||||
int requestedTokens = 3;
|
||||
|
||||
// # After the burst is done, check the steady state
|
||||
for (int i = 0; i < replenishRate; i++) {
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("steady state # %s is allowed", i)
|
||||
.isTrue();
|
||||
}
|
||||
String routeId = "low_rate_route";
|
||||
rateLimiter.getConfig().put(routeId,
|
||||
new RedisRateLimiter.Config().setBurstCapacity(burstCapacity)
|
||||
.setReplenishRate(replenishRate)
|
||||
.setRequestedTokens(requestedTokens));
|
||||
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("steady state # %s is allowed", replenishRate)
|
||||
.isFalse();
|
||||
checkLimitEnforced(id, replenishRate, burstCapacity, requestedTokens, routeId);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -113,8 +112,6 @@ public class RedisRateLimiterTests extends BaseWebClientTests {
|
||||
|
||||
@Test
|
||||
public void redisRateLimiterDoesNotSendHeadersIfDeactivated() throws Exception {
|
||||
assumeThat("Ignore on Circle", System.getenv("CIRCLECI"), is(nullValue()));
|
||||
|
||||
String id = UUID.randomUUID().toString();
|
||||
String routeId = "myroute";
|
||||
|
||||
@@ -128,6 +125,62 @@ public class RedisRateLimiterTests extends BaseWebClientTests {
|
||||
.doesNotContainKey(RedisRateLimiter.REPLENISH_RATE_HEADER);
|
||||
assertThat(response.getHeaders())
|
||||
.doesNotContainKey(RedisRateLimiter.BURST_CAPACITY_HEADER);
|
||||
assertThat(response.getHeaders())
|
||||
.doesNotContainKey(RedisRateLimiter.REQUESTED_TOKENS_HEADER);
|
||||
}
|
||||
|
||||
private void checkLimitEnforced(String id, int replenishRate, int burstCapacity,
|
||||
int requestedTokens, String routeId) throws InterruptedException {
|
||||
// Bursts work
|
||||
simulateBurst(id, replenishRate, burstCapacity, requestedTokens, routeId);
|
||||
|
||||
checkLimitReached(id, burstCapacity, routeId);
|
||||
|
||||
Thread.sleep(Math.max(1, requestedTokens / replenishRate) * 1000);
|
||||
|
||||
// # After the burst is done, check the steady state
|
||||
checkSteadyState(id, replenishRate, routeId);
|
||||
}
|
||||
|
||||
private void simulateBurst(String id, int replenishRate, int burstCapacity,
|
||||
int requestedTokens, String routeId) {
|
||||
for (int i = 0; i < burstCapacity / requestedTokens; i++) {
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("Burst # %s is allowed", i).isTrue();
|
||||
assertThat(response.getHeaders())
|
||||
.containsKey(RedisRateLimiter.REMAINING_HEADER);
|
||||
assertThat(response.getHeaders()).containsEntry(
|
||||
RedisRateLimiter.REPLENISH_RATE_HEADER,
|
||||
String.valueOf(replenishRate));
|
||||
assertThat(response.getHeaders()).containsEntry(
|
||||
RedisRateLimiter.BURST_CAPACITY_HEADER,
|
||||
String.valueOf(burstCapacity));
|
||||
assertThat(response.getHeaders()).containsEntry(
|
||||
RedisRateLimiter.REQUESTED_TOKENS_HEADER,
|
||||
String.valueOf(requestedTokens));
|
||||
}
|
||||
}
|
||||
|
||||
private void checkLimitReached(String id, int burstCapacity, String routeId) {
|
||||
Response response = rateLimiter.isAllowed(routeId, id).block();
|
||||
if (response.isAllowed()) { // TODO: sometimes there is an off by one error
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
}
|
||||
assertThat(response.isAllowed()).as("Burst # %s is not allowed", burstCapacity)
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
private void checkSteadyState(String id, int replenishRate, String routeId) {
|
||||
Response response;
|
||||
for (int i = 0; i < replenishRate; i++) {
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("steady state # %s is allowed", i)
|
||||
.isTrue();
|
||||
}
|
||||
|
||||
response = rateLimiter.isAllowed(routeId, id).block();
|
||||
assertThat(response.isAllowed()).as("steady state # %s is allowed", replenishRate)
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@EnableAutoConfiguration
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2013-2019 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.ratelimit;
|
||||
|
||||
import io.lettuce.core.RedisException;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.Mockito;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.cloud.gateway.support.ConfigurationService;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.data.redis.core.ReactiveStringRedisTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.data.MapEntry.entry;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* @author Denis Cutic
|
||||
*/
|
||||
@RunWith(MockitoJUnitRunner.class)
|
||||
public class RedisRateLimiterUnitTests {
|
||||
|
||||
private static final int DEFAULT_REPLENISH_RATE = 1;
|
||||
|
||||
private static final int DEFAULT_BURST_CAPACITY = 1;
|
||||
|
||||
public static final String ROUTE_ID = "routeId";
|
||||
|
||||
public static final String REQUEST_ID = "id";
|
||||
|
||||
public static final String[] CONFIGURATION_SERVICE_BEANS = new String[0];
|
||||
|
||||
public static final RedisException REDIS_EXCEPTION = new RedisException(
|
||||
"Mocked problem");
|
||||
|
||||
@Mock
|
||||
private ApplicationContext applicationContext;
|
||||
|
||||
@Mock
|
||||
private ReactiveStringRedisTemplate redisTemplate;
|
||||
|
||||
private RedisRateLimiter redisRateLimiter;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
when(applicationContext.getBean(ReactiveStringRedisTemplate.class))
|
||||
.thenReturn(redisTemplate);
|
||||
when(applicationContext.getBeanNamesForType(ConfigurationService.class))
|
||||
.thenReturn(CONFIGURATION_SERVICE_BEANS);
|
||||
redisRateLimiter = new RedisRateLimiter(DEFAULT_REPLENISH_RATE,
|
||||
DEFAULT_BURST_CAPACITY);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
Mockito.reset(applicationContext);
|
||||
}
|
||||
|
||||
@Test(expected = IllegalStateException.class)
|
||||
public void shouldThrowWhenNotInitialized() {
|
||||
redisRateLimiter.isAllowed(ROUTE_ID, REQUEST_ID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldAllowRequestWhenRedisIssueOccurs() {
|
||||
when(redisTemplate.execute(any(), anyList(), anyList()))
|
||||
.thenThrow(REDIS_EXCEPTION);
|
||||
redisRateLimiter.setApplicationContext(applicationContext);
|
||||
Mono<RateLimiter.Response> response = redisRateLimiter.isAllowed(ROUTE_ID,
|
||||
REQUEST_ID);
|
||||
assertThat(response.block()).extracting(RateLimiter.Response::isAllowed)
|
||||
.isEqualTo(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void shouldReturnHeadersWhenRedisIssueOccurs() {
|
||||
when(redisTemplate.execute(any(), anyList(), anyList()))
|
||||
.thenThrow(REDIS_EXCEPTION);
|
||||
redisRateLimiter.setApplicationContext(applicationContext);
|
||||
Mono<RateLimiter.Response> response = redisRateLimiter.isAllowed(ROUTE_ID,
|
||||
REQUEST_ID);
|
||||
assertThat(response.block().getHeaders()).containsOnly(
|
||||
entry(redisRateLimiter.getRemainingHeader(), "-1"),
|
||||
entry(redisRateLimiter.getBurstCapacityHeader(),
|
||||
DEFAULT_BURST_CAPACITY + ""),
|
||||
entry(redisRateLimiter.getReplenishRateHeader(),
|
||||
DEFAULT_REPLENISH_RATE + ""),
|
||||
entry(redisRateLimiter.getRequestedTokensHeader(), "1"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
org.springframework.cloud.gateway.config.MaxDataSizeValidator
|
||||
@@ -14,4 +14,13 @@ spring:
|
||||
redis-rate-limiter:
|
||||
replenish-rate: 10
|
||||
burst-capacity: 20
|
||||
- id: redis_rate_limiter_minimal_config_test
|
||||
uri: ${test.uri}
|
||||
predicates:
|
||||
- Path=/
|
||||
filters:
|
||||
- name: RequestRateLimiter
|
||||
args:
|
||||
redis-rate-limiter:
|
||||
replenish-rate: 2
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ spring:
|
||||
redis-rate-limiter:
|
||||
replenish-rate: 70
|
||||
burst-capacity: 80
|
||||
requested-tokens: 10
|
||||
routes:
|
||||
# =====================================
|
||||
- id: redis_rate_limiter_config_default_test
|
||||
|
||||
@@ -18,8 +18,12 @@ package org.springframework.cloud.gateway.mvc.config;
|
||||
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Set;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.springframework.cloud.gateway.mvc.ProxyExchange;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
@@ -31,7 +35,7 @@ import org.springframework.web.method.support.ModelAndViewContainer;
|
||||
|
||||
/**
|
||||
* @author Dave Syer
|
||||
*
|
||||
* @author Tim Ysewyn
|
||||
*/
|
||||
public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
@@ -39,6 +43,8 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
|
||||
private HttpHeaders headers;
|
||||
|
||||
private Set<String> autoForwardedHeaders;
|
||||
|
||||
private Set<String> sensitive;
|
||||
|
||||
public ProxyExchangeArgumentResolver(RestTemplate builder) {
|
||||
@@ -49,6 +55,10 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public void setAutoForwardedHeaders(Set<String> autoForwardedHeaders) {
|
||||
this.autoForwardedHeaders = autoForwardedHeaders;
|
||||
}
|
||||
|
||||
public void setSensitive(Set<String> sensitive) {
|
||||
this.sensitive = sensitive;
|
||||
}
|
||||
@@ -65,6 +75,9 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
ProxyExchange<?> proxy = new ProxyExchange<>(rest, webRequest, mavContainer,
|
||||
binderFactory, type(parameter));
|
||||
proxy.headers(headers);
|
||||
if (this.autoForwardedHeaders.size() > 0) {
|
||||
proxy.headers(extractAutoForwardedHeaders(webRequest));
|
||||
}
|
||||
if (sensitive != null) {
|
||||
proxy.sensitive(sensitive.toArray(new String[0]));
|
||||
}
|
||||
@@ -80,4 +93,19 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
return type;
|
||||
}
|
||||
|
||||
private HttpHeaders extractAutoForwardedHeaders(NativeWebRequest webRequest) {
|
||||
HttpServletRequest nativeRequest = webRequest
|
||||
.getNativeRequest(HttpServletRequest.class);
|
||||
Enumeration<String> headerNames = nativeRequest.getHeaderNames();
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
while (headerNames.hasMoreElements()) {
|
||||
String header = headerNames.nextElement();
|
||||
if (this.autoForwardedHeaders.contains(header)) {
|
||||
headers.addAll(header,
|
||||
Collections.list(nativeRequest.getHeaders(header)));
|
||||
}
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.cloud.gateway.mvc.config;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -29,6 +30,7 @@ import org.springframework.http.HttpHeaders;
|
||||
* <code>@RequestMapping</code> methods.
|
||||
*
|
||||
* @author Dave Syer
|
||||
* @author Tim Ysewyn
|
||||
*
|
||||
*/
|
||||
@ConfigurationProperties("spring.cloud.gateway.proxy")
|
||||
@@ -39,6 +41,11 @@ public class ProxyProperties {
|
||||
*/
|
||||
private Map<String, String> headers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* A set of header names that should be send downstream by default.
|
||||
*/
|
||||
private Set<String> autoForward = new HashSet<>();
|
||||
|
||||
/**
|
||||
* A set of sensitive header names that will not be sent downstream by default.
|
||||
*/
|
||||
@@ -52,6 +59,14 @@ public class ProxyProperties {
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public Set<String> getAutoForward() {
|
||||
return autoForward;
|
||||
}
|
||||
|
||||
public void setAutoForward(Set<String> autoForward) {
|
||||
this.autoForward = autoForward;
|
||||
}
|
||||
|
||||
public Set<String> getSensitive() {
|
||||
return sensitive;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
* <code>@RequestMapping</code> methods.
|
||||
*
|
||||
* @author Dave Syer
|
||||
* @author Tim Ysewyn
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication
|
||||
@@ -69,6 +70,7 @@ public class ProxyResponseAutoConfiguration implements WebMvcConfigurer {
|
||||
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(
|
||||
template);
|
||||
resolver.setHeaders(proxy.convertHeaders());
|
||||
resolver.setAutoForwardedHeaders(proxy.getAutoForward());
|
||||
resolver.setSensitive(proxy.getSensitive()); // can be null
|
||||
return resolver;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,8 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
@SpringBootTest(properties = {
|
||||
"spring.cloud.gateway.proxy.auto-forward=baz" }, webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
@ContextConfiguration(classes = TestApplication.class)
|
||||
public class ProductionConfigurationTests {
|
||||
|
||||
@@ -247,15 +248,21 @@ public class ProductionConfigurationTests {
|
||||
@Test
|
||||
@SuppressWarnings({ "Duplicates", "unchecked" })
|
||||
public void headers() throws Exception {
|
||||
Map<String, List<String>> headers = rest.exchange(RequestEntity
|
||||
.get(rest.getRestTemplate().getUriTemplateHandler()
|
||||
.expand("/proxy/headers"))
|
||||
.header("foo", "bar").header("abc", "xyz").build(), Map.class).getBody();
|
||||
Map<String, List<String>> headers = rest
|
||||
.exchange(
|
||||
RequestEntity
|
||||
.get(rest.getRestTemplate().getUriTemplateHandler()
|
||||
.expand("/proxy/headers"))
|
||||
.header("foo", "bar").header("abc", "xyz")
|
||||
.header("baz", "fob").build(),
|
||||
Map.class)
|
||||
.getBody();
|
||||
assertThat(headers).doesNotContainKey("foo").doesNotContainKey("hello")
|
||||
.containsKeys("bar", "abc");
|
||||
|
||||
assertThat(headers.get("bar")).containsOnly("hello");
|
||||
assertThat(headers.get("abc")).containsOnly("123");
|
||||
assertThat(headers.get("baz")).containsOnly("fob");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -34,7 +34,7 @@ import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* @author Dave Syer
|
||||
*
|
||||
* @author Tim Ysewyn
|
||||
*/
|
||||
public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResolver {
|
||||
|
||||
@@ -42,6 +42,8 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
|
||||
private HttpHeaders headers;
|
||||
|
||||
private Set<String> autoForwardedHeaders;
|
||||
|
||||
private Set<String> sensitive;
|
||||
|
||||
public ProxyExchangeArgumentResolver(WebClient builder) {
|
||||
@@ -52,6 +54,10 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public void setAutoForwardedHeaders(Set<String> autoForwardedHeaders) {
|
||||
this.autoForwardedHeaders = autoForwardedHeaders;
|
||||
}
|
||||
|
||||
public void setSensitive(Set<String> sensitive) {
|
||||
this.sensitive = sensitive;
|
||||
}
|
||||
@@ -79,10 +85,23 @@ public class ProxyExchangeArgumentResolver implements HandlerMethodArgumentResol
|
||||
ProxyExchange<?> proxy = new ProxyExchange<>(rest, exchange, bindingContext,
|
||||
type(parameter));
|
||||
proxy.headers(headers);
|
||||
if (this.autoForwardedHeaders.size() > 0) {
|
||||
proxy.headers(extractAutoForwardedHeaders(exchange));
|
||||
}
|
||||
if (sensitive != null) {
|
||||
proxy.sensitive(sensitive.toArray(new String[0]));
|
||||
}
|
||||
return Mono.just(proxy);
|
||||
}
|
||||
|
||||
private HttpHeaders extractAutoForwardedHeaders(ServerWebExchange exchange) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
exchange.getRequest().getHeaders().forEach((header, values) -> {
|
||||
if (this.autoForwardedHeaders.contains(header)) {
|
||||
headers.addAll(header, values);
|
||||
}
|
||||
});
|
||||
return headers;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package org.springframework.cloud.gateway.webflux.config;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
@@ -29,6 +30,7 @@ import org.springframework.http.HttpHeaders;
|
||||
* <code>@RequestMapping</code> methods.
|
||||
*
|
||||
* @author Dave Syer
|
||||
* @author Tim Ysewyn
|
||||
*
|
||||
*/
|
||||
@ConfigurationProperties("spring.cloud.gateway.proxy")
|
||||
@@ -39,6 +41,11 @@ public class ProxyProperties {
|
||||
*/
|
||||
private Map<String, String> headers = new LinkedHashMap<>();
|
||||
|
||||
/**
|
||||
* A set of header names that should be send downstream by default.
|
||||
*/
|
||||
private Set<String> autoForward = new HashSet<>();
|
||||
|
||||
/**
|
||||
* A set of sensitive header names that will not be sent downstream by default.
|
||||
*/
|
||||
@@ -52,6 +59,14 @@ public class ProxyProperties {
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public Set<String> getAutoForward() {
|
||||
return autoForward;
|
||||
}
|
||||
|
||||
public void setAutoForward(Set<String> autoForward) {
|
||||
this.autoForward = autoForward;
|
||||
}
|
||||
|
||||
public Set<String> getSensitive() {
|
||||
return sensitive;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import org.springframework.web.reactive.result.method.annotation.ArgumentResolve
|
||||
* <code>@RequestMapping</code> methods.
|
||||
*
|
||||
* @author Dave Syer
|
||||
* @author Tim Ysewyn
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnWebApplication
|
||||
@@ -56,6 +57,7 @@ public class ProxyResponseAutoConfiguration implements WebFluxConfigurer {
|
||||
ProxyExchangeArgumentResolver resolver = new ProxyExchangeArgumentResolver(
|
||||
template);
|
||||
resolver.setHeaders(proxy.convertHeaders());
|
||||
resolver.setAutoForwardedHeaders(proxy.getAutoForward());
|
||||
resolver.setSensitive(proxy.getSensitive()); // can be null
|
||||
return resolver;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,8 @@ import org.springframework.web.util.UriComponentsBuilder;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
@RunWith(SpringRunner.class)
|
||||
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
@SpringBootTest(properties = {
|
||||
"spring.cloud.gateway.proxy.auto-forward=baz" }, webEnvironment = WebEnvironment.RANDOM_PORT)
|
||||
@ContextConfiguration(classes = TestApplication.class)
|
||||
@DirtiesContext
|
||||
public class ProductionConfigurationTests {
|
||||
@@ -193,15 +194,21 @@ public class ProductionConfigurationTests {
|
||||
@Test
|
||||
@SuppressWarnings({ "Duplicates", "unchecked" })
|
||||
public void headers() throws Exception {
|
||||
Map<String, List<String>> headers = rest.exchange(RequestEntity
|
||||
.get(rest.getRestTemplate().getUriTemplateHandler()
|
||||
.expand("/proxy/headers"))
|
||||
.header("foo", "bar").header("abc", "xyz").build(), Map.class).getBody();
|
||||
Map<String, List<String>> headers = rest
|
||||
.exchange(
|
||||
RequestEntity
|
||||
.get(rest.getRestTemplate().getUriTemplateHandler()
|
||||
.expand("/proxy/headers"))
|
||||
.header("foo", "bar").header("abc", "xyz")
|
||||
.header("baz", "fob").build(),
|
||||
Map.class)
|
||||
.getBody();
|
||||
assertThat(headers).doesNotContainKey("foo").doesNotContainKey("hello")
|
||||
.containsKeys("bar", "abc");
|
||||
|
||||
assertThat(headers.get("bar")).containsOnly("hello");
|
||||
assertThat(headers.get("abc")).containsOnly("123");
|
||||
assertThat(headers.get("baz")).containsOnly("fob");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user