Merge branch '2.0.x'

This commit is contained in:
Spencer Gibb
2018-12-10 15:46:43 -05:00
8 changed files with 256 additions and 44 deletions

View File

@@ -547,6 +547,8 @@ The `KeyResolver` interface allows pluggable strategies to derive the key for li
The default implementation of `KeyResolver` is the `PrincipalNameKeyResolver` which retrieves the `Principal` from the `ServerWebExchange` and calls `Principal.getName()`.
By default, if the `KeyResolver` does not find a key, requests will be denied. This behavior can be adjust with the `spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key` (true or false) and `spring.cloud.gateway.filter.request-rate-limiter.empty-key-status-code` properties.
NOTE: The RequestRateLimiter is not configurable via the "shortcut" notation. The example below is __invalid__
.application.properties

View File

@@ -557,13 +557,14 @@ public class GatewayAutoConfiguration {
@Bean(name = PrincipalNameKeyResolver.BEAN_NAME)
@ConditionalOnBean(RateLimiter.class)
@ConditionalOnMissingBean(KeyResolver.class)
public PrincipalNameKeyResolver principalNameKeyResolver() {
return new PrincipalNameKeyResolver();
}
@Bean
@ConditionalOnBean({RateLimiter.class, KeyResolver.class})
public RequestRateLimiterGatewayFilterFactory requestRateLimiterGatewayFilterFactory(RateLimiter rateLimiter, PrincipalNameKeyResolver resolver) {
public RequestRateLimiterGatewayFilterFactory requestRateLimiterGatewayFilterFactory(RateLimiter rateLimiter, KeyResolver resolver) {
return new RequestRateLimiterGatewayFilterFactory(rateLimiter, resolver);
}

View File

@@ -22,12 +22,12 @@ import java.util.Arrays;
import java.util.List;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.support.HttpStatusHolder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.util.Assert;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.parse;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus;
import reactor.core.publisher.Mono;
@@ -55,14 +55,17 @@ public class RedirectToGatewayFilterFactory extends AbstractGatewayFilterFactory
}
public GatewayFilter apply(String statusString, String urlString) {
final HttpStatus httpStatus = parse(statusString);
HttpStatusHolder httpStatus = HttpStatusHolder.parse(statusString);
Assert.isTrue(httpStatus.is3xxRedirection(), "status must be a 3xx code, but was " + statusString);
final URI url = URI.create(urlString);
return apply(httpStatus, url);
}
public GatewayFilter apply(HttpStatus httpStatus, URI uri) {
return apply(new HttpStatusHolder(httpStatus, null), uri);
}
public GatewayFilter apply(HttpStatusHolder httpStatus, URI uri) {
return (exchange, chain) ->
chain.filter(exchange).then(Mono.defer(() -> {
if (!exchange.getResponse().isCommitted()) {

View File

@@ -17,25 +17,37 @@
package org.springframework.cloud.gateway.filter.factory;
import java.util.Map;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.support.HttpStatusHolder;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.HttpStatus;
import java.util.Map;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus;
/**
* User Request Rate Limiter filter. See https://stripe.com/blog/rate-limiters and
*/
@ConfigurationProperties("spring.cloud.gateway.filter.request-rate-limiter")
public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilterFactory<RequestRateLimiterGatewayFilterFactory.Config> {
public static final String KEY_RESOLVER_KEY = "keyResolver";
private static final String EMPTY_KEY = "____EMPTY_KEY__";
private final RateLimiter defaultRateLimiter;
private final KeyResolver defaultKeyResolver;
/** Switch to deny requests if the Key Resolver returns an empty key, defaults to true. */
private boolean denyEmptyKey = true;
/** HttpStatus to return when denyEmptyKey is true, defaults to FORBIDDEN. */
private String emptyKeyStatusCode = HttpStatus.FORBIDDEN.toString();
public RequestRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter,
KeyResolver defaultKeyResolver) {
super(Config.class);
@@ -51,37 +63,68 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte
return defaultRateLimiter;
}
public boolean isDenyEmptyKey() {
return denyEmptyKey;
}
public void setDenyEmptyKey(boolean denyEmptyKey) {
this.denyEmptyKey = denyEmptyKey;
}
public String getEmptyKeyStatusCode() {
return emptyKeyStatusCode;
}
public void setEmptyKeyStatusCode(String emptyKeyStatusCode) {
this.emptyKeyStatusCode = emptyKeyStatusCode;
}
@SuppressWarnings("unchecked")
@Override
public GatewayFilter apply(Config config) {
KeyResolver resolver = (config.keyResolver == null) ? defaultKeyResolver : config.keyResolver;
RateLimiter<Object> limiter = (config.rateLimiter == null) ? defaultRateLimiter : config.rateLimiter;
KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver);
RateLimiter<Object> limiter = getOrDefault(config.rateLimiter, defaultRateLimiter);
boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey);
HttpStatusHolder emptyKeyStatus = HttpStatusHolder.parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode));
return (exchange, chain) -> {
Route route = exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
return resolver.resolve(exchange).flatMap(key ->
// TODO: if key is empty?
limiter.isAllowed(route.getId(), key).flatMap(response -> {
for (Map.Entry<String, String> header : response.getHeaders().entrySet()) {
exchange.getResponse().getHeaders().add(header.getKey(), header.getValue());
}
if (response.isAllowed()) {
return chain.filter(exchange);
}
exchange.getResponse().setStatusCode(config.getStatusCode());
return resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY).flatMap(key -> {
if (EMPTY_KEY.equals(key)) {
if (denyEmpty) {
setResponseStatus(exchange, emptyKeyStatus);
return exchange.getResponse().setComplete();
}));
}
return chain.filter(exchange);
}
return limiter.isAllowed(route.getId(), key).flatMap(response -> {
for (Map.Entry<String, String> header : response.getHeaders().entrySet()) {
exchange.getResponse().getHeaders().add(header.getKey(), header.getValue());
}
if (response.isAllowed()) {
return chain.filter(exchange);
}
setResponseStatus(exchange, config.getStatusCode());
return exchange.getResponse().setComplete();
});
});
};
}
private <T> T getOrDefault(T configValue, T defaultValue) {
return (configValue != null) ? configValue : defaultValue;
}
public static class Config {
private KeyResolver keyResolver;
private RateLimiter rateLimiter;
private HttpStatus statusCode = HttpStatus.TOO_MANY_REQUESTS;
private Boolean denyEmptyKey;
private String emptyKeyStatus;
public KeyResolver getKeyResolver() {
return keyResolver;
@@ -108,6 +151,24 @@ public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilte
this.statusCode = statusCode;
return this;
}
public Boolean getDenyEmptyKey() {
return denyEmptyKey;
}
public Config setDenyEmptyKey(Boolean denyEmptyKey) {
this.denyEmptyKey = denyEmptyKey;
return this;
}
public String getEmptyKeyStatus() {
return emptyKeyStatus;
}
public Config setEmptyKeyStatus(String emptyKeyStatus) {
this.emptyKeyStatus = emptyKeyStatus;
return this;
}
}
}

