From 206c6cfea44d4da12ec3ec7687ead999c559f8f8 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Thu, 7 Mar 2024 20:34:41 +0100 Subject: [PATCH] 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