diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java index d50ba9c3d..cd1a683fc 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayAutoConfiguration.java @@ -105,6 +105,7 @@ import org.springframework.cloud.gateway.filter.factory.RequestHeaderSizeGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RequestHeaderToRequestUriGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterProperties; import org.springframework.cloud.gateway.filter.factory.RequestSizeGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RetryGatewayFilterFactory; import org.springframework.cloud.gateway.filter.factory.RewriteLocationResponseHeaderGatewayFilterFactory; @@ -213,7 +214,7 @@ */ @Configuration(proxyBeanMethods = false) @ConditionalOnProperty(name = "spring.cloud.gateway.server.webflux.enabled", matchIfMissing = true) -@EnableConfigurationProperties +@EnableConfigurationProperties(RequestRateLimiterProperties.class) @AutoConfigureBefore({ HttpHandlerAutoConfiguration.class, WebFluxAutoConfiguration.class }) @AutoConfigureAfter({ GatewayReactiveLoadBalancerClientAutoConfiguration.class, GatewayClassPathWarningAutoConfiguration.class }) @@ -677,8 +678,8 @@ public PrincipalNameKeyResolver principalNameKeyResolver() { @ConditionalOnBean({ RateLimiter.class, KeyResolver.class }) @ConditionalOnEnabledFilter public RequestRateLimiterGatewayFilterFactory requestRateLimiterGatewayFilterFactory(RateLimiter rateLimiter, - KeyResolver resolver) { - return new RequestRateLimiterGatewayFilterFactory(rateLimiter, resolver); + KeyResolver resolver, RequestRateLimiterProperties properties) { + return new RequestRateLimiterGatewayFilterFactory(rateLimiter, resolver, properties); } @Bean diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayRedisAutoConfiguration.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayRedisAutoConfiguration.java index 599632319..12359f300 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayRedisAutoConfiguration.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/config/GatewayRedisAutoConfiguration.java @@ -25,8 +25,10 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.data.redis.autoconfigure.DataRedisReactiveAutoConfiguration; import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter; +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiterProperties; import org.springframework.cloud.gateway.route.RedisRouteDefinitionRepository; import org.springframework.cloud.gateway.route.RouteDefinition; import org.springframework.cloud.gateway.support.ConfigurationService; @@ -51,6 +53,7 @@ @ConditionalOnBean(ReactiveRedisTemplate.class) @ConditionalOnClass({ RedisTemplate.class, DispatcherHandler.class }) @ConditionalOnProperty(name = GatewayProperties.PREFIX + ".redis.enabled", matchIfMissing = true) +@EnableConfigurationProperties(RedisRateLimiterProperties.class) class GatewayRedisAutoConfiguration { @Bean @@ -67,8 +70,8 @@ public RedisScript redisRequestRateLimiterScript() { @ConditionalOnMissingBean public RedisRateLimiter redisRateLimiter(ReactiveStringRedisTemplate redisTemplate, @Qualifier(RedisRateLimiter.REDIS_SCRIPT_NAME) RedisScript> redisScript, - ConfigurationService configurationService) { - return new RedisRateLimiter(redisTemplate, redisScript, configurationService); + ConfigurationService configurationService, RedisRateLimiterProperties redisRateLimiterProperties) { + return new RedisRateLimiter(redisTemplate, redisScript, configurationService, redisRateLimiterProperties); } @Bean diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java index c653c64f9..39e30e1df 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterGatewayFilterFactory.java @@ -22,7 +22,6 @@ import org.jspecify.annotations.Nullable; import reactor.core.publisher.Mono; -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; @@ -39,7 +38,6 @@ * User Request Rate Limiter filter. See https://stripe.com/blog/rate-limiters and * https://gist.github.com/ptarjan/e38f45f2dfe601419ca3af937fff574d#file-1-check_request_rate_limiter-rb-L11-L34. */ -@ConfigurationProperties("spring.cloud.gateway.server.webflux.filter.request-rate-limiter") public class RequestRateLimiterGatewayFilterFactory extends AbstractGatewayFilterFactory { @@ -54,24 +52,18 @@ public class RequestRateLimiterGatewayFilterFactory 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.name(); - - /** - * Switch to throw a {@link HttpClientErrorException} when the request is denied by - * the RateLimiter, defaults to false. - */ - private boolean throwOnLimit = false; + private final RequestRateLimiterProperties properties; public RequestRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter, KeyResolver defaultKeyResolver) { + this(defaultRateLimiter, defaultKeyResolver, new RequestRateLimiterProperties()); + } + + public RequestRateLimiterGatewayFilterFactory(RateLimiter defaultRateLimiter, KeyResolver defaultKeyResolver, + RequestRateLimiterProperties properties) { super(Config.class); this.defaultRateLimiter = defaultRateLimiter; this.defaultKeyResolver = defaultKeyResolver; + this.properties = properties; } public KeyResolver getDefaultKeyResolver() { @@ -82,28 +74,76 @@ public RateLimiter getDefaultRateLimiter() { return defaultRateLimiter; } + /** + * The externalized filter properties bound from configuration. + * @return the properties backing this filter factory + */ + public RequestRateLimiterProperties getProperties() { + return properties; + } + + /** + * @return whether requests with an empty key are denied + * @deprecated in favor of {@link RequestRateLimiterProperties#isDenyEmptyKey()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public boolean isDenyEmptyKey() { - return denyEmptyKey; + return properties.isDenyEmptyKey(); } + /** + * @param denyEmptyKey whether requests with an empty key are denied + * @deprecated in favor of + * {@link RequestRateLimiterProperties#setDenyEmptyKey(boolean)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setDenyEmptyKey(boolean denyEmptyKey) { - this.denyEmptyKey = denyEmptyKey; + properties.setDenyEmptyKey(denyEmptyKey); } + /** + * @return the status code returned when an empty key is denied + * @deprecated in favor of + * {@link RequestRateLimiterProperties#getEmptyKeyStatusCode()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public String getEmptyKeyStatusCode() { - return emptyKeyStatusCode; + return properties.getEmptyKeyStatusCode(); } + /** + * @param emptyKeyStatusCode the status code returned when an empty key is denied + * @deprecated in favor of + * {@link RequestRateLimiterProperties#setEmptyKeyStatusCode(String)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setEmptyKeyStatusCode(String emptyKeyStatusCode) { - this.emptyKeyStatusCode = emptyKeyStatusCode; + properties.setEmptyKeyStatusCode(emptyKeyStatusCode); } + /** + * @return whether an exception is thrown when the request is rate limited + * @deprecated in favor of {@link RequestRateLimiterProperties#isThrowOnLimit()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public boolean isThrowOnLimit() { - return throwOnLimit; + return properties.isThrowOnLimit(); } + /** + * @param throwOnLimit whether an exception is thrown when the request is rate limited + * @deprecated in favor of + * {@link RequestRateLimiterProperties#setThrowOnLimit(boolean)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setThrowOnLimit(boolean throwOnLimit) { - this.throwOnLimit = throwOnLimit; + properties.setThrowOnLimit(throwOnLimit); } @SuppressWarnings("unchecked") @@ -111,10 +151,10 @@ public void setThrowOnLimit(boolean throwOnLimit) { public GatewayFilter apply(Config config) { KeyResolver resolver = getOrDefault(config.keyResolver, defaultKeyResolver); RateLimiter limiter = getOrDefault(config.rateLimiter, defaultRateLimiter); - boolean denyEmpty = getOrDefault(config.denyEmptyKey, this.denyEmptyKey); + boolean denyEmpty = getOrDefault(config.denyEmptyKey, properties.isDenyEmptyKey()); HttpStatusHolder emptyKeyStatus = HttpStatusHolder - .parse(getOrDefault(config.emptyKeyStatus, this.emptyKeyStatusCode)); - boolean throwLimit = getOrDefault(config.throwOnLimit, this.throwOnLimit); + .parse(getOrDefault(config.emptyKeyStatus, properties.getEmptyKeyStatusCode())); + boolean throwLimit = getOrDefault(config.throwOnLimit, properties.isThrowOnLimit()); return (exchange, chain) -> resolver.resolve(exchange).defaultIfEmpty(EMPTY_KEY).flatMap(key -> { if (EMPTY_KEY.equals(key)) { diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterProperties.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterProperties.java new file mode 100644 index 000000000..dab14dd93 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterProperties.java @@ -0,0 +1,83 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.filter.factory; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.style.ToStringCreator; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; + +/** + * Externalized configuration for {@link RequestRateLimiterGatewayFilterFactory}. + * Extracting the bound properties into a dedicated class with a default constructor lets + * Spring Cloud's {@code ConfigurationPropertiesRebinder} rebind them on a refresh + * (including resetting removed properties to their defaults), which is not possible while + * the properties live on the {@link RequestRateLimiterGatewayFilterFactory} bean whose + * {@code RateLimiter}/{@code KeyResolver} dependencies are constructor injected. + * + * @author Aryamann Singh + */ +@ConfigurationProperties("spring.cloud.gateway.server.webflux.filter.request-rate-limiter") +public class RequestRateLimiterProperties { + + /** + * 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.name(); + + /** + * Switch to throw a {@link HttpClientErrorException} when the request is denied by + * the RateLimiter, defaults to false. + */ + private boolean throwOnLimit = false; + + 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; + } + + public boolean isThrowOnLimit() { + return throwOnLimit; + } + + public void setThrowOnLimit(boolean throwOnLimit) { + this.throwOnLimit = throwOnLimit; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("denyEmptyKey", denyEmptyKey) + .append("emptyKeyStatusCode", emptyKeyStatusCode) + .append("throwOnLimit", throwOnLimit) + .toString(); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java index 9720ca0a6..75e80b6a5 100644 --- a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiter.java @@ -32,8 +32,6 @@ import reactor.core.publisher.Mono; import org.springframework.beans.BeansException; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.cloud.gateway.config.GatewayProperties; import org.springframework.cloud.gateway.route.RouteDefinitionRouteLocator; import org.springframework.cloud.gateway.support.ConfigurationService; import org.springframework.context.ApplicationContext; @@ -53,7 +51,6 @@ * @author Denis Cutic * @author Andrey Muchnik */ -@ConfigurationProperties(GatewayProperties.PREFIX + ".redis-rate-limiter") public class RedisRateLimiter extends AbstractRateLimiter implements ApplicationContextAware { /** @@ -103,33 +100,19 @@ public class RedisRateLimiter extends AbstractRateLimiter> script, ConfigurationService configurationService) { + this(redisTemplate, script, configurationService, new RedisRateLimiterProperties()); + } + + public RedisRateLimiter(ReactiveStringRedisTemplate redisTemplate, RedisScript> script, + ConfigurationService configurationService, RedisRateLimiterProperties properties) { super(Config.class, CONFIGURATION_PROPERTY_NAME, configurationService); this.redisTemplate = redisTemplate; this.script = script; + this.properties = properties; this.initialized.compareAndSet(false, true); } @@ -141,6 +124,7 @@ public RedisRateLimiter(ReactiveStringRedisTemplate redisTemplate, RedisScript getKeys(String id, String routeId) { return Arrays.asList(tokenKey, timestampKey); } + /** + * The externalized rate limiter properties bound from configuration. + * @return the properties backing this rate limiter + */ + public RedisRateLimiterProperties getProperties() { + return properties; + } + + /** + * The per-route configuration is held by {@link RedisRateLimiterProperties} so that + * {@code redis-rate-limiter.config.*} is bound onto the properties bean, which is + * where the binding now lives. Reading through to it keeps a single source of truth + * and picks up any rebinding on refresh. + */ + @Override + public Map getConfig() { + return properties.getConfig(); + } + + /** + * @return whether rate limiter headers are included + * @deprecated in favor of {@link RedisRateLimiterProperties#isIncludeHeaders()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public boolean isIncludeHeaders() { - return includeHeaders; + return properties.isIncludeHeaders(); } + /** + * @param includeHeaders whether rate limiter headers are included + * @deprecated in favor of + * {@link RedisRateLimiterProperties#setIncludeHeaders(boolean)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setIncludeHeaders(boolean includeHeaders) { - this.includeHeaders = includeHeaders; + properties.setIncludeHeaders(includeHeaders); } + /** + * @return the remaining-requests header name + * @deprecated in favor of {@link RedisRateLimiterProperties#getRemainingHeader()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public String getRemainingHeader() { - return remainingHeader; + return properties.getRemainingHeader(); } + /** + * @param remainingHeader the remaining-requests header name + * @deprecated in favor of + * {@link RedisRateLimiterProperties#setRemainingHeader(String)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setRemainingHeader(String remainingHeader) { - this.remainingHeader = remainingHeader; + properties.setRemainingHeader(remainingHeader); } + /** + * @return the replenish-rate header name + * @deprecated in favor of {@link RedisRateLimiterProperties#getReplenishRateHeader()} + * via {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public String getReplenishRateHeader() { - return replenishRateHeader; + return properties.getReplenishRateHeader(); } + /** + * @param replenishRateHeader the replenish-rate header name + * @deprecated in favor of + * {@link RedisRateLimiterProperties#setReplenishRateHeader(String)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setReplenishRateHeader(String replenishRateHeader) { - this.replenishRateHeader = replenishRateHeader; + properties.setReplenishRateHeader(replenishRateHeader); } + /** + * @return the burst-capacity header name + * @deprecated in favor of {@link RedisRateLimiterProperties#getBurstCapacityHeader()} + * via {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public String getBurstCapacityHeader() { - return burstCapacityHeader; + return properties.getBurstCapacityHeader(); } + /** + * @param burstCapacityHeader the burst-capacity header name + * @deprecated in favor of + * {@link RedisRateLimiterProperties#setBurstCapacityHeader(String)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setBurstCapacityHeader(String burstCapacityHeader) { - this.burstCapacityHeader = burstCapacityHeader; + properties.setBurstCapacityHeader(burstCapacityHeader); } + /** + * @return the requested-tokens header name + * @deprecated in favor of + * {@link RedisRateLimiterProperties#getRequestedTokensHeader()} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public String getRequestedTokensHeader() { - return requestedTokensHeader; + return properties.getRequestedTokensHeader(); } + /** + * @param requestedTokensHeader the requested-tokens header name + * @deprecated in favor of + * {@link RedisRateLimiterProperties#setRequestedTokensHeader(String)} via + * {@link #getProperties()} + */ + @Deprecated(since = "5.0.3") public void setRequestedTokensHeader(String requestedTokensHeader) { - this.requestedTokensHeader = requestedTokensHeader; + properties.setRequestedTokensHeader(requestedTokensHeader); } /** @@ -313,11 +382,11 @@ public Mono isAllowed(String routeId, String id) { public Map getHeaders(Config config, Long tokensLeft) { Map headers = new HashMap<>(); - if (isIncludeHeaders()) { - headers.put(this.remainingHeader, tokensLeft.toString()); - headers.put(this.replenishRateHeader, String.valueOf(config.getReplenishRate())); - headers.put(this.burstCapacityHeader, String.valueOf(config.getBurstCapacity())); - headers.put(this.requestedTokensHeader, String.valueOf(config.getRequestedTokens())); + if (properties.isIncludeHeaders()) { + headers.put(properties.getRemainingHeader(), tokensLeft.toString()); + headers.put(properties.getReplenishRateHeader(), String.valueOf(config.getReplenishRate())); + headers.put(properties.getBurstCapacityHeader(), String.valueOf(config.getBurstCapacity())); + headers.put(properties.getRequestedTokensHeader(), String.valueOf(config.getRequestedTokens())); } return headers; } diff --git a/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterProperties.java b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterProperties.java new file mode 100644 index 000000000..f382f902b --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterProperties.java @@ -0,0 +1,125 @@ +/* + * Copyright 2013-present 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 java.util.HashMap; +import java.util.Map; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.cloud.gateway.config.GatewayProperties; +import org.springframework.core.style.ToStringCreator; + +/** + * Externalized configuration for {@link RedisRateLimiter}. Extracting the bound + * properties into a dedicated class with a default constructor lets Spring Cloud's + * {@code ConfigurationPropertiesRebinder} rebind them on a refresh (including resetting + * removed properties to their defaults), which is not possible while the properties live + * on the constructor-injected {@link RedisRateLimiter} bean. + * + * @author Aryamann Singh + */ +@ConfigurationProperties(GatewayProperties.PREFIX + ".redis-rate-limiter") +public class RedisRateLimiterProperties { + + /** + * Whether or not to include headers containing rate limiter information, defaults to + * true. + */ + private boolean includeHeaders = true; + + /** + * The name of the header that returns number of remaining requests during the current + * second. + */ + private String remainingHeader = RedisRateLimiter.REMAINING_HEADER; + + /** The name of the header that returns the replenish rate configuration. */ + private String replenishRateHeader = RedisRateLimiter.REPLENISH_RATE_HEADER; + + /** The name of the header that returns the burst capacity configuration. */ + private String burstCapacityHeader = RedisRateLimiter.BURST_CAPACITY_HEADER; + + /** The name of the header that returns the requested tokens configuration. */ + private String requestedTokensHeader = RedisRateLimiter.REQUESTED_TOKENS_HEADER; + + /** + * Per-route rate limiter configuration, keyed by route id. This map backs + * {@link RedisRateLimiter#getConfig()} so that {@code redis-rate-limiter.config.*} + * keeps binding as it did when the properties lived on the rate limiter itself. + */ + private Map config = new HashMap<>(); + + public boolean isIncludeHeaders() { + return includeHeaders; + } + + public void setIncludeHeaders(boolean includeHeaders) { + this.includeHeaders = includeHeaders; + } + + public String getRemainingHeader() { + return remainingHeader; + } + + public void setRemainingHeader(String remainingHeader) { + this.remainingHeader = remainingHeader; + } + + public String getReplenishRateHeader() { + return replenishRateHeader; + } + + public void setReplenishRateHeader(String replenishRateHeader) { + this.replenishRateHeader = replenishRateHeader; + } + + public String getBurstCapacityHeader() { + return burstCapacityHeader; + } + + public void setBurstCapacityHeader(String burstCapacityHeader) { + this.burstCapacityHeader = burstCapacityHeader; + } + + public String getRequestedTokensHeader() { + return requestedTokensHeader; + } + + public void setRequestedTokensHeader(String requestedTokensHeader) { + this.requestedTokensHeader = requestedTokensHeader; + } + + public Map getConfig() { + return config; + } + + public void setConfig(Map config) { + this.config = config; + } + + @Override + public String toString() { + return new ToStringCreator(this).append("includeHeaders", includeHeaders) + .append("remainingHeader", remainingHeader) + .append("replenishRateHeader", replenishRateHeader) + .append("burstCapacityHeader", burstCapacityHeader) + .append("requestedTokensHeader", requestedTokensHeader) + .append("config", config) + .toString(); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/RateLimiterPropertiesBindingTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/RateLimiterPropertiesBindingTests.java new file mode 100644 index 000000000..4da53f639 --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/RateLimiterPropertiesBindingTests.java @@ -0,0 +1,100 @@ +/* + * Copyright 2013-present 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 org.junit.jupiter.api.Test; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterGatewayFilterFactory; +import org.springframework.cloud.gateway.filter.factory.RequestRateLimiterProperties; +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter; +import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiterProperties; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the extracted {@link RedisRateLimiterProperties} and + * {@link RequestRateLimiterProperties} are bound from the environment and injected into + * the beans that consume them. + * + * @author Aryamann Singh + */ +@SpringBootTest(properties = { "spring.cloud.gateway.server.webflux.redis-rate-limiter.include-headers=false", + "spring.cloud.gateway.server.webflux.redis-rate-limiter.remaining-header=X-Remaining-Custom", + "spring.cloud.gateway.server.webflux.redis-rate-limiter.config.myroute.replenish-rate=99", + "spring.cloud.gateway.server.webflux.redis-rate-limiter.config.myroute.burst-capacity=199", + "spring.cloud.gateway.server.webflux.redis-rate-limiter.config.myroute.requested-tokens=7", + "spring.cloud.gateway.server.webflux.filter.request-rate-limiter.deny-empty-key=false", + "spring.cloud.gateway.server.webflux.filter.request-rate-limiter.empty-key-status-code=BAD_REQUEST", + "spring.cloud.gateway.server.webflux.filter.request-rate-limiter.throw-on-limit=true" }) +@DirtiesContext +public class RateLimiterPropertiesBindingTests { + + @Autowired + private RedisRateLimiter redisRateLimiter; + + @Autowired + private RedisRateLimiterProperties redisRateLimiterProperties; + + @Autowired + private RequestRateLimiterGatewayFilterFactory requestRateLimiterFactory; + + @Autowired + private RequestRateLimiterProperties requestRateLimiterProperties; + + @Test + public void redisRateLimiterPropertiesAreBoundAndInjected() { + assertThat(redisRateLimiterProperties.isIncludeHeaders()).isFalse(); + assertThat(redisRateLimiterProperties.getRemainingHeader()).isEqualTo("X-Remaining-Custom"); + // the rate limiter bean receives the same bound properties instance + assertThat(redisRateLimiter.getProperties()).isSameAs(redisRateLimiterProperties); + } + + @Test + public void perRouteConfigIsStillBound() { + // `redis-rate-limiter.config..*` was bindable because RedisRateLimiter + // was itself the @ConfigurationProperties bean, exposing the inherited + // AbstractStatefulConfigurable#getConfig() map. The properties bean now owns that + // map and the rate limiter reads through to it, so the binding is unchanged. + assertThat(redisRateLimiter.getConfig()).containsKey("myroute"); + RedisRateLimiter.Config routeConfig = redisRateLimiter.getConfig().get("myroute"); + assertThat(routeConfig.getReplenishRate()).isEqualTo(99); + assertThat(routeConfig.getBurstCapacity()).isEqualTo(199); + assertThat(routeConfig.getRequestedTokens()).isEqualTo(7); + assertThat(redisRateLimiter.getConfig()).isSameAs(redisRateLimiterProperties.getConfig()); + } + + @Test + public void requestRateLimiterPropertiesAreBoundAndInjected() { + assertThat(requestRateLimiterProperties.isDenyEmptyKey()).isFalse(); + assertThat(requestRateLimiterProperties.getEmptyKeyStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(requestRateLimiterProperties.isThrowOnLimit()).isTrue(); + assertThat(requestRateLimiterFactory.getProperties()).isSameAs(requestRateLimiterProperties); + } + + @EnableAutoConfiguration + @SpringBootConfiguration + public static class TestConfig { + + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterPropertiesTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterPropertiesTests.java new file mode 100644 index 000000000..e2c3eaefa --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterPropertiesTests.java @@ -0,0 +1,69 @@ +/* + * Copyright 2013-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.cloud.gateway.filter.factory; + +import org.junit.jupiter.api.Test; + +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.cloud.gateway.filter.ratelimit.RateLimiter; +import org.springframework.http.HttpStatus; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * @author Aryamann Singh + */ +public class RequestRateLimiterPropertiesTests { + + @Test + public void defaultsAreUnchanged() { + RequestRateLimiterProperties properties = new RequestRateLimiterProperties(); + + assertThat(properties.isDenyEmptyKey()).isTrue(); + assertThat(properties.getEmptyKeyStatusCode()).isEqualTo(HttpStatus.FORBIDDEN.name()); + assertThat(properties.isThrowOnLimit()).isFalse(); + } + + @Test + public void factoryExposesInjectedProperties() { + RequestRateLimiterProperties properties = new RequestRateLimiterProperties(); + RequestRateLimiterGatewayFilterFactory factory = new RequestRateLimiterGatewayFilterFactory( + mock(RateLimiter.class), mock(KeyResolver.class), properties); + + assertThat(factory.getProperties()).isSameAs(properties); + } + + @Test + public void deprecatedAccessorsDelegateToProperties() { + RequestRateLimiterGatewayFilterFactory factory = new RequestRateLimiterGatewayFilterFactory( + mock(RateLimiter.class), mock(KeyResolver.class)); + + factory.setDenyEmptyKey(false); + factory.setEmptyKeyStatusCode(HttpStatus.BAD_REQUEST.name()); + factory.setThrowOnLimit(true); + + assertThat(factory.getProperties().isDenyEmptyKey()).isFalse(); + assertThat(factory.getProperties().getEmptyKeyStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST.name()); + assertThat(factory.getProperties().isThrowOnLimit()).isTrue(); + + // read-through via the deprecated getters + assertThat(factory.isDenyEmptyKey()).isFalse(); + assertThat(factory.isThrowOnLimit()).isTrue(); + } + +} diff --git a/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterPropertiesTests.java b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterPropertiesTests.java new file mode 100644 index 000000000..087b5e5ac --- /dev/null +++ b/spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterPropertiesTests.java @@ -0,0 +1,68 @@ +/* + * Copyright 2013-present 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 org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * @author Aryamann Singh + */ +public class RedisRateLimiterPropertiesTests { + + @Test + public void defaultsMatchHeaderConstants() { + RedisRateLimiterProperties properties = new RedisRateLimiterProperties(); + + assertThat(properties.isIncludeHeaders()).isTrue(); + assertThat(properties.getRemainingHeader()).isEqualTo(RedisRateLimiter.REMAINING_HEADER); + assertThat(properties.getReplenishRateHeader()).isEqualTo(RedisRateLimiter.REPLENISH_RATE_HEADER); + assertThat(properties.getBurstCapacityHeader()).isEqualTo(RedisRateLimiter.BURST_CAPACITY_HEADER); + assertThat(properties.getRequestedTokensHeader()).isEqualTo(RedisRateLimiter.REQUESTED_TOKENS_HEADER); + } + + @Test + public void rateLimiterExposesInjectedProperties() { + RedisRateLimiterProperties properties = new RedisRateLimiterProperties(); + RedisRateLimiter rateLimiter = new RedisRateLimiter(1, 1); + + assertThat(rateLimiter.getProperties()).isNotNull(); + + rateLimiter.getProperties().setIncludeHeaders(false); + rateLimiter.getProperties().setRemainingHeader("X-Remaining"); + + // deprecated delegates read through to the properties object + assertThat(rateLimiter.isIncludeHeaders()).isFalse(); + assertThat(rateLimiter.getRemainingHeader()).isEqualTo("X-Remaining"); + + // the standalone properties instance is independent + assertThat(properties.isIncludeHeaders()).isTrue(); + } + + @Test + public void deprecatedSettersWriteThroughToProperties() { + RedisRateLimiter rateLimiter = new RedisRateLimiter(1, 1); + + rateLimiter.setIncludeHeaders(false); + rateLimiter.setRequestedTokensHeader("X-Requested"); + + assertThat(rateLimiter.getProperties().isIncludeHeaders()).isFalse(); + assertThat(rateLimiter.getProperties().getRequestedTokensHeader()).isEqualTo("X-Requested"); + } + +}