View File

@@ -23,9 +23,7 @@ import java.util.List;
import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.AbstractServerHttpResponse;
import org.springframework.cloud.gateway.support.HttpStatusHolder;
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.setResponseStatus;
@@ -47,13 +45,7 @@ public class SetStatusGatewayFilterFactory extends AbstractGatewayFilterFactory<
@Override
public GatewayFilter apply(Config config) {
final HttpStatus status = ServerWebExchangeUtils.parse(config.status);
final Integer intStatus;
if (status == null) {
intStatus = Integer.parseInt(config.status);
} else {
intStatus = null;
}
HttpStatusHolder statusHolder = HttpStatusHolder.parse(config.status);
return (exchange, chain) -> {
// option 1 (runs in filter order)
@@ -67,13 +59,7 @@ public class SetStatusGatewayFilterFactory extends AbstractGatewayFilterFactory<
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
// check not really needed, since it is guarded in setStatusCode,
// but it's a good example
if (!exchange.getResponse().isCommitted()) {
if (status != null) { // standard status
setResponseStatus(exchange, status);
} else if (intStatus != null && exchange.getResponse() instanceof AbstractServerHttpResponse) { //non-standard
((AbstractServerHttpResponse)exchange.getResponse()).setStatusCodeValue(intStatus);
}
}
setResponseStatus(exchange, statusHolder);
}));
};
}

View File

