From 206c6cfea44d4da12ec3ec7687ead999c559f8f8 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Thu, 7 Mar 2024 20:34:41 +0100 Subject: [PATCH 1/9] Support for Method level @RateLimiting annoation #250 --- .../boot/starter/context/RateLimitCheck.java | 18 +- .../boot/starter/context/RateLimiting.java | 21 ++ .../properties/Bucket4JBootProperties.java | 5 + .../context/properties/MethodProperties.java | 21 ++ .../starter/context/properties/RateLimit.java | 158 +++++--- bucket4j-spring-boot-starter/pom.xml | 10 + .../boot/starter/config/aspect/AopConfig.java | 34 ++ .../config/aspect/RateLimitAspect.java | 140 +++++++ .../config/aspect/RateLimitException.java | 4 + .../filter/Bucket4JBaseConfiguration.java | 347 ++---------------- ...ConfigurationSpringCloudGatewayFilter.java | 49 +-- ...gurationSpringCloudGatewayFilterBeans.java | 13 +- ...ucket4JAutoConfigurationWebfluxFilter.java | 51 +-- ...4JAutoConfigurationWebfluxFilterBeans.java | 12 - ...ucket4JAutoConfigurationServletFilter.java | 51 +-- ...4JAutoConfigurationServletFilterBeans.java | 12 +- .../config/service/ServiceConfiguration.java | 34 ++ .../reactive/AbstractReactiveFilter.java | 2 +- .../filter/servlet/ServletRequestFilter.java | 3 +- .../starter/service/ExpressionService.java | 31 ++ .../starter/service/RateLimitService.java | 331 +++++++++++++++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...SpringCloudGatewayRateLimitFilterTest.java | 14 +- .../servlet/ServletRateLimitFilterTest.java | 28 +- .../webflux/WebfluxRateLimitFilterTest.java | 14 +- examples/caffeine/pom.xml | 4 + .../caffeine/CaffeineApplication.java | 2 + .../caffeine/RateLimitExceptionHandler.java | 18 + .../examples/caffeine/TestController.java | 2 + .../src/main/resources/application.yml | 13 + 30 files changed, 888 insertions(+), 555 deletions(-) create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java create mode 100644 examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/RateLimitExceptionHandler.java diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java index 7ec5e7fe..df5eb6af 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java @@ -1,19 +1,19 @@ package com.giffing.bucket4j.spring.boot.starter.context; +import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; /** - * Used to check if the rate limit should be performed independently from the servlet|webflux|gateway request filter - * + * Used to check if the rate limit should be performed independently from the servlet|webflux|gateway request filter */ @FunctionalInterface public interface RateLimitCheck { - /** - * @param request the request information object - * - * @return null if no rate limit should be performed. (maybe skipped or shouldn't be executed). - */ - RateLimitResultWrapper rateLimit(R request); - + /** + * @param request the request information object + * @param mainRateLimit overwrites the rate limit configuration from the properties + * @return null if no rate limit should be performed. (maybe skipped or shouldn't be executed). + */ + RateLimitResultWrapper rateLimit(R request, RateLimit mainRateLimit); + } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java new file mode 100644 index 00000000..95a1d471 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java @@ -0,0 +1,21 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface RateLimiting { + + String name(); + + String cacheKey() default ""; + + String executeCondition() default ""; + + String skipCondition() default ""; + + String fallbackMethodName() default ""; +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java index 40b9add6..9acb40ef 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java @@ -43,6 +43,9 @@ public class Bucket4JBootProperties { */ private String cacheToUse; + @Valid + private List methods = new ArrayList<>(); + private boolean filterConfigCachingEnabled = false; /** @@ -51,6 +54,8 @@ public class Bucket4JBootProperties { @NotBlank private String filterConfigCacheName = "filterConfigCache"; + + @Valid private List filters = new ArrayList<>(); diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java new file mode 100644 index 00000000..5c0660d4 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java @@ -0,0 +1,21 @@ +package com.giffing.bucket4j.spring.boot.starter.context.properties; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.ToString; + +@Data +@ToString +public class MethodProperties { + + @NotBlank + private String name; + + @NotBlank + private String cacheName; + + @NotNull + private RateLimit rateLimit; + +} \ No newline at end of file diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java index accbdf53..0d0bc32b 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java @@ -1,67 +1,113 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; -import java.io.Serializable; -import java.util.ArrayList; -import java.util.List; - import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicateDefinition; import com.giffing.bucket4j.spring.boot.starter.context.constraintvalidations.ValidBandWidthIds; - -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; - import io.github.bucket4j.TokensInheritanceStrategy; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import lombok.Data; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + @Data @ValidBandWidthIds public class RateLimit implements Serializable { - - /** - * SpEl condition to check if the rate limit should be executed. If null there is no check. - */ - private String executeCondition; - - /** - * TODO comment - */ - private String postExecuteCondition; - - @Valid - private List executePredicates = new ArrayList<>(); - - /** - * SpEl condition to check if the rate-limit should apply. If null there is no check. - */ - private String skipCondition; - - @Valid - private List skipPredicates = new ArrayList<>(); - - /** - * SPEL expression to dynamic evaluate filter key - */ - @NotBlank - private String cacheKey = "1"; - - @Null(message = "The expression is depcreated since 0.8. Please use cache-key instead") - @Deprecated - private String expression; - - /** - * The number of tokens that should be consumed - */ - @NotNull - @Min(1) - private Integer numTokens = 1; - - @NotEmpty - @Valid - private List bandwidths = new ArrayList<>(); - - /** - * The token inheritance strategy to use when replacing the configuration of a bucket - */ - @NotNull - private TokensInheritanceStrategy tokensInheritanceStrategy = TokensInheritanceStrategy.RESET; + + /** + * SpEl condition to check if the rate limit should be executed. If null there is no check. + */ + private String executeCondition; + + /** + * TODO comment + */ + private String postExecuteCondition; + + @Valid + private List executePredicates = new ArrayList<>(); + + /** + * SpEl condition to check if the rate-limit should apply. If null there is no check. + */ + private String skipCondition; + + @Valid + private List skipPredicates = new ArrayList<>(); + + /** + * SPEL expression to dynamic evaluate filter key + */ + @NotBlank + private String cacheKey = "1"; + + /** + * The number of tokens that should be consumed + */ + @NotNull + @Min(1) + private Integer numTokens = 1; + + @NotEmpty + @Valid + private List bandwidths = new ArrayList<>(); + + /** + * The token inheritance strategy to use when replacing the configuration of a bucket + */ + @NotNull + private TokensInheritanceStrategy tokensInheritanceStrategy = TokensInheritanceStrategy.RESET; + + public RateLimit copy() { + var copy = new RateLimit(); + copy.setExecuteCondition(this.executeCondition); + copy.setPostExecuteCondition(this.postExecuteCondition); + copy.setExecutePredicates(this.executePredicates); + copy.setSkipCondition(this.skipCondition); + copy.setSkipPredicates(this.skipPredicates); + copy.setCacheKey(this.cacheKey); + copy.setNumTokens(this.numTokens); + copy.setBandwidths(this.bandwidths); + copy.setTokensInheritanceStrategy(this.tokensInheritanceStrategy); + return copy; + } + + public void consumeNotNullValues(RateLimit toConsume) { + if(toConsume == null) { + return; + } + + if (toConsume.getExecuteCondition() != null && !toConsume.getExecuteCondition().isEmpty()) { + this.setExecuteCondition(toConsume.getExecuteCondition()); + } + if (toConsume.getPostExecuteCondition() != null && !toConsume.getPostExecuteCondition().isEmpty()) { + this.setPostExecuteCondition(toConsume.getPostExecuteCondition()); + } + if (toConsume.getExecutePredicates() != null && !toConsume.getExecutePredicates().isEmpty()) { + this.setExecutePredicates(toConsume.getExecutePredicates()); + } + if (toConsume.getSkipCondition() != null && !toConsume.getSkipCondition().isEmpty()) { + this.setSkipCondition(toConsume.getSkipCondition()); + } + if (toConsume.getSkipPredicates() != null && !toConsume.getSkipPredicates().isEmpty()) { + this.setSkipPredicates(toConsume.getSkipPredicates()); + } + if (toConsume.getCacheKey() != null && !toConsume.getCacheKey().equals("1") && !toConsume.getCacheKey().isEmpty()) { + this.setCacheKey(toConsume.getCacheKey()); + } + if(toConsume.getNumTokens() != null && toConsume.getNumTokens() != 1) { + this.setNumTokens(toConsume.getNumTokens()); + } + if(toConsume.getBandwidths() != null && !toConsume.getBandwidths().isEmpty()) { + this.setBandwidths(toConsume.getBandwidths()); + } + if(toConsume.getTokensInheritanceStrategy() != null) { + this.setTokensInheritanceStrategy(toConsume.getTokensInheritanceStrategy()); + } + } + } \ No newline at end of file diff --git a/bucket4j-spring-boot-starter/pom.xml b/bucket4j-spring-boot-starter/pom.xml index 4d3e9a5d..109f6a88 100644 --- a/bucket4j-spring-boot-starter/pom.xml +++ b/bucket4j-spring-boot-starter/pom.xml @@ -98,6 +98,16 @@ ${redisson.version} provided + + org.springframework + spring-aop + provided + + + org.aspectj + aspectjweaver + provided + com.hazelcast hazelcast diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java new file mode 100644 index 00000000..210fc0c1 --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java @@ -0,0 +1,34 @@ +package com.giffing.bucket4j.spring.boot.starter.config.aspect; + +import com.giffing.bucket4j.spring.boot.starter.config.cache.Bucket4jCacheConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; +import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; +import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +@Configuration +@ConditionalOnClass(Aspect.class) +@ConditionalOnProperty(prefix = Bucket4JBootProperties.PROPERTY_PREFIX, value = {"enabled"}, matchIfMissing = true) +@EnableConfigurationProperties({Bucket4JBootProperties.class}) +@AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) +@ConditionalOnBean(value = SyncCacheResolver.class) +@Import(value = {ServiceConfiguration.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class}) +public class AopConfig { + + @Bean + public RateLimitAspect rateLimitAspect(RateLimitService rateLimitService, Bucket4JBootProperties bucket4JBootProperties, SyncCacheResolver syncCacheResolver) { + return new RateLimitAspect(rateLimitService, bucket4JBootProperties.getMethods(), syncCacheResolver); + } + +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java new file mode 100644 index 00000000..200e0d87 --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java @@ -0,0 +1,140 @@ +package com.giffing.bucket4j.spring.boot.starter.config.aspect; + +import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.context.properties.MethodProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Metrics; +import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +public class RateLimitAspect { + + private final RateLimitService rateLimitService; + + private final List methodProperties; + + private final SyncCacheResolver syncCacheResolver; + + private Map> rateLimitConfigResults = new HashMap<>(); + + @PostConstruct + public void init() { + for(var methodProperty : methodProperties) { + var proxyManagerWrapper = syncCacheResolver.resolve(methodProperty.getCacheName()); + var rateLimitConfig = RateLimitService.RateLimitConfig.builder() + .rateLimits(List.of(methodProperty.getRateLimit())) + .metricHandlers(List.of()) + .executePredicates(Map.of()) + .cacheName(methodProperty.getCacheName()) + .configVersion(0) + .keyFunction((rl, sr) -> rateLimitService.getKeyFilter(methodProperty.getName(), rl).key(sr)) + .metrics(new Metrics()) + .proxyWrapper(proxyManagerWrapper) + .build(); + var rateLimitConfigResult = rateLimitService.configureRateLimit(rateLimitConfig); + rateLimitConfigResults.put(methodProperty.getName(), rateLimitConfigResult); + } + } + + @Around("methodsAnnotatedWithRateLimitAnnotation()") + public Object processMethodsAnnotatedWithRateLimitAnnotation(ProceedingJoinPoint joinPoint) throws Throwable { + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + + var args = joinPoint.getArgs(); + var parameterNames = signature.getParameterNames(); + + var evaluationContext = new StandardEvaluationContext(); + for (int i = 0; i< args.length; i++) { + log.debug("expresion-params;name:{};arg:{}",parameterNames[i], args[i]); + evaluationContext.setVariable(parameterNames[i], args[i]); + + } + + RateLimiting rateLimitAnnotation = method.getAnnotation(RateLimiting.class); + + + if(!rateLimitConfigResults.containsKey(rateLimitAnnotation.name())) { + throw new IllegalStateException("Could not find cache " + rateLimitAnnotation.name()); + } + var rateLimitConfigResult = rateLimitConfigResults.get(rateLimitAnnotation.name()); + + var annotationRateLimit = new RateLimit(); + annotationRateLimit.setExecuteCondition(rateLimitAnnotation.executeCondition()); + annotationRateLimit.setCacheKey(rateLimitAnnotation.cacheKey()); + annotationRateLimit.setSkipCondition(rateLimitAnnotation.skipCondition()); + + + boolean allConsumed = true; + Long remainingLimit = null; + for (RateLimitCheck rl : rateLimitConfigResult.getRateLimitChecks()) { + var wrapper = rl.rateLimit(null, annotationRateLimit); + if (wrapper != null && wrapper.getRateLimitResult() != null) { + var rateLimitResult = wrapper.getRateLimitResult(); + if (rateLimitResult.isConsumed()) { + remainingLimit = getRemainingLimit(remainingLimit, rateLimitResult); + } else { + log.debug("rate-limit!"); + allConsumed = false; + break; + } + } + } + + Object methodResult; + if (allConsumed) { + if (remainingLimit != null) { + log.debug("rate-limit-remaining-header;limit:{}", remainingLimit); + } + + methodResult = joinPoint.proceed(); + + for (var rlc : rateLimitConfigResult.getPostRateLimitChecks()) { + var result = rlc.rateLimit(null, methodResult); + if (result != null) { + log.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); + } + } + } else { + throw new RateLimitException(); + } + + return methodResult; + } + @Pointcut("@annotation(com.giffing.bucket4j.spring.boot.starter.context.RateLimiting)") + private void methodsAnnotatedWithRateLimitAnnotation() { + + } + + private long getRemainingLimit(Long remaining, RateLimitResult rateLimitResult) { + if (rateLimitResult != null) { + if (remaining == null) { + remaining = rateLimitResult.getRemainingTokens(); + } else if (rateLimitResult.getRemainingTokens() < remaining) { + remaining = rateLimitResult.getRemainingTokens(); + } + } + return remaining; + } +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java new file mode 100644 index 00000000..87e26ada --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java @@ -0,0 +1,4 @@ +package com.giffing.bucket4j.spring.boot.starter.config.aspect; + +public class RateLimitException extends RuntimeException{ +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java index 95979cbd..01e348bc 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java @@ -1,37 +1,21 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter; -import java.lang.reflect.InvocationTargetException; -import java.time.Duration; -import java.util.List; -import java.util.function.Predicate; -import java.util.stream.Collectors; - -import org.jetbrains.annotations.Nullable; -import org.springframework.beans.factory.BeanFactory; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; -import org.springframework.context.expression.BeanFactoryResolver; -import org.springframework.expression.Expression; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.support.StandardEvaluationContext; - import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateListener; import com.giffing.bucket4j.spring.boot.starter.config.cache.ProxyManagerWrapper; import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.gateway.Bucket4JAutoConfigurationSpringCloudGatewayFilter; import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.webflux.Bucket4JAutoConfigurationWebfluxFilter; import com.giffing.bucket4j.spring.boot.starter.config.filter.servlet.Bucket4JAutoConfigurationServletFilter; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import com.giffing.bucket4j.spring.boot.starter.context.*; -import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricBucketListener; import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; -import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricTagResult; import com.giffing.bucket4j.spring.boot.starter.context.properties.*; -import com.giffing.bucket4j.spring.boot.starter.exception.ExecutePredicateInstantiationException; - -import io.github.bucket4j.Bandwidth; -import io.github.bucket4j.BucketConfiguration; -import io.github.bucket4j.ConfigurationBuilder; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.Map; + /** * Holds helper Methods which are reused by the * {@link Bucket4JAutoConfigurationServletFilter} @@ -40,79 +24,44 @@ * configuration classes */ @Slf4j +@RequiredArgsConstructor public abstract class Bucket4JBaseConfiguration implements CacheUpdateListener { + private final RateLimitService rateLimitService; + private final CacheManager configCacheManager; - protected Bucket4JBaseConfiguration(CacheManager configCacheManager) { - this.configCacheManager = configCacheManager; - } + private final List metricHandlers; - public abstract List getMetricHandlers(); + private final Map> executePredicates; public FilterConfiguration buildFilterConfig( Bucket4JConfiguration config, - ProxyManagerWrapper proxyWrapper, - ExpressionParser expressionParser, - ConfigurableBeanFactory beanFactory) { + ProxyManagerWrapper proxyWrapper) { - FilterConfiguration filterConfig = mapFilterConfiguration(config); - config.getRateLimits().forEach(rl -> { - log.debug("RL: {}", rl.toString()); - var configurationBuilder = prepareBucket4jConfigurationBuilder(rl); - var executionPredicate = prepareExecutionPredicates(rl); - var skipPredicate = prepareSkipPredicates(rl); - var bucketConfiguration = configurationBuilder.build(); - RateLimitCheck rlc = servletRequest -> { - var skipRateLimit = performSkipRateLimitCheck(expressionParser, beanFactory, rl, executionPredicate, skipPredicate, servletRequest); - if (!skipRateLimit) { - var key = getKeyFilter(filterConfig.getUrl(), rl, expressionParser, beanFactory).key(servletRequest); - var metricBucketListener = createMetricListener(config.getCacheName(), expressionParser, beanFactory, filterConfig, servletRequest); - log.debug("try-and-consume;key:{};tokens:{}", key, rl.getNumTokens()); - final long configVersion = config.getBucket4JVersionNumber(); - return proxyWrapper.tryConsumeAndReturnRemaining( - key, - rl.getNumTokens(), - rl.getPostExecuteCondition() != null, - bucketConfiguration, - metricBucketListener, - configVersion, - rl.getTokensInheritanceStrategy() - ); - } - return null; - }; - filterConfig.addRateLimitCheck(rlc); + var rateLimitConfig = RateLimitService.RateLimitConfig.builder() + .rateLimits(config.getRateLimits()) + .metricHandlers(metricHandlers) + .executePredicates(executePredicates) + .cacheName(config.getCacheName()) + .configVersion(config.getBucket4JVersionNumber()) + .keyFunction((rl, sr) -> rateLimitService.getKeyFilter(config.getUrl(), rl).key(sr)) + .metrics(config.getMetrics()) + .proxyWrapper(proxyWrapper) + .build(); + + var rateLimitConfigResult = rateLimitService.configureRateLimit(rateLimitConfig); - if (rl.getPostExecuteCondition() != null) { - log.debug("PRL: {}", rl); - PostRateLimitCheck postRlc = (request, response) -> { - var skipRateLimit = performPostSkipRateLimitCheck(expressionParser, beanFactory, rl, - executionPredicate, skipPredicate, request, response); - if (!skipRateLimit) { - var key = getKeyFilter(filterConfig.getUrl(), rl, expressionParser, beanFactory).key(request); - var metricBucketListener = createMetricListener(config.getCacheName(), expressionParser, beanFactory, filterConfig, request); - log.debug("try-and-consume-post;key:{};tokens:{}", key, rl.getNumTokens()); - final long configVersion = config.getBucket4JVersionNumber(); - return proxyWrapper.tryConsumeAndReturnRemaining( - key, - rl.getNumTokens(), - false, - bucketConfiguration, - metricBucketListener, - configVersion, - rl.getTokensInheritanceStrategy() - ); - } - return null; - }; - filterConfig.addPostRateLimitCheck(postRlc); - } - }); + FilterConfiguration filterConfig = mapFilterConfiguration(config); + rateLimitConfigResult.getRateLimitChecks().forEach(filterConfig::addRateLimitCheck); + rateLimitConfigResult.getPostRateLimitChecks().forEach(filterConfig::addPostRateLimitCheck); + return filterConfig; } + + private FilterConfiguration mapFilterConfiguration(Bucket4JConfiguration config) { FilterConfiguration filterConfig = new FilterConfiguration<>(); filterConfig.setUrl(config.getUrl().strip()); @@ -127,245 +76,7 @@ private FilterConfiguration mapFilterConfiguration(Bucket4JConfiguration c return filterConfig; } - private boolean performPostSkipRateLimitCheck(ExpressionParser expressionParser, - ConfigurableBeanFactory beanFactory, - RateLimit rl, - Predicate executionPredicate, - Predicate skipPredicate, - R request, - P response - ) { - var skipRateLimit = performSkipRateLimitCheck( - expressionParser, beanFactory, - rl, executionPredicate, - skipPredicate, request); - - if (!skipRateLimit && rl.getPostExecuteCondition() != null) { - skipRateLimit = !executeResponseCondition(rl, expressionParser, beanFactory).evalute(response); - log.debug("skip-rate-limit - post-execute-condition: {}", skipRateLimit); - } - - return skipRateLimit; - } - - private boolean performSkipRateLimitCheck(ExpressionParser expressionParser, - ConfigurableBeanFactory beanFactory, - RateLimit rl, - Predicate executionPredicate, - Predicate skipPredicate, - R request) { - boolean skipRateLimit = false; - if (rl.getSkipCondition() != null) { - skipRateLimit = skipCondition(rl, expressionParser, beanFactory).evalute(request); - log.debug("skip-rate-limit - skip-condition: {}", skipRateLimit); - } - - if (!skipRateLimit) { - skipRateLimit = skipPredicate.test(request); - log.debug("skip-rate-limit - skip-predicates: {}", skipRateLimit); - } - - if (!skipRateLimit && rl.getExecuteCondition() != null) { - skipRateLimit = !executeCondition(rl, expressionParser, beanFactory).evalute(request); - log.debug("skip-rate-limit - execute-condition: {}", skipRateLimit); - } - - if (!skipRateLimit) { - skipRateLimit = !executionPredicate.test(request); - log.debug("skip-rate-limit - execute-predicates: {}", skipRateLimit); - } - return skipRateLimit; - } - - protected abstract ExecutePredicate getExecutePredicateByName(String name); - - private ConfigurationBuilder prepareBucket4jConfigurationBuilder(RateLimit rl) { - var configBuilder = BucketConfiguration.builder(); - for (BandWidth bandWidth : rl.getBandwidths()) { - long capacity = bandWidth.getCapacity(); - long refillCapacity = bandWidth.getRefillCapacity() != null ? bandWidth.getRefillCapacity() : bandWidth.getCapacity(); - var refillPeriod = Duration.of(bandWidth.getTime(), bandWidth.getUnit()); - var bucket4jBandWidth = switch (bandWidth.getRefillSpeed()) { - case GREEDY -> - Bandwidth.builder().capacity(capacity).refillGreedy(refillCapacity, refillPeriod).id(bandWidth.getId()); - case INTERVAL -> - Bandwidth.builder().capacity(capacity).refillIntervally(refillCapacity, refillPeriod).id(bandWidth.getId()); - }; - - if (bandWidth.getInitialCapacity() != null) { - bucket4jBandWidth = bucket4jBandWidth.initialTokens(bandWidth.getInitialCapacity()); - } - configBuilder = configBuilder.addLimit(bucket4jBandWidth.build()); - } - return configBuilder; - } - - private MetricBucketListener createMetricListener(String cacheName, - ExpressionParser expressionParser, - ConfigurableBeanFactory beanFactory, - FilterConfiguration filterConfig, - R servletRequest) { - - var metricTagResults = getMetricTags( - expressionParser, - beanFactory, - filterConfig, - servletRequest); - - return new MetricBucketListener( - cacheName, - getMetricHandlers(), - filterConfig.getMetrics().getTypes(), - metricTagResults); - } - - private List getMetricTags( - ExpressionParser expressionParser, - ConfigurableBeanFactory beanFactory, - FilterConfiguration filterConfig, - R servletRequest) { - - return filterConfig - .getMetrics() - .getTags() - .stream() - .map(metricMetaTag -> { - var context = new StandardEvaluationContext(); - context.setBeanResolver(new BeanFactoryResolver(beanFactory)); - //TODO performance problem - how can the request object reused in the expression without setting it as a rootObject - var expr = expressionParser.parseExpression(metricMetaTag.getExpression()); - var value = expr.getValue(context, servletRequest, String.class); - - return new MetricTagResult(metricMetaTag.getKey(), value, metricMetaTag.getTypes()); - }).toList(); - } - - /** - * Creates the key filter lambda which is responsible to decide how the rate limit will be performed. The key - * is the unique identifier like an IP address or a username. - * - * @param url is used to generated a unique cache key - * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string - * @param expressionParser is used to evaluate the expression if the filter key type is EXPRESSION. - * @param beanFactory used to get full access to all java beans in the SpEl - * @return should not been null. If no filter key type is matching a plain 1 is returned so that all requests uses the same key. - */ - public KeyFilter getKeyFilter(String url, RateLimit rateLimit, ExpressionParser expressionParser, BeanFactory beanFactory) { - var cacheKeyexpression = rateLimit.getCacheKey(); - var context = new StandardEvaluationContext(); - context.setBeanResolver(new BeanFactoryResolver(beanFactory)); - return request -> { - //TODO performance problem - how can the request object reused in the expression without setting it as a rootObject - Expression expr = expressionParser.parseExpression(cacheKeyexpression); - final String value = expr.getValue(context, request, String.class); - return url + "-" + value; - }; - } - - /** - * Creates the lambda for the skip condition which will be evaluated on each request - * - * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string - * @param expressionParser is used to evaluate the skip expression - * @param beanFactory used to get full access to all java beans in the SpEl - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - public Condition skipCondition(RateLimit rateLimit, ExpressionParser expressionParser, BeanFactory beanFactory) { - var context = new StandardEvaluationContext(); - context.setBeanResolver(new BeanFactoryResolver(beanFactory)); - - if (rateLimit.getSkipCondition() != null) { - return request -> { - Expression expr = expressionParser.parseExpression(rateLimit.getSkipCondition()); - return expr.getValue(context, request, Boolean.class); - }; - } - return null; - } - - /** - * Creates the lambda for the execute condition which will be evaluated on each request. - * - * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string - * @param expressionParser is used to evaluate the execution expression - * @param beanFactory used to get full access to all java beans in the SpEl - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - public Condition executeCondition(RateLimit rateLimit, ExpressionParser expressionParser, BeanFactory beanFactory) { - return executeExpression(rateLimit.getExecuteCondition(), expressionParser, beanFactory); - } - - /** - * Creates the lambda for the execute condition which will be evaluated on each request. - * - * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string - * @param expressionParser is used to evaluate the execution expression - * @param beanFactory used to get full access to all java beans in the SpEl - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - public Condition

