From 96e23ecbdb5840c0fadab0c0cc1bb04837d4ea6e Mon Sep 17 00:00:00 2001 From: Aryamann Singh <107678802+AryamannSingh7@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:55:50 +0530 Subject: [PATCH] Extract rate limiter configuration properties into separate classes RedisRateLimiter and RequestRateLimiterGatewayFilterFactory were themselves @ConfigurationProperties beans, but neither has a no-arg constructor because their collaborators are constructor injected. On a refresh, ConfigurationPropertiesRebinder cannot build a throwaway defaults instance for such a bean, so it skips resetting the properties to their class defaults before rebinding. A property removed from the environment therefore keeps its previously bound value rather than reverting. Move the bound state onto dedicated RedisRateLimiterProperties and RequestRateLimiterProperties classes, which have default constructors and so can be reset and rebound normally. The property prefixes are unchanged, so no configuration keys change for users, and the existing accessors are retained as deprecated delegates. RedisRateLimiter also inherits a bindable per-route config map from AbstractStatefulConfigurable, which was populated only because the rate limiter was itself the @ConfigurationProperties bean. That map now lives on RedisRateLimiterProperties and RedisRateLimiter#getConfig() reads through to it, so redis-rate-limiter.config.* binds exactly as before and is now rebound on refresh as well. Closes gh-4233 Signed-off-by: Aryamann Singh <107678802+AryamannSingh7@users.noreply.github.com> --- .../config/GatewayAutoConfiguration.java | 7 +- .../config/GatewayRedisAutoConfiguration.java | 7 +- ...equestRateLimiterGatewayFilterFactory.java | 88 ++++++++--- .../factory/RequestRateLimiterProperties.java | 83 ++++++++++ .../filter/ratelimit/RedisRateLimiter.java | 147 +++++++++++++----- .../ratelimit/RedisRateLimiterProperties.java | 125 +++++++++++++++ .../RateLimiterPropertiesBindingTests.java | 100 ++++++++++++ .../RequestRateLimiterPropertiesTests.java | 69 ++++++++ .../RedisRateLimiterPropertiesTests.java | 68 ++++++++ 9 files changed, 626 insertions(+), 68 deletions(-) create mode 100644 spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterProperties.java create mode 100644 spring-cloud-gateway-server-webflux/src/main/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterProperties.java create mode 100644 spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/config/RateLimiterPropertiesBindingTests.java create mode 100644 spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/factory/RequestRateLimiterPropertiesTests.java create mode 100644 spring-cloud-gateway-server-webflux/src/test/java/org/springframework/cloud/gateway/filter/ratelimit/RedisRateLimiterPropertiesTests.java 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"); + } + +}