@@ -0,0 +1,124 @@
/*
* Copyright 2013-2018 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
*
* http://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.core.style.ToStringCreator;
import org.springframework.http.HttpStatus;
import org.springframework.util.Assert;
public class HttpStatusHolder {
private final HttpStatus httpStatus;
private final Integer status;
public static HttpStatusHolder parse(String status) {
final HttpStatus httpStatus = ServerWebExchangeUtils.parse(status);
final Integer intStatus;
if (httpStatus == null) {
intStatus = Integer.parseInt(status);
} else {
intStatus = null;
}
return new HttpStatusHolder(httpStatus, intStatus);
}
public HttpStatusHolder(HttpStatus httpStatus, Integer status) {
Assert.isTrue(httpStatus != null || status != null,
"httpStatus and status may not both be null");
this.httpStatus = httpStatus;
this.status = status;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
public Integer getStatus() {
return status;
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#INFORMATIONAL}.
*/
public boolean is1xxInformational() {
return HttpStatus.Series.INFORMATIONAL.equals(getSeries());
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#SUCCESSFUL}.
*/
public boolean is2xxSuccessful() {
return HttpStatus.Series.SUCCESSFUL.equals(getSeries());
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#REDIRECTION}.
*/
public boolean is3xxRedirection() {
return HttpStatus.Series.REDIRECTION.equals(getSeries());
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#CLIENT_ERROR}.
*/
public boolean is4xxClientError() {
return HttpStatus.Series.CLIENT_ERROR.equals(getSeries());
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#SERVER_ERROR}.
*/
public boolean is5xxServerError() {
return HttpStatus.Series.SERVER_ERROR.equals(getSeries());
}
public HttpStatus.Series getSeries() {
if (httpStatus != null) {
return httpStatus.series();
}
if (status != null) {
return HttpStatus.Series.valueOf(status);
}
return null;
}
/**
* Whether this status code is in the HTTP series
* {@link org.springframework.http.HttpStatus.Series#CLIENT_ERROR} or
* {@link org.springframework.http.HttpStatus.Series#SERVER_ERROR}.
*/
public boolean isError() {
return is4xxClientError() || is5xxServerError();
}
@Override
public String toString() {
return new ToStringCreator(this)
.append("httpStatus", httpStatus)
.append("status", status)
.toString();
}
}

View File

@@ -28,6 +28,7 @@ import reactor.core.publisher.Mono;
import org.springframework.cloud.gateway.handler.AsyncPredicate;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.AbstractServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
/**
@@ -80,6 +81,21 @@ public class ServerWebExchangeUtils {
return response;
}
public static boolean setResponseStatus(ServerWebExchange exchange, HttpStatusHolder statusHolder) {
if (exchange.getResponse().isCommitted()) {
return false;
}
if (statusHolder.getHttpStatus() != null) {
return setResponseStatus(exchange, statusHolder.getHttpStatus());
}
if (statusHolder.getStatus() != null
&& exchange.getResponse() instanceof AbstractServerHttpResponse) { //non-standard
((AbstractServerHttpResponse)exchange.getResponse()).setStatusCodeValue(statusHolder.getStatus());
return true;
}
return false;
}
public static boolean containsEncodedParts(URI uri) {
boolean encoded = (uri.getRawQuery() != null && uri.getRawQuery().contains("%"))
|| (uri.getPath() != null && uri.getRawPath().contains("%"));

View File

@@ -19,6 +19,7 @@ import org.springframework.cloud.gateway.test.BaseWebClientTests;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.http.HttpStatus;
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
import org.springframework.mock.web.server.MockServerWebExchange;
@@ -52,17 +53,14 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
@MockBean
private GatewayFilterChain filterChain;
@Autowired
@Qualifier("resolver1")
KeyResolver resolver1;
@Autowired
@Qualifier("resolver2")
KeyResolver resolver2;
@Test
public void allowedWorks() {
assertFilterFactory(resolver1, "allowedkey", true, HttpStatus.OK);
// tests that auto wired as default works
assertFilterFactory(null, "allowedkey", true, HttpStatus.OK);
}
@Test
@@ -70,14 +68,31 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
assertFilterFactory(resolver2, "notallowedkey", false, HttpStatus.TOO_MANY_REQUESTS);
}
@Test
public void emptyKeyDenied() {
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.FORBIDDEN);
}
@Test
public void emptyKeyAllowed() {
assertFilterFactory(exchange -> Mono.empty(), null, true, HttpStatus.OK, false);
}
private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus) {
assertFilterFactory(keyResolver, key, allowed, expectedStatus, null);
}
private void assertFilterFactory(KeyResolver keyResolver, String key, boolean allowed, HttpStatus expectedStatus,
Boolean denyEmptyKey) {
String tokensRemaining = allowed ? "1" : "0";
Map<String, String> headers = Collections.singletonMap("X-Tokens-Remaining", tokensRemaining);
when(rateLimiter.isAllowed("myroute", key))
.thenReturn(Mono.just(new Response(allowed, headers)));
if (key != null) {
when(rateLimiter.isAllowed("myroute", key))
.thenReturn(Mono.just(new Response(allowed, headers)));
}
MockServerHttpRequest request = MockServerHttpRequest.get("/").build();
MockServerWebExchange exchange = MockServerWebExchange.from(request);
@@ -89,6 +104,9 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
when(this.filterChain.filter(exchange)).thenReturn(Mono.empty());
RequestRateLimiterGatewayFilterFactory factory = this.context.getBean(RequestRateLimiterGatewayFilterFactory.class);
if (denyEmptyKey != null) {
factory.setDenyEmptyKey(denyEmptyKey);
}
GatewayFilter filter = factory.apply(config -> config.setKeyResolver(keyResolver));
Mono<Void> response = filter.filter(exchange, this.filterChain);
@@ -105,6 +123,7 @@ public class RequestRateLimiterGatewayFilterFactoryTests extends BaseWebClientTe
@Import(BaseWebClientTests.DefaultTestConfig.class)
public static class TestConfig {
@Bean
@Primary
KeyResolver resolver1() {
return exchange -> Mono.just("allowedkey");
}