executeResponseCondition(RateLimit rateLimit, ExpressionParser expressionParser, BeanFactory beanFactory) { - return executeExpression(rateLimit.getPostExecuteCondition(), expressionParser, beanFactory); - } - - @Nullable - private static Condition executeExpression(String condition, ExpressionParser expressionParser, BeanFactory beanFactory) { - var context = new StandardEvaluationContext(); - context.setBeanResolver(new BeanFactoryResolver(beanFactory)); - - if (condition != null) { - return request -> { - Expression expr = expressionParser.parseExpression(condition); - return Boolean.TRUE.equals(expr.getValue(context, request, Boolean.class)); - }; - } - return null; - } - - - protected void addDefaultMetricTags(Bucket4JBootProperties properties, Bucket4JConfiguration filter) { - if (!properties.getDefaultMetricTags().isEmpty()) { - var metricTags = filter.getMetrics().getTags(); - var filterMetricTagKeys = metricTags - .stream() - .map(MetricTag::getKey) - .collect(Collectors.toSet()); - properties.getDefaultMetricTags().forEach(defaultTag -> { - if (!filterMetricTagKeys.contains(defaultTag.getKey())) { - metricTags.add(defaultTag); - } - }); - } - } - - private Predicate prepareExecutionPredicates(RateLimit rl) { - return rl.getExecutePredicates() - .stream() - .map(this::createPredicate) - .reduce(Predicate::and) - .orElseGet(() -> p -> true); - } - - private Predicate prepareSkipPredicates(RateLimit rl) { - return rl.getSkipPredicates() - .stream() - .map(this::createPredicate) - .reduce(Predicate::and) - .orElseGet(() -> p -> false); - } - - protected Predicate createPredicate(ExecutePredicateDefinition pd) { - var predicate = getExecutePredicateByName(pd.getName()); - log.debug("create-predicate;name:{};value:{}", pd.getName(), pd.getArgs()); - try { - @SuppressWarnings("unchecked") - ExecutePredicate newPredicateInstance = predicate.getClass().getDeclaredConstructor().newInstance(); - return newPredicateInstance.init(pd.getArgs()); - } catch (InstantiationException | IllegalAccessException | IllegalArgumentException - | InvocationTargetException | NoSuchMethodException | SecurityException e) { - throw new ExecutePredicateInstantiationException(pd.getName(), predicate.getClass()); - } - } /** * Try to load a filter configuration from the cache with the same id as the provided filter. diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java index b9aebf59..ae64219f 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java @@ -7,6 +7,8 @@ import com.giffing.bucket4j.spring.boot.starter.config.filter.Bucket4JBaseConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.predicate.WebfluxExecutePredicateConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicate; import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; @@ -16,7 +18,7 @@ import com.giffing.bucket4j.spring.boot.starter.filter.reactive.gateway.SpringCloudGatewayRateLimitFilter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; @@ -29,14 +31,14 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.expression.ExpressionParser; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.StringUtils; import java.util.List; -import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; /** * Configures Servlet Filters for Bucket4Js rate limit. @@ -48,42 +50,39 @@ @AutoConfigureBefore(GatewayAutoConfiguration.class) @AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) @ConditionalOnBean(value = AsyncCacheResolver.class) -@Import(value = { WebfluxExecutePredicateConfiguration.class, SpringBootActuatorConfig.class, Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.class }) +@Import(value = { ServiceConfiguration.class, WebfluxExecutePredicateConfiguration.class, SpringBootActuatorConfig.class, Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.class }) public class Bucket4JAutoConfigurationSpringCloudGatewayFilter extends Bucket4JBaseConfiguration { private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationSpringCloudGatewayFilter.class); private final Bucket4JBootProperties properties; - private final ConfigurableBeanFactory beanFactory; - private final GenericApplicationContext context; private final AsyncCacheResolver cacheResolver; - private final List metricHandlers; + private final RateLimitService rateLimitService; private final Bucket4jConfigurationHolder gatewayConfigurationHolder; - private final ExpressionParser gatewayFilterExpressionParser; public Bucket4JAutoConfigurationSpringCloudGatewayFilter( Bucket4JBootProperties properties, - ConfigurableBeanFactory beanFactory, GenericApplicationContext context, AsyncCacheResolver cacheResolver, List metricHandlers, + List> executePredicates, Bucket4jConfigurationHolder gatewayConfigurationHolder, - ExpressionParser gatewayFilterExpressionParser, - Optional> configCacheManager) { - super(configCacheManager.orElse(null)); + RateLimitService rateLimitService, + @Autowired(required = false) CacheManager configCacheManager) { + super(rateLimitService, configCacheManager, metricHandlers, executePredicates + .stream() + .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); this.properties = properties; - this.beanFactory = beanFactory; this.context = context; this.cacheResolver = cacheResolver; - this.metricHandlers = metricHandlers; + this.rateLimitService = rateLimitService; this.gatewayConfigurationHolder = gatewayConfigurationHolder; - this.gatewayFilterExpressionParser = gatewayFilterExpressionParser; initFilters(); } @@ -96,13 +95,11 @@ public void initFilters() { .filter(filter -> StringUtils.hasText(filter.getUrl()) && filter.getFilterMethod().equals(FilterMethod.GATEWAY)) .map(filter -> properties.isFilterConfigCachingEnabled() ? getOrUpdateConfigurationFromCache(filter) : filter) .forEach(filter -> { - addDefaultMetricTags(properties, filter); + rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); var filterConfig = buildFilterConfig( filter, - cacheResolver.resolve(filter.getCacheName()), - gatewayFilterExpressionParser, - beanFactory); + cacheResolver.resolve(filter.getCacheName())); gatewayConfigurationHolder.addFilterConfiguration(filter); @@ -114,16 +111,6 @@ public void initFilters() { }); } - @Override - public List getMetricHandlers() { - return this.metricHandlers; - } - - - @Override - protected ExecutePredicate getExecutePredicateByName(String name) { - throw new UnsupportedOperationException("Execution predicates not supported"); - } @Override public void onCacheUpdateEvent(CacheUpdateEvent event) { @@ -134,9 +121,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e SpringCloudGatewayRateLimitFilter filter = context.getBean(event.getKey(), SpringCloudGatewayRateLimitFilter.class); var newFilterConfig = buildFilterConfig( newConfig, - cacheResolver.resolve(newConfig.getCacheName()), - gatewayFilterExpressionParser, - beanFactory); + cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Gateway Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.java index ddcfb4ed..4010a4ea 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.java @@ -4,10 +4,6 @@ import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Gateway; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.SpelCompilerMode; -import org.springframework.expression.spel.SpelParserConfiguration; -import org.springframework.expression.spel.standard.SpelExpressionParser; @Configuration public class Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans { @@ -18,12 +14,5 @@ public Bucket4jConfigurationHolder gatewayConfigurationHolder() { return new Bucket4jConfigurationHolder(); } - @Bean - public ExpressionParser gatewayFilterExpressionParser() { - SpelParserConfiguration config = new SpelParserConfiguration( - SpelCompilerMode.IMMEDIATE, - this.getClass().getClassLoader()); - return new SpelExpressionParser(config); - } - + } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java index e8ac0097..d97124e9 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java @@ -1,15 +1,15 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.webflux; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; @@ -21,7 +21,6 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.expression.ExpressionParser; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.util.StringUtils; @@ -56,48 +55,39 @@ @AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) @ConditionalOnBean(value = AsyncCacheResolver.class) @EnableConfigurationProperties({ Bucket4JBootProperties.class}) -@Import(value = { WebfluxExecutePredicateConfiguration.class, Bucket4JAutoConfigurationWebfluxFilterBeans.class, SpringBootActuatorConfig.class }) +@Import(value = { ServiceConfiguration.class, WebfluxExecutePredicateConfiguration.class, Bucket4JAutoConfigurationWebfluxFilterBeans.class, SpringBootActuatorConfig.class }) public class Bucket4JAutoConfigurationWebfluxFilter extends Bucket4JBaseConfiguration { private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationWebfluxFilter.class); private final Bucket4JBootProperties properties; - private final ConfigurableBeanFactory beanFactory; - private final GenericApplicationContext context; private final AsyncCacheResolver cacheResolver; - private final List metricHandlers; - - private final Map> executePredicates; + private final RateLimitService rateLimitService; private final Bucket4jConfigurationHolder servletConfigurationHolder; - private final ExpressionParser webfluxFilterExpressionParser; public Bucket4JAutoConfigurationWebfluxFilter( Bucket4JBootProperties properties, - ConfigurableBeanFactory beanFactory, GenericApplicationContext context, AsyncCacheResolver cacheResolver, List metricHandlers, List> executePredicates, Bucket4jConfigurationHolder servletConfigurationHolder, - ExpressionParser webfluxFilterExpressionParser, - Optional> configCacheManager) { - super(configCacheManager.orElse(null)); + RateLimitService rateLimitService, + @Autowired(required = false) CacheManager configCacheManager) { + super(rateLimitService, configCacheManager, metricHandlers, executePredicates + .stream() + .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); this.properties = properties; - this.beanFactory = beanFactory; this.context = context; this.cacheResolver = cacheResolver; - this.metricHandlers = metricHandlers; - this.executePredicates = executePredicates - .stream() - .collect(Collectors.toMap(ExecutePredicate::name, Function.identity())); + this.rateLimitService = rateLimitService; this.servletConfigurationHolder = servletConfigurationHolder; - this.webfluxFilterExpressionParser = webfluxFilterExpressionParser; } @PostConstruct @@ -109,12 +99,10 @@ public void initFilters() { .filter(filter -> StringUtils.hasText(filter.getUrl()) && filter.getFilterMethod().equals(FilterMethod.WEBFLUX)) .map(filter -> properties.isFilterConfigCachingEnabled() ? getOrUpdateConfigurationFromCache(filter) : filter) .forEach(filter -> { - addDefaultMetricTags(properties, filter); + rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); FilterConfiguration filterConfig = buildFilterConfig(filter, cacheResolver.resolve( - filter.getCacheName()), - webfluxFilterExpressionParser, - beanFactory); + filter.getCacheName())); servletConfigurationHolder.addFilterConfiguration(filter); @@ -126,15 +114,6 @@ public void initFilters() { }); } - @Override - public List getMetricHandlers() { - return this.metricHandlers; - } - - @Override - protected ExecutePredicate getExecutePredicateByName(String name) { - return executePredicates.getOrDefault(name, null); - } @Override public void onCacheUpdateEvent(CacheUpdateEvent event) { @@ -145,9 +124,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e WebfluxWebFilter filter = context.getBean(event.getKey(), WebfluxWebFilter.class); FilterConfiguration newFilterConfig = buildFilterConfig( newConfig, - cacheResolver.resolve(newConfig.getCacheName()), - webfluxFilterExpressionParser, - beanFactory); + cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Webflux Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilterBeans.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilterBeans.java index 21e6fc08..fbfb6237 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilterBeans.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilterBeans.java @@ -4,10 +4,6 @@ import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Webflux; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.SpelCompilerMode; -import org.springframework.expression.spel.SpelParserConfiguration; -import org.springframework.expression.spel.standard.SpelExpressionParser; @Configuration public class Bucket4JAutoConfigurationWebfluxFilterBeans { @@ -18,12 +14,4 @@ public Bucket4jConfigurationHolder servletConfigurationHolder() { return new Bucket4jConfigurationHolder(); } - @Bean - public ExpressionParser webfluxFilterExpressionParser() { - SpelParserConfiguration config = new SpelParserConfiguration( - SpelCompilerMode.IMMEDIATE, - this.getClass().getClassLoader()); - return new SpelExpressionParser(config); - } - } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java index 95d25193..e05bfd51 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java @@ -7,6 +7,8 @@ import com.giffing.bucket4j.spring.boot.starter.config.filter.Bucket4JBaseConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.filter.servlet.predicate.ServletRequestExecutePredicateConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicate; import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; @@ -18,7 +20,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; @@ -32,12 +34,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; import org.springframework.context.support.GenericApplicationContext; -import org.springframework.expression.ExpressionParser; import org.springframework.util.StringUtils; import java.util.List; -import java.util.Map; -import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Function; import java.util.stream.Collectors; @@ -52,48 +51,38 @@ @AutoConfigureBefore(ServletWebServerFactoryAutoConfiguration.class) @AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) @ConditionalOnBean(value = SyncCacheResolver.class) -@Import(value = {ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class }) +@Import(value = { ServiceConfiguration.class, ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class }) @Slf4j public class Bucket4JAutoConfigurationServletFilter extends Bucket4JBaseConfiguration implements WebServerFactoryCustomizer { private final Bucket4JBootProperties properties; - private final ConfigurableBeanFactory beanFactory; - private final GenericApplicationContext context; private final SyncCacheResolver cacheResolver; - private final List metricHandlers; - - private final Map> executePredicates; + private final RateLimitService rateLimitService; private final Bucket4jConfigurationHolder servletConfigurationHolder; - private final ExpressionParser servletFilterExpressionParser; - public Bucket4JAutoConfigurationServletFilter( Bucket4JBootProperties properties, - ConfigurableBeanFactory beanFactory, GenericApplicationContext context, SyncCacheResolver cacheResolver, List metricHandlers, List> executePredicates, Bucket4jConfigurationHolder servletConfigurationHolder, - ExpressionParser servletFilterExpressionParser, - Optional> configCacheManager) { - super(configCacheManager.orElse(null)); + RateLimitService rateLimitService, + @Autowired(required = false) CacheManager configCacheManager) { + super(rateLimitService, configCacheManager, metricHandlers, executePredicates + .stream() + .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); this.properties = properties; - this.beanFactory = beanFactory; this.context = context; this.cacheResolver = cacheResolver; - this.metricHandlers = metricHandlers; - this.executePredicates = executePredicates - .stream() - .collect(Collectors.toMap(ExecutePredicate::name, Function.identity())); + this.rateLimitService = rateLimitService; this.servletConfigurationHolder = servletConfigurationHolder; - this.servletFilterExpressionParser = servletFilterExpressionParser; } @Override @@ -105,12 +94,11 @@ public void customize(ConfigurableServletWebServerFactory factory) { .filter(filter -> StringUtils.hasText(filter.getUrl()) && filter.getFilterMethod().equals(FilterMethod.SERVLET)) .map(filter -> properties.isFilterConfigCachingEnabled() ? getOrUpdateConfigurationFromCache(filter) : filter) .forEach(filter -> { - addDefaultMetricTags(properties, filter); + rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); var filterConfig = buildFilterConfig( filter, - cacheResolver.resolve(filter.getCacheName()), - servletFilterExpressionParser, beanFactory); + cacheResolver.resolve(filter.getCacheName())); servletConfigurationHolder.addFilterConfiguration(filter); @@ -122,16 +110,6 @@ public void customize(ConfigurableServletWebServerFactory factory) { }); } - @Override - public List getMetricHandlers() { - return this.metricHandlers; - } - - @Override - protected ExecutePredicate getExecutePredicateByName(String name) { - return executePredicates.getOrDefault(name, null); - } - @Override public void onCacheUpdateEvent(CacheUpdateEvent event) { //only handle servlet filter updates @@ -141,8 +119,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e ServletRequestFilter filter = context.getBean(event.getKey(), ServletRequestFilter.class); var newFilterConfig = buildFilterConfig( newConfig, - cacheResolver.resolve(newConfig.getCacheName()), - servletFilterExpressionParser, beanFactory); + cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Servlet Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilterBeans.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilterBeans.java index 569e3335..b136a942 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilterBeans.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilterBeans.java @@ -4,10 +4,6 @@ import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Servlet; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.expression.ExpressionParser; -import org.springframework.expression.spel.SpelCompilerMode; -import org.springframework.expression.spel.SpelParserConfiguration; -import org.springframework.expression.spel.standard.SpelExpressionParser; @Configuration public class Bucket4JAutoConfigurationServletFilterBeans { @@ -17,11 +13,5 @@ public class Bucket4JAutoConfigurationServletFilterBeans { public Bucket4jConfigurationHolder servletConfigurationHolder() { return new Bucket4jConfigurationHolder(); } - - @Bean - public ExpressionParser servletFilterExpressionParser() { - SpelParserConfiguration config = new SpelParserConfiguration(SpelCompilerMode.IMMEDIATE, this.getClass().getClassLoader()); - return new SpelExpressionParser(config); - } - + } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java new file mode 100644 index 00000000..a98e7e0f --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java @@ -0,0 +1,34 @@ +package com.giffing.bucket4j.spring.boot.starter.config.service; + +import com.giffing.bucket4j.spring.boot.starter.service.ExpressionService; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.SpelCompilerMode; +import org.springframework.expression.spel.SpelParserConfiguration; +import org.springframework.expression.spel.standard.SpelExpressionParser; + +@Configuration +public class ServiceConfiguration { + + + @Bean + public ExpressionParser expressionParser() { + SpelParserConfiguration config = new SpelParserConfiguration( + SpelCompilerMode.IMMEDIATE, + this.getClass().getClassLoader()); + return new SpelExpressionParser(config); + } + + @Bean + ExpressionService expressionService(ExpressionParser expressionParser, ConfigurableBeanFactory beanFactory) { + return new ExpressionService(expressionParser, beanFactory); + } + + @Bean + RateLimitService rateLimitService(ExpressionService expressionService) { + return new RateLimitService(expressionService); + } +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java index 1d85571c..94a0f2e0 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java @@ -42,7 +42,7 @@ protected Mono chainWithRateLimitCheck(ServerWebExchange exchange, Reactiv var response = exchange.getResponse(); List> asyncConsumptionProbes = new ArrayList<>(); for (var rlc : filterConfig.getRateLimitChecks()) { - var wrapper = rlc.rateLimit(request); + var wrapper = rlc.rateLimit(request, null); if(wrapper != null && wrapper.getRateLimitResultCompletableFuture() != null){ asyncConsumptionProbes.add(Mono.fromFuture(wrapper.getRateLimitResultCompletableFuture())); if(filterConfig.getStrategy() == RateLimitConditionMatchingStrategy.FIRST){ diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java index 52b700d7..033b406f 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java @@ -44,7 +44,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse boolean allConsumed = true; Long remainingLimit = null; for (RateLimitCheck rl : filterConfig.getRateLimitChecks()) { - var wrapper = rl.rateLimit(request); + var wrapper = rl.rateLimit(request, null); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { @@ -58,7 +58,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse break; } } - } if (allConsumed) { diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java new file mode 100644 index 00000000..1fe07ae3 --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java @@ -0,0 +1,31 @@ +package com.giffing.bucket4j.spring.boot.starter.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.expression.BeanFactoryResolver; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +@RequiredArgsConstructor +public class ExpressionService { + + private final ExpressionParser expressionParser; + + private final ConfigurableBeanFactory beanFactory; + + public String parseString(String expression, Object rootObject) { + var expr = expressionParser.parseExpression(expression); + return expr.getValue(getContext(), rootObject, String.class); + } + + public Boolean parseBoolean(String expression, Object request) { + var expr = expressionParser.parseExpression(expression); + return Boolean.TRUE.equals(expr.getValue(getContext(), request, Boolean.class)); + } + + private StandardEvaluationContext getContext() { + var context = new StandardEvaluationContext(); + context.setBeanResolver(new BeanFactoryResolver(beanFactory)); + return context; + } +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java new file mode 100644 index 00000000..95b94d4e --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java @@ -0,0 +1,331 @@ +package com.giffing.bucket4j.spring.boot.starter.service; + + +import com.giffing.bucket4j.spring.boot.starter.config.cache.ProxyManagerWrapper; +import com.giffing.bucket4j.spring.boot.starter.context.*; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricBucketListener; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricTagResult; +import com.giffing.bucket4j.spring.boot.starter.context.properties.*; +import com.giffing.bucket4j.spring.boot.starter.exception.ExecutePredicateInstantiationException; +import io.github.bucket4j.Bandwidth; +import io.github.bucket4j.BucketConfiguration; +import io.github.bucket4j.ConfigurationBuilder; +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.InvocationTargetException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@Slf4j +@RequiredArgsConstructor +public class RateLimitService { + + private final ExpressionService expressionService; + + @Builder + @Data + public static class RateLimitConfig { + @NonNull private List rateLimits; + @NonNull private List metricHandlers; + @NonNull private Map> executePredicates; + @NonNull private String cacheName; + @NonNull private ProxyManagerWrapper proxyWrapper; + @NonNull private BiFunction keyFunction; + @NonNull private Metrics metrics; + private long configVersion; + } + + @Builder + @Data + public static class RateLimitConfigresult { + private List> rateLimitChecks; + private List> postRateLimitChecks; + } + + public RateLimitConfigresult configureRateLimit(RateLimitConfig rateLimitConfig) { + + + var metricHandlers = rateLimitConfig.getMetricHandlers(); + var executePredicates = rateLimitConfig.getExecutePredicates(); + var cacheName = rateLimitConfig.getCacheName(); + var metrics = rateLimitConfig.getMetrics(); + var keyFunction = rateLimitConfig.getKeyFunction(); + var proxyWrapper = rateLimitConfig.getProxyWrapper(); + var configVersion = rateLimitConfig.getConfigVersion(); + + List> rateLimitChecks = new ArrayList<>(); + List> postRateLimitChecks = new ArrayList<>(); + rateLimitConfig.getRateLimits().forEach(rl -> { + log.debug("RL: {}", rl.toString()); + var bucketConfiguration = prepareBucket4jConfigurationBuilder(rl).build(); + var executionPredicate = prepareExecutionPredicates(rl, executePredicates); + var skipPredicate = prepareSkipPredicates(rl, executePredicates); + + RateLimitCheck rlc = (rootObject, overridableRateLimit) -> { + + var rlToUse = rl.copy(); + rlToUse.consumeNotNullValues(overridableRateLimit); + + var skipRateLimit = performSkipRateLimitCheck(rlToUse, executionPredicate, skipPredicate, rootObject); + if (!skipRateLimit) { + var key = keyFunction.apply(rlToUse, rootObject); + var metricBucketListener = createMetricListener(cacheName, metrics, metricHandlers, rootObject); + log.debug("try-and-consume;key:{};tokens:{}", key, rlToUse.getNumTokens()); + return proxyWrapper.tryConsumeAndReturnRemaining( + key, + rlToUse.getNumTokens(), + rlToUse.getPostExecuteCondition() != null, + bucketConfiguration, + metricBucketListener, + configVersion, + rlToUse.getTokensInheritanceStrategy() + ); + } + return null; + }; + rateLimitChecks.add(rlc); + + + if (rl.getPostExecuteCondition() != null) { + log.debug("PRL: {}", rl); + PostRateLimitCheck postRlc = (rootObject, response) -> { + var skipRateLimit = performPostSkipRateLimitCheck(rl, + executionPredicate, skipPredicate, rootObject, response); + if (!skipRateLimit) { + var key = keyFunction.apply(rl, rootObject); + var metricBucketListener = createMetricListener(cacheName, metrics, metricHandlers, rootObject); + log.debug("try-and-consume-post;key:{};tokens:{}", key, rl.getNumTokens()); + return proxyWrapper.tryConsumeAndReturnRemaining( + key, + rl.getNumTokens(), + false, + bucketConfiguration, + metricBucketListener, + configVersion, + rl.getTokensInheritanceStrategy() + ); + } + return null; + }; + postRateLimitChecks.add(postRlc); + + } + }); + + return new RateLimitConfigresult<>(rateLimitChecks, postRateLimitChecks); + } + + + private boolean performPostSkipRateLimitCheck(RateLimit rl, + Predicate executionPredicate, + Predicate skipPredicate, + R request, + P response + ) { + var skipRateLimit = performSkipRateLimitCheck( + rl, executionPredicate, + skipPredicate, request); + + if (!skipRateLimit && rl.getPostExecuteCondition() != null) { + skipRateLimit = !executeResponseCondition(rl).evalute(response); + log.debug("skip-rate-limit - post-execute-condition: {}", skipRateLimit); + } + + return skipRateLimit; + } + + private boolean performSkipRateLimitCheck(RateLimit rl, + Predicate executionPredicate, + Predicate skipPredicate, + R rootObject) { + boolean skipRateLimit = false; + if (rl.getSkipCondition() != null) { + skipRateLimit = skipCondition(rl).evalute(rootObject); + log.debug("skip-rate-limit - skip-condition: {}", skipRateLimit); + } + + if (!skipRateLimit) { + skipRateLimit = skipPredicate.test(rootObject); + log.debug("skip-rate-limit - skip-predicates: {}", skipRateLimit); + } + + if (!skipRateLimit && rl.getExecuteCondition() != null) { + skipRateLimit = !executeCondition(rl).evalute(rootObject); + log.debug("skip-rate-limit - execute-condition: {}", skipRateLimit); + } + + if (!skipRateLimit) { + skipRateLimit = !executionPredicate.test(rootObject); + log.debug("skip-rate-limit - execute-predicates: {}", skipRateLimit); + } + return skipRateLimit; + } + + /** + * Creates the lambda for the execute condition which will be evaluated on each request. + * + * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string + * @return the lambda condition which will be evaluated lazy - null if there is no condition available. + */ + private

Condition

executeResponseCondition(RateLimit rateLimit) { + return executeExpression(rateLimit.getPostExecuteCondition()); + } + + /** + * Creates the lambda for the skip condition which will be evaluated on each request + * + * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string + * @return the lambda condition which will be evaluated lazy - null if there is no condition available. + */ + private Condition skipCondition(RateLimit rateLimit) { + if (rateLimit.getSkipCondition() != null) { + return request -> expressionService.parseBoolean(rateLimit.getSkipCondition(), request); + } + return null; + } + + + /** + * Creates the lambda for the execute condition which will be evaluated on each request. + * + * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string + * @return the lambda condition which will be evaluated lazy - null if there is no condition available. + */ + private Condition executeCondition(RateLimit rateLimit) { + return executeExpression(rateLimit.getExecuteCondition()); + } + + + + private Condition executeExpression(String condition) { + if (condition != null) { + return request -> expressionService.parseBoolean(condition, request); + } + return null; + } + + public List getMetricTagResults(R rootObject, Metrics metrics) { + return metrics + .getTags() + .stream() + .map(metricMetaTag -> { + var value = expressionService.parseString(metricMetaTag.getExpression(), rootObject); + return new MetricTagResult(metricMetaTag.getKey(), value, metricMetaTag.getTypes()); + }).toList(); + } + + /** + * Creates the key filter lambda which is responsible to decide how the rate limit will be performed. The key + * is the unique identifier like an IP address or a username. + * + * @param url is used to generated a unique cache key + * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string + * @return should not been null. If no filter key type is matching a plain 1 is returned so that all requests uses the same key. + */ + public KeyFilter getKeyFilter(String url, RateLimit rateLimit) { + return request -> { + var value = expressionService.parseString(rateLimit.getCacheKey(), request); + return url + "-" + value; + }; + } + + + private ConfigurationBuilder prepareBucket4jConfigurationBuilder(RateLimit rl) { + var configBuilder = BucketConfiguration.builder(); + for (BandWidth bandWidth : rl.getBandwidths()) { + long capacity = bandWidth.getCapacity(); + long refillCapacity = bandWidth.getRefillCapacity() != null ? bandWidth.getRefillCapacity() : bandWidth.getCapacity(); + var refillPeriod = Duration.of(bandWidth.getTime(), bandWidth.getUnit()); + var bucket4jBandWidth = switch (bandWidth.getRefillSpeed()) { + case GREEDY -> + Bandwidth.builder().capacity(capacity).refillGreedy(refillCapacity, refillPeriod).id(bandWidth.getId()); + case INTERVAL -> + Bandwidth.builder().capacity(capacity).refillIntervally(refillCapacity, refillPeriod).id(bandWidth.getId()); + }; + + if (bandWidth.getInitialCapacity() != null) { + bucket4jBandWidth = bucket4jBandWidth.initialTokens(bandWidth.getInitialCapacity()); + } + configBuilder = configBuilder.addLimit(bucket4jBandWidth.build()); + } + return configBuilder; + } + + private MetricBucketListener createMetricListener(String cacheName, + Metrics metrics, + List metricHandlers, + R rootObject) { + + var metricTagResults = getMetricTags( + metrics, + rootObject); + + return new MetricBucketListener( + cacheName, + metricHandlers, + metrics.getTypes(), + metricTagResults); + } + + private List getMetricTags( + Metrics metrics, + R servletRequest) { + + return getMetricTagResults(servletRequest, metrics); + } + + public void addDefaultMetricTags(Bucket4JBootProperties properties, Bucket4JConfiguration filter) { + if (!properties.getDefaultMetricTags().isEmpty()) { + var metricTags = filter.getMetrics().getTags(); + var filterMetricTagKeys = metricTags + .stream() + .map(MetricTag::getKey) + .collect(Collectors.toSet()); + properties.getDefaultMetricTags().forEach(defaultTag -> { + if (!filterMetricTagKeys.contains(defaultTag.getKey())) { + metricTags.add(defaultTag); + } + }); + } + } + + private Predicate prepareExecutionPredicates(RateLimit rl, Map> executePredicates) { + return rl.getExecutePredicates() + .stream() + .map(p -> createPredicate(p, executePredicates)) + .reduce(Predicate::and) + .orElseGet(() -> p -> true); + } + + private Predicate prepareSkipPredicates(RateLimit rl, Map> executePredicates) { + return rl.getSkipPredicates() + .stream() + .map(p -> createPredicate(p, executePredicates)) + .reduce(Predicate::and) + .orElseGet(() -> p -> false); + } + + protected Predicate createPredicate(ExecutePredicateDefinition pd, Map> executePredicates) { + var predicate = executePredicates.getOrDefault(pd.getName(), null); + log.debug("create-predicate;name:{};value:{}", pd.getName(), pd.getArgs()); + try { + @SuppressWarnings("unchecked") + ExecutePredicate newPredicateInstance = predicate.getClass().getDeclaredConstructor().newInstance(); + return newPredicateInstance.init(pd.getArgs()); + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException + | InvocationTargetException | NoSuchMethodException | SecurityException e) { + throw new ExecutePredicateInstantiationException(pd.getName(), predicate.getClass()); + } + } + +} diff --git a/bucket4j-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/bucket4j-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index e1387ab7..3ef6295b 100644 --- a/bucket4j-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/bucket4j-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,4 +1,5 @@ com.giffing.bucket4j.spring.boot.starter.config.cache.Bucket4jCacheConfiguration +com.giffing.bucket4j.spring.boot.starter.config.aspect.AopConfig com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.gateway.Bucket4JAutoConfigurationSpringCloudGatewayFilter com.giffing.bucket4j.spring.boot.starter.config.filter.servlet.Bucket4JAutoConfigurationServletFilter com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.webflux.Bucket4JAutoConfigurationWebfluxFilter diff --git a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/gateway/SpringCloudGatewayRateLimitFilterTest.java b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/gateway/SpringCloudGatewayRateLimitFilterTest.java index edc5fa7b..01eeafb6 100644 --- a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/gateway/SpringCloudGatewayRateLimitFilterTest.java +++ b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/gateway/SpringCloudGatewayRateLimitFilterTest.java @@ -107,9 +107,9 @@ void should_execute_all_checks_when_using_RateLimitConditionMatchingStrategy_All result.block(); }); - verify(rateLimitCheck1, times(1)).rateLimit(any()); - verify(rateLimitCheck2, times(1)).rateLimit(any()); - verify(rateLimitCheck3, times(1)).rateLimit(any()); + verify(rateLimitCheck1, times(1)).rateLimit(any(), any()); + verify(rateLimitCheck2, times(1)).rateLimit(any(), any()); + verify(rateLimitCheck3, times(1)).rateLimit(any(), any()); } @Test @@ -132,9 +132,9 @@ void should_execute_only_one_check_when_using_RateLimitConditionMatchingStrategy List values = captor.getAllValues(); Assertions.assertEquals("30", values.stream().findFirst().get()); - verify(rateLimitCheck1, times(1)).rateLimit(any()); - verify(rateLimitCheck2, times(0)).rateLimit(any()); - verify(rateLimitCheck3, times(0)).rateLimit(any()); + verify(rateLimitCheck1, times(1)).rateLimit(any(), any()); + verify(rateLimitCheck2, times(0)).rateLimit(any(), any()); + verify(rateLimitCheck3, times(0)).rateLimit(any(), any()); } private void rateLimitConfig(Long remainingTokens, RateLimitCheck rateLimitCheck) { @@ -144,6 +144,6 @@ private void rateLimitConfig(Long remainingTokens, RateLimitCheck values = captor.getAllValues(); Assertions.assertEquals("30", values.stream().findFirst().get()); - verify(rateLimitCheck1, times(1)).rateLimit(any()); - verify(rateLimitCheck2, times(0)).rateLimit(any()); - verify(rateLimitCheck3, times(0)).rateLimit(any()); + verify(rateLimitCheck1, times(1)).rateLimit(any(), any()); + verify(rateLimitCheck2, times(0)).rateLimit(any(), any()); + verify(rateLimitCheck3, times(0)).rateLimit(any(), any()); } private void rateLimitConfig(Long remainingTokens, RateLimitCheck rateLimitCheck) { @@ -147,7 +147,7 @@ private void rateLimitConfig(Long remainingTokens, RateLimitCheckorg.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-aop + org.springframework.boot spring-boot-starter-actuator diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineApplication.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineApplication.java index ad2c9e28..947e97d7 100644 --- a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineApplication.java +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.EnableAspectJAutoProxy; @SpringBootApplication @EnableCaching +@EnableAspectJAutoProxy public class CaffeineApplication { public static void main(String[] args) { diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/RateLimitExceptionHandler.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/RateLimitExceptionHandler.java new file mode 100644 index 00000000..ff681e88 --- /dev/null +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/RateLimitExceptionHandler.java @@ -0,0 +1,18 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; + +import com.giffing.bucket4j.spring.boot.starter.config.aspect.RateLimitException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class RateLimitExceptionHandler { + + + @ExceptionHandler(value = {RateLimitException.class}) + protected ResponseEntity handleRateLimit(RateLimitException e) { + return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); + } + +} diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java index 43bccd15..1b724026 100644 --- a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java @@ -5,6 +5,7 @@ import java.util.Optional; import java.util.Set; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; @@ -41,6 +42,7 @@ public ResponseEntity unsecure() { } @GetMapping("hello") + @RateLimiting(name = "hello", executeCondition = "1 eq 1") public ResponseEntity hello() { return ResponseEntity.ok("Hello World"); } diff --git a/examples/caffeine/src/main/resources/application.yml b/examples/caffeine/src/main/resources/application.yml index 63cdab69..0a56d322 100644 --- a/examples/caffeine/src/main/resources/application.yml +++ b/examples/caffeine/src/main/resources/application.yml @@ -21,6 +21,19 @@ bucket4j: enabled: true filter-config-caching-enabled: true filter-config-cache-name: filterConfigCache + methods: + - name: hello + cache-name: buckets + rate-limit: + cache-key: 1 + post-execute-condition: true + bandwidths: + - capacity: 1 + refill-capacity: 1 + time: 30 + unit: seconds + initial-capacity: 1 + refill-speed: interval filters: - id: filter1 cache-name: buckets From adb79dbbdfed10c12bd5e57486df19f91ffa7276 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Fri, 8 Mar 2024 15:21:11 +0100 Subject: [PATCH 2/9] Support for Method level @RateLimiting annoation #250 --- README.adoc | 76 ++++--- .../boot/starter/context/Condition.java | 4 +- .../starter/context/ExpressionParams.java | 33 +++ .../boot/starter/context/KeyFilter.java | 8 +- .../boot/starter/context/RateLimitCheck.java | 4 +- .../starter/context/RateLimitException.java | 8 + .../boot/starter/context/RateLimiting.java | 34 +++ .../properties/Bucket4JBootProperties.java | 107 ++++----- .../context/properties/MethodProperties.java | 10 + .../starter/context/properties/RateLimit.java | 6 +- .../boot/starter/config/aspect/AopConfig.java | 4 + .../config/aspect/RateLimitAspect.java | 141 +++++++----- .../config/aspect/RateLimitException.java | 4 - .../filter/Bucket4JBaseConfiguration.java | 6 +- ...ConfigurationSpringCloudGatewayFilter.java | 17 +- ...ucket4JAutoConfigurationWebfluxFilter.java | 56 ++--- ...ucket4JAutoConfigurationServletFilter.java | 12 +- .../metrics/actuator/Bucket4jEndpoint.java | 2 - .../config/service/ServiceConfiguration.java | 3 + .../reactive/AbstractReactiveFilter.java | 4 +- .../filter/servlet/ServletRequestFilter.java | 20 +- .../starter/service/ExpressionService.java | 25 ++- .../starter/service/RateLimitService.java | 205 ++++++++---------- .../webflux/WebfluxRateLimitFilterTest.java | 34 ++- .../caffeine/RateLimitExceptionHandler.java | 3 +- .../examples/caffeine/TestController.java | 34 +-- .../examples/caffeine/TestService.java | 26 +++ .../src/main/resources/application.yml | 7 +- 28 files changed, 506 insertions(+), 387 deletions(-) create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitException.java delete mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java create mode 100644 examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java diff --git a/README.adoc b/README.adoc index 7c7365d7..1cf73756 100644 --- a/README.adoc +++ b/README.adoc @@ -47,7 +47,7 @@ Project version overview: == Introduction This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for Bucket4j. -It can be used limit the rate of access to your REST APIs. +It can be used limit the rate of access to your REST APIs or method calls. * Prevention of DoS Attacks, brute-force logins attempts * Request throttling for specific regions, unauthenticated users, authenticated users, not paying users. @@ -60,6 +60,10 @@ have to write a single line of code. This section is meant to help you migrate your application to new version of this starter project. +=== Spring Boot Starter Bucket4j 0.12 + +* Removed deprecated expression property + === Spring Boot Starter Bucket4j 0.9 * Upgrade to Spring Boot 3 @@ -72,21 +76,21 @@ This section is meant to help you migrate your application to new version of thi ==== Compatibility to Java 8 The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release -of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. +of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. The project is switching to the dedicated artifacts which supports Java 8. You can read more about -it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. +it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. ==== Rename property expression to cache-key The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. An Exception is thrown on startup if the *expression* property is configured. -To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires +To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires a Bean Validation implementation. ==== JSR 380 - Bean Validation implementation required -To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. +To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. You can add the Spring Boot Starter Validation which will automatically configures one. [source, xml] @@ -99,7 +103,7 @@ You can add the Spring Boot Starter Validation which will automatically configur ==== Explicit Configuration of the Refill Speed - API Break -The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between +The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. @@ -122,7 +126,7 @@ You can read more about the refill speed configuration here <> [[getting_started]] == Getting started -To use the rate limit in your project you have to add the Bucket4j Spring Boot Starter dependency in +To use the rate limit in your project you have to add the Bucket4j Spring Boot Starter dependency in your project. Additionally you have to choose a caching provider <>. The next example uses https://www.jcp.org/en/jsr/detail?id=107[JSR 107] Ehcache which will be auto configured with the https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-caching.html[Spring Boot Starter Cache]. @@ -145,7 +149,7 @@ The next example uses https://www.jcp.org/en/jsr/detail?id=107[JSR 107] Ehcache > Don't forget to enable the caching feature by adding the @EnableCaching annotation to any of the configuration classes. -The configuration can be done in the application.properties / application.yml. +The configuration can be done in the application.properties / application.yml. The following configuration limits all requests independently from the user. It allows a maximum of 5 requests within 10 seconds independently from the user. @@ -164,7 +168,7 @@ bucket4j: ---- For Ehcache 3 you also need a *ehcache.xml* which can be placed in the classpath. -The configured cache name *buckets* must be defined in the configuration file. +The configured cache name *buckets* must be defined in the configuration file. [source,yml] ---- @@ -232,16 +236,16 @@ The following list contains the Caching implementation which will be autoconfigu |=== -Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver +Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver or the AsynchCacheResolver by yourself. -You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting -it to an invalid value to disable all auto configurations. +You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting +it to an invalid value to disable all auto configurations. [source, properties] ---- -bucket4j.cache-to-use=jcache # ----- +bucket4j.cache-to-use=jcache # +---- [[filters]] == Filter @@ -261,7 +265,7 @@ The *first* is the default strategy. This the default strategy which only execut ==== all -The *all* strategy executes all rate limit independently. +The *all* strategy executes all rate limit independently. [[cache_key]] == Cache Key @@ -307,7 +311,7 @@ public class SecurityService { } return name; } - + } ---- @@ -318,7 +322,7 @@ public class SecurityService { == Refill Speed The refill speed defines the period of the regeneration of consumed tokens. -This starter supports two types of token regeneration. The refill speed can be set with the following +This starter supports two types of token regeneration. The refill speed can be set with the following property: [source, properties] @@ -340,7 +344,7 @@ The following section describes the build in Execution Predicates and how to use === Path Predicates -The Path Predicate takes a list of path parameters where any of the paths must match. +The Path Predicate takes a list of path parameters where any of the paths must match. See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java[PathPattern] for the available configuration options. Segments are not evaluated further. [source, properties] @@ -394,7 +398,7 @@ You can also define you own Execution Predicate: public class MyQueryExecutePredicate extends ExecutePredicate { private String query; - + public String name() { // The name which can be used on the properties return "MY_QUERY"; @@ -410,7 +414,7 @@ public class MyQueryExecutePredicate extends ExecutePredicate parseSimpleConfig(String simpleConfig) { // the configuration which is configured behind the equal sign // MY_QUERY=P_1 -> simpleConfig == "P_1" - // + // this.query = simpleConfig; return this; } @@ -523,7 +527,7 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes bucket4j.filters[0].rate-limits[0].bandwidths[0].initial-capacity= # Optional initial tokens bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] bucket4j.filters[0].metrics.enabled=true -bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter +bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter bucket4j.filters[0].metrics.tags[0].key=IP bucket4j.filters[0].metrics.tags[0].expression=getRemoteAddr() bucket4j.filters[0].metrics.tags[0].types=REJECTED_COUNTER # (optionial) this tag should for example only be applied for the rejected counter @@ -551,7 +555,7 @@ Spring Boot ships with a great support for collecting metrics. This project auto bucket4j: enabled: true filters: - - cache-name: buckets + - cache-name: buckets filter-method: servlet filter-order: 1 url: .* @@ -583,12 +587,12 @@ Simple configuration to allow a maximum of 5 requests within 10 seconds independ ---- bucket4j: enabled: true - filters: - - cache-name: buckets + filters: + - cache-name: buckets url: .* rate-limits: - - bandwidths: - - capacity: 5 + - bandwidths: + - capacity: 5 time: 10 unit: seconds ---- @@ -601,8 +605,8 @@ you havn't to check in the second rate-limit that the user is logged in. Only th bucket4j: enabled: true filters: - - cache-name: buckets - filter-method: servlet + - cache-name: buckets + filter-method: servlet url: .* rate-limits: - execute-condition: @securityService.notSignedIn() # only for not logged in users @@ -611,7 +615,7 @@ bucket4j: - capacity: 10 time: 1 unit: minutes - - execute-condition: "@securityService.username() != 'admin'" # strategy is only evaluate first. so the user must be logged in and user is not admin + - execute-condition: "@securityService.username() != 'admin'" # strategy is only evaluate first. so the user must be logged in and user is not admin expression: @securityService.username() bandwidths: - capacity: 1000 @@ -636,27 +640,27 @@ bucket4j: url: /admin* rate-limits: bandwidths: # maximum of 5 requests within 10 seconds - - capacity: 5 + - capacity: 5 time: 10 unit: seconds - - cache-name: buckets + - cache-name: buckets url: /public* rate-limits: - expression: getRemoteAddress() # IP based filter bandwidths: # maximum of 5 requests within 10 seconds - - capacity: 5 + - capacity: 5 time: 10 unit: seconds - - cache-name: buckets + - cache-name: buckets url: /users* rate-limits: - skip-condition: "@securityService.username() == 'admin'" # we don't check the rate limit if user is the admin user - expression: "@securityService.username()?: getRemoteAddr()" # use the username as key. if authenticated use the ip address - bandwidths: + expression: "@securityService.username()?: getRemoteAddr()" # use the username as key. if authenticated use the ip address + bandwidths: - capacity: 100 time: 1 unit: seconds - capacity: 10000 time: 1 - unit: minutes + unit: minutes ---- diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/Condition.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/Condition.java index 38495138..05d5826e 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/Condition.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/Condition.java @@ -9,9 +9,9 @@ public interface Condition { /** * - * @param request e.g. to skip or execute rate limit based on the IP address + * @param expressionParams parameters to evaluate the expression * @return true if the rate limit check should be skipped */ - boolean evalute(R request); + boolean evaluate(ExpressionParams expressionParams); } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java new file mode 100644 index 00000000..183ab4a2 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java @@ -0,0 +1,33 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.expression.Expression; + +import java.util.HashMap; +import java.util.Map; + +/** + * Parameter information for the evaluation of a Spring {@link Expression} + * + * @param the type of the root object which us used for the SpEl expression. + */ +@RequiredArgsConstructor +public class ExpressionParams { + + @Getter + private final R rootObject; + + @Getter + private final Map params = new HashMap<>(); + + public void addParam(String name, Object value) { + params.put(name, value); + } + + public ExpressionParams addParams(Map params) { + this.params.putAll(params); + return this; + } + +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/KeyFilter.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/KeyFilter.java index 83956e20..5a1d926b 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/KeyFilter.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/KeyFilter.java @@ -9,11 +9,11 @@ public interface KeyFilter { /** * Return the unique Bucket4j storage key. You can think of the key as a unique identifier - * which is for example an IP-Address or a user name. The rate limit is then applied to each individual key. + * which is for example an IP-Address or a username. The rate limit is then applied to each individual key. * - * @param request HTTP request information of the current request - * @return the key to identify the the rate limit (IP, username, ...) + * @param expressionParams the expression params + * @return the key to identify the rate limit (IP, username, ...) */ - String key(R request); + String key(ExpressionParams expressionParams); } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java index df5eb6af..b82213c6 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitCheck.java @@ -10,10 +10,10 @@ public interface RateLimitCheck { /** - * @param request the request information object + * @param params parameter information * @param mainRateLimit overwrites the rate limit configuration from the properties * @return null if no rate limit should be performed. (maybe skipped or shouldn't be executed). */ - RateLimitResultWrapper rateLimit(R request, RateLimit mainRateLimit); + RateLimitResultWrapper rateLimit(ExpressionParams params, RateLimit mainRateLimit); } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitException.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitException.java new file mode 100644 index 00000000..96f6e74a --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitException.java @@ -0,0 +1,8 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + +/** + * This exception is thrown when the rate limit is reached in the context of a method level when using the + * {@link RateLimiting} annotation. + */ +public class RateLimitException extends RuntimeException { +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java index 95a1d471..4ce760c2 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimiting.java @@ -9,13 +9,47 @@ @Retention(RetentionPolicy.RUNTIME) public @interface RateLimiting { + /** + * @return The name of the rate limit configuration as a reference to the property file + */ String name(); + /** + * The cache key which is mayby modified the e.g. the method name {@link RateLimiting#ratePerMethod()} + * + * @return the cache key. + */ String cacheKey() default ""; + /** + * An optional execute condition which overrides the execute condition from the property file + * + * @return the expression in the Spring Expression Language format. + */ String executeCondition() default ""; + /** + * An optional execute condition which overrides the execute condition from the property file + * + * @return the expression in the Spring Expression Language format. + */ String skipCondition() default ""; + /** + * The Name of the annotated method will be added to the cache key. + * It's maybe a problem + * + * @return true if the method name should be added to the cache key. + */ + boolean ratePerMethod() default false; + + /** + * An optional fall back method when the rate limit occurs instead of throwing an exception. + * The return type must be the same... + * + * TODO + * + * @return the name of the public method which resists in the same class. + */ String fallbackMethodName() default ""; } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java index 9acb40ef..b63eb379 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.List; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; @@ -15,63 +16,65 @@ /** * Holds all the relevant starter properties which can be configured with - * Spring Boots application.properties / application.yml configuration files. + * Spring Boots application.properties / application.yml configuration files. */ @Data @ConfigurationProperties(prefix = Bucket4JBootProperties.PROPERTY_PREFIX) @Validated public class Bucket4JBootProperties { - public static final String PROPERTY_PREFIX = "bucket4j"; - - /** - * Enables or disables the Bucket4j Spring Boot Starter. - */ - @NotNull - private Boolean enabled = true; - - /** - * Sets the cache implementation which should be auto configured. - * This property can be used if multiple caches are configured by the starter - * and you have to choose one due to a startup error. - *
    - *
  • jcache
  • - *
  • hazelcast
  • - *
  • ignite
  • - *
  • redis
  • - *
- */ - private String cacheToUse; - - @Valid - private List methods = new ArrayList<>(); - - private boolean filterConfigCachingEnabled = false; - - /** - * If Filter configuration caching is enabled, a cache with this name should exist, or it will cause an exception. - */ - @NotBlank - private String filterConfigCacheName = "filterConfigCache"; - - - - @Valid - private List filters = new ArrayList<>(); - - @AssertTrue(message = "FilterConfiguration caching is enabled, but not all filters have an identifier configured") - public boolean isValidFilterIds(){ - return !filterConfigCachingEnabled || filters.stream().noneMatch(filter -> filter.getId() == null); - } - - /** - * A list of default metric tags which should be applied to all filters - */ - @Valid - private List defaultMetricTags = new ArrayList<>(); - - public static String getPropertyPrefix() { - return PROPERTY_PREFIX; - } + public static final String PROPERTY_PREFIX = "bucket4j"; + + /** + * Enables or disables the Bucket4j Spring Boot Starter. + */ + @NotNull + private Boolean enabled = true; + + /** + * Sets the cache implementation which should be auto configured. + * This property can be used if multiple caches are configured by the starter + * and you have to choose one due to a startup error. + *
    + *
  • jcache
  • + *
  • hazelcast
  • + *
  • ignite
  • + *
  • redis
  • + *
+ */ + private String cacheToUse; + + /** + * Configuration for the {@link RateLimiting} annotation on method level. + */ + @Valid + private List methods = new ArrayList<>(); + + private boolean filterConfigCachingEnabled = false; + + /** + * If Filter configuration caching is enabled, a cache with this name should exist, or it will cause an exception. + */ + @NotBlank + private String filterConfigCacheName = "filterConfigCache"; + + + @Valid + private List filters = new ArrayList<>(); + + @AssertTrue(message = "FilterConfiguration caching is enabled, but not all filters have an identifier configured") + public boolean isValidFilterIds() { + return !filterConfigCachingEnabled || filters.stream().noneMatch(filter -> filter.getId() == null); + } + + /** + * A list of default metric tags which should be applied to all filters + */ + @Valid + private List defaultMetricTags = new ArrayList<>(); + + public static String getPropertyPrefix() { + return PROPERTY_PREFIX; + } } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java index 5c0660d4..4d3bd615 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/MethodProperties.java @@ -1,5 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -9,12 +10,21 @@ @ToString public class MethodProperties { + /** + * The name of the configuration to reference in the {@link RateLimiting} annotation. + */ @NotBlank private String name; + /** + * The name of the cache. + */ @NotBlank private String cacheName; + /** + * The rate limit configuration + */ @NotNull private RateLimit rateLimit; diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java index 0d0bc32b..9b8a8b9f 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/RateLimit.java @@ -24,7 +24,11 @@ public class RateLimit implements Serializable { private String executeCondition; /** - * TODO comment + * If you provide a post execution condition. The incoming check only estimates the + * token consumption. It will not consume a token. This check is based on the response + * to decide if the token should be consumed or not. + * + * */ private String postExecuteCondition; diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java index 210fc0c1..a9e8570d 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/AopConfig.java @@ -4,6 +4,7 @@ import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import org.aspectj.lang.annotation.Aspect; @@ -17,6 +18,9 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +/** + * Enables the support for the {@link RateLimiting} annotation to rate limit on method level. + */ @Configuration @ConditionalOnClass(Aspect.class) @ConditionalOnProperty(prefix = Bucket4JBootProperties.PROPERTY_PREFIX, value = {"enabled"}, matchIfMissing = true) diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java index 200e0d87..81f286ac 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java @@ -1,9 +1,7 @@ package com.giffing.bucket4j.spring.boot.starter.config.aspect; import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.context.*; import com.giffing.bucket4j.spring.boot.starter.context.properties.MethodProperties; import com.giffing.bucket4j.spring.boot.starter.context.properties.Metrics; import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; @@ -16,10 +14,10 @@ import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; -import org.springframework.expression.spel.support.StandardEvaluationContext; import org.springframework.stereotype.Component; import java.lang.reflect.Method; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -36,19 +34,22 @@ public class RateLimitAspect { private final SyncCacheResolver syncCacheResolver; - private Map> rateLimitConfigResults = new HashMap<>(); + private Map> rateLimitConfigResults = new HashMap<>(); @PostConstruct public void init() { for(var methodProperty : methodProperties) { var proxyManagerWrapper = syncCacheResolver.resolve(methodProperty.getCacheName()); - var rateLimitConfig = RateLimitService.RateLimitConfig.builder() + var rateLimitConfig = RateLimitService.RateLimitConfig.builder() .rateLimits(List.of(methodProperty.getRateLimit())) .metricHandlers(List.of()) .executePredicates(Map.of()) .cacheName(methodProperty.getCacheName()) .configVersion(0) - .keyFunction((rl, sr) -> rateLimitService.getKeyFilter(methodProperty.getName(), rl).key(sr)) + .keyFunction((rl, sr) -> { + KeyFilter keyFilter = rateLimitService.getKeyFilter(sr.getRootObject().getName(), rl); + return keyFilter.key(sr); + }) .metrics(new Metrics()) .proxyWrapper(proxyManagerWrapper) .build(); @@ -57,84 +58,116 @@ public void init() { } } + @Pointcut("@annotation(com.giffing.bucket4j.spring.boot.starter.context.RateLimiting)") + private void methodsAnnotatedWithRateLimitAnnotation() { + } + @Around("methodsAnnotatedWithRateLimitAnnotation()") public Object processMethodsAnnotatedWithRateLimitAnnotation(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); + RateLimiting rateLimitAnnotation = method.getAnnotation(RateLimiting.class); - var args = joinPoint.getArgs(); - var parameterNames = signature.getParameterNames(); + Method fallbackMethod = null; + if(rateLimitAnnotation.fallbackMethodName() != null) { + var fallbackMethods = Arrays.stream(method.getDeclaringClass().getMethods()) + .filter(p -> p.getName().equals(rateLimitAnnotation.fallbackMethodName())) + .toList(); + if(fallbackMethods.size() > 1) { + throw new IllegalStateException("Found " + fallbackMethods.size() + " fallbackMethods for " + rateLimitAnnotation.fallbackMethodName()); + } + if(!fallbackMethods.isEmpty()) { + fallbackMethod = joinPoint.getTarget().getClass().getMethod(rateLimitAnnotation.fallbackMethodName(), ((MethodSignature) joinPoint.getSignature()).getParameterTypes()); + } + } - var evaluationContext = new StandardEvaluationContext(); - for (int i = 0; i< args.length; i++) { - log.debug("expresion-params;name:{};arg:{}",parameterNames[i], args[i]); - evaluationContext.setVariable(parameterNames[i], args[i]); + Map params = collectExpressionParameter( + joinPoint.getArgs(), + signature.getParameterNames()); - } + assertValidCacheName(rateLimitAnnotation); - RateLimiting rateLimitAnnotation = method.getAnnotation(RateLimiting.class); + var annotationRateLimit = buildMainRateLimitConfiguration(rateLimitAnnotation); + var rateLimitConfigResult = rateLimitConfigResults.get(rateLimitAnnotation.name()); + RateLimitConsumedResult consumedResult = performRateLimit(rateLimitConfigResult, method, params, annotationRateLimit); - if(!rateLimitConfigResults.containsKey(rateLimitAnnotation.name())) { - throw new IllegalStateException("Could not find cache " + rateLimitAnnotation.name()); + Object methodResult; + + if (consumedResult.allConsumed()) { + // no rate limit - execute the surrounding method + methodResult = joinPoint.proceed(); + performPostRateLimit(rateLimitConfigResult, method, methodResult); + } else if (fallbackMethod != null){ + return fallbackMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs()); + } else { + throw new RateLimitException(); } - var rateLimitConfigResult = rateLimitConfigResults.get(rateLimitAnnotation.name()); - var annotationRateLimit = new RateLimit(); - annotationRateLimit.setExecuteCondition(rateLimitAnnotation.executeCondition()); - annotationRateLimit.setCacheKey(rateLimitAnnotation.cacheKey()); - annotationRateLimit.setSkipCondition(rateLimitAnnotation.skipCondition()); + return methodResult; + } + private static void performPostRateLimit(RateLimitService.RateLimitConfigresult rateLimitConfigResult, Method method, Object methodResult) { + for (var rlc : rateLimitConfigResult.getPostRateLimitChecks()) { + var result = rlc.rateLimit(method, methodResult); + if (result != null) { + log.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); + } + } + } + private static RateLimitConsumedResult performRateLimit(RateLimitService.RateLimitConfigresult rateLimitConfigResult, Method method, Map params, RateLimit annotationRateLimit) { boolean allConsumed = true; Long remainingLimit = null; - for (RateLimitCheck rl : rateLimitConfigResult.getRateLimitChecks()) { - var wrapper = rl.rateLimit(null, annotationRateLimit); + for (RateLimitCheck rl : rateLimitConfigResult.getRateLimitChecks()) { + var wrapper = rl.rateLimit(new ExpressionParams<>(method).addParams(params), annotationRateLimit); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { - remainingLimit = getRemainingLimit(remainingLimit, rateLimitResult); + remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); } else { - log.debug("rate-limit!"); allConsumed = false; break; } } } - - Object methodResult; - if (allConsumed) { - if (remainingLimit != null) { - log.debug("rate-limit-remaining-header;limit:{}", remainingLimit); - } - - methodResult = joinPoint.proceed(); - - for (var rlc : rateLimitConfigResult.getPostRateLimitChecks()) { - var result = rlc.rateLimit(null, methodResult); - if (result != null) { - log.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); - } - } - } else { - throw new RateLimitException(); + if(allConsumed) { + log.debug("rate-limit-remaining;limit:{}", remainingLimit); } + return new RateLimitConsumedResult(allConsumed, remainingLimit); + } - return methodResult; + private record RateLimitConsumedResult(boolean allConsumed, Long remainingLimit) { } - @Pointcut("@annotation(com.giffing.bucket4j.spring.boot.starter.context.RateLimiting)") - private void methodsAnnotatedWithRateLimitAnnotation() { + /* + * Uses the configuration of the annotation to crate a main RateLimit which overrides + * the configuration from the property files. + */ + private static RateLimit buildMainRateLimitConfiguration(RateLimiting rateLimitAnnotation) { + var annotationRateLimit = new RateLimit(); + annotationRateLimit.setExecuteCondition(rateLimitAnnotation.executeCondition()); + annotationRateLimit.setCacheKey(rateLimitAnnotation.cacheKey()); + annotationRateLimit.setSkipCondition(rateLimitAnnotation.skipCondition()); + return annotationRateLimit; } - private long getRemainingLimit(Long remaining, RateLimitResult rateLimitResult) { - if (rateLimitResult != null) { - if (remaining == null) { - remaining = rateLimitResult.getRemainingTokens(); - } else if (rateLimitResult.getRemainingTokens() < remaining) { - remaining = rateLimitResult.getRemainingTokens(); - } + private void assertValidCacheName(RateLimiting rateLimitAnnotation) { + if(!rateLimitConfigResults.containsKey(rateLimitAnnotation.name())) { + throw new IllegalStateException("Could not find cache " + rateLimitAnnotation.name()); + } + } + + private static Map collectExpressionParameter(Object[] args, String[] parameterNames) { + Map params = new HashMap<>(); + for (int i = 0; i< args.length; i++) { + log.debug("expresion-params;name:{};arg:{}", parameterNames[i], args[i]); + params.put(parameterNames[i], args[i]); } - return remaining; + return params; } + + + + } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java deleted file mode 100644 index 87e26ada..00000000 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitException.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.config.aspect; - -public class RateLimitException extends RuntimeException{ -} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java index 01e348bc..0e30693c 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/Bucket4JBaseConfiguration.java @@ -39,14 +39,16 @@ public FilterConfiguration buildFilterConfig( Bucket4JConfiguration config, ProxyManagerWrapper proxyWrapper) { - var rateLimitConfig = RateLimitService.RateLimitConfig.builder() .rateLimits(config.getRateLimits()) .metricHandlers(metricHandlers) .executePredicates(executePredicates) .cacheName(config.getCacheName()) .configVersion(config.getBucket4JVersionNumber()) - .keyFunction((rl, sr) -> rateLimitService.getKeyFilter(config.getUrl(), rl).key(sr)) + .keyFunction((rl, sr) -> { + KeyFilter keyFilter = rateLimitService.getKeyFilter(config.getUrl(), rl); + return keyFilter.key(sr); + }) .metrics(config.getMetrics()) .proxyWrapper(proxyWrapper) .build(); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java index ae64219f..d3c564cf 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/gateway/Bucket4JAutoConfigurationSpringCloudGatewayFilter.java @@ -7,7 +7,6 @@ import com.giffing.bucket4j.spring.boot.starter.config.filter.Bucket4JBaseConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.predicate.WebfluxExecutePredicateConfiguration; import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; -import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicate; @@ -16,8 +15,8 @@ import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; import com.giffing.bucket4j.spring.boot.starter.filter.reactive.gateway.SpringCloudGatewayRateLimitFilter; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -51,10 +50,9 @@ @AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) @ConditionalOnBean(value = AsyncCacheResolver.class) @Import(value = { ServiceConfiguration.class, WebfluxExecutePredicateConfiguration.class, SpringBootActuatorConfig.class, Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.class }) +@Slf4j public class Bucket4JAutoConfigurationSpringCloudGatewayFilter extends Bucket4JBaseConfiguration { - private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationSpringCloudGatewayFilter.class); - private final Bucket4JBootProperties properties; private final GenericApplicationContext context; @@ -75,6 +73,7 @@ public Bucket4JAutoConfigurationSpringCloudGatewayFilter( Bucket4jConfigurationHolder gatewayConfigurationHolder, RateLimitService rateLimitService, @Autowired(required = false) CacheManager configCacheManager) { + super(rateLimitService, configCacheManager, metricHandlers, executePredicates .stream() .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); @@ -97,9 +96,7 @@ public void initFilters() { .forEach(filter -> { rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); - var filterConfig = buildFilterConfig( - filter, - cacheResolver.resolve(filter.getCacheName())); + var filterConfig = buildFilterConfig(filter, cacheResolver.resolve(filter.getCacheName())); gatewayConfigurationHolder.addFilterConfiguration(filter); @@ -119,9 +116,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e if (newConfig.getFilterMethod().equals(FilterMethod.GATEWAY)) { try { SpringCloudGatewayRateLimitFilter filter = context.getBean(event.getKey(), SpringCloudGatewayRateLimitFilter.class); - var newFilterConfig = buildFilterConfig( - newConfig, - cacheResolver.resolve(newConfig.getCacheName())); + var newFilterConfig = buildFilterConfig(newConfig, cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Gateway Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java index d97124e9..1e25c121 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/reactive/webflux/Bucket4JAutoConfigurationWebfluxFilter.java @@ -1,14 +1,23 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.webflux; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; -import java.util.stream.Collectors; - -import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import com.giffing.bucket4j.spring.boot.starter.config.cache.AsyncCacheResolver; +import com.giffing.bucket4j.spring.boot.starter.config.cache.Bucket4jCacheConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; +import com.giffing.bucket4j.spring.boot.starter.config.filter.Bucket4JBaseConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.predicate.WebfluxExecutePredicateConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; +import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicate; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.giffing.bucket4j.spring.boot.starter.filter.reactive.webflux.WebfluxWebFilter; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import jakarta.annotation.PostConstruct; - +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -26,24 +35,10 @@ import org.springframework.util.StringUtils; import org.springframework.web.server.WebFilter; -import com.giffing.bucket4j.spring.boot.starter.config.cache.AsyncCacheResolver; -import com.giffing.bucket4j.spring.boot.starter.config.cache.Bucket4jCacheConfiguration; -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheUpdateEvent; -import com.giffing.bucket4j.spring.boot.starter.config.filter.Bucket4JBaseConfiguration; -import com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.predicate.WebfluxExecutePredicateConfiguration; -import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; -import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; -import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicate; -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; -import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; -import com.giffing.bucket4j.spring.boot.starter.filter.reactive.webflux.WebfluxWebFilter; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Function; +import java.util.stream.Collectors; /** * Configures Servlet Filters for Bucket4Js rate limit. @@ -56,10 +51,9 @@ @ConditionalOnBean(value = AsyncCacheResolver.class) @EnableConfigurationProperties({ Bucket4JBootProperties.class}) @Import(value = { ServiceConfiguration.class, WebfluxExecutePredicateConfiguration.class, Bucket4JAutoConfigurationWebfluxFilterBeans.class, SpringBootActuatorConfig.class }) +@Slf4j public class Bucket4JAutoConfigurationWebfluxFilter extends Bucket4JBaseConfiguration { - private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationWebfluxFilter.class); - private final Bucket4JBootProperties properties; private final GenericApplicationContext context; @@ -70,7 +64,6 @@ public class Bucket4JAutoConfigurationWebfluxFilter extends Bucket4JBaseConfigur private final Bucket4jConfigurationHolder servletConfigurationHolder; - public Bucket4JAutoConfigurationWebfluxFilter( Bucket4JBootProperties properties, GenericApplicationContext context, @@ -101,8 +94,7 @@ public void initFilters() { .forEach(filter -> { rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); - FilterConfiguration filterConfig = buildFilterConfig(filter, cacheResolver.resolve( - filter.getCacheName())); + var filterConfig = buildFilterConfig(filter, cacheResolver.resolve(filter.getCacheName())); servletConfigurationHolder.addFilterConfiguration(filter); @@ -122,9 +114,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e if (newConfig.getFilterMethod().equals(FilterMethod.WEBFLUX)) { try { WebfluxWebFilter filter = context.getBean(event.getKey(), WebfluxWebFilter.class); - FilterConfiguration newFilterConfig = buildFilterConfig( - newConfig, - cacheResolver.resolve(newConfig.getCacheName())); + var newFilterConfig = buildFilterConfig(newConfig, cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Webflux Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java index e05bfd51..f688b6cf 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/filter/servlet/Bucket4JAutoConfigurationServletFilter.java @@ -96,14 +96,12 @@ public void customize(ConfigurableServletWebServerFactory factory) { .forEach(filter -> { rateLimitService.addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); - var filterConfig = buildFilterConfig( - filter, - cacheResolver.resolve(filter.getCacheName())); + var filterConfig = buildFilterConfig(filter, cacheResolver.resolve(filter.getCacheName())); servletConfigurationHolder.addFilterConfiguration(filter); //Use either the filter id as bean name or the prefix + counter if no id is configured - String beanName = filter.getId() != null ? filter.getId() : ("bucket4JServletRequestFilter" + filterCount); + var beanName = filter.getId() != null ? filter.getId() : ("bucket4JServletRequestFilter" + filterCount); context.registerBean(beanName, Filter.class, () -> new ServletRequestFilter(filterConfig)); log.info("create-servlet-filter;{};{};{}", filterCount, filter.getCacheName(), filter.getUrl()); @@ -116,10 +114,8 @@ public void onCacheUpdateEvent(CacheUpdateEvent e Bucket4JConfiguration newConfig = event.getNewValue(); if(newConfig.getFilterMethod().equals(FilterMethod.SERVLET)) { try { - ServletRequestFilter filter = context.getBean(event.getKey(), ServletRequestFilter.class); - var newFilterConfig = buildFilterConfig( - newConfig, - cacheResolver.resolve(newConfig.getCacheName())); + var filter = context.getBean(event.getKey(), ServletRequestFilter.class); + var newFilterConfig = buildFilterConfig(newConfig, cacheResolver.resolve(newConfig.getCacheName())); filter.setFilterConfig(newFilterConfig); } catch (Exception exception) { log.warn("Failed to update Servlet Filter configuration. {}", exception.getMessage()); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jEndpoint.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jEndpoint.java index 7c431186..08aa6c42 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jEndpoint.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jEndpoint.java @@ -52,11 +52,9 @@ public Map bucket4jConfig() { if(webfluxConfigs != null) { result.put("webflux", webfluxConfigs.getFilterConfiguration()); } - if(gatewayConfigs != null) { result.put("gateway", gatewayConfigs.getFilterConfiguration()); } - return result; } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java index a98e7e0f..069ee800 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/service/ServiceConfiguration.java @@ -10,6 +10,9 @@ import org.springframework.expression.spel.SpelParserConfiguration; import org.springframework.expression.spel.standard.SpelExpressionParser; +/** + * General Service configuration which can be imported from other autoconfiguration classes. + */ @Configuration public class ServiceConfiguration { diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java index 94a0f2e0..a2e35ec2 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/AbstractReactiveFilter.java @@ -1,8 +1,8 @@ package com.giffing.bucket4j.spring.boot.starter.filter.reactive; +import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResultWrapper; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; import lombok.Data; import lombok.extern.slf4j.Slf4j; @@ -42,7 +42,7 @@ protected Mono chainWithRateLimitCheck(ServerWebExchange exchange, Reactiv var response = exchange.getResponse(); List> asyncConsumptionProbes = new ArrayList<>(); for (var rlc : filterConfig.getRateLimitChecks()) { - var wrapper = rlc.rateLimit(request, null); + var wrapper = rlc.rateLimit(new ExpressionParams<>(request), null); if(wrapper != null && wrapper.getRateLimitResultCompletableFuture() != null){ asyncConsumptionProbes.add(Mono.fromFuture(wrapper.getRateLimitResultCompletableFuture())); if(filterConfig.getStrategy() == RateLimitConditionMatchingStrategy.FIRST){ diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java index 033b406f..9e3aca93 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java @@ -1,10 +1,12 @@ package com.giffing.bucket4j.spring.boot.starter.filter.servlet; +import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResultWrapper; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; +import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; +import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -43,12 +45,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throws ServletException, IOException { boolean allConsumed = true; Long remainingLimit = null; - for (RateLimitCheck rl : filterConfig.getRateLimitChecks()) { - var wrapper = rl.rateLimit(request, null); + for (var rl : filterConfig.getRateLimitChecks()) { + var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { - remainingLimit = getRemainingLimit(remainingLimit, rateLimitResult); + remainingLimit = RateLimitService.getRemainingLimit(remainingLimit, rateLimitResult); } else { allConsumed = false; handleHttpResponseOnRateLimiting(response, rateLimitResult); @@ -89,16 +91,6 @@ private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, } } - private long getRemainingLimit(Long remaining, RateLimitResult rateLimitResult) { - if (rateLimitResult != null) { - if (remaining == null) { - remaining = rateLimitResult.getRemainingTokens(); - } else if (rateLimitResult.getRemainingTokens() < remaining) { - remaining = rateLimitResult.getRemainingTokens(); - } - } - return remaining; - } @Override diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java index 1fe07ae3..8d5e362b 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/ExpressionService.java @@ -1,30 +1,45 @@ package com.giffing.bucket4j.spring.boot.starter.service; +import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.context.expression.BeanFactoryResolver; import org.springframework.expression.ExpressionParser; import org.springframework.expression.spel.support.StandardEvaluationContext; +import java.util.Map; + +/** + * The expression service wraps Springs {@link ExpressionParser} to execute SpEl expressions. + */ @RequiredArgsConstructor +@Slf4j public class ExpressionService { private final ExpressionParser expressionParser; private final ConfigurableBeanFactory beanFactory; - public String parseString(String expression, Object rootObject) { + public String parseString(String expression, ExpressionParams params) { + var context = getContext(params.getParams()); var expr = expressionParser.parseExpression(expression); - return expr.getValue(getContext(), rootObject, String.class); + String result = expr.getValue(context, params.getRootObject(), String.class); + log.info("parse-string-expression;result:{};expression:{};root:{};params:{}", result, expression, params.getRootObject(), params.getParams()); + return result; } - public Boolean parseBoolean(String expression, Object request) { + public Boolean parseBoolean(String expression, ExpressionParams params) { + var context = getContext(params.getParams()); var expr = expressionParser.parseExpression(expression); - return Boolean.TRUE.equals(expr.getValue(getContext(), request, Boolean.class)); + boolean result = Boolean.TRUE.equals(expr.getValue(context, params.getRootObject(), Boolean.class)); + log.info("parse-boolean-expression;result:{};expression:{};root:{};params:{}", result, expression, params.getRootObject(), params.getParams()); + return result; } - private StandardEvaluationContext getContext() { + private StandardEvaluationContext getContext(Map params) { var context = new StandardEvaluationContext(); + params.forEach(context::setVariable); context.setBeanResolver(new BeanFactoryResolver(beanFactory)); return context; } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java index 95b94d4e..e13dbfe4 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/RateLimitService.java @@ -35,13 +35,20 @@ public class RateLimitService { @Builder @Data public static class RateLimitConfig { - @NonNull private List rateLimits; - @NonNull private List metricHandlers; - @NonNull private Map> executePredicates; - @NonNull private String cacheName; - @NonNull private ProxyManagerWrapper proxyWrapper; - @NonNull private BiFunction keyFunction; - @NonNull private Metrics metrics; + @NonNull + private List rateLimits; + @NonNull + private List metricHandlers; + @NonNull + private Map> executePredicates; + @NonNull + private String cacheName; + @NonNull + private ProxyManagerWrapper proxyWrapper; + @NonNull + private BiFunction, String> keyFunction; + @NonNull + private Metrics metrics; private long configVersion; } @@ -55,13 +62,8 @@ public static class RateLimitConfigresult { public RateLimitConfigresult configureRateLimit(RateLimitConfig rateLimitConfig) { - var metricHandlers = rateLimitConfig.getMetricHandlers(); var executePredicates = rateLimitConfig.getExecutePredicates(); - var cacheName = rateLimitConfig.getCacheName(); - var metrics = rateLimitConfig.getMetrics(); - var keyFunction = rateLimitConfig.getKeyFunction(); - var proxyWrapper = rateLimitConfig.getProxyWrapper(); - var configVersion = rateLimitConfig.getConfigVersion(); + List> rateLimitChecks = new ArrayList<>(); List> postRateLimitChecks = new ArrayList<>(); @@ -71,51 +73,38 @@ public RateLimitConfigresult configureRateLimit(RateLimitConfig var executionPredicate = prepareExecutionPredicates(rl, executePredicates); var skipPredicate = prepareSkipPredicates(rl, executePredicates); - RateLimitCheck rlc = (rootObject, overridableRateLimit) -> { + RateLimitCheck rlc = (expressionParams, overridableRateLimit) -> { var rlToUse = rl.copy(); rlToUse.consumeNotNullValues(overridableRateLimit); - var skipRateLimit = performSkipRateLimitCheck(rlToUse, executionPredicate, skipPredicate, rootObject); + var skipRateLimit = performSkipRateLimitCheck(rlToUse, executionPredicate, skipPredicate, expressionParams); + boolean isEstimation = rlToUse.getPostExecuteCondition() != null; + RateLimitResultWrapper rateLimitResultWrapper = null; if (!skipRateLimit) { - var key = keyFunction.apply(rlToUse, rootObject); - var metricBucketListener = createMetricListener(cacheName, metrics, metricHandlers, rootObject); - log.debug("try-and-consume;key:{};tokens:{}", key, rlToUse.getNumTokens()); - return proxyWrapper.tryConsumeAndReturnRemaining( - key, - rlToUse.getNumTokens(), - rlToUse.getPostExecuteCondition() != null, - bucketConfiguration, - metricBucketListener, - configVersion, - rlToUse.getTokensInheritanceStrategy() - ); + + rateLimitResultWrapper = tryConsume(rateLimitConfig, expressionParams, rlToUse, isEstimation, bucketConfiguration); } - return null; + return rateLimitResultWrapper; }; rateLimitChecks.add(rlc); if (rl.getPostExecuteCondition() != null) { log.debug("PRL: {}", rl); - PostRateLimitCheck postRlc = (rootObject, response) -> { + PostRateLimitCheck postRlc = (request, response) -> { + ExpressionParams expressionParams = new ExpressionParams<>(request); var skipRateLimit = performPostSkipRateLimitCheck(rl, - executionPredicate, skipPredicate, rootObject, response); + executionPredicate, + skipPredicate, + expressionParams, + response); + boolean isEstimation = false; + RateLimitResultWrapper rateLimitResultWrapper = null; if (!skipRateLimit) { - var key = keyFunction.apply(rl, rootObject); - var metricBucketListener = createMetricListener(cacheName, metrics, metricHandlers, rootObject); - log.debug("try-and-consume-post;key:{};tokens:{}", key, rl.getNumTokens()); - return proxyWrapper.tryConsumeAndReturnRemaining( - key, - rl.getNumTokens(), - false, - bucketConfiguration, - metricBucketListener, - configVersion, - rl.getTokensInheritanceStrategy() - ); + rateLimitResultWrapper = tryConsume(rateLimitConfig, expressionParams, rl, isEstimation, bucketConfiguration); } - return null; + return rateLimitResultWrapper; }; postRateLimitChecks.add(postRlc); @@ -125,101 +114,85 @@ public RateLimitConfigresult configureRateLimit(RateLimitConfig return new RateLimitConfigresult<>(rateLimitChecks, postRateLimitChecks); } + private RateLimitResultWrapper tryConsume(RateLimitConfig rateLimitConfig, ExpressionParams expressionParams, RateLimit rlToUse, boolean isEstimation, BucketConfiguration bucketConfiguration) { + RateLimitResultWrapper rateLimitResultWrapper; + var metricHandlers = rateLimitConfig.getMetricHandlers(); + var cacheName = rateLimitConfig.getCacheName(); + var metrics = rateLimitConfig.getMetrics(); + var keyFunction = rateLimitConfig.getKeyFunction(); + var proxyWrapper = rateLimitConfig.getProxyWrapper(); + var configVersion = rateLimitConfig.getConfigVersion(); + + var key = keyFunction.apply(rlToUse, expressionParams); + var metricBucketListener = createMetricListener(cacheName, metrics, metricHandlers, expressionParams); + log.debug("try-and-consume;key:{};tokens:{}", key, rlToUse.getNumTokens()); + rateLimitResultWrapper = proxyWrapper.tryConsumeAndReturnRemaining( + key, + rlToUse.getNumTokens(), + isEstimation, + bucketConfiguration, + metricBucketListener, + configVersion, + rlToUse.getTokensInheritanceStrategy() + ); + return rateLimitResultWrapper; + } + private boolean performPostSkipRateLimitCheck(RateLimit rl, - Predicate executionPredicate, - Predicate skipPredicate, - R request, - P response + Predicate executionPredicate, + Predicate skipPredicate, + ExpressionParams expressionParams, + P response ) { - var skipRateLimit = performSkipRateLimitCheck( + var skipRateLimit = performSkipRateLimitCheck( rl, executionPredicate, - skipPredicate, request); + skipPredicate, expressionParams); if (!skipRateLimit && rl.getPostExecuteCondition() != null) { - skipRateLimit = !executeResponseCondition(rl).evalute(response); + Condition

condition = exp -> expressionService.parseBoolean(rl.getPostExecuteCondition(), exp); + skipRateLimit = !condition.evaluate(new ExpressionParams<>(response).addParams(expressionParams.getParams())); log.debug("skip-rate-limit - post-execute-condition: {}", skipRateLimit); } return skipRateLimit; } - private boolean performSkipRateLimitCheck(RateLimit rl, + private boolean performSkipRateLimitCheck(RateLimit rl, Predicate executionPredicate, Predicate skipPredicate, - R rootObject) { + ExpressionParams expressionParams) { boolean skipRateLimit = false; if (rl.getSkipCondition() != null) { - skipRateLimit = skipCondition(rl).evalute(rootObject); + Condition expresison = exp -> expressionService.parseBoolean(rl.getSkipCondition(), exp); + skipRateLimit = expresison.evaluate(expressionParams); log.debug("skip-rate-limit - skip-condition: {}", skipRateLimit); } if (!skipRateLimit) { - skipRateLimit = skipPredicate.test(rootObject); + skipRateLimit = skipPredicate.test(expressionParams.getRootObject()); log.debug("skip-rate-limit - skip-predicates: {}", skipRateLimit); } if (!skipRateLimit && rl.getExecuteCondition() != null) { - skipRateLimit = !executeCondition(rl).evalute(rootObject); + Condition condition = exp -> expressionService.parseBoolean(rl.getExecuteCondition(), exp); + skipRateLimit = !condition.evaluate(expressionParams); log.debug("skip-rate-limit - execute-condition: {}", skipRateLimit); } if (!skipRateLimit) { - skipRateLimit = !executionPredicate.test(rootObject); + skipRateLimit = !executionPredicate.test(expressionParams.getRootObject()); log.debug("skip-rate-limit - execute-predicates: {}", skipRateLimit); } return skipRateLimit; } - /** - * Creates the lambda for the execute condition which will be evaluated on each request. - * - * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - private

Condition

executeResponseCondition(RateLimit rateLimit) { - return executeExpression(rateLimit.getPostExecuteCondition()); - } - - /** - * Creates the lambda for the skip condition which will be evaluated on each request - * - * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - private Condition skipCondition(RateLimit rateLimit) { - if (rateLimit.getSkipCondition() != null) { - return request -> expressionService.parseBoolean(rateLimit.getSkipCondition(), request); - } - return null; - } - - - /** - * Creates the lambda for the execute condition which will be evaluated on each request. - * - * @param rateLimit the {@link RateLimit} configuration which holds the execute condition string - * @return the lambda condition which will be evaluated lazy - null if there is no condition available. - */ - private Condition executeCondition(RateLimit rateLimit) { - return executeExpression(rateLimit.getExecuteCondition()); - } - - - - private Condition executeExpression(String condition) { - if (condition != null) { - return request -> expressionService.parseBoolean(condition, request); - } - return null; - } - - public List getMetricTagResults(R rootObject, Metrics metrics) { + public List getMetricTagResults(ExpressionParams expressionParams, Metrics metrics) { return metrics .getTags() .stream() .map(metricMetaTag -> { - var value = expressionService.parseString(metricMetaTag.getExpression(), rootObject); + var value = expressionService.parseString(metricMetaTag.getExpression(), expressionParams); return new MetricTagResult(metricMetaTag.getKey(), value, metricMetaTag.getTypes()); }).toList(); } @@ -228,13 +201,13 @@ public List getMetricTagResults(R rootObject, Metrics metri * Creates the key filter lambda which is responsible to decide how the rate limit will be performed. The key * is the unique identifier like an IP address or a username. * - * @param url is used to generated a unique cache key - * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string + * @param url is used to generated a unique cache key + * @param rateLimit the {@link RateLimit} configuration which holds the skip condition string * @return should not been null. If no filter key type is matching a plain 1 is returned so that all requests uses the same key. */ public KeyFilter getKeyFilter(String url, RateLimit rateLimit) { - return request -> { - var value = expressionService.parseString(rateLimit.getCacheKey(), request); + return expressionParams -> { + String value = expressionService.parseString(rateLimit.getCacheKey(), expressionParams); return url + "-" + value; }; } @@ -262,14 +235,11 @@ private ConfigurationBuilder prepareBucket4jConfigurationBuilder(RateLimit rl) { } private MetricBucketListener createMetricListener(String cacheName, - Metrics metrics, - List metricHandlers, - R rootObject) { - - var metricTagResults = getMetricTags( - metrics, - rootObject); + Metrics metrics, + List metricHandlers, + ExpressionParams expressionParams) { + var metricTagResults = getMetricTags(metrics, expressionParams); return new MetricBucketListener( cacheName, metricHandlers, @@ -279,9 +249,9 @@ private MetricBucketListener createMetricListener(String cacheName, private List getMetricTags( Metrics metrics, - R servletRequest) { + ExpressionParams expressionParams) { - return getMetricTagResults(servletRequest, metrics); + return getMetricTagResults(expressionParams, metrics); } public void addDefaultMetricTags(Bucket4JBootProperties properties, Bucket4JConfiguration filter) { @@ -328,4 +298,11 @@ protected Predicate createPredicate(ExecutePredicateDefinition pd, Map handleRateLimit(RateLimitException e) { return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).build(); diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java index 1b724026..1fa78cdc 100644 --- a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestController.java @@ -1,29 +1,26 @@ package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; -import java.util.List; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; - -import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; +import com.giffing.bucket4j.spring.boot.starter.context.properties.BandWidth; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; +import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Valid; import jakarta.validation.Validator; - import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; - -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; -import com.giffing.bucket4j.spring.boot.starter.context.properties.BandWidth; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; -import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; -import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; import org.springframework.web.util.HtmlUtils; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + @RestController public class TestController { @@ -31,9 +28,14 @@ public class TestController { private final CacheManager configCacheManager; - public TestController(Validator validator, @Nullable CacheManager configCacheManager) { + private final TestService testService; + + public TestController(Validator validator, + @Nullable CacheManager configCacheManager, + TestService testService) { this.validator = validator; this.configCacheManager = configCacheManager; + this.testService = testService; } @GetMapping("unsecure") @@ -42,8 +44,8 @@ public ResponseEntity unsecure() { } @GetMapping("hello") - @RateLimiting(name = "hello", executeCondition = "1 eq 1") - public ResponseEntity hello() { + public ResponseEntity hello(@RequestParam(required = false) String name) { + testService.execute(name); return ResponseEntity.ok("Hello World"); } diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java new file mode 100644 index 00000000..c6cc875e --- /dev/null +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java @@ -0,0 +1,26 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; + +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +public class TestService { + + + @RateLimiting(name = "hello", + executeCondition = "#myParamName != 'admin'", + ratePerMethod = true, + fallbackMethodName = "dummy") + public String execute(String myParamName) { + log.info("Method with Param {} executed", myParamName); + return myParamName; + } + + public String dummy(String myParamName) { + log.info("Fallback-Method with Param {} executed", myParamName); + return myParamName; + } + +} diff --git a/examples/caffeine/src/main/resources/application.yml b/examples/caffeine/src/main/resources/application.yml index 0a56d322..b08ce7d2 100644 --- a/examples/caffeine/src/main/resources/application.yml +++ b/examples/caffeine/src/main/resources/application.yml @@ -26,18 +26,19 @@ bucket4j: cache-name: buckets rate-limit: cache-key: 1 - post-execute-condition: true + execute-condition: true + post-execute-condition: "#root == 'alpha'" bandwidths: - capacity: 1 refill-capacity: 1 - time: 30 + time: 2 unit: seconds initial-capacity: 1 refill-speed: interval filters: - id: filter1 cache-name: buckets - url: .* + url: world rate-limits: - cache-key: getRemoteAddr() post-execute-condition: getStatus() eq 200 From 9cea03c0aa6b764d8f3bf4c90d7ad1b9a5a6a9fd Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Sun, 10 Mar 2024 22:41:11 +0100 Subject: [PATCH 3/9] Support for Method level @RateLimiting annoation #250 --- README.adoc | 692 +++++++++--------- .../RateLimitConditionMatchingStrategy.java | 25 +- .../post_execution_condition.plantuml | 30 + .../doc/plantuml/post_execution_condition.png | Bin 0 -> 30956 bytes 4 files changed, 394 insertions(+), 353 deletions(-) create mode 100644 src/main/doc/plantuml/post_execution_condition.plantuml create mode 100644 src/main/doc/plantuml/post_execution_condition.png diff --git a/README.adoc b/README.adoc index 1cf73756..882db897 100644 --- a/README.adoc +++ b/README.adoc @@ -9,317 +9,177 @@ image:{url-repo}/actions/workflows/maven.yml/badge.svg[Build Status,link={url-re image:{url-repo}/actions/workflows/codeql.yml/badge.svg[Build Status,link={url-repo}/actions/worklows/codeql.yml] image:{url-repo}/actions/workflows/pmd.yml/badge.svg[Build Status,link={url-repo}/actions/worklows/pmd.yml] -= Spring Boot Starter for Bucket4j - -https://github.com/vladimir-bukhtoyarov/bucket4j - Project version overview: * 0.11.x - Bucket4j 8.8.0 - Spring Boot 3.2.x * 0.10.x - Bucket4j 8.7.0 - Spring Boot 3.1.x - -*Examples:* - -* {url-examples}/ehcache[Ehcache] -* {url-examples}/hazelcast[Hazelcast] -* {url-examples}/caffeine[Caffeine] -* {url-examples}/webflux[Webflux (Async)] -* {url-examples}/gateway[Spring Cloud Gateway (Async)] - -= Contents +== Contents * <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> -* <> - - -[[introduction]] -== Introduction - -This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for Bucket4j. -It can be used limit the rate of access to your REST APIs or method calls. - -* Prevention of DoS Attacks, brute-force logins attempts -* Request throttling for specific regions, unauthenticated users, authenticated users, not paying users. - -The benefit of this project is the configuration of Bucket4j via Spring Boots *properties* or *yaml* files. You don't -have to write a single line of code. - -[[migration_guide]] -== Migration Guide - -This section is meant to help you migrate your application to new version of this starter project. - -=== Spring Boot Starter Bucket4j 0.12 - -* Removed deprecated expression property - -=== Spring Boot Starter Bucket4j 0.9 - -* Upgrade to Spring Boot 3 -* Spring Boot 3 requires Java 17 so use at least Java 17 -* Replaced Java 8 compatible Bucket4j dependencies -* Exclude example webflux-infinispan due to startup problems +* <> +** <> +*** <> +*** <> +*** <> +*** <> +*** <> -=== Spring Boot Starter Bucket4j 0.8 +* <> +** <> +** <> -==== Compatibility to Java 8 +* <> +** <> +** <> +** <> +** <> -The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release -of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. -The project is switching to the dedicated artifacts which supports Java 8. You can read more about -it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. - -==== Rename property expression to cache-key - -The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. -An Exception is thrown on startup if the *expression* property is configured. -To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires -a Bean Validation implementation. - -==== JSR 380 - Bean Validation implementation required - -To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. -You can add the Spring Boot Starter Validation which will automatically configures one. +[[introduction]] +== Spring Boot Starter for Bucket4j -[source, xml] ----- - - org.springframework.boot - spring-boot-starter-validation - ----- +This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for https://github.com/vladimir-bukhtoyarov/bucket4j[Bucket4j]. You can use it to set access limits on your API. The benefit of this project is the configuration via *properties* or *yaml* files. You don't have to write a single line of code. -==== Explicit Configuration of the Refill Speed - API Break +The following bullets are example use cases. -The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between -a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. +* Prevention of DoS Attacks +* Brute-force logins attempts +* Request throttling for specific regions, unauthenticated users, authenticated users +* Rate limit for not paying users or users with different permissions -Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. +The following features are provided. Some of them use Springs Expression Language to dynamically interpret conditions. -[source, properties] ----- -bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval=0 -bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minutes ----- - -These properties are removed and replaced by the following configuration: +* Cache key for differentiate the by username, IP address, ...) +* Execute by a specific condition +* Skip by a specific condition +* <> +* Post token consumption based on the filter/method result -[source, properties] ----- -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=interval ----- +You have two choices for rate limit configuration. The first is to add a filter for incoming web requests or a fine-grained on method level. -You can read more about the refill speed configuration here <> +=== Filter -[[getting_started]] -== Getting started +Filters are pluggable components that intercepts the incoming web requests and can reject it to stop the further processing. You can add multiple filters for different urls or skip the rate limit at all for authenticated users. If the limit exceeds, the web requests is aborted and the request is declined with the HTTP Status 429 Too Many Requests. -To use the rate limit in your project you have to add the Bucket4j Spring Boot Starter dependency in -your project. Additionally you have to choose a caching provider <>. +This projects supports the following filters: -The next example uses https://www.jcp.org/en/jsr/detail?id=107[JSR 107] Ehcache which will be auto configured with the https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-caching.html[Spring Boot Starter Cache]. +* https://docs.oracle.com/javaee%2F6%2Fapi%2F%2F/javax/servlet/Filter.html[Servlet Filter] (Default) +* https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/server/WebFilter.html[Webflux Webfilter] (reactive) +* https://docs.spring.io/spring-cloud-gateway/reference/spring-cloud-gateway/global-filters.html[Spring Cloud Gateway Global Filter] (reactive) -[source, xml] +[source,properties] ---- - - com.giffing.bucket4j.spring.boot.starter - bucket4j-spring-boot-starter - - - org.springframework.boot - spring-boot-starter-cache - - - org.ehcache - ehcache - +bucket4j.filters[0].cache-name=buckets # the name of the cache +bucket4j.filters[0].url=^(/hello).* # regular expression for the url +bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5 # refills 5 tokens every 10 seconds (intervall) +bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10 +bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=intervall ---- -> Don't forget to enable the caching feature by adding the @EnableCaching annotation to any of the configuration classes. +=== Method -The configuration can be done in the application.properties / application.yml. -The following configuration limits all requests independently from the user. It allows a maximum of 5 requests within 10 seconds independently from the user. +By annotating your method with @RateLimiting, AOP is used to intercept the method. You have full access to the method parameters to define the rate limit key or skip the rate limit on your conditions. - -[source,yml] +[source,properties] ---- -bucket4j: - enabled: true - filters: - - cache-name: buckets - url: .* - rate-limits: - - bandwidths: - - capacity: 5 - time: 10 - unit: seconds +bucket4j.methods[0].name=not_an_admin # the name of the configuration for annotation reference +bucket4j.methods[0].cache-name=buckets # the name of the cache +bucket4j.methods[0].rate-limits[0].bandwidths[0].capacity=5 # refills 5 tokens every 10 seconds (intervall) +bucket4j.methods[0].rate-limits[0].bandwidths[0].time=10 +bucket4j.methods[0].rate-limits[0].bandwidths[0].unit=seconds +bucket4j.methods[0].rate-limits[0].bandwidths[0].refill-speed=intervall ---- -For Ehcache 3 you also need a *ehcache.xml* which can be placed in the classpath. -The configured cache name *buckets* must be defined in the configuration file. - -[source,yml] ----- -spring: - cache: - jcache: - config: classpath:ehcache.xml ----- - -[source,xml] ----- - - - - 3600 - - 1000000 - - - +[source,java] ---- +@RateLimiting( + // reference to the property file + name = "not_an_admin", + // only when the parameter is not admin + executeCondition = "#myParamName != 'admin'", + // the method name is added to the cache key to prevent conflicts with other methods + ratePerMethod = true, + // if the limit is exceeded the fallback method is called. If not provided an exception is thrown + fallbackMethodName = "myFallbackMethod") + public String execute(String myParamName) { + log.info("Method with Param {} executed", myParamName); + return myParamName; + } -[[overview_cache_autoconfiguration]] -== Overview Cache Autoconfiguration - -The following list contains the Caching implementation which will be autoconfigured by this starter. - -[cols="1,1,1"] -|=== -|*Reactive* -|*Name* -|*cache-to-use* - -|N -|{url-config-cache}/jcache/JCacheBucket4jConfiguration.java[JSR 107 -JCache] -|jcache - -|Yes -|{url-config-cache}/ignite/IgniteBucket4jCacheConfiguration.java[Ignite] -|jcache-ignite - -|no -|{url-config-cache}/hazelcast/HazelcastSpringBucket4jCacheConfiguration.java[Hazelcast] -|hazelcast-spring - -|yes -|{url-config-cache}/hazelcast/HazelcastReactiveBucket4jCacheConfiguration.java[Hazelcast] -|hazelcast-reactive - -|Yes -|{url-config-cache}/infinispan/InfinispanBucket4jCacheConfiguration.java[Infinispan] -|infinispan - -|No -|{url-config-cache}/redis/jedis/JedisBucket4jConfiguration.java[Redis-Jedis] -|redis-jedis - -|Yes -|{url-config-cache}/redis/lettuce/LettuceBucket4jConfiguration.java[Redis-Lettuce] -|redis-lettuce - -|Yes -|{url-config-cache}/redis/redission/RedissonBucket4jConfiguration.java[Redis-Redisson] -|redis-redisson - -|=== - -Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver -or the AsynchCacheResolver by yourself. - -You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting -it to an invalid value to disable all auto configurations. - -[source, properties] ----- -bucket4j.cache-to-use=jcache # + public String myFallbackMethod(String myParamName) { + log.info("Fallback-Method with Param {} executed", myParamName); + return myParamName; + } ---- -[[filters]] -== Filter -=== Filter strategy +[[project_configuration]] +== Project Configuration -The filter strategy defines how the execution of the rate limits will be performed. +[[bucket4j_complete_properties]] +=== General Bucket4j properties [source, properties] ---- -bucket4j.filters[0].strategy=first # [first, all] ----- - -==== first - -The *first* is the default strategy. This the default strategy which only executes one rate limit configuration. - -==== all - -The *all* strategy executes all rate limit independently. - -[[cache_key]] -== Cache Key - -To differentiate incoming request you can provide an expression which is used as a key resolver for the underlying cache. - -The expression uses the https://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html[Spring Expression Language] (SpEL) which -provides the most flexible solution to determine the cache key written in one line of code. https://docs.spring.io/spring/docs/current/spring-framework-reference/html/expressions.html#expressions-spel-compilation[The expression compiles to a Java class which will be used]. - -Depending on the filter method [servlet, webflux, gateway] different SpEL root objects can be used in the expression so that you have a direct access to the method of these request objects: - -* servlet: jakarta.servlet.http.HttpServletRequest (e.g. getRemoteAddr() or getRequestURI()) -* webflux: org.springframework.http.server.reactive.ServerHttpRequest -* gateway: org.springframework.http.server.reactive.ServerHttpRequest - -The configured URL which is used for filtering is added to the cache-key to provide a unique cache-key for multiple URL. -You can read more about it https://github.com/MarcGiffing/bucket4j-spring-boot-starter/issues/19[here]. +bucket4j.enabled=true # enable/disable bucket4j support +bucket4j.cache-to-use= # If you use multiple caching implementation in your project and you want to choose a specific one you can set the cache here (jcache, hazelcast, ignite, redis) -*Limiting based on IP-Address*: -[source] ----- -getRemoteAddress() +# Optional default metric tags for all filters +bucket4j.default-metric-tags[0].key=IP +bucket4j.default-metric-tags[0].expression=getRemoteAddr() +bucket4j.default-metric-tags[0].types=REJECTED_COUNTER ---- +==== Filter Bucket4j properties -*Limiting based on Username - If not logged in use IP-Address*: -[source] ----- -@securityService.username()?: getRemoteAddr() ----- -[source,java] +[source, properties] ---- -/** -* You can define custom beans like the SecurityService which can be used in the SpEl expressions. -**/ -@Service -public class SecurityService { - - public String username() { - String name = SecurityContextHolder.getContext().getAuthentication().getName(); - if(name == "anonymousUser") { - return null; - } - return name; - } - -} +bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. +bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. +bucket4j.filters[0].id=filter1 # The id of the filter. This field is mandatory when configuration caching is enabled and should always be a unique string. +bucket4j.filters[0].major-version=1 # [min = 1, max = 92 million] Major version number of the configuration. +bucket4j.filters[0].minor-version=1 # [min = 1, max = 99 billion] Minor version number of the configuration. (intended for internal updates, for example based on CPU-usage, but can also be used for regular updates) +bucket4j.filters[0].cache-name=buckets # the name of the cache key +bucket4j.filters[0].filter-method=servlet # [servlet,webflux,gateway] +bucket4j.filters[0].filter-order= # Per default the lowest integer plus 10. Set it to a number higher then zero to execute it after e.g. Spring Security. +bucket4j.filters[0].http-content-type=application/json +bucket4j.filters[0].http-status-code=TOO_MANY_REQUESTS # Enum value of org.springframework.http.HttpStatus +bucket4j.filters[0].http-response-body={ "message": "Too many requests" } # the json response which should be added to the body +bucket4j.filters[0].http-response-headers.=MY_CUSTOM_HEADER_VALUE # You can add any numbers of custom headers +bucket4j.filters[0].hide-http-response-headers=true # Hides response headers like x-rate-limit-remaining or x-rate-limit-retry-after-seconds on rate limiting +bucket4j.filters[0].url=.* # a regular expression +bucket4j.filters[0].strategy=first # [first, all] if multiple rate limits configured the 'first' strategy stops the processing after the first matching +bucket4j.filters[0].rate-limits[0].cache-key=getRemoteAddr() # defines the cache key. It will be evaluated with the Spring Expression Language +bucket4j.filters[0].rate-limits[0].num-tokens=1 # The number of tokens to consume +bucket4j.filters[0].rate-limits[0].execute-condition=1==1 # an optional SpEl expression to decide to execute the rate limit or not +bucket4j.filters[1].rate-limits[0].post-execute-condition= # an optional SpEl expression to decide if the token consumption should only estimated for the incoming request and the returning response used to check if the token must be consumed: getStatus() eq 401 +bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world # On the HTTP Path as a list +bucket4j.filters[0].rate-limits[0].execute-predicates[1]=METHOD=GET,POST # On the HTTP Method +bucket4j.filters[0].rate-limits[0].execute-predicates[2]=QUERY=HELLO # Checks for the existence of a Query Parameter +bucket4j.filters[0].rate-limits[0].skip-condition=1==1 # an optional SpEl expression to skip the rate limit +bucket4j.filters[0].rate-limits[0].tokens-inheritance-strategy=RESET # [RESET, AS_IS, ADDITIVE, PROPORTIONALLY], defaults to RESET and is only used for dynamically updating configurations +bucket4j.filters[0].rate-limits[0].bandwidths[0].id=bandwidthId # Optional when using tokensInheritanceStrategy.RESET or if the rate-limit only contains 1 bandwidth. The id should be unique within the rate-limit. +bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=10 +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-capacity= # default is capacity +bucket4j.filters[0].rate-limits[0].bandwidths[0].time=1 +bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes +bucket4j.filters[0].rate-limits[0].bandwidths[0].initial-capacity= # Optional initial tokens +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] +bucket4j.filters[0].metrics.enabled=true +bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter +bucket4j.filters[0].metrics.tags[0].key=IP +bucket4j.filters[0].metrics.tags[0].expression=getRemoteAddr() +bucket4j.filters[0].metrics.tags[0].types=REJECTED_COUNTER # (optionial) this tag should for example only be applied for the rejected counter +bucket4j.filters[0].metrics.tags[1].key=URL +bucket4j.filters[0].metrics.tags[1].expression=getRequestURI() +bucket4j.filters[0].metrics.tags[2].key=USERNAME +bucket4j.filters[0].metrics.tags[2].expression=@securityService.username() != null ? @securityService.username() : 'anonym' ---- -[[cache_overview]] - - [[refill_speed]] -== Refill Speed +==== Refill Speed The refill speed defines the period of the regeneration of consumed tokens. This starter supports two types of token regeneration. The refill speed can be set with the following @@ -335,14 +195,31 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,i You can read more about the refill speed in the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. +[[rate_limit_strategy]] +==== Rate Limit Strategy + +If multiple rate limits are defined the strategy defines how many of them should be executed. + +[source, properties] +---- +bucket4j.filters[0].strategy=first # [first, all] +---- + +===== first + +The *first* is the default strategy. This the default strategy which only executes one rate limit configuration. If a rate limit configuration is skipped due to the provided condition. It does not count as an executed rate limit. + +===== all + +The *all* strategy executes all rate limit independently. [[skip_execution_predicates]] -== Skip and Execution Predicates (experimental) +==== Skip and Execution Predicates (experimental) Skip and Execution Predicates can be used to conditionally skip or execute the rate limiting. Each predicate has a unique name and a self-contained configuration. The following section describes the build in Execution Predicates and how to use them. -=== Path Predicates +===== Path Predicates The Path Predicate takes a list of path parameters where any of the paths must match. See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java[PathPattern] for the available configuration options. Segments are not evaluated further. @@ -355,7 +232,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world,/adm Matches the paths '/hello', '/world' or '/admin'. -=== Method Predicate +===== Method Predicate The Method Predicate takes a list of method parameters where any of the methods must match the used HTTP method. @@ -365,7 +242,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=METHOD=GET,POST ---- Matches if the HTTP method is 'GET' or 'POST'. -=== Query Predicate +===== Query Predicate The Query Predicate takes a single parameter to check for the existence of the query parameter. @@ -375,7 +252,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=QUERY=PARAM_1 ---- Matches if the query parameter 'PARAM_1' exists. -=== Header Predicate +===== Header Predicate The Header Predicate takes to parameters. @@ -387,7 +264,7 @@ bucket4j.filters[0].rate-limits[0].execute-predicates[0]=Content-Type,.*PDF.* ---- Matches if the query parameter 'PARAM_1' exists. -=== Custom Predicate +===== Custom Predicate You can also define you own Execution Predicate: @@ -421,15 +298,71 @@ public class MyQueryExecutePredicate extends ExecutePredicate BAD_REQUEST - The configCacheManager currently does *not* contain validation in the setValue method. The configuration should be validated before calling the this method. -[[bucket4j_complete_properties]] -== Bucket4j properties - - -[source, properties] ----- -bucket4j.enabled=true # enable/disable bucket4j support -bucket4j.cache-to-use= # If you use multiple caching implementation in your project and you want to choose a specific one you can set the cache here (jcache, hazelcast, ignite, redis) -bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. -bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. -bucket4j.filters[0].id=filter1 # The id of the filter. This field is mandatory when configuration caching is enabled and should always be a unique string. -bucket4j.filters[0].major-version=1 # [min = 1, max = 92 million] Major version number of the configuration. -bucket4j.filters[0].minor-version=1 # [min = 1, max = 99 billion] Minor version number of the configuration. (intended for internal updates, for example based on CPU-usage, but can also be used for regular updates) -bucket4j.filters[0].cache-name=buckets # the name of the cache key -bucket4j.filters[0].filter-method=servlet # [servlet,webflux,gateway] -bucket4j.filters[0].filter-order= # Per default the lowest integer plus 10. Set it to a number higher then zero to execute it after e.g. Spring Security. -bucket4j.filters[0].http-content-type=application/json -bucket4j.filters[0].http-status-code=TOO_MANY_REQUESTS # Enum value of org.springframework.http.HttpStatus -bucket4j.filters[0].http-response-body={ "message": "Too many requests" } # the json response which should be added to the body -bucket4j.filters[0].http-response-headers.=MY_CUSTOM_HEADER_VALUE # You can add any numbers of custom headers -bucket4j.filters[0].hide-http-response-headers=true # Hides response headers like x-rate-limit-remaining or x-rate-limit-retry-after-seconds on rate limiting -bucket4j.filters[0].url=.* # a regular expression -bucket4j.filters[0].strategy=first # [first, all] if multiple rate limits configured the 'first' strategy stops the processing after the first matching -bucket4j.filters[0].rate-limits[0].cache-key=getRemoteAddr() # defines the cache key. It will be evaluated with the Spring Expression Language -bucket4j.filters[0].rate-limits[0].num-tokens=1 # The number of tokens to consume -bucket4j.filters[0].rate-limits[0].execute-condition=1==1 # an optional SpEl expression to decide to execute the rate limit or not -bucket4j.filters[1].rate-limits[0].post-execute-condition= # an optional SpEl expression to decide if the token consumption should only estimated for the incoming request and the returning response used to check if the token must be consumed: getStatus() eq 401 -bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world # On the HTTP Path as a list -bucket4j.filters[0].rate-limits[0].execute-predicates[1]=METHOD=GET,POST # On the HTTP Method -bucket4j.filters[0].rate-limits[0].execute-predicates[2]=QUERY=HELLO # Checks for the existence of a Query Parameter -bucket4j.filters[0].rate-limits[0].skip-condition=1==1 # an optional SpEl expression to skip the rate limit -bucket4j.filters[0].rate-limits[0].tokens-inheritance-strategy=RESET # [RESET, AS_IS, ADDITIVE, PROPORTIONALLY], defaults to RESET and is only used for dynamically updating configurations -bucket4j.filters[0].rate-limits[0].bandwidths[0].id=bandwidthId # Optional when using tokensInheritanceStrategy.RESET or if the rate-limit only contains 1 bandwidth. The id should be unique within the rate-limit. -bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=10 -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-capacity= # default is capacity -bucket4j.filters[0].rate-limits[0].bandwidths[0].time=1 -bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=minutes -bucket4j.filters[0].rate-limits[0].bandwidths[0].initial-capacity= # Optional initial tokens -bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] -bucket4j.filters[0].metrics.enabled=true -bucket4j.filters[0].metrics.types=CONSUMED_COUNTER,REJECTED_COUNTER # (optional) if your not interested in the consumed counter you can specify only the rejected counter -bucket4j.filters[0].metrics.tags[0].key=IP -bucket4j.filters[0].metrics.tags[0].expression=getRemoteAddr() -bucket4j.filters[0].metrics.tags[0].types=REJECTED_COUNTER # (optionial) this tag should for example only be applied for the rejected counter -bucket4j.filters[0].metrics.tags[1].key=URL -bucket4j.filters[0].metrics.tags[1].expression=getRequestURI() -bucket4j.filters[0].metrics.tags[2].key=USERNAME -bucket4j.filters[0].metrics.tags[2].expression=@securityService.username() != null ? @securityService.username() : 'anonym' - -# Optional default metric tags for all filters -bucket4j.default-metric-tags[0].key=IP -bucket4j.default-metric-tags[0].expression=getRemoteAddr() -bucket4j.default-metric-tags[0].types=REJECTED_COUNTER - -# Hide HTTP response headers ----- - - [[monitoring]] -== Monitoring - Spring Boot Actuator +=== Monitoring - Spring Boot Actuator Spring Boot ships with a great support for collecting metrics. This project automatically provides metric information about the consumed and rejected buckets. You can extend these information with configurable https://micrometer.io/docs/concepts#_tag_naming[custom tags] like the username or the IP-Address which can then be evaluated in a monitoring system like prometheus/grafana. @@ -577,9 +452,148 @@ bucket4j: unit: minutes ---- +[[appendix]] +== Appendix -[[configuration_examples]] -== Configuration via properties +[[migration_guide]] +=== Migration Guide + +This section is meant to help you migrate your application to new version of this starter project. + +==== Spring Boot Starter Bucket4j 0.12 + +* Removed deprecated expression property + +==== Spring Boot Starter Bucket4j 0.9 + +* Upgrade to Spring Boot 3 +* Spring Boot 3 requires Java 17 so use at least Java 17 +* Replaced Java 8 compatible Bucket4j dependencies +* Exclude example webflux-infinispan due to startup problems + +==== Spring Boot Starter Bucket4j 0.8 + +===== Compatibility to Java 8 + +The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release +of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. +The project is switching to the dedicated artifacts which supports Java 8. You can read more about +it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. + +===== Rename property expression to cache-key + +The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. +An Exception is thrown on startup if the *expression* property is configured. + +To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires +a Bean Validation implementation. + +===== JSR 380 - Bean Validation implementation required + +To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. +You can add the Spring Boot Starter Validation which will automatically configures one. + +[source, xml] +---- + + org.springframework.boot + spring-boot-starter-validation + +---- + +===== Explicit Configuration of the Refill Speed - API Break + +The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between +a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. + +Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. + +[source, properties] +---- +bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval=0 +bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minutes +---- + +These properties are removed and replaced by the following configuration: + +[source, properties] +---- +bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=interval +---- + +You can read more about the refill speed configuration here <> + +[[overview_cache_autoconfiguration]] +=== Overview Cache Autoconfiguration + +The following list contains the Caching implementation which will be autoconfigured by this starter. + +[cols="1,1,1"] +|=== +|*Reactive* +|*Name* +|*cache-to-use* + +|N +|{url-config-cache}/jcache/JCacheBucket4jConfiguration.java[JSR 107 -JCache] +|jcache + +|Yes +|{url-config-cache}/ignite/IgniteBucket4jCacheConfiguration.java[Ignite] +|jcache-ignite + +|no +|{url-config-cache}/hazelcast/HazelcastSpringBucket4jCacheConfiguration.java[Hazelcast] +|hazelcast-spring + +|yes +|{url-config-cache}/hazelcast/HazelcastReactiveBucket4jCacheConfiguration.java[Hazelcast] +|hazelcast-reactive + +|Yes +|{url-config-cache}/infinispan/InfinispanBucket4jCacheConfiguration.java[Infinispan] +|infinispan + +|No +|{url-config-cache}/redis/jedis/JedisBucket4jConfiguration.java[Redis-Jedis] +|redis-jedis + +|Yes +|{url-config-cache}/redis/lettuce/LettuceBucket4jConfiguration.java[Redis-Lettuce] +|redis-lettuce + +|Yes +|{url-config-cache}/redis/redission/RedissonBucket4jConfiguration.java[Redis-Redisson] +|redis-redisson + +|=== + +Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver +or the AsynchCacheResolver by yourself. + +You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting +it to an invalid value to disable all auto configurations. + +[source, properties] +---- +bucket4j.cache-to-use=jcache # +---- + +[[examples]] +=== Examples + +* {url-examples}/ehcache[Ehcache] +* {url-examples}/hazelcast[Hazelcast] +* {url-examples}/caffeine[Caffeine] +* {url-examples}/redis-jedis[Redis Jedis] +* {url-examples}/redis-lettuce[Redis Lettuce] +* {url-examples}/redis-redisson[Redis Redisson] +* {url-examples}/webflux[Webflux (Async)] +* {url-examples}/gateway[Spring Cloud Gateway (Async)] +* {url-examples}/webflux-infinispan[Infinispan] + +[[property_configuration_examples]] +=== Property Configuration Examples Simple configuration to allow a maximum of 5 requests within 10 seconds independently from the user. @@ -663,4 +677,4 @@ bucket4j: - capacity: 10000 time: 1 unit: minutes ----- +---- \ No newline at end of file diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java index 1a42f1b7..698e241a 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitConditionMatchingStrategy.java @@ -2,21 +2,18 @@ /** * Bad name :-) - * - * If multiple rate limits configured this strategy decides when to stop - * the evaluation. - * - * + *

+ * If multiple rate limits configured this strategy decides when to stop the evaluation. */ public enum RateLimitConditionMatchingStrategy { - /** - * All rate limits should be evaluated - */ - ALL, - /** - * Only the first matching rate limit will be evaluated - */ - FIRST, - + /** + * All rate limits should be evaluated + */ + ALL, + /** + * Only the first matching rate limit will be evaluated + */ + FIRST, + } diff --git a/src/main/doc/plantuml/post_execution_condition.plantuml b/src/main/doc/plantuml/post_execution_condition.plantuml new file mode 100644 index 00000000..7f35233f --- /dev/null +++ b/src/main/doc/plantuml/post_execution_condition.plantuml @@ -0,0 +1,30 @@ +@startuml + +== First Request - 1 Token available == + +User -> Bucket4jFilter: webRequest +box "Webserver" #LightBlue +participant Bucket4jFilter +participant SpringSecurityFilter + +Bucket4jFilter -> Bucket4jFilter : check remaining tokens +Bucket4jFilter -> SpringSecurityFilter : tokens available proceed +SpringSecurityFilter -> SpringSecurityFilter : authenticate +SpringSecurityFilter -> Bucket4jFilter : authentication Failed 401 +Bucket4jFilter -> Bucket4jFilter : if response status 401 consume token +Bucket4jFilter -> User : response 401 +end box + +== Second Request - 0 Token available == + + +User -> Bucket4jFilter: webRequest +box "Webserver" #LightBlue +participant Bucket4jFilter +participant SpringSecurityFilter + +Bucket4jFilter -> Bucket4jFilter : check remaining tokens -> no token available +Bucket4jFilter -> User : reject request 429 +end box + +@enduml \ No newline at end of file diff --git a/src/main/doc/plantuml/post_execution_condition.png b/src/main/doc/plantuml/post_execution_condition.png new file mode 100644 index 0000000000000000000000000000000000000000..8490255a071f57d27cd9cc8fb1639852e6f2eb32 GIT binary patch literal 30956 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 Date: Mon, 11 Mar 2024 19:45:01 +0100 Subject: [PATCH 4/9] Support for Method level @RateLimiting annoation #250 --- .../caffeine/CaffeineGeneralSuiteTest.java | 4 +- .../ehcache/EhcacheGeneralSuiteTest.java | 4 +- examples/general-tests/pom.xml | 5 ++ .../filter/method/MethodRateLimitTest.java | 88 +++++++++++++++++++ .../filter/method/MethodTestApplication.java | 17 ++++ .../tests/filter/method/MethodTestSuite.java | 11 +++ .../tests/filter/method/TestService.java | 45 ++++++++++ 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodRateLimitTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestApplication.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestSuite.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/TestService.java diff --git a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java b/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java index c9b2d3b9..9dce3395 100644 --- a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java +++ b/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java @@ -1,12 +1,14 @@ package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method.MethodTestSuite; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @Suite @SelectClasses({ - ServletTestSuite.class + ServletTestSuite.class, + MethodTestSuite.class }) public class CaffeineGeneralSuiteTest { } diff --git a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java index a07b9d2c..7821b066 100644 --- a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java +++ b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java @@ -1,12 +1,14 @@ package com.giffing.bucket4j.spring.boot.starter.examples.ehcache; +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method.MethodTestSuite; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @Suite @SelectClasses({ - ServletTestSuite.class + ServletTestSuite.class, + MethodTestSuite.class, }) public class EhcacheGeneralSuiteTest { } diff --git a/examples/general-tests/pom.xml b/examples/general-tests/pom.xml index 53d38e6d..5f021d70 100644 --- a/examples/general-tests/pom.xml +++ b/examples/general-tests/pom.xml @@ -23,6 +23,11 @@ org.springframework.boot spring-boot-starter-test + + org.springframework.boot + spring-boot-starter-aop + provided + org.springframework.boot spring-boot-starter-validation diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodRateLimitTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodRateLimitTest.java new file mode 100644 index 00000000..f1a4b80e --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodRateLimitTest.java @@ -0,0 +1,88 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method; + +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitException; +import lombok.RequiredArgsConstructor; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(properties = { + "debug=true", + "bucket4j.methods[0].name=default", + "bucket4j.methods[0].cache-name=buckets", + "bucket4j.methods[0].rate-limit.bandwidths[0].capacity=5", + "bucket4j.methods[0].rate-limit.bandwidths[0].time=10", + "bucket4j.methods[0].rate-limit.bandwidths[0].unit=seconds", + "bucket4j.methods[0].rate-limit.bandwidths[0].refill-speed=greedy", +}) +@RequiredArgsConstructor +@DirtiesContext +public class MethodRateLimitTest { + + @Autowired + private TestService testService; + + + @Test + public void assert_rate_limit_with_execute_condition_matches() { + for(int i = 0; i < 5; i++) { + // rate limit executed because it's not the admin + testService.withExecuteCondition("normal_user"); + } + assertThrows(RateLimitException.class, () -> testService.withExecuteCondition("normal_user")); + } + + @Test + public void assert_no_rate_limit_with_execute_condition_does_not_match() { + assertAll(() -> { + for(int i = 0; i < 10; i++) { + // rate limit not executed for admin parameter + testService.withExecuteCondition("admin"); + } + }); + } + + @Test + public void assert_rate_limit_with_fallback_method() { + for(int i = 0; i < 5; i++) { + assertEquals("normal-method-executed;param:my-test", testService.withFallbackMethod("my-test")); + } + // no exception is thrown. fall back method is executed + assertEquals("fallback-method-executed;param:my-test", testService.withFallbackMethod("my-test")); + } + + @Test + public void assert_rate_limit_with_skip_condition_does_not_match() { + for(int i = 0; i < 5; i++) { + // skip condition does not match. rate limit is performed + testService.withSkipCondition("normal_user"); + } + assertThrows(RateLimitException.class, () -> testService.withSkipCondition("normal_user")); + } + + @Test + public void assert_no_rate_limit_with_skip_condition_matches() { + assertAll(() -> { + for(int i = 0; i < 10; i++) { + // no token consumption. admin is skipped + testService.withSkipCondition("admin"); + } + }); + } + + @Test + public void assert_rate_limit_with_cache_key() { + for(int i = 0; i < 5; i++) { + // rate limit by parameter value + testService.withCacheKey("key1"); + testService.withCacheKey("key2"); + // all tokens consumed + } + assertThrows(RateLimitException.class, () -> testService.withCacheKey("key1")); + assertThrows(RateLimitException.class, () -> testService.withCacheKey("key2")); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestApplication.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestApplication.java new file mode 100644 index 00000000..39ba101e --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestApplication.java @@ -0,0 +1,17 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.EnableAspectJAutoProxy; + +@SpringBootApplication +@EnableCaching +@EnableAspectJAutoProxy +public class MethodTestApplication { + + public static void main(String[] args) { + SpringApplication.run(MethodTestApplication.class, args); + } + +} \ No newline at end of file diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestSuite.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestSuite.java new file mode 100644 index 00000000..a0e9764e --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/MethodTestSuite.java @@ -0,0 +1,11 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + MethodRateLimitTest.class +}) +public class MethodTestSuite { +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/TestService.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/TestService.java new file mode 100644 index 00000000..5ec5c18e --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/method/TestService.java @@ -0,0 +1,45 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method; + +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +public class TestService { + + + @RateLimiting( + name = "default", + executeCondition = "#myParamName != 'admin'") + public String withExecuteCondition(String myParamName) { + log.info("Method withExecuteCondition with Param {} executed", myParamName); + return myParamName; + } + + @RateLimiting( + name = "default", + skipCondition = "#myParamName eq 'admin'") + public String withSkipCondition(String myParamName) { + log.info("Method withSkipCondition with Param {} executed", myParamName); + return myParamName; + } + + @RateLimiting( + name = "default", + cacheKey = "#cacheKey") + public String withCacheKey(String cacheKey) { + log.info("Method withCacheKey with Param {} executed", cacheKey); + return cacheKey; + } + + @RateLimiting(name = "default", cacheKey = "'normal'", fallbackMethodName = "fallbackMethod") + public String withFallbackMethod(String myParamName) { + return "normal-method-executed;param:" + myParamName; + } + + public String fallbackMethod(String myParamName) { + return "fallback-method-executed;param:" + myParamName; + } + +} From 0860e0c294880b531c6cc7b7fcedb8ab45b87227 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 11 Mar 2024 20:12:50 +0100 Subject: [PATCH 5/9] Support for Method level @RateLimiting annoation #250 --- .../ehcache/EhcacheGeneralSuiteTest.java | 2 - .../post_execution_condition.plantuml | 47 ++++++++++-------- .../doc/plantuml/post_execution_condition.png | Bin 30956 -> 36049 bytes 3 files changed, 26 insertions(+), 23 deletions(-) diff --git a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java index 7821b066..dcfd47e8 100644 --- a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java +++ b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java @@ -1,6 +1,5 @@ package com.giffing.bucket4j.spring.boot.starter.examples.ehcache; -import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.method.MethodTestSuite; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @@ -8,7 +7,6 @@ @Suite @SelectClasses({ ServletTestSuite.class, - MethodTestSuite.class, }) public class EhcacheGeneralSuiteTest { } diff --git a/src/main/doc/plantuml/post_execution_condition.plantuml b/src/main/doc/plantuml/post_execution_condition.plantuml index 7f35233f..22c89377 100644 --- a/src/main/doc/plantuml/post_execution_condition.plantuml +++ b/src/main/doc/plantuml/post_execution_condition.plantuml @@ -1,30 +1,35 @@ @startuml -== First Request - 1 Token available == - -User -> Bucket4jFilter: webRequest -box "Webserver" #LightBlue -participant Bucket4jFilter -participant SpringSecurityFilter - -Bucket4jFilter -> Bucket4jFilter : check remaining tokens -Bucket4jFilter -> SpringSecurityFilter : tokens available proceed -SpringSecurityFilter -> SpringSecurityFilter : authenticate -SpringSecurityFilter -> Bucket4jFilter : authentication Failed 401 -Bucket4jFilter -> Bucket4jFilter : if response status 401 consume token -Bucket4jFilter -> User : response 401 -end box +User -> Bucket4jFilter: request + +box "Webserver" #f5e4e4 + + Bucket4jFilter -> Bucket4jFilter : estimate_remaining_tokens + participant Bucket4jFilter + participant SpringSecurityFilter + alt 1 token available + + note right of Bucket4jFilter: There is one token available. \nThe request will not be aborted + Bucket4jFilter -> SpringSecurityFilter : request + SpringSecurityFilter -> SpringSecurityFilter : authenticate + SpringSecurityFilter -> Bucket4jFilter : response(401) + alt HTTP Response Status == 401 + note right of Bucket4jFilter: The token will only be consumed\n if the HTTP Status is 401\n + Bucket4jFilter -> Bucket4jFilter : consume_token + end + Bucket4jFilter -> User : response(401) + + else 0 token available + note right of Bucket4jFilter: The token was consumed \nbecause of the HTTP Response Status 401 + Bucket4jFilter -> Bucket4jFilter : reject request + Bucket4jFilter -> User : response\n(429 Too Many Requests) + end + -== Second Request - 0 Token available == -User -> Bucket4jFilter: webRequest -box "Webserver" #LightBlue -participant Bucket4jFilter -participant SpringSecurityFilter -Bucket4jFilter -> Bucket4jFilter : check remaining tokens -> no token available -Bucket4jFilter -> User : reject request 429 end box + @enduml \ No newline at end of file diff --git a/src/main/doc/plantuml/post_execution_condition.png b/src/main/doc/plantuml/post_execution_condition.png index 8490255a071f57d27cd9cc8fb1639852e6f2eb32..31f3f8cb589f70a009c6e43f6b50a860fe11ff23 100644 GIT binary patch literal 36049 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 Date: Mon, 11 Mar 2024 20:23:04 +0100 Subject: [PATCH 6/9] Support for Method level @RateLimiting annoation #250 --- .../spring/boot/starter/examples/caffeine/TestService.java | 2 +- examples/caffeine/src/main/resources/application.yml | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java index c6cc875e..84f1b75b 100644 --- a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/TestService.java @@ -9,7 +9,7 @@ public class TestService { - @RateLimiting(name = "hello", + @RateLimiting(name = "default", executeCondition = "#myParamName != 'admin'", ratePerMethod = true, fallbackMethodName = "dummy") diff --git a/examples/caffeine/src/main/resources/application.yml b/examples/caffeine/src/main/resources/application.yml index b08ce7d2..ba046996 100644 --- a/examples/caffeine/src/main/resources/application.yml +++ b/examples/caffeine/src/main/resources/application.yml @@ -22,12 +22,10 @@ bucket4j: filter-config-caching-enabled: true filter-config-cache-name: filterConfigCache methods: - - name: hello + - name: default cache-name: buckets rate-limit: cache-key: 1 - execute-condition: true - post-execute-condition: "#root == 'alpha'" bandwidths: - capacity: 1 refill-capacity: 1 @@ -38,7 +36,7 @@ bucket4j: filters: - id: filter1 cache-name: buckets - url: world + url: .* rate-limits: - cache-key: getRemoteAddr() post-execute-condition: getStatus() eq 200 From 6a48bd8b7ed2ce3855a8e393d15f35e8d1f76eed Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 11 Mar 2024 20:32:09 +0100 Subject: [PATCH 7/9] Support for Method level @RateLimiting annoation #250 --- README.adoc | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/README.adoc b/README.adoc index 882db897..ef5f818b 100644 --- a/README.adoc +++ b/README.adoc @@ -39,28 +39,32 @@ Project version overview: [[introduction]] == Spring Boot Starter for Bucket4j -This project is a http://projects.spring.io/spring-boot/[Spring Boot Starter] for https://github.com/vladimir-bukhtoyarov/bucket4j[Bucket4j]. You can use it to set access limits on your API. The benefit of this project is the configuration via *properties* or *yaml* files. You don't have to write a single line of code. +This project is a Spring Boot Starter for Bucket4j, allowing you to set access limits on your API effortlessly. Its key advantage lies in the configuration via properties or yaml files, eliminating the need for manual code authoring. -The following bullets are example use cases. -* Prevention of DoS Attacks -* Brute-force logins attempts -* Request throttling for specific regions, unauthenticated users, authenticated users -* Rate limit for not paying users or users with different permissions -The following features are provided. Some of them use Springs Expression Language to dynamically interpret conditions. +Here are some example use cases: + +* Preventing DoS Attacks +* Thwarting brute-force login attempts +* Implementing request throttling for specific regions, unauthenticated users, and authenticated users +* Applying rate limits for non-paying users or users with varying permissions + +The project offers several features, some utilizing Spring's Expression Language for dynamic condition interpretation: * Cache key for differentiate the by username, IP address, ...) -* Execute by a specific condition -* Skip by a specific condition +* Execution based on specific conditions +* Skipping based on specific conditions * <> -* Post token consumption based on the filter/method result +* Post-token consumption actions based on filter/method results -You have two choices for rate limit configuration. The first is to add a filter for incoming web requests or a fine-grained on method level. +You have two options for rate limit configuration: adding a filter for incoming web requests or applying fine-grained control at the method level. === Filter -Filters are pluggable components that intercepts the incoming web requests and can reject it to stop the further processing. You can add multiple filters for different urls or skip the rate limit at all for authenticated users. If the limit exceeds, the web requests is aborted and the request is declined with the HTTP Status 429 Too Many Requests. +Filters are customizable components designed to intercept incoming web requests, capable of rejecting requests to halt further processing. You can incorporate multiple filters for various URLs or opt to bypass rate limits entirely for authenticated users. When the limit is exceeded, the web request is aborted, and the client receives an HTTP Status 429 Too Many Requests error. + +This project supports the following filters: This projects supports the following filters: @@ -82,6 +86,8 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=intervall By annotating your method with @RateLimiting, AOP is used to intercept the method. You have full access to the method parameters to define the rate limit key or skip the rate limit on your conditions. +Utilizing the '@RateLimiting' annotation, AOP intercepts your method. This grants you comprehensive access to method parameters, empowering you to define the rate limit key or conditionally skip rate limiting with ease. + [source,properties] ---- bucket4j.methods[0].name=not_an_admin # the name of the configuration for annotation reference From 0f66fc216abb49069f87c0dd0c9f90ecd6f3728b Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 11 Mar 2024 20:33:54 +0100 Subject: [PATCH 8/9] Support for Method level @RateLimiting annoation #250 --- README.adoc | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.adoc b/README.adoc index ef5f818b..8adf2a45 100644 --- a/README.adoc +++ b/README.adoc @@ -64,8 +64,6 @@ You have two options for rate limit configuration: adding a filter for incoming Filters are customizable components designed to intercept incoming web requests, capable of rejecting requests to halt further processing. You can incorporate multiple filters for various URLs or opt to bypass rate limits entirely for authenticated users. When the limit is exceeded, the web request is aborted, and the client receives an HTTP Status 429 Too Many Requests error. -This project supports the following filters: - This projects supports the following filters: * https://docs.oracle.com/javaee%2F6%2Fapi%2F%2F/javax/servlet/Filter.html[Servlet Filter] (Default) @@ -84,8 +82,6 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=intervall === Method -By annotating your method with @RateLimiting, AOP is used to intercept the method. You have full access to the method parameters to define the rate limit key or skip the rate limit on your conditions. - Utilizing the '@RateLimiting' annotation, AOP intercepts your method. This grants you comprehensive access to method parameters, empowering you to define the rate limit key or conditionally skip rate limiting with ease. [source,properties] From e5e850b537d413ebdf4ffe99c32a941216f554e7 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 11 Mar 2024 20:36:26 +0100 Subject: [PATCH 9/9] Support for Method level @RateLimiting annoation #250 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index fa71c986..8a7ec64f 100644 --- a/pom.xml +++ b/pom.xml @@ -45,7 +45,7 @@ bucket4j-spring-boot-starter-parent-0.3.4 - 0.11.0 + 0.12.0 UTF-8 UTF-8 17