From 9bf6cd057fd46786bf3041ea7a8254c356148e76 Mon Sep 17 00:00:00 2001 From: MarcGiffing Date: Sat, 2 Mar 2024 15:20:34 +0100 Subject: [PATCH] #71 add post-execute-condition (#243) * #71 add post-execute-condition * #71 add post-execute-condition * #71 add post-execute-condition reduce code duplication for tests (not optimal but it improves maintainability) * #71 add post-execute-condition prepare snapshot release * #71 add post-execute-condition fix wrong directory for general-tests * #71 add post-execute-condition * github actions test report * #71 add post-execute-condition minor refactoring add empty response body test * #71 add post-execute-condition tests: - change response status code - additional response headers * #71 add post-execute-condition tests: - skip condition * #71 add post-execute-condition tests: - execute condition --- .github/workflows/maven.yml | 7 +- .gitignore | 3 +- README.adoc | 1 + .../context/ConsumptionProbeHolder.java | 24 -- .../starter/context/PostRateLimitCheck.java | 20 ++ .../boot/starter/context/RateLimitCheck.java | 2 +- .../boot/starter/context/RateLimitResult.java | 25 ++ .../context/RateLimitResultWrapper.java | 23 ++ .../starter/context/properties/BandWidth.java | 15 +- .../properties/FilterConfiguration.java | 24 +- .../starter/context/properties/RateLimit.java | 7 +- .../starter/context/qualifier/Gateway.java | 15 + .../starter/context/qualifier/Servlet.java | 15 + .../starter/context/qualifier/Webflux.java | 15 + .../cache/AbstractCacheResolverTemplate.java | 125 ++++++-- .../starter/config/cache/CacheResolver.java | 2 +- .../config/cache/ProxyManagerWrapper.java | 8 +- .../filter/Bucket4JBaseConfiguration.java | 104 +++++-- ...ConfigurationSpringCloudGatewayFilter.java | 48 ++-- ...gurationSpringCloudGatewayFilterBeans.java | 7 +- ...ucket4JAutoConfigurationWebfluxFilter.java | 7 +- ...4JAutoConfigurationWebfluxFilterBeans.java | 7 +- ...ucket4JAutoConfigurationServletFilter.java | 53 ++-- ...4JAutoConfigurationServletFilterBeans.java | 7 +- .../metrics/actuator/Bucket4jEndpoint.java | 42 +-- .../reactive/AbstractReactiveFilter.java | 84 +++--- .../SpringCloudGatewayRateLimitFilter.java | 3 +- .../reactive/webflux/WebfluxWebFilter.java | 3 +- .../filter/servlet/ServletRequestFilter.java | 157 ++++++----- ...SpringCloudGatewayRateLimitFilterTest.java | 53 ++-- .../servlet/ServletRateLimitFilterTest.java | 27 +- .../webflux/WebfluxRateLimitFilterTest.java | 17 +- examples/caffeine/pom.xml | 10 + .../caffeine/SimpleSecurityFilter.java | 33 +++ .../examples/caffeine/TestController.java | 5 + .../src/main/resources/application.yml | 9 +- .../caffeine/CaffeineGeneralSuiteTest.java | 12 + .../test/resources/application-servlet.yml | 28 -- .../src/test/resources/application.yml | 7 + examples/ehcache/pom.xml | 37 +-- .../config/security/SecurityConfig.java | 38 --- .../config/security/SecurityService.java | 24 -- .../ehcache/controller/TestController.java | 105 +------ .../ehcache/EhcacheGeneralSuiteTest.java | 12 + .../ehcache/EhcacheSampleApplicationTest.java | 214 -------------- .../src/test/resources/application.yml | 4 + .../examples/gateway/TestController.java | 5 +- .../gateway/GatewaySampleApplicationTest.java | 37 ++- examples/general-tests/.gitignore | 9 + examples/general-tests/pom.xml | 76 +++++ .../ReactiveGreadyRefillSpeedTest.java | 73 +++++ .../ReactiveIntervalRefillSpeedTest.java | 72 +++++ .../reactive/ReactiveRateLimitTest.java | 266 ++++++++++++++++++ .../reactive/ReactiveTestApplication.java | 15 + .../filter/reactive/WebfluxTestSuite.java | 13 + .../controller/ReactiveController.java | 87 ++++++ .../filter/servlet/AddResponseHeaderTest.java | 53 ++++ .../ChangeResponseHttpStatusCodeTest.java | 44 +++ .../filter/servlet/EmptyHttpResponseTest.java | 45 +++ .../filter/servlet/ExecuteConditionTest.java | 79 ++++++ .../filter/servlet/GreadyRefillSpeedTest.java | 44 +++ .../servlet/IntervalRefillSpeedTest.java | 44 +++ .../tests/filter/servlet/MockMvcHelper.java | 46 +++ .../servlet/PostExecuteConditionTest.java | 77 +++++ .../filter/servlet}/ServletRateLimitTest.java | 133 ++++++--- .../servlet/ServletTestApplication.java | 15 + .../filter/servlet/ServletTestSuite.java | 19 ++ .../filter/servlet/SkipConditionTest.java | 78 +++++ .../servlet/controller/ServletController.java | 153 ++++++++++ .../security/SimpleSecurityFilter.java | 33 +++ examples/hazelcast/pom.xml | 10 + .../examples/hazelcast/TestController.java | 2 +- .../hazelcast/HazelcastGeneralSuiteTest.java | 12 + .../examples/hazelcast/HazelcastTest.java | 214 -------------- .../src/test/resources/application.yml | 7 + examples/redis-jedis/pom.xml | 16 +- .../examples/redis/RedisJedisTest.java | 248 ---------------- .../servlet/JedisGreadyRefillSpeedTest.java | 27 ++ .../servlet/JedisIntervalRefillSpeedTest.java | 27 ++ .../servlet/JedisServletRateLimitTest.java | 27 ++ .../src/test/resources/application.yml | 6 + examples/redis-lettuce/pom.xml | 6 + .../boot/starter/DebugMetricHandler.java | 29 -- .../spring/boot/starter/TestController.java | 5 +- .../examples/redis/RedisLettuceTest.java | 262 ----------------- .../LettuceGreadyRefillSpeedTest.java | 27 ++ .../LettuceIntervalRefillSpeedTest.java | 27 ++ .../reactive/LettuceServletRateLimitTest.java | 30 ++ .../src/test/resources/application.yml | 6 + examples/redis-redisson/pom.xml | 6 + .../spring/boot/starter/TestController.java | 9 +- .../examples/redis/RedisRedissonTest.java | 264 ----------------- .../RedissonGreadyRefillSpeedTest.java | 27 ++ .../RedissonIntervalRefillSpeedTest.java | 27 ++ .../RedissonServletRateLimitTest.java | 27 ++ .../src/test/resources/application.yml | 7 + examples/webflux-infinispan/pom.xml | 11 + .../examples/webflux/DebugMetricHandler.java | 6 +- .../examples/webflux/MyController.java | 7 +- .../webflux/WebfluxGeneralSuiteTest.java | 12 + .../WebfluxInfinispanRateLimitTest.java | 243 ---------------- .../src/test/resources/application.yml | 6 + examples/webflux/pom.xml | 6 + .../examples/webflux/MyController.java | 7 +- .../webflux/WebfluxGeneralSuiteTest.java | 12 + .../webflux/WebfluxRateLimitTest.java | 244 ---------------- .../src/test/resources/application.yml | 3 + pom.xml | 5 +- 108 files changed, 2563 insertions(+), 2358 deletions(-) delete mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ConsumptionProbeHolder.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/PostRateLimitCheck.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResult.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResultWrapper.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Gateway.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Servlet.java create mode 100644 bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Webflux.java create mode 100644 examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/SimpleSecurityFilter.java create mode 100644 examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java delete mode 100644 examples/caffeine/src/test/resources/application-servlet.yml create mode 100644 examples/caffeine/src/test/resources/application.yml delete mode 100644 examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityConfig.java delete mode 100644 examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityService.java create mode 100644 examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java delete mode 100644 examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheSampleApplicationTest.java create mode 100644 examples/ehcache/src/test/resources/application.yml create mode 100644 examples/general-tests/.gitignore create mode 100644 examples/general-tests/pom.xml create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveGreadyRefillSpeedTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveIntervalRefillSpeedTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveRateLimitTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveTestApplication.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/WebfluxTestSuite.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/controller/ReactiveController.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/AddResponseHeaderTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ChangeResponseHttpStatusCodeTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/EmptyHttpResponseTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ExecuteConditionTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/GreadyRefillSpeedTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/IntervalRefillSpeedTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/MockMvcHelper.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/PostExecuteConditionTest.java rename examples/{caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine => general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet}/ServletRateLimitTest.java (69%) create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestApplication.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestSuite.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/SkipConditionTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/controller/ServletController.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/security/SimpleSecurityFilter.java create mode 100644 examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuiteTest.java delete mode 100644 examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastTest.java create mode 100644 examples/hazelcast/src/test/resources/application.yml delete mode 100644 examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisJedisTest.java create mode 100644 examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisGreadyRefillSpeedTest.java create mode 100644 examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisIntervalRefillSpeedTest.java create mode 100644 examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisServletRateLimitTest.java create mode 100644 examples/redis-jedis/src/test/resources/application.yml delete mode 100644 examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/DebugMetricHandler.java delete mode 100644 examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisLettuceTest.java create mode 100644 examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceGreadyRefillSpeedTest.java create mode 100644 examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceIntervalRefillSpeedTest.java create mode 100644 examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceServletRateLimitTest.java create mode 100644 examples/redis-lettuce/src/test/resources/application.yml delete mode 100644 examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisRedissonTest.java create mode 100644 examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonGreadyRefillSpeedTest.java create mode 100644 examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonIntervalRefillSpeedTest.java create mode 100644 examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonServletRateLimitTest.java create mode 100644 examples/redis-redisson/src/test/resources/application.yml create mode 100644 examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java delete mode 100644 examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxInfinispanRateLimitTest.java create mode 100644 examples/webflux-infinispan/src/test/resources/application.yml create mode 100644 examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java delete mode 100644 examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxRateLimitTest.java create mode 100644 examples/webflux/src/test/resources/application.yml diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index 2263ed3b..293ff920 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -4,9 +4,7 @@ on: [push] jobs: build: - runs-on: ubuntu-latest - steps: - uses: actions/checkout@v1 - name: Set up JDK 17 @@ -15,3 +13,8 @@ jobs: java-version: 17 - name: Build with Maven run: mvn -B package --file pom.xml + - name: Publish Test Report + uses: mikepenz/action-junit-report@v4 + if: success() || failure() # always run even if the previous step fails + with: + report_paths: '**/target/surefire-reports/TEST-*.xml' diff --git a/.gitignore b/.gitignore index 4ae1fcc8..7a4282c1 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ *.iml .factorypath .apt_generated -.springBeans \ No newline at end of file +.springBeans +.flattened-pom.xml \ No newline at end of file diff --git a/README.adoc b/README.adoc index 1eaeb327..7c7365d7 100644 --- a/README.adoc +++ b/README.adoc @@ -509,6 +509,7 @@ bucket4j.filters[0].strategy=first # [first, all] if multiple rate limits config 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 diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ConsumptionProbeHolder.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ConsumptionProbeHolder.java deleted file mode 100644 index 8b187fbe..00000000 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ConsumptionProbeHolder.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.context; - -import java.util.concurrent.CompletableFuture; - -import io.github.bucket4j.ConsumptionProbe; -import lombok.Data; - - -@Data -public class ConsumptionProbeHolder { - - private ConsumptionProbe consumptionProbe; - - private CompletableFuture consumptionProbeCompletableFuture; - - public ConsumptionProbeHolder(ConsumptionProbe consumptionProbe) { - this.consumptionProbe = consumptionProbe; - } - - public ConsumptionProbeHolder(CompletableFuture consumptionProbeCompletableFuture) { - this.consumptionProbeCompletableFuture = consumptionProbeCompletableFuture; - } - -} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/PostRateLimitCheck.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/PostRateLimitCheck.java new file mode 100644 index 00000000..a00b9df6 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/PostRateLimitCheck.java @@ -0,0 +1,20 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + + + +/** + * Used to check if the rate limit should be performed independently from the servlet|webflux|gateway request filter + * + */ +@FunctionalInterface +public interface PostRateLimitCheck { + + /** + * @param request the request information object + * @param response the response information object + * + * @return null if no rate limit should be performed. (maybe skipped or shouldn't be executed). + */ + RateLimitResultWrapper rateLimit(R request, P response); + +} 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 e61c2c19..7ec5e7fe 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 @@ -14,6 +14,6 @@ public interface RateLimitCheck { * * @return null if no rate limit should be performed. (maybe skipped or shouldn't be executed). */ - ConsumptionProbeHolder rateLimit(R request); + RateLimitResultWrapper rateLimit(R request); } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResult.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResult.java new file mode 100644 index 00000000..b06b12a3 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResult.java @@ -0,0 +1,25 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + +import lombok.Builder; +import lombok.Data; +import lombok.NonNull; + +@Data +@Builder +public class RateLimitResult { + + @NonNull + private final boolean estimation; + + @NonNull + private final boolean consumed; + + @NonNull + private final long remainingTokens; + + @NonNull + private final long nanosToWaitForRefill; + + @NonNull + private final long nanosToWaitForReset; +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResultWrapper.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResultWrapper.java new file mode 100644 index 00000000..919984cd --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/RateLimitResultWrapper.java @@ -0,0 +1,23 @@ +package com.giffing.bucket4j.spring.boot.starter.context; + +import lombok.Data; + +import java.util.concurrent.CompletableFuture; + + +@Data +public class RateLimitResultWrapper { + + private RateLimitResult rateLimitResult; + + private CompletableFuture rateLimitResultCompletableFuture; + + public RateLimitResultWrapper(RateLimitResult rateLimitResult) { + this.rateLimitResult = rateLimitResult; + } + + public RateLimitResultWrapper(CompletableFuture rateLimitResultCompletableFuture) { + this.rateLimitResultCompletableFuture = rateLimitResultCompletableFuture; + } + +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/BandWidth.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/BandWidth.java index dd643403..8c329431 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/BandWidth.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/BandWidth.java @@ -1,20 +1,15 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; -import java.io.Serializable; -import java.time.temporal.ChronoUnit; - -import jakarta.validation.constraints.AssertTrue; +import com.giffing.bucket4j.spring.boot.starter.context.RefillSpeed; +import com.giffing.bucket4j.spring.boot.starter.context.constraintvalidations.ValidDurationChronoUnit; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; - +import lombok.Data; import org.springframework.util.StringUtils; -import com.giffing.bucket4j.spring.boot.starter.context.RefillSpeed; -import com.giffing.bucket4j.spring.boot.starter.context.constraintvalidations.ValidDurationChronoUnit; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import lombok.Data; +import java.io.Serializable; +import java.time.temporal.ChronoUnit; /** * Configures the rate of data which should be transfered diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/FilterConfiguration.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/FilterConfiguration.java index 9ea451fb..fcda6d34 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/FilterConfiguration.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/FilterConfiguration.java @@ -1,17 +1,16 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import org.springframework.http.HttpStatus; - +import com.giffing.bucket4j.spring.boot.starter.context.PostRateLimitCheck; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; - import lombok.Data; import lombok.ToString; +import org.springframework.http.HttpStatus; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; /** * This class is the main configuration class which is used to build the servlet|webflux|gateway request filter @@ -19,7 +18,7 @@ */ @Data @ToString -public class FilterConfiguration { +public class FilterConfiguration { private RateLimitConditionMatchingStrategy strategy = RateLimitConditionMatchingStrategy.FIRST; @@ -32,7 +31,7 @@ public class FilterConfiguration { * The order of the filter depending on other filters independently from the Bucket4j filters. */ private int order; - + /** * Hides the HTTP response headers * x-rate-limit-remaining @@ -58,6 +57,8 @@ public class FilterConfiguration { private Map httpResponseHeaders = new HashMap<>(); private List> rateLimitChecks = new ArrayList<>(); + + private List> postRateLimitChecks = new ArrayList<>(); public void addRateLimitCheck(RateLimitCheck rateLimitCheck) { this.rateLimitChecks.add(rateLimitCheck); @@ -65,4 +66,7 @@ public void addRateLimitCheck(RateLimitCheck rateLimitCheck) { private Metrics metrics; + public void addPostRateLimitCheck(PostRateLimitCheck prlc) { + getPostRateLimitChecks().add(prlc); + } } 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 193d7c8f..accbdf53 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 @@ -21,7 +21,12 @@ 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<>(); diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Gateway.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Gateway.java new file mode 100644 index 00000000..baf863e0 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Gateway.java @@ -0,0 +1,15 @@ +package com.giffing.bucket4j.spring.boot.starter.context.qualifier; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Qualifier(Gateway.VALUE) +public @interface Gateway { + String VALUE = "GATEWAY"; +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Servlet.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Servlet.java new file mode 100644 index 00000000..0937ce64 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Servlet.java @@ -0,0 +1,15 @@ +package com.giffing.bucket4j.spring.boot.starter.context.qualifier; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Qualifier(Servlet.VALUE) +public @interface Servlet { + String VALUE = "SERVLET"; +} diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Webflux.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Webflux.java new file mode 100644 index 00000000..509ce9d7 --- /dev/null +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/qualifier/Webflux.java @@ -0,0 +1,15 @@ +package com.giffing.bucket4j.spring.boot.starter.context.qualifier; + +import org.springframework.beans.factory.annotation.Qualifier; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Qualifier(Webflux.VALUE) +public @interface Webflux { + String VALUE = "WEBFLUX"; +} diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/AbstractCacheResolverTemplate.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/AbstractCacheResolverTemplate.java index 175bfb78..5789700e 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/AbstractCacheResolverTemplate.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/AbstractCacheResolverTemplate.java @@ -1,33 +1,41 @@ package com.giffing.bucket4j.spring.boot.starter.config.cache; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; -import io.github.bucket4j.Bucket; +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.metrics.MetricBucketListener; +import io.github.bucket4j.*; import io.github.bucket4j.distributed.AsyncBucketProxy; import io.github.bucket4j.distributed.proxy.AbstractProxyManager; +import lombok.extern.slf4j.Slf4j; import java.util.concurrent.CompletableFuture; +@Slf4j public abstract class AbstractCacheResolverTemplate { public ProxyManagerWrapper resolve(String cacheName) { AbstractProxyManager proxyManager = getProxyManager(cacheName); - return ((key, numTokens, bucketConfiguration, metricsListener, version, replaceStrategy) -> { + return (key, numTokens, estimate, bucketConfiguration, metricsListener, version, replaceStrategy) -> { + if(isAsync()) { - AsyncBucketProxy bucket = proxyManager.asAsync() - .builder() - .withImplicitConfigurationReplacement(version, replaceStrategy) - .build(castStringToCacheKey(key), () -> CompletableFuture.completedFuture(bucketConfiguration)) - .toListenable(metricsListener); - return new ConsumptionProbeHolder(bucket.tryConsumeAndReturnRemaining(numTokens)); + AsyncBucketProxy bucket = getAsyncBucketProxy(key, bucketConfiguration, metricsListener, version, replaceStrategy, proxyManager); + CompletableFuture result; + if (estimate) { + result = getAsyncEstimatedRateLimit(key, numTokens, estimate, bucket); + } else { + result = getAsyncRateLimit(numTokens, bucket); + } + return new RateLimitResultWrapper(result); } else { - Bucket bucket = proxyManager - .builder() - .withImplicitConfigurationReplacement(version, replaceStrategy) - .build(castStringToCacheKey(key), () -> bucketConfiguration) - .toListenable(metricsListener); - return new ConsumptionProbeHolder(bucket.tryConsumeAndReturnRemaining(numTokens)); + Bucket bucket = getSyncBucket(key, bucketConfiguration, metricsListener, version, replaceStrategy, proxyManager); + log.debug("execute-rate-limit;sync:{};key:{};numTokens:{};estimate:{}", false, key, numTokens, estimate); + if (estimate) { + return getSyncEstimatedRateLimit(numTokens, bucket); + } else { + return getSyncRateLimit(numTokens, bucket); + } } - }); + }; } public abstract T castStringToCacheKey(String key); @@ -36,6 +44,91 @@ public ProxyManagerWrapper resolve(String cacheName) { public abstract AbstractProxyManager getProxyManager(String cacheName); + private RateLimitResultWrapper getSyncRateLimit(Integer numTokens, Bucket bucket) { + log.debug("consume-token"); + var consumptionProbe = bucket.tryConsumeAndReturnRemaining(numTokens); + var result = mapToRateLimitResult(consumptionProbe); + return new RateLimitResultWrapper(result); + } + + private RateLimitResultWrapper getSyncEstimatedRateLimit(Integer numTokens, Bucket bucket) { + var estimatedConsumptionProbe = bucket.estimateAbilityToConsume(numTokens); + if(estimatedConsumptionProbe.canBeConsumed()) { + log.debug("estimation-can-consume no token taken"); + var result = mapToRateLimitResult(estimatedConsumptionProbe); + return new RateLimitResultWrapper(result); + } else { + log.debug("estimation-cannot-consume take tokens"); + var consumptionProbe = bucket.tryConsumeAndReturnRemaining(numTokens); + var result = mapToRateLimitResult(consumptionProbe); + return new RateLimitResultWrapper(result); + } + } + + private CompletableFuture getAsyncRateLimit(Integer numTokens, AsyncBucketProxy bucket) { + CompletableFuture result; + result = bucket.tryConsumeAndReturnRemaining(numTokens) + .thenApply(consumptionProbe-> { + log.debug("consume-token"); + return mapToRateLimitResult(consumptionProbe); + }); + return result; + } + + private CompletableFuture getAsyncEstimatedRateLimit(String key, Integer numTokens, boolean estimate, AsyncBucketProxy bucket) { + CompletableFuture result; + result = bucket.estimateAbilityToConsume(numTokens) + .thenCompose(ecp -> { + log.debug("execute-rate-limit;sync:{};key:{};numTokens:{};estimate:{}", true, key, numTokens, estimate); + if (ecp.canBeConsumed()) { + log.debug("estimation-can-consume no token taken"); + return CompletableFuture.completedFuture(mapToRateLimitResult(ecp)); + } else { + log.debug("estimation-cannot-consume take tokens"); + return bucket.tryConsumeAndReturnRemaining(numTokens) + .thenApply(this::mapToRateLimitResult); + } + }); + return result; + } + + private Bucket getSyncBucket(String key, BucketConfiguration bucketConfiguration, MetricBucketListener metricsListener, long version, TokensInheritanceStrategy replaceStrategy, AbstractProxyManager proxyManager) { + return proxyManager + .builder() + .withImplicitConfigurationReplacement(version, replaceStrategy) + .build(castStringToCacheKey(key), () -> bucketConfiguration) + .toListenable(metricsListener); + } + + private AsyncBucketProxy getAsyncBucketProxy(String key, BucketConfiguration bucketConfiguration, MetricBucketListener metricsListener, long version, TokensInheritanceStrategy replaceStrategy, AbstractProxyManager proxyManager) { + return proxyManager.asAsync() + .builder() + .withImplicitConfigurationReplacement(version, replaceStrategy) + .build(castStringToCacheKey(key), () -> CompletableFuture.completedFuture(bucketConfiguration)) + .toListenable(metricsListener); + } + + private RateLimitResult mapToRateLimitResult(EstimationProbe estimatedConsumptionProbe) { + return RateLimitResult + .builder() + .estimation(true) + .consumed(estimatedConsumptionProbe.canBeConsumed()) + .remainingTokens(estimatedConsumptionProbe.getRemainingTokens()) + .nanosToWaitForReset(0) + .nanosToWaitForRefill(estimatedConsumptionProbe.getNanosToWaitForRefill()) + .build(); + } + + private RateLimitResult mapToRateLimitResult(ConsumptionProbe cp) { + return RateLimitResult + .builder() + .estimation(false) + .consumed(cp.isConsumed()) + .remainingTokens(cp.getRemainingTokens()) + .nanosToWaitForReset(cp.getNanosToWaitForReset()) + .nanosToWaitForRefill(cp.getNanosToWaitForRefill()) + .build(); + } } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/CacheResolver.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/CacheResolver.java index 3e4d85e5..93d81ced 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/CacheResolver.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/CacheResolver.java @@ -5,7 +5,7 @@ /** * The CacheResolver is used to resolve Bucket4js {@link ProxyManager} by * a given cache name. Each cache implementation should implement this interface. - * + *

* But the interface shouldn't be implemented directly. The CacheResolver is divided * to the blocking {@link SyncCacheResolver} and the asynchronous {@link AsyncCacheResolver}. * diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/ProxyManagerWrapper.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/ProxyManagerWrapper.java index 882da4d1..01c56722 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/ProxyManagerWrapper.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache/ProxyManagerWrapper.java @@ -1,17 +1,17 @@ package com.giffing.bucket4j.spring.boot.starter.config.cache; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResultWrapper; import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricBucketListener; - import io.github.bucket4j.BucketConfiguration; import io.github.bucket4j.TokensInheritanceStrategy; @FunctionalInterface public interface ProxyManagerWrapper { - - ConsumptionProbeHolder tryConsumeAndReturnRemaining( + + RateLimitResultWrapper tryConsumeAndReturnRemaining( String key, Integer numTokens, + boolean isEstimation, BucketConfiguration bucketConfiguration, MetricBucketListener metricBucketListener, long configVersion, 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 61c5ebb3..95979cbd 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 @@ -6,6 +6,7 @@ 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; @@ -39,23 +40,23 @@ * configuration classes */ @Slf4j -public abstract class Bucket4JBaseConfiguration implements CacheUpdateListener { +public abstract class Bucket4JBaseConfiguration implements CacheUpdateListener { private final CacheManager configCacheManager; - public Bucket4JBaseConfiguration(CacheManager configCacheManager) { + protected Bucket4JBaseConfiguration(CacheManager configCacheManager) { this.configCacheManager = configCacheManager; } public abstract List getMetricHandlers(); - public FilterConfiguration buildFilterConfig( + public FilterConfiguration buildFilterConfig( Bucket4JConfiguration config, ProxyManagerWrapper proxyWrapper, ExpressionParser expressionParser, ConfigurableBeanFactory beanFactory) { - FilterConfiguration filterConfig = mapFilterConfiguration(config); + FilterConfiguration filterConfig = mapFilterConfiguration(config); config.getRateLimits().forEach(rl -> { log.debug("RL: {}", rl.toString()); @@ -73,6 +74,7 @@ public FilterConfiguration buildFilterConfig( return proxyWrapper.tryConsumeAndReturnRemaining( key, rl.getNumTokens(), + rl.getPostExecuteCondition() != null, bucketConfiguration, metricBucketListener, configVersion, @@ -82,12 +84,37 @@ public FilterConfiguration buildFilterConfig( return null; }; filterConfig.addRateLimitCheck(rlc); + + 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); + } }); return filterConfig; } - private FilterConfiguration mapFilterConfiguration(Bucket4JConfiguration config) { - FilterConfiguration filterConfig = new FilterConfiguration<>(); + private FilterConfiguration mapFilterConfiguration(Bucket4JConfiguration config) { + FilterConfiguration filterConfig = new FilterConfiguration<>(); filterConfig.setUrl(config.getUrl().strip()); filterConfig.setOrder(config.getFilterOrder()); filterConfig.setStrategy(config.getStrategy()); @@ -100,29 +127,51 @@ private FilterConfiguration mapFilterConfiguration(Bucket4JConfiguration conf 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, + private boolean performSkipRateLimitCheck(ExpressionParser expressionParser, + ConfigurableBeanFactory beanFactory, RateLimit rl, - Predicate executionPredicate, Predicate skipPredicate, - R servletRequest) { + Predicate executionPredicate, + Predicate skipPredicate, + R request) { boolean skipRateLimit = false; if (rl.getSkipCondition() != null) { - skipRateLimit = skipCondition(rl, expressionParser, beanFactory).evalute(servletRequest); + skipRateLimit = skipCondition(rl, expressionParser, beanFactory).evalute(request); log.debug("skip-rate-limit - skip-condition: {}", skipRateLimit); } if (!skipRateLimit) { - skipRateLimit = skipPredicate.test(servletRequest); + skipRateLimit = skipPredicate.test(request); log.debug("skip-rate-limit - skip-predicates: {}", skipRateLimit); } if (!skipRateLimit && rl.getExecuteCondition() != null) { - skipRateLimit = !executeCondition(rl, expressionParser, beanFactory).evalute(servletRequest); + skipRateLimit = !executeCondition(rl, expressionParser, beanFactory).evalute(request); log.debug("skip-rate-limit - execute-condition: {}", skipRateLimit); } if (!skipRateLimit) { - skipRateLimit = !executionPredicate.test(servletRequest); + skipRateLimit = !executionPredicate.test(request); log.debug("skip-rate-limit - execute-predicates: {}", skipRateLimit); } return skipRateLimit; @@ -154,7 +203,7 @@ private ConfigurationBuilder prepareBucket4jConfigurationBuilder(RateLimit rl) { private MetricBucketListener createMetricListener(String cacheName, ExpressionParser expressionParser, ConfigurableBeanFactory beanFactory, - FilterConfiguration filterConfig, + FilterConfiguration filterConfig, R servletRequest) { var metricTagResults = getMetricTags( @@ -173,7 +222,7 @@ private MetricBucketListener createMetricListener(String cacheName, private List getMetricTags( ExpressionParser expressionParser, ConfigurableBeanFactory beanFactory, - FilterConfiguration filterConfig, + FilterConfiguration filterConfig, R servletRequest) { return filterConfig @@ -243,18 +292,37 @@ public Condition skipCondition(RateLimit rateLimit, ExpressionParser expressi * @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 (rateLimit.getExecuteCondition() != null) { + if (condition != null) { return request -> { - Expression expr = expressionParser.parseExpression(rateLimit.getExecuteCondition()); - return expr.getValue(context, request, Boolean.class); + 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(); 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 e814a5c4..b9aebf59 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 @@ -1,9 +1,21 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.gateway; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.atomic.AtomicInteger; - +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.filter.reactive.gateway.SpringCloudGatewayRateLimitFilter; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.config.ConfigurableBeanFactory; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -19,26 +31,12 @@ 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 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.gateway.SpringCloudGatewayRateLimitFilter; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; /** * Configures Servlet Filters for Bucket4Js rate limit. @@ -51,7 +49,7 @@ @AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) @ConditionalOnBean(value = AsyncCacheResolver.class) @Import(value = { WebfluxExecutePredicateConfiguration.class, SpringBootActuatorConfig.class, Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans.class }) -public class Bucket4JAutoConfigurationSpringCloudGatewayFilter extends Bucket4JBaseConfiguration { +public class Bucket4JAutoConfigurationSpringCloudGatewayFilter extends Bucket4JBaseConfiguration { private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationSpringCloudGatewayFilter.class); @@ -100,7 +98,7 @@ public void initFilters() { .forEach(filter -> { addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); - FilterConfiguration filterConfig = buildFilterConfig( + var filterConfig = buildFilterConfig( filter, cacheResolver.resolve(filter.getCacheName()), gatewayFilterExpressionParser, @@ -134,7 +132,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e if (newConfig.getFilterMethod().equals(FilterMethod.GATEWAY)) { try { SpringCloudGatewayRateLimitFilter filter = context.getBean(event.getKey(), SpringCloudGatewayRateLimitFilter.class); - FilterConfiguration newFilterConfig = buildFilterConfig( + var newFilterConfig = buildFilterConfig( newConfig, cacheResolver.resolve(newConfig.getCacheName()), gatewayFilterExpressionParser, 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 a0434d5e..ddcfb4ed 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 @@ -1,6 +1,7 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.gateway; -import org.springframework.beans.factory.annotation.Qualifier; +import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +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; @@ -8,13 +9,11 @@ import org.springframework.expression.spel.SpelParserConfiguration; import org.springframework.expression.spel.standard.SpelExpressionParser; -import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; - @Configuration public class Bucket4JAutoConfigurationSpringCloudGatewayFilterBeans { @Bean - @Qualifier("GATEWAY") + @Gateway public Bucket4jConfigurationHolder gatewayConfigurationHolder() { return new Bucket4jConfigurationHolder(); } 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 3134dd1a..e8ac0097 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 @@ -23,6 +23,7 @@ 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 org.springframework.web.server.WebFilter; @@ -56,7 +57,7 @@ @ConditionalOnBean(value = AsyncCacheResolver.class) @EnableConfigurationProperties({ Bucket4JBootProperties.class}) @Import(value = { WebfluxExecutePredicateConfiguration.class, Bucket4JAutoConfigurationWebfluxFilterBeans.class, SpringBootActuatorConfig.class }) -public class Bucket4JAutoConfigurationWebfluxFilter extends Bucket4JBaseConfiguration { +public class Bucket4JAutoConfigurationWebfluxFilter extends Bucket4JBaseConfiguration { private final Logger log = LoggerFactory.getLogger(Bucket4JAutoConfigurationWebfluxFilter.class); @@ -110,7 +111,7 @@ public void initFilters() { .forEach(filter -> { addDefaultMetricTags(properties, filter); filterCount.incrementAndGet(); - FilterConfiguration filterConfig = buildFilterConfig(filter, cacheResolver.resolve( + FilterConfiguration filterConfig = buildFilterConfig(filter, cacheResolver.resolve( filter.getCacheName()), webfluxFilterExpressionParser, beanFactory); @@ -142,7 +143,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e if (newConfig.getFilterMethod().equals(FilterMethod.WEBFLUX)) { try { WebfluxWebFilter filter = context.getBean(event.getKey(), WebfluxWebFilter.class); - FilterConfiguration newFilterConfig = buildFilterConfig( + FilterConfiguration newFilterConfig = buildFilterConfig( newConfig, cacheResolver.resolve(newConfig.getCacheName()), webfluxFilterExpressionParser, 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 133c55eb..21e6fc08 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 @@ -1,6 +1,7 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.reactive.webflux; -import org.springframework.beans.factory.annotation.Qualifier; +import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +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; @@ -8,13 +9,11 @@ import org.springframework.expression.spel.SpelParserConfiguration; import org.springframework.expression.spel.standard.SpelExpressionParser; -import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; - @Configuration public class Bucket4JAutoConfigurationWebfluxFilterBeans { @Bean - @Qualifier("WEBFLUX") + @Webflux public Bucket4jConfigurationHolder servletConfigurationHolder() { return new Bucket4jConfigurationHolder(); } 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 38f0704b..95d25193 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 @@ -1,15 +1,23 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.servlet; -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.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.cache.SyncCacheResolver; +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.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.servlet.ServletRequestFilter; import jakarta.servlet.Filter; 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.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.AutoConfigureBefore; @@ -27,24 +35,12 @@ import org.springframework.expression.ExpressionParser; import org.springframework.util.StringUtils; -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.cache.SyncCacheResolver; -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.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.servlet.ServletRequestFilter; - -import io.github.bucket4j.grid.jcache.JCacheProxyManager; -import lombok.extern.slf4j.Slf4j; +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; /** * Configures {@link Filter}s for Bucket4Js rate limit. @@ -58,7 +54,8 @@ @ConditionalOnBean(value = SyncCacheResolver.class) @Import(value = {ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class }) @Slf4j -public class Bucket4JAutoConfigurationServletFilter extends Bucket4JBaseConfiguration implements WebServerFactoryCustomizer { +public class Bucket4JAutoConfigurationServletFilter extends Bucket4JBaseConfiguration + implements WebServerFactoryCustomizer { private final Bucket4JBootProperties properties; @@ -142,7 +139,7 @@ public void onCacheUpdateEvent(CacheUpdateEvent e if(newConfig.getFilterMethod().equals(FilterMethod.SERVLET)) { try { ServletRequestFilter filter = context.getBean(event.getKey(), ServletRequestFilter.class); - FilterConfiguration newFilterConfig = buildFilterConfig( + var newFilterConfig = buildFilterConfig( newConfig, cacheResolver.resolve(newConfig.getCacheName()), servletFilterExpressionParser, beanFactory); 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 e04191d5..569e3335 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 @@ -1,6 +1,7 @@ package com.giffing.bucket4j.spring.boot.starter.config.filter.servlet; -import org.springframework.beans.factory.annotation.Qualifier; +import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +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; @@ -8,13 +9,11 @@ import org.springframework.expression.spel.SpelParserConfiguration; import org.springframework.expression.spel.standard.SpelExpressionParser; -import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; - @Configuration public class Bucket4JAutoConfigurationServletFilterBeans { @Bean - @Qualifier("SERVLET") + @Servlet public Bucket4jConfigurationHolder servletConfigurationHolder() { return new Bucket4jConfigurationHolder(); } 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 f8e262fe..7c431186 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 @@ -1,16 +1,17 @@ package com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator; -import java.util.HashMap; -import java.util.Map; - +import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Gateway; +import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Servlet; +import com.giffing.bucket4j.spring.boot.starter.context.qualifier.Webflux; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.context.annotation.Configuration; -import com.giffing.bucket4j.spring.boot.starter.context.Bucket4jConfigurationHolder; +import java.util.HashMap; +import java.util.Map; @Configuration @ConditionalOnClass(Endpoint.class) @@ -20,19 +21,28 @@ public class Bucket4jEndpoint { @Endpoint(id = "bucket4j") public static class Bucket4jEndpointConfig { - @Autowired(required = false) - @Qualifier("SERVLET") - private Bucket4jConfigurationHolder servletConfigs; - - @Autowired(required = false) - @Qualifier("WEBFLUX") - private Bucket4jConfigurationHolder webfluxConfigs; - - @Autowired(required = false) - @Qualifier("GATEWAY") - private Bucket4jConfigurationHolder gatewayConfigs; + private final Bucket4jConfigurationHolder servletConfigs; + private final Bucket4jConfigurationHolder webfluxConfigs; + private final Bucket4jConfigurationHolder gatewayConfigs; + + public Bucket4jEndpointConfig( + @Autowired(required = false) + @Servlet + Bucket4jConfigurationHolder servletConfigs, + @Autowired(required = false) + @Webflux + Bucket4jConfigurationHolder webfluxConfigs, + @Autowired(required = false) + @Gateway + Bucket4jConfigurationHolder gatewayConfigs) { + this.servletConfigs = servletConfigs; + this.webfluxConfigs = webfluxConfigs; + this.gatewayConfigs = gatewayConfigs; + } + + @ReadOperation public Map bucket4jConfig() { Map result = new HashMap<>(); 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 7d385364..1d85571c 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,37 +1,34 @@ package com.giffing.bucket4j.spring.boot.starter.filter.reactive; -import static java.nio.charset.StandardCharsets.UTF_8; - -import java.util.ArrayList; -import java.util.List; - -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; -import org.springframework.core.io.buffer.DataBuffer; -import org.springframework.http.server.reactive.ServerHttpRequest; -import org.springframework.http.server.reactive.ServerHttpResponse; -import org.springframework.web.server.ServerWebExchange; - 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 io.github.bucket4j.ConsumptionProbe; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.web.server.ServerWebExchange; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import java.util.ArrayList; +import java.util.List; + +import static java.nio.charset.StandardCharsets.UTF_8; + @Data @Slf4j public class AbstractReactiveFilter { - private FilterConfiguration filterConfig; + private FilterConfiguration filterConfig; - public AbstractReactiveFilter(FilterConfiguration filterConfig) { + public AbstractReactiveFilter(FilterConfiguration filterConfig) { this.filterConfig = filterConfig; } - public void setFilterConfig(FilterConfiguration filterConfig){ + public void setFilterConfig(FilterConfiguration filterConfig){ this.filterConfig = filterConfig; } @@ -41,13 +38,13 @@ protected boolean urlMatches(ServerHttpRequest request) { protected Mono chainWithRateLimitCheck(ServerWebExchange exchange, ReactiveFilterChain chain) { log.debug("reate-limit-check;method:{};uri:{}", exchange.getRequest().getMethod(), exchange.getRequest().getURI()); - ServerHttpRequest request = exchange.getRequest(); - ServerHttpResponse response = exchange.getResponse(); - List> asyncConsumptionProbes = new ArrayList<>(); - for (RateLimitCheck rlc : filterConfig.getRateLimitChecks()) { - ConsumptionProbeHolder cph = rlc.rateLimit(request); - if(cph != null && cph.getConsumptionProbeCompletableFuture() != null){ - asyncConsumptionProbes.add(Mono.fromFuture(cph.getConsumptionProbeCompletableFuture())); + var request = exchange.getRequest(); + var response = exchange.getResponse(); + List> asyncConsumptionProbes = new ArrayList<>(); + for (var rlc : filterConfig.getRateLimitChecks()) { + var wrapper = rlc.rateLimit(request); + if(wrapper != null && wrapper.getRateLimitResultCompletableFuture() != null){ + asyncConsumptionProbes.add(Mono.fromFuture(wrapper.getRateLimitResultCompletableFuture())); if(filterConfig.getStrategy() == RateLimitConditionMatchingStrategy.FIRST){ break; } @@ -59,11 +56,11 @@ protected Mono chainWithRateLimitCheck(ServerWebExchange exchange, Reactiv return Flux .concat(asyncConsumptionProbes) .reduce(this::reduceConsumptionProbe) - .flatMap(consumptionProbe -> handleConsumptionProbe(exchange, chain, response, consumptionProbe)); + .flatMap(rateLimitResult -> handleConsumptionProbe(exchange, chain, response, rateLimitResult)); } - protected ConsumptionProbe reduceConsumptionProbe(ConsumptionProbe x, ConsumptionProbe y) { - ConsumptionProbe result; + protected RateLimitResult reduceConsumptionProbe(RateLimitResult x, RateLimitResult y) { + RateLimitResult result; if(!x.isConsumed()) { result = x; } else if(!y.isConsumed()) { @@ -79,18 +76,18 @@ protected ConsumptionProbe reduceConsumptionProbe(ConsumptionProbe x, Consumptio } protected Mono handleConsumptionProbe(ServerWebExchange exchange, ReactiveFilterChain chain, - ServerHttpResponse response, ConsumptionProbe cp) { + ServerHttpResponse response, RateLimitResult rateLimitResult) { log.debug("probe-results;isConsumed:{};remainingTokens:{};nanosToWaitForRefill:{};nanosToWaitForReset:{}", - cp.isConsumed(), - cp.getRemainingTokens(), - cp.getNanosToWaitForRefill(), - cp.getNanosToWaitForReset()); + rateLimitResult.isConsumed(), + rateLimitResult.getRemainingTokens(), + rateLimitResult.getNanosToWaitForRefill(), + rateLimitResult.getNanosToWaitForReset()); - if(!cp.isConsumed()) { - if(Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + if (!rateLimitResult.isConsumed()) { + if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { filterConfig.getHttpResponseHeaders().forEach(response.getHeaders()::addIfAbsent); } - if(filterConfig.getHttpResponseBody() != null) { + if (filterConfig.getHttpResponseBody() != null) { response.setStatusCode(filterConfig.getHttpStatusCode()); response.getHeaders().set("Content-Type", filterConfig.getHttpContentType()); DataBuffer buffer = exchange.getResponse().bufferFactory().wrap(filterConfig.getHttpResponseBody().getBytes(UTF_8)); @@ -99,10 +96,19 @@ protected Mono handleConsumptionProbe(ServerWebExchange exchange, Reactive return Mono.error(new ReactiveRateLimitException(filterConfig.getHttpStatusCode(), null)); } } - if(Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { - log.debug("header;X-Rate-Limit-Remaining:{}", cp.getRemainingTokens()); - response.getHeaders().set("X-Rate-Limit-Remaining", String.valueOf(cp.getRemainingTokens())); + if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + log.debug("header;X-Rate-Limit-Remaining:{}", rateLimitResult.getRemainingTokens()); + response.getHeaders().set("X-Rate-Limit-Remaining", String.valueOf(rateLimitResult.getRemainingTokens())); } - return chain.apply(exchange); + + Mono postRateLimitMonos = Mono.empty(); + filterConfig.getPostRateLimitChecks().forEach(rlc -> { + var wrapper = rlc.rateLimit(exchange.getRequest(), response); + if (wrapper != null && wrapper.getRateLimitResultCompletableFuture() != null) { + postRateLimitMonos.and(Mono.fromFuture(wrapper.getRateLimitResultCompletableFuture())); + } + }); + + return chain.apply(exchange).then(postRateLimitMonos); } } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/gateway/SpringCloudGatewayRateLimitFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/gateway/SpringCloudGatewayRateLimitFilter.java index cac7039a..27b2443c 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/gateway/SpringCloudGatewayRateLimitFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/gateway/SpringCloudGatewayRateLimitFilter.java @@ -4,6 +4,7 @@ import org.springframework.cloud.gateway.filter.GlobalFilter; import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; @@ -16,7 +17,7 @@ */ public class SpringCloudGatewayRateLimitFilter extends AbstractReactiveFilter implements GlobalFilter, Ordered { - public SpringCloudGatewayRateLimitFilter(FilterConfiguration filterConfig) { + public SpringCloudGatewayRateLimitFilter(FilterConfiguration filterConfig) { super(filterConfig); } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/webflux/WebfluxWebFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/webflux/WebfluxWebFilter.java index ac383548..c97ae7a1 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/webflux/WebfluxWebFilter.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/reactive/webflux/WebfluxWebFilter.java @@ -2,6 +2,7 @@ import org.springframework.core.Ordered; import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilter; import org.springframework.web.server.WebFilterChain; @@ -14,7 +15,7 @@ public class WebfluxWebFilter extends AbstractReactiveFilter implements WebFilter, Ordered { - public WebfluxWebFilter(FilterConfiguration filterConfig) { + public WebfluxWebFilter(FilterConfiguration filterConfig) { super(filterConfig); } 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 468dda51..52b700d7 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,102 +1,109 @@ package com.giffing.bucket4j.spring.boot.starter.filter.servlet; -import java.io.IOException; -import java.util.concurrent.TimeUnit; - +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 jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; - +import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.web.filter.OncePerRequestFilter; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; -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.properties.FilterConfiguration; - -import io.github.bucket4j.ConsumptionProbe; +import java.io.IOException; +import java.util.concurrent.TimeUnit; /** - * Servlet {@link Filter} class to configure Bucket4j on each request. + * Servlet {@link Filter} class to configure Bucket4j on each request. */ +@Slf4j public class ServletRequestFilter extends OncePerRequestFilter implements Ordered { - private FilterConfiguration filterConfig; - - public ServletRequestFilter(FilterConfiguration filterConfig) { - this.filterConfig = filterConfig; + private FilterConfiguration filterConfig; + + public ServletRequestFilter(FilterConfiguration filterConfig) { + this.filterConfig = filterConfig; + } + + public void setFilterConfig(FilterConfiguration filterConfig) { + this.filterConfig = filterConfig; } - public void setFilterConfig(FilterConfiguration filterConfig){ - this.filterConfig = filterConfig; - } - @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { - return !request.getRequestURI().matches(filterConfig.getUrl()); - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { + return !request.getRequestURI().matches(filterConfig.getUrl()); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { boolean allConsumed = true; Long remainingLimit = null; for (RateLimitCheck rl : filterConfig.getRateLimitChecks()) { - ConsumptionProbeHolder probeHolder = rl.rateLimit(request); - if (probeHolder != null && probeHolder.getConsumptionProbe() != null) { - ConsumptionProbe probe = probeHolder.getConsumptionProbe(); - if(probe.isConsumed()) { - remainingLimit = getRemainingLimit(remainingLimit, probe); - } else{ - allConsumed = false; - handleHttpResponseOnRateLimiting(response, probe); - break; - } - if(filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { - break; - } - } - - } - - if(allConsumed) { - if(remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { - response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); - } - filterChain.doFilter(request, response); - } - - } + var wrapper = rl.rateLimit(request); + if (wrapper != null && wrapper.getRateLimitResult() != null) { + var rateLimitResult = wrapper.getRateLimitResult(); + if (rateLimitResult.isConsumed()) { + remainingLimit = getRemainingLimit(remainingLimit, rateLimitResult); + } else { + allConsumed = false; + handleHttpResponseOnRateLimiting(response, rateLimitResult); + break; + } + if (filterConfig.getStrategy().equals(RateLimitConditionMatchingStrategy.FIRST)) { + break; + } + } - private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, ConsumptionProbe probe) throws IOException { - httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); - if(Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { - httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(probe.getNanosToWaitForRefill())); - filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); - } - if(filterConfig.getHttpResponseBody() != null) { - httpResponse.setContentType(filterConfig.getHttpContentType()); - httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); - } - } + } - private long getRemainingLimit(Long remaining, ConsumptionProbe probe) { - if(probe != null) { - if(remaining == null) { - remaining = probe.getRemainingTokens(); - } else if(probe.getRemainingTokens() < remaining) { - remaining = probe.getRemainingTokens(); - } - } - return remaining; - } + if (allConsumed) { + if (remainingLimit != null && Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + log.debug("add-x-rate-limit-remaining-header;limit:{}", remainingLimit); + response.setHeader("X-Rate-Limit-Remaining", "" + remainingLimit); + } + filterChain.doFilter(request, response); + filterConfig.getPostRateLimitChecks() + .forEach(rlc -> { + var result = rlc.rateLimit(request, response); + if(result != null) { + log.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); + } + }); + } + } - @Override - public int getOrder() { - return filterConfig.getOrder(); - } + private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, RateLimitResult rateLimitResult) throws IOException { + httpResponse.setStatus(filterConfig.getHttpStatusCode().value()); + if (Boolean.FALSE.equals(filterConfig.getHideHttpResponseHeaders())) { + httpResponse.setHeader("X-Rate-Limit-Retry-After-Seconds", "" + TimeUnit.NANOSECONDS.toSeconds(rateLimitResult.getNanosToWaitForRefill())); + filterConfig.getHttpResponseHeaders().forEach(httpResponse::setHeader); + } + if (filterConfig.getHttpResponseBody() != null) { + httpResponse.setContentType(filterConfig.getHttpContentType()); + httpResponse.getWriter().append(filterConfig.getHttpResponseBody()); + } + } + + 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 + public int getOrder() { + return filterConfig.getOrder(); + } } 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 2f9fbebb..edc5fa7b 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 @@ -1,19 +1,12 @@ package com.giffing.bucket4j.spring.boot.starter.gateway; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.net.URI; -import java.net.URISyntaxException; -import java.util.Arrays; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicBoolean; - +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.filter.reactive.ReactiveRateLimitException; +import com.giffing.bucket4j.spring.boot.starter.filter.reactive.gateway.SpringCloudGatewayRateLimitFilter; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -25,21 +18,23 @@ import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.http.server.reactive.ServerHttpResponse; import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; -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.properties.FilterConfiguration; -import com.giffing.bucket4j.spring.boot.starter.filter.reactive.ReactiveRateLimitException; -import com.giffing.bucket4j.spring.boot.starter.filter.reactive.gateway.SpringCloudGatewayRateLimitFilter; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; -import io.github.bucket4j.ConsumptionProbe; -import reactor.core.publisher.Mono; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; class SpringCloudGatewayRateLimitFilterTest { private GlobalFilter filter; - private FilterConfiguration configuration; + private FilterConfiguration configuration; private RateLimitCheck rateLimitCheck1; private RateLimitCheck rateLimitCheck2; private RateLimitCheck rateLimitCheck3; @@ -143,12 +138,12 @@ void should_execute_only_one_check_when_using_RateLimitConditionMatchingStrategy } private void rateLimitConfig(Long remainingTokens, RateLimitCheck rateLimitCheck) { - ConsumptionProbeHolder consumptionHolder = Mockito.mock(ConsumptionProbeHolder.class); - ConsumptionProbe probe = Mockito.mock(ConsumptionProbe.class); - when(probe.isConsumed()).thenReturn(remainingTokens > 0); - when(probe.getRemainingTokens()).thenReturn(remainingTokens); - when(consumptionHolder.getConsumptionProbeCompletableFuture()) - .thenReturn(CompletableFuture.completedFuture(probe)); + RateLimitResultWrapper consumptionHolder = Mockito.mock(RateLimitResultWrapper.class); + RateLimitResult rateLimitResult = Mockito.mock(RateLimitResult.class); + when(rateLimitResult.isConsumed()).thenReturn(remainingTokens > 0); + when(rateLimitResult.getRemainingTokens()).thenReturn(remainingTokens); + when(consumptionHolder.getRateLimitResultCompletableFuture()) + .thenReturn(CompletableFuture.completedFuture(rateLimitResult)); when(rateLimitCheck.rateLimit(any())).thenReturn(consumptionHolder); } } diff --git a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/servlet/ServletRateLimitFilterTest.java b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/servlet/ServletRateLimitFilterTest.java index bc38daf6..611b00bc 100644 --- a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/servlet/ServletRateLimitFilterTest.java +++ b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/servlet/ServletRateLimitFilterTest.java @@ -10,6 +10,9 @@ import java.util.Arrays; import java.util.Map; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResultWrapper; +import jakarta.servlet.http.HttpServletResponse; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -18,32 +21,30 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; 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.properties.FilterConfiguration; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRequestFilter; -import io.github.bucket4j.ConsumptionProbe; import jakarta.servlet.http.HttpServletRequest; @ExtendWith(MockitoExtension.class) class ServletRateLimitFilterTest { private ServletRequestFilter filter; - private FilterConfiguration configuration; + private FilterConfiguration configuration; @Mock private RateLimitCheck rateLimitCheck1; @Mock private RateLimitCheck rateLimitCheck2; @Mock private RateLimitCheck rateLimitCheck3; - @Mock private ConsumptionProbeHolder consumptionProbeHolder; - @Mock private ConsumptionProbe consumptionProbe; + @Mock private RateLimitResultWrapper rateLimitResultWrapper; + @Mock private RateLimitResult rateLimitResult; @BeforeEach public void setup() { - when(consumptionProbe.isConsumed()).thenReturn(true); - when(consumptionProbeHolder.getConsumptionProbe()).thenReturn(consumptionProbe); + when(rateLimitResult.isConsumed()).thenReturn(true); + when(rateLimitResultWrapper.getRateLimitResult()).thenReturn(rateLimitResult); configuration = new FilterConfiguration<>(); configuration.setRateLimitChecks(Arrays.asList(rateLimitCheck1, rateLimitCheck2, rateLimitCheck3)); @@ -55,9 +56,9 @@ public void setup() { @Test void should_execute_all_checks_when_using_RateLimitConditionMatchingStrategy_All() throws Exception { - when(rateLimitCheck1.rateLimit(any())).thenReturn(consumptionProbeHolder); - when(rateLimitCheck2.rateLimit(any())).thenReturn(consumptionProbeHolder); - when(rateLimitCheck3.rateLimit(any())).thenReturn(consumptionProbeHolder); + when(rateLimitCheck1.rateLimit(any())).thenReturn(rateLimitResultWrapper); + when(rateLimitCheck2.rateLimit(any())).thenReturn(rateLimitResultWrapper); + when(rateLimitCheck3.rateLimit(any())).thenReturn(rateLimitResultWrapper); configuration.setStrategy(RateLimitConditionMatchingStrategy.ALL); @@ -73,9 +74,9 @@ void should_execute_all_checks_when_using_RateLimitConditionMatchingStrategy_All @Test void should_execute_first_check_when_using_RateLimitConditionMatchingStrategy_All_but_first_is_not_consumed() throws Exception { - when(rateLimitCheck1.rateLimit(any())).thenReturn(consumptionProbeHolder); + when(rateLimitCheck1.rateLimit(any())).thenReturn(rateLimitResultWrapper); - when(consumptionProbe.isConsumed()).thenReturn(false); + when(rateLimitResult.isConsumed()).thenReturn(false); configuration.setStrategy(RateLimitConditionMatchingStrategy.ALL); @@ -92,7 +93,7 @@ void should_execute_first_check_when_using_RateLimitConditionMatchingStrategy_Al void should_execute_only_one_check_when_using_RateLimitConditionMatchingStrategy_FIRST() throws Exception { configuration.setStrategy(RateLimitConditionMatchingStrategy.FIRST); - when(rateLimitCheck1.rateLimit(any())).thenReturn(consumptionProbeHolder); + when(rateLimitCheck1.rateLimit(any())).thenReturn(rateLimitResultWrapper); standaloneSetup(new TestController()) .addFilters(filter).build() diff --git a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/webflux/WebfluxRateLimitFilterTest.java b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/webflux/WebfluxRateLimitFilterTest.java index aa2b9350..54fe7840 100644 --- a/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/webflux/WebfluxRateLimitFilterTest.java +++ b/bucket4j-spring-boot-starter/src/test/java/com/giffing/bucket4j/spring/boot/starter/webflux/WebfluxRateLimitFilterTest.java @@ -13,6 +13,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; +import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResultWrapper; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,7 +26,6 @@ import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.WebFilterChain; -import com.giffing.bucket4j.spring.boot.starter.context.ConsumptionProbeHolder; 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.properties.FilterConfiguration; @@ -37,7 +38,7 @@ class WebfluxRateLimitFilterTest { private WebfluxWebFilter filter; - private FilterConfiguration configuration; + private FilterConfiguration configuration; private RateLimitCheck rateLimitCheck1; private RateLimitCheck rateLimitCheck2; private RateLimitCheck rateLimitCheck3; @@ -140,12 +141,12 @@ void should_execute_only_one_check_when_using_RateLimitConditionMatchingStrategy } private void rateLimitConfig(Long remainingTokens, RateLimitCheck rateLimitCheck) { - ConsumptionProbeHolder consumptionHolder = Mockito.mock(ConsumptionProbeHolder.class); - ConsumptionProbe probe = Mockito.mock(ConsumptionProbe.class); - when(probe.isConsumed()).thenReturn(remainingTokens > 0); - when(probe.getRemainingTokens()).thenReturn(remainingTokens); - when(consumptionHolder.getConsumptionProbeCompletableFuture()) - .thenReturn(CompletableFuture.completedFuture(probe)); + RateLimitResultWrapper consumptionHolder = Mockito.mock(RateLimitResultWrapper.class); + RateLimitResult rateLimitResult = Mockito.mock(RateLimitResult.class); + when(rateLimitResult.isConsumed()).thenReturn(remainingTokens > 0); + when(rateLimitResult.getRemainingTokens()).thenReturn(remainingTokens); + when(consumptionHolder.getRateLimitResultCompletableFuture()) + .thenReturn(CompletableFuture.completedFuture(rateLimitResult)); when(rateLimitCheck.rateLimit(any())).thenReturn(consumptionHolder); } diff --git a/examples/caffeine/pom.xml b/examples/caffeine/pom.xml index f5e58432..587dac8e 100644 --- a/examples/caffeine/pom.xml +++ b/examples/caffeine/pom.xml @@ -56,6 +56,16 @@ org.projectlombok lombok + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + + + org.junit.platform + junit-platform-suite-engine + diff --git a/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/SimpleSecurityFilter.java b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/SimpleSecurityFilter.java new file mode 100644 index 00000000..f4e3d78f --- /dev/null +++ b/examples/caffeine/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/SimpleSecurityFilter.java @@ -0,0 +1,33 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; + +/** + * A user authorized for the url /secure when the query parameter 'username' has the value 'admin' + */ +@Component +@Order(0) +public class SimpleSecurityFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + boolean isSecurePath = request.getRequestURI().equals("/secure"); + boolean isNotAdmin = !Objects.equals("admin", request.getParameter("username")); + if(isSecurePath && isNotAdmin) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.getWriter().write("Hello World"); + } else { + filterChain.doFilter(request, response); + } + } +} 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 81359f3d..43bccd15 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 @@ -50,6 +50,11 @@ public ResponseEntity world() { return ResponseEntity.ok("Hello World"); } + @GetMapping("secure") + public ResponseEntity secure() { + return ResponseEntity.ok("Hello World"); + } + /** * Example of how a filter configuration can be updated during runtime * @param filterId id of the filter to update diff --git a/examples/caffeine/src/main/resources/application.yml b/examples/caffeine/src/main/resources/application.yml index a864405c..63cdab69 100644 --- a/examples/caffeine/src/main/resources/application.yml +++ b/examples/caffeine/src/main/resources/application.yml @@ -25,16 +25,13 @@ bucket4j: - id: filter1 cache-name: buckets url: .* - http-response-body: null rate-limits: - cache-key: getRemoteAddr() - execute-predicates: - - name: PATH=/hell** - - name: METHOD=GET + post-execute-condition: getStatus() eq 200 bandwidths: - capacity: 10 refill-capacity: 1 - time: 1 + time: 10 unit: seconds - initial-capacity: 20 + initial-capacity: 5 refill-speed: interval 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 new file mode 100644 index 00000000..c9b2d3b9 --- /dev/null +++ b/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; + +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 +}) +public class CaffeineGeneralSuiteTest { +} diff --git a/examples/caffeine/src/test/resources/application-servlet.yml b/examples/caffeine/src/test/resources/application-servlet.yml deleted file mode 100644 index 2171ac8b..00000000 --- a/examples/caffeine/src/test/resources/application-servlet.yml +++ /dev/null @@ -1,28 +0,0 @@ -spring: - cache: - cache-names: - - buckets_test - - filterConfigCache - caffeine: - spec: maximumSize=1000000,expireAfterAccess=3600s -bucket4j: - enabled: true - filter-config-caching-enabled: true - filter-config-cache-name: filterConfigCache - filters: - - id: filter1 - cache-name: buckets_test - url: ^(/hello).* - rate-limits: - - bandwidths: - - capacity: 5 - time: 10 - unit: seconds - - id: filter2 - cache-name: buckets_test - url: ^(/world).* - rate-limits: - - bandwidths: - - capacity: 10 - time: 10 - unit: seconds \ No newline at end of file diff --git a/examples/caffeine/src/test/resources/application.yml b/examples/caffeine/src/test/resources/application.yml new file mode 100644 index 00000000..fa10bd29 --- /dev/null +++ b/examples/caffeine/src/test/resources/application.yml @@ -0,0 +1,7 @@ +spring: + cache: + cache-names: + - buckets + - filterConfigCache + caffeine: + spec: maximumSize=1000000,expireAfterAccess=3600s \ No newline at end of file diff --git a/examples/ehcache/pom.xml b/examples/ehcache/pom.xml index ba81128a..fdad897a 100644 --- a/examples/ehcache/pom.xml +++ b/examples/ehcache/pom.xml @@ -40,32 +40,6 @@ org.springframework.boot spring-boot-starter-web - - org.springframework.boot - spring-boot-starter-security - - - com.fasterxml.jackson.core - jackson-core - - - com.fasterxml.jackson.core - jackson-databind - - - jakarta.xml.bind - jakarta.xml.bind-api - - - com.sun.xml.bind - jaxb-core - ${jaxb-core.version} - - - com.sun.xml.bind - jaxb-impl - ${jaxb-core.version} - org.ehcache ehcache @@ -113,7 +87,16 @@ hamcrest test - + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + + + org.junit.platform + junit-platform-suite-engine + diff --git a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityConfig.java b/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityConfig.java deleted file mode 100644 index 4c6d67f1..00000000 --- a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityConfig.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.ehcache.config.security; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.security.provisioning.InMemoryUserDetailsManager; -import org.springframework.security.web.SecurityFilterChain; - -@Configuration -public class SecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.csrf(x -> x.ignoringRequestMatchers("/filters/**")); - http.authorizeHttpRequests(auth -> { - auth.requestMatchers("/unsecure").permitAll(); - auth.requestMatchers("/actuator/*").permitAll(); - auth.requestMatchers("/login").permitAll(); - auth.requestMatchers("/filters/**").permitAll(); - auth.requestMatchers("/hello").permitAll(); - auth.requestMatchers("/secure").hasAnyRole("ADMIN", "USER"); - }); - return http.build(); - } - - @Bean - public UserDetailsService inMemoryUser() { - UserDetails user = User.builder() - .username("admin") - .password("123") - .roles("ADMIN") - .build(); - return new InMemoryUserDetailsManager(user); - } -} diff --git a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityService.java b/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityService.java deleted file mode 100644 index bd22c39a..00000000 --- a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/config/security/SecurityService.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.ehcache.config.security; - -import java.util.Objects; - -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; - -@Service -public class SecurityService { - - public String username() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if(authentication == null) { - return null; - } - String name = authentication.getName(); - if(Objects.equals(name, "anonymousUser")) { - return null; - } - return name; - } - -} diff --git a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/controller/TestController.java b/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/controller/TestController.java index f4d71972..13a8a221 100644 --- a/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/controller/TestController.java +++ b/examples/ehcache/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/controller/TestController.java @@ -2,122 +2,23 @@ import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; -import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; import jakarta.annotation.Nullable; -import jakarta.validation.Valid; -import lombok.Getter; -import org.springframework.context.support.DefaultMessageSourceResolvable; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.validation.BindingResult; -import org.springframework.web.bind.annotation.*; - -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/") public class TestController { -// http.authorizeRequests().antMatchers("/unsecure").permitAll(); -// http.authorizeRequests().antMatchers("/login").permitAll(); -// http.authorizeRequests().antMatchers("/secure").hasAnyRole("ADMIN","USER"). - - private final CacheManager configCacheManager; - - public TestController(@Nullable CacheManager configCacheManager) { - this.configCacheManager = configCacheManager; - } - - @GetMapping("unsecure") - public ResponseEntity unsecure() { - return ResponseEntity.ok().build(); - } - @GetMapping("login") - public ResponseEntity login() { - - Collection grantedAuthorities = new ArrayList<>(); - //anonymous inner type - GrantedAuthority grantedAuthority = () -> "ROLE_USER"; - grantedAuthorities.add(grantedAuthority); - - Authentication auth = new UsernamePasswordAuthenticationToken(new User("admin"), null, grantedAuthorities); - SecurityContextHolder.getContext().setAuthentication(auth); - - return ResponseEntity.ok().build(); - } - - @GetMapping("secure") public ResponseEntity secure() { return ResponseEntity.ok().build(); } - @Getter - public static class User { - public String username; - - public User(String username) { - this.username = username; - } - - public void setUsername(String username) { - this.username = username; - } - - @Override - public String toString() { - return username; - } - } - @GetMapping("hello") public ResponseEntity hello() { return ResponseEntity.ok("Hello World"); } - - /** - * Example of how a filter configuration can be updated during runtime - * @param filterId id of the filter to update - * @param newConfig the new filter configuration - * @param bindingResult the result of the Jakarta validation - * @return - */ - @PostMapping("filters/{filterId}") - public ResponseEntity updateConfig( - @PathVariable String filterId, - @RequestBody @Valid Bucket4JConfiguration newConfig, - BindingResult bindingResult) { - if(configCacheManager == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Dynamic updating is disabled"); - - //validate that the path id matches the body - if (!newConfig.getId().equals(filterId)) { - return ResponseEntity.badRequest().body("The id in the path does not match the id in the request body."); - } - - //validate that there are no errors by the Jakarta validation - if (bindingResult.hasErrors()) { - List errors = bindingResult.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).toList(); - return ResponseEntity.badRequest().body(new ValidationErrorResponse("Configuration validation failed", errors)); - } - - //retrieve the old config and validate that it can be replaced by the new config - Bucket4JConfiguration oldConfig = configCacheManager.getValue(filterId); - ResponseEntity validationResponse = Bucket4JUtils.validateConfigurationUpdate(oldConfig, newConfig); - if (validationResponse != null) { - return validationResponse; - } - - //insert the new config into the cache, so it will trigger the cacheUpdateListeners - configCacheManager.setValue(filterId, newConfig); - - return ResponseEntity.ok().build(); - } - - private record ValidationErrorResponse(String message, List errors) {} } 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 new file mode 100644 index 00000000..a07b9d2c --- /dev/null +++ b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuiteTest.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.ehcache; + +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 +}) +public class EhcacheGeneralSuiteTest { +} diff --git a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheSampleApplicationTest.java b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheSampleApplicationTest.java deleted file mode 100644 index bf22f5b0..00000000 --- a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheSampleApplicationTest.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.ehcache; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -import java.util.Collections; -import java.util.stream.IntStream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class EhcacheSampleApplicationTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - private Bucket4JBootProperties properties; - - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter2"; - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("The id in the path does not match the id in the request body."))); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isNotFound()) - .andExpect(content().string(containsString("No filter with id 'nonexistent' could be found."))); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string("The new configuration should have a higher version than the current configuration.")); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.WEBFLUX); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterMethod of an existing filter."))); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterOrder of an existing filter."))); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the cacheName of an existing filter."))); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate name: INVALID-EXEC")); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate names: INVALID-EXEC, INVALID-SKIP")); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .andExpect(status().isOk()); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private ResultActions updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private ResultActions updateFilterCache(String filterId, String content) throws Exception { - return this.mockMvc - .perform(post("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .content(content)); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - try { - this.mockMvc - .perform(get(url)) - .andExpect(status().isOk()) - .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) - .andExpect(content().string(containsString("Hello World"))); - } catch (Exception e) { - e.printStackTrace(); - fail(e.getMessage()); - } - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - this.mockMvc - .perform(get(url)) - .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) - .andExpect(content().string(containsString("{ \"message\": \"Too many requests!\" }"))); - } -} diff --git a/examples/ehcache/src/test/resources/application.yml b/examples/ehcache/src/test/resources/application.yml new file mode 100644 index 00000000..0c90fe64 --- /dev/null +++ b/examples/ehcache/src/test/resources/application.yml @@ -0,0 +1,4 @@ +spring: + cache: + jcache: + config: classpath:ehcache.xml \ No newline at end of file diff --git a/examples/gateway/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/TestController.java b/examples/gateway/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/TestController.java index 841c6cef..f219eb12 100644 --- a/examples/gateway/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/TestController.java +++ b/examples/gateway/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/TestController.java @@ -6,7 +6,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -18,12 +17,12 @@ @RequestMapping("/") public class TestController { - @Autowired Validator validator; private final CacheManager configCacheManager; - public TestController(@Nullable CacheManager configCacheManager){ + public TestController(Validator validator, @Nullable CacheManager configCacheManager){ + this.validator = validator; this.configCacheManager = configCacheManager; } diff --git a/examples/gateway/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/GatewaySampleApplicationTest.java b/examples/gateway/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/GatewaySampleApplicationTest.java index 69ad8126..810a23b0 100644 --- a/examples/gateway/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/GatewaySampleApplicationTest.java +++ b/examples/gateway/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/gateway/GatewaySampleApplicationTest.java @@ -1,13 +1,14 @@ package com.giffing.bucket4j.spring.boot.starter.examples.gateway; -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.arrayWithSize; -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.util.Collections; -import java.util.stream.IntStream; - +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.github.tomakehurst.wiremock.client.WireMock; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; @@ -17,16 +18,13 @@ import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.reactive.server.WebTestClient; -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import java.time.Duration; +import java.util.Collections; +import java.util.stream.IntStream; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.tomakehurst.wiremock.client.WireMock; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { @@ -178,8 +176,9 @@ void replaceConfigTest() throws Exception { updateFilterCache(filter) .expectStatus().isOk(); - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); + // Allow the cacheUpdateListeners to update the filter configuration + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> successfulWebRequest(url, newFilterCapacity - 1)); } private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { diff --git a/examples/general-tests/.gitignore b/examples/general-tests/.gitignore new file mode 100644 index 00000000..b4b5f2f8 --- /dev/null +++ b/examples/general-tests/.gitignore @@ -0,0 +1,9 @@ +/target/ +/.settings/ +.classpath +.project +.idea/ +*.iml +.factorypath +.apt_generated +.springBeans \ No newline at end of file diff --git a/examples/general-tests/pom.xml b/examples/general-tests/pom.xml new file mode 100644 index 00000000..53d38e6d --- /dev/null +++ b/examples/general-tests/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-parent + ${revision} + ../.. + + + general-tests + + + 17 + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter-test + + + org.springframework.boot + spring-boot-starter-validation + + + org.junit.platform + junit-platform-suite-engine + + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter + provided + + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-context + provided + + + org.springframework.boot + spring-boot-starter-web + provided + + + org.springframework.boot + spring-boot-starter-webflux + provided + + + org.projectlombok + lombok + provided + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + **/** + + + + + + + \ No newline at end of file diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveGreadyRefillSpeedTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveGreadyRefillSpeedTest.java new file mode 100644 index 00000000..9bfdc40a --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveGreadyRefillSpeedTest.java @@ -0,0 +1,73 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.util.Collections; +import java.util.stream.IntStream; + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].filter-method=webflux", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=greedy", + "bucket4j.filters[0].url=^(/hello).*", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class ReactiveGreadyRefillSpeedTest { + + @Autowired + ApplicationContext context; + + WebTestClient rest; + + @BeforeEach + public void setup() { + this.rest = WebTestClient + .bindToApplicationContext(this.context) + .configureClient() + .build(); + } + + @Test + @Order(1) + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> successfulWebRequest(url, counter - 1)); + + blockedWebRequestDueToRateLimit(url); + } + + private void blockedWebRequestDueToRateLimit(String url) throws Exception { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) + .expectBody().jsonPath("error", "Too many requests!"); + } + + private void successfulWebRequest(String url, Integer remainingTries) { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveIntervalRefillSpeedTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveIntervalRefillSpeedTest.java new file mode 100644 index 00000000..794ea95f --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveIntervalRefillSpeedTest.java @@ -0,0 +1,72 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.util.Collections; +import java.util.stream.IntStream; + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].filter-method=webflux", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=interval", + "bucket4j.filters[0].url=^(/hello).*", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class ReactiveIntervalRefillSpeedTest { + @Autowired + ApplicationContext context; + + WebTestClient rest; + + @BeforeEach + public void setup() { + this.rest = WebTestClient + .bindToApplicationContext(this.context) + .configureClient() + .build(); + } + + @Test + @Order(1) + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> successfulWebRequest(url, counter - 1)); + + blockedWebRequestDueToRateLimit(url); + } + + private void blockedWebRequestDueToRateLimit(String url) throws Exception { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) + .expectBody().jsonPath("error", "Too many requests!"); + } + + private void successfulWebRequest(String url, Integer remainingTries) { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveRateLimitTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveRateLimitTest.java new file mode 100644 index 00000000..2f911023 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveRateLimitTest.java @@ -0,0 +1,266 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.reactive.server.WebTestClient; + +import java.time.Duration; +import java.util.Collections; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; + +@SpringBootTest(properties = { + "bucket4j.enabled=true", + "bucket4j.filter-config-cache-name=filterConfigCache", + "bucket4j.filter-config-caching-enabled=true", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].id=filter1", + "bucket4j.filters[0].filter-method=webflux", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[0].url=^(/hello).*", + "bucket4j.filters[1].cache-name=buckets", + "bucket4j.filters[1].id=filter2", + "bucket4j.filters[1].filter-method=webflux", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[1].url=^(/world).*", + "bucket4j.filters[2].cache-name=buckets", + "bucket4j.filters[2].id=filter3", + "bucket4j.filters[2].filter-method=webflux", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[2].rate-limits[0].post-execute-condition=getStatus() eq 401", + "bucket4j.filters[2].url=^(/secure).*" +}) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DirtiesContext +public class ReactiveRateLimitTest { + + @Autowired + ApplicationContext context; + + @Autowired + Bucket4JBootProperties properties; + + WebTestClient rest; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String FILTER_ID = "filter1"; + + @BeforeEach + public void setup() { + this.rest = WebTestClient + .bindToApplicationContext(this.context) + .configureClient() + .build(); + } + + @Test + @Order(1) + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> successfulWebRequest(url, counter - 1)); + + blockedWebRequestDueToRateLimit(url); + } + + @Test + @Order(1) + void worldTest() throws Exception { + String url = "/world"; + IntStream.rangeClosed(1, 10) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> successfulWebRequest(url, counter - 1)); + + blockedWebRequestDueToRateLimit(url); + } + + @Test + @Order(1) + void invalidNonMatchingIdReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) + .expectStatus().isBadRequest() + .expectBody().jsonPath("$").value( + containsString("The id in the path does not match the id in the request body.") + ); + } + + @Test + @Order(1) + void invalidNonExistingReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setId("nonexistent"); + updateFilterCache(filter) + .expectStatus().isNotFound() + .expectBody().jsonPath("$").value( + containsString("No filter with id 'nonexistent' could be found.") + ); + } + + @Test + @Order(1) + void invalidVersionReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + updateFilterCache(filter) + .expectStatus().isBadRequest() + .expectBody().jsonPath("$").value( + containsString("The new configuration should have a higher version than the current configuration.") + ); + } + + @Test + @Order(1) + void invalidMethodReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setFilterMethod(FilterMethod.GATEWAY); + updateFilterCache(filter) + .expectStatus().isBadRequest() + .expectBody().jsonPath("$").value( + containsString("It is not possible to modify the filterMethod of an existing filter.") + ); + } + + @Test + @Order(1) + void invalidOrderReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setFilterOrder(filter.getFilterOrder() + 1); + updateFilterCache(filter) + .expectStatus().isBadRequest() + .expectBody().jsonPath("$").value( + containsString("It is not possible to modify the filterOrder of an existing filter.") + ); + } + + @Test + @Order(1) + void invalidCacheNameReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setCacheName("nonexistent"); + updateFilterCache(filter) + .expectStatus().isBadRequest() + .expectBody().jsonPath("$").value( + containsString("It is not possible to modify the cacheName of an existing filter.") + ); + } + + @Test + @Order(1) + void invalidPredicateReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); + String json = documentContext + .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") + .jsonString(); + updateFilterCache(filter.getId(), json) + .expectStatus().isBadRequest() + .expectBody() + .jsonPath("$.message").isEqualTo("Configuration validation failed") + .jsonPath("$.errors.length()").isEqualTo(1) + .jsonPath("$.errors[0]").isEqualTo("Invalid predicate name: INVALID-EXEC"); + } + + @Test + @Order(1) + void invalidPredicatesReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); + String json = documentContext + .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") + .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") + .jsonString(); + updateFilterCache(filter.getId(), json) + .expectStatus().isBadRequest() + .expectBody() + .jsonPath("$.message").isEqualTo("Configuration validation failed") + .jsonPath("$.errors[0]").isEqualTo("Invalid predicate names: INVALID-EXEC, INVALID-SKIP"); + } + + @Test + @Order(2) + void replaceConfigTest() throws Exception { + String url = "/hello"; + int newFilterCapacity = 1000; + + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMajorVersion(filter.getMajorVersion() + 1); + filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); + + updateFilterCache(filter) + .expectStatus().isOk(); + + // Allow the cacheUpdateListeners to update the filter configuration + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> successfulWebRequest(url, newFilterCapacity - 1)); + } + + private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { + Bucket4JConfiguration config = properties.getFilters() + .stream() + .filter(x -> id.matches(x.getId())).findFirst().orElse(null); + assertThat(config).isNotNull(); + //returns a clone to prevent modifying the original in the properties + return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); + } + + private WebTestClient.ResponseSpec updateFilterCache(Bucket4JConfiguration filter) throws Exception { + return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); + } + + private WebTestClient.ResponseSpec updateFilterCache(String filterId, String content) throws Exception { + return rest.post() + .uri("/filters/".concat(filterId)) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(content) + .exchange(); + } + + private void successfulWebRequest(String url, Integer remainingTries) { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isOk() + .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); + } + + private void blockedWebRequestDueToRateLimit(String url) throws Exception { + rest + .get() + .uri(url) + .exchange() + .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) + .expectBody().jsonPath("error", "Too many requests!"); + } + +} \ No newline at end of file diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveTestApplication.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveTestApplication.java new file mode 100644 index 00000000..141f0881 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/ReactiveTestApplication.java @@ -0,0 +1,15 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +@SpringBootApplication +@EnableCaching +public class ReactiveTestApplication { + + public static void main(String[] args) { + SpringApplication.run(ReactiveTestApplication.class, args); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/WebfluxTestSuite.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/WebfluxTestSuite.java new file mode 100644 index 00000000..8cee2c76 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/WebfluxTestSuite.java @@ -0,0 +1,13 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + ReactiveRateLimitTest.class, + ReactiveGreadyRefillSpeedTest.class, + ReactiveIntervalRefillSpeedTest.class, +}) +public class WebfluxTestSuite { +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/controller/ReactiveController.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/controller/ReactiveController.java new file mode 100644 index 00000000..1a21ba53 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/controller/ReactiveController.java @@ -0,0 +1,87 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive.controller; + +import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; +import jakarta.annotation.Nullable; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.Validator; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Set; + +@RestController +@RequestMapping +public class ReactiveController { + + private final Validator validator; + + private final CacheManager configCacheManager; + + public ReactiveController(Validator validator, @Nullable CacheManager configCacheManager) { + this.validator = validator; + this.configCacheManager = configCacheManager; + } + + @GetMapping("/hello") + public Mono hello( + @RequestParam(defaultValue = "World") String name) { + return Mono.just("Hello") + .flatMap(s -> Mono + .just(s + ", " + name + "!\n") + ); + } + + @GetMapping("/world") + public Mono world( + @RequestParam(defaultValue = "World") String name) { + return Mono.just("Hello") + .flatMap(s -> Mono + .just(s + ", " + name + "!\n") + ); + } + + + /** + * Example of how a filter configuration can be updated during runtime + * @param filterId id of the filter to update + * @param newConfig the new filter configuration + * @return + */ + @PostMapping("filters/{filterId}") + public ResponseEntity updateConfig( + @PathVariable String filterId, + @RequestBody Bucket4JConfiguration newConfig) { + if(configCacheManager == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Dynamic updating is disabled"); + + //validate that the path id matches the body + if (!newConfig.getId().equals(filterId)) { + return ResponseEntity.badRequest().body("The id in the path does not match the id in the request body."); + } + + //validate that there are no errors by the Jakarta validation + Set> violations = validator.validate(newConfig); + if (!violations.isEmpty()) { + List errors = violations.stream().map(ConstraintViolation::getMessage).toList(); + return ResponseEntity.badRequest().body(new ValidationErrorResponse("Configuration validation failed", errors)); + } + + //retrieve the old config and validate that it can be replaced by the new config + Bucket4JConfiguration oldConfig = configCacheManager.getValue(filterId); + ResponseEntity validationResponse = Bucket4JUtils.validateConfigurationUpdate(oldConfig, newConfig); + if (validationResponse != null) { + return validationResponse; + } + + //insert the new config into the cache, so it will trigger the cacheUpdateListeners + configCacheManager.setValue(filterId, newConfig); + + return ResponseEntity.ok().build(); + } + + private record ValidationErrorResponse(String message, List errors) {} +} \ No newline at end of file diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/AddResponseHeaderTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/AddResponseHeaderTest.java new file mode 100644 index 00000000..f0906767 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/AddResponseHeaderTest.java @@ -0,0 +1,53 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].url=.*", + "bucket4j.filters[0].http-response-headers.hello=world", + "bucket4j.filters[0].http-response-headers.abc=cba", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class AddResponseHeaderTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void assert_custom_response_header() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + + mockMvc + .perform(get(url)) + .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) + .andExpect(header().exists("X-Rate-Limit-Retry-After-Seconds")) + .andExpect(header().string("hello", "world")) + .andExpect(header().string("abc", "cba")); + } + + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ChangeResponseHttpStatusCodeTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ChangeResponseHttpStatusCodeTest.java new file mode 100644 index 00000000..28ebdde2 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ChangeResponseHttpStatusCodeTest.java @@ -0,0 +1,44 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestWithStatus; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].url=.*", + "bucket4j.filters[0].http-status-code=NOT_FOUND", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class ChangeResponseHttpStatusCodeTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void assert_response_http_status_code() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestWithStatus(mockMvc, url, HttpStatus.NOT_FOUND); + } + + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/EmptyHttpResponseTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/EmptyHttpResponseTest.java new file mode 100644 index 00000000..7f9c32c5 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/EmptyHttpResponseTest.java @@ -0,0 +1,45 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestDueToRateLimitWithEmptyBody; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].url=^(/hello).*", + "bucket4j.filters[0].http-response-body=null", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=interval", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class EmptyHttpResponseTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void assert_empty_response() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestDueToRateLimitWithEmptyBody(mockMvc, url); + } + + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ExecuteConditionTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ExecuteConditionTest.java new file mode 100644 index 00000000..e8c38a59 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ExecuteConditionTest.java @@ -0,0 +1,79 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestDueToRateLimit; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + "logging.level.com.giffing.bucket4j=debug", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].url=.*", + "bucket4j.filters[0].rate-limits[0].execute-condition=getHeader('user') ne 'admin'", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class ExecuteConditionTest { + + public static final String HEADER_USER = "user"; + public static final String TEST_URL = "/hello"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DirtiesContext + void request_is_blocked_when_header_username_is_not_admin() throws Exception { + String url = TEST_URL; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestDueToRateLimit(mockMvc, TEST_URL); + } + + @Test + @DirtiesContext + void no_rate_limit_for_user_admin_in_header() throws Exception { + for(int i = 1; i <=20; i++) { + mockMvc + .perform(get(TEST_URL) + .header("user", "admin") + ) + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(header().doesNotExist("X-Rate-Limit-Remaining")) + .andExpect(content().string(containsString("Hello World"))); + } + } + + @Test + @DirtiesContext + void rate_limit_for_user_bilbo_in_header() throws Exception { + for(long remainingTries = 5; remainingTries >= 1; remainingTries--) { + mockMvc + .perform(get(TEST_URL) + .header(HEADER_USER, "bilbo") + ) + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries - 1)) + .andExpect(content().string(containsString("Hello World"))); + } + blockedWebRequestDueToRateLimit(mockMvc, TEST_URL); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/GreadyRefillSpeedTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/GreadyRefillSpeedTest.java new file mode 100644 index 00000000..cb018561 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/GreadyRefillSpeedTest.java @@ -0,0 +1,44 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestDueToRateLimit; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=greedy", + "bucket4j.filters[0].url=^(/hello).*", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class GreadyRefillSpeedTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestDueToRateLimit(mockMvc, url); + } + + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/IntervalRefillSpeedTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/IntervalRefillSpeedTest.java new file mode 100644 index 00000000..050fe8af --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/IntervalRefillSpeedTest.java @@ -0,0 +1,44 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestDueToRateLimit; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=interval", + "bucket4j.filters[0].url=^(/hello).*", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class IntervalRefillSpeedTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestDueToRateLimit(mockMvc, url); + } + + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/MockMvcHelper.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/MockMvcHelper.java new file mode 100644 index 00000000..826f49d8 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/MockMvcHelper.java @@ -0,0 +1,46 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.springframework.http.HttpStatus; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.fail; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +public class MockMvcHelper { + + public static void webRequestWithStatus( + MockMvc mockMvc, + String url, + HttpStatus httpStatus, + Integer remainingTries) { + try { + mockMvc + .perform(get(url)) + .andExpect(status().is(httpStatus.value())) + .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) + .andExpect(content().string(containsString("Hello World"))); + } catch (Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + } + + public static void blockedWebRequestDueToRateLimit(MockMvc mockMvc, String url) throws Exception { + blockedWebRequestWithStatus(mockMvc, url, HttpStatus.TOO_MANY_REQUESTS); + } + public static void blockedWebRequestWithStatus(MockMvc mockMvc, String url, HttpStatus httpStatus) throws Exception { + mockMvc + .perform(get(url)) + .andExpect(status().is(httpStatus.value())) + .andExpect(content().string(containsString("{ \"message\": \"Too many requests!\" }"))); + } + + public static void blockedWebRequestDueToRateLimitWithEmptyBody(MockMvc mockMvc, String url) throws Exception { + mockMvc + .perform(get(url)) + .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) + .andExpect(jsonPath("$").doesNotExist()); + } +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/PostExecuteConditionTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/PostExecuteConditionTest.java new file mode 100644 index 00000000..cf19c728 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/PostExecuteConditionTest.java @@ -0,0 +1,77 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.blockedWebRequestDueToRateLimit; +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.webRequestWithStatus; +import static org.assertj.core.api.Assertions.fail; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.hamcrest.Matchers.containsString; + + +@SpringBootTest(properties = { + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "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=interval", + "bucket4j.filters[0].url=^(/hello).*", + + "bucket4j.filters[1].rate-limits[0].post-execute-condition= getStatus() eq 401", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].refill-speed=interval", + "bucket4j.filters[1].url=^(/secure).*", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class PostExecuteConditionTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void assert_rate_limit_when_unauthorized() throws Exception { + String url = "/secure"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.UNAUTHORIZED, counter)); + + blockedWebRequestDueToRateLimit(mockMvc, url); + } + + @Test + void assert_no_rate_limit_when_authorized() { + String url = "/secure"; + IntStream.rangeClosed(1, 5) + .forEach(counter -> { + try { + this.mockMvc + .perform(get(url) + .queryParam("username", "admin") + ) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Hello World"))) + // the rate limit does not decrease + .andExpect(header().string("X-Rate-Limit-Remaining", "5")); + + } catch (Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + }); + } + +} diff --git a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/ServletRateLimitTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java similarity index 69% rename from examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/ServletRateLimitTest.java rename to examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java index d5b3c066..313a564e 100644 --- a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/ServletRateLimitTest.java +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java @@ -1,41 +1,69 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -import java.util.Collections; -import java.util.stream.IntStream; +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import com.jayway.jsonpath.DocumentContext; +import com.jayway.jsonpath.JsonPath; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; +import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import java.time.Duration; +import java.util.Collections; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.fail; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; -@SpringBootTest +@SpringBootTest(properties = { + "bucket4j.enabled=true", + "bucket4j.filter-config-cache-name=filterConfigCache", + "bucket4j.filter-config-caching-enabled=true", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].id=filter1", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[0].url=^(/hello).*", + "bucket4j.filters[1].cache-name=buckets", + "bucket4j.filters[1].id=filter2", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[1].url=^(/world).*", + "bucket4j.filters[2].cache-name=buckets", + "bucket4j.filters[2].id=filter3", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[2].rate-limits[0].post-execute-condition=getStatus() eq 401", + "bucket4j.filters[2].url=^(/secure).*", + "bucket4j.filter-config-caching-enabled=true", + "bucket4j.filter-config-cache-name=filterConfigCache" +}) @AutoConfigureMockMvc -@ActiveProfiles("servlet") // Like this -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) @TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class ServletRateLimitTest { +@DirtiesContext +public class ServletRateLimitTest { @Autowired private MockMvc mockMvc; @@ -54,12 +82,50 @@ void helloTest() throws Exception { .boxed() .sorted(Collections.reverseOrder()) .forEach(counter -> { - successfulWebRequest(url, counter - 1); + successfulWebRequest(url, counter - 1, HttpStatus.OK); }); blockedWebRequestDueToRateLimit(url); } + @Test + @Order(1) + void assert_rate_limit_when_unauthorized() throws Exception { + String url = "/secure"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> { + System.out.println("################## counter" + counter); + successfulWebRequest(url, counter, HttpStatus.UNAUTHORIZED); + }); + + blockedWebRequestDueToRateLimit(url); + } + + @Test + @Order(1) + void assert_no_rate_limit_when_authorized() throws Exception { + String url = "/secure"; + IntStream.rangeClosed(1, 5) + .forEach(counter -> { + try { + this.mockMvc + .perform(get(url) + .queryParam("username", "admin") + ) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Hello World"))) + // the rate limit does not decrease + .andExpect(header().string("X-Rate-Limit-Remaining", "5")); + + } catch (Exception e) { + e.printStackTrace(); + fail(e.getMessage()); + } + }); + } + @Test @Order(1) @@ -68,9 +134,7 @@ void worldTest() throws Exception { IntStream.rangeClosed(1, 10) .boxed() .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); + .forEach(counter -> successfulWebRequest(url, counter - 1, HttpStatus.OK)); blockedWebRequestDueToRateLimit(url); } @@ -182,15 +246,16 @@ void replaceConfigTest() throws Exception { updateFilterCache(filter) .andExpect(status().isOk()); - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); + // Allow the cacheUpdateListeners to update the filter configuration + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> successfulWebRequest(url, newFilterCapacity - 1, HttpStatus.OK)); } private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { Bucket4JConfiguration config = properties.getFilters() .stream() .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); + assertNotNull(config); //returns a clone to prevent modifying the original in the properties return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); } @@ -206,11 +271,11 @@ private ResultActions updateFilterCache(String filterId, String content) throws .content(content)); } - private void successfulWebRequest(String url, Integer remainingTries) { + private void successfulWebRequest(String url, Integer remainingTries, HttpStatus httpStatus) { try { this.mockMvc .perform(get(url)) - .andExpect(status().isOk()) + .andExpect(status().is(httpStatus.value())) .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) .andExpect(content().string(containsString("Hello World"))); } catch (Exception e) { diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestApplication.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestApplication.java new file mode 100644 index 00000000..4250033b --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestApplication.java @@ -0,0 +1,15 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; + +@SpringBootApplication +@EnableCaching +public class ServletTestApplication { + + public static void main(String[] args) { + SpringApplication.run(ServletTestApplication.class, args); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestSuite.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestSuite.java new file mode 100644 index 00000000..56c42253 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletTestSuite.java @@ -0,0 +1,19 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + ServletRateLimitTest.class, + GreadyRefillSpeedTest.class, + IntervalRefillSpeedTest.class, + PostExecuteConditionTest.class, + EmptyHttpResponseTest.class, + ChangeResponseHttpStatusCodeTest.class, + AddResponseHeaderTest.class, + SkipConditionTest.class, + ExecuteConditionTest.class +}) +public class ServletTestSuite { +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/SkipConditionTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/SkipConditionTest.java new file mode 100644 index 00000000..fa78929a --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/SkipConditionTest.java @@ -0,0 +1,78 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.MockMvcHelper.*; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + "logging.level.com.giffing.bucket4j=debug", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].url=.*", + "bucket4j.filters[0].rate-limits[0].skip-condition=getHeader('user') eq 'admin'", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", +}) +@AutoConfigureMockMvc +@DirtiesContext +public class SkipConditionTest { + + public static final String HEADER_USER = "user"; + public static final String TEST_URL = "/hello"; + + @Autowired + private MockMvc mockMvc; + + @Test + @DirtiesContext + void request_is_blocked_when_header_username_is_not_admin() throws Exception { + String url = TEST_URL; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> webRequestWithStatus(mockMvc, url, HttpStatus.OK, counter - 1)); + blockedWebRequestDueToRateLimit(mockMvc, TEST_URL); + } + + @Test + @DirtiesContext + void no_rate_limit_for_user_admin_in_header() throws Exception { + for(int i = 1; i <=20; i++) { + mockMvc + .perform(get(TEST_URL) + .header("user", "admin") + ) + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(header().doesNotExist("X-Rate-Limit-Remaining")) + .andExpect(content().string(containsString("Hello World"))); + } + } + + @Test + @DirtiesContext + void rate_limit_for_user_bilbo_in_header() throws Exception { + for(int remainingTries = 5; remainingTries >= 1; remainingTries--) { + mockMvc + .perform(get(TEST_URL) + .header(HEADER_USER, "bilbo") + ) + .andExpect(status().is(HttpStatus.OK.value())) + .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries -1)) + .andExpect(content().string(containsString("Hello World"))); + } + blockedWebRequestDueToRateLimit(mockMvc, TEST_URL); + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/controller/ServletController.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/controller/ServletController.java new file mode 100644 index 00000000..96450f3c --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/controller/ServletController.java @@ -0,0 +1,153 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.controller; + +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 org.springframework.web.util.HtmlUtils; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +@RestController +public class ServletController { + + private final Validator validator; + + private final CacheManager configCacheManager; + + public ServletController(Validator validator, @Nullable CacheManager configCacheManager) { + this.validator = validator; + this.configCacheManager = configCacheManager; + } + + @GetMapping("unsecure") + public ResponseEntity unsecure() { + return ResponseEntity.ok().build(); + } + + @GetMapping("hello") + public ResponseEntity hello() { + return ResponseEntity.ok("Hello World"); + } + + @GetMapping("world") + public ResponseEntity world() { + return ResponseEntity.ok("Hello World"); + } + + @GetMapping("secure") + public ResponseEntity secure() { + return ResponseEntity.ok("Hello World"); + } + + /** + * Example of how a filter configuration can be updated during runtime + * @param filterId id of the filter to update + * @param newConfig the new filter configuration + * @param bindingResult the result of the Jakarta validation + * @return + */ + @PostMapping("filters/{filterId}") + public ResponseEntity updateConfig( + @PathVariable String filterId, + @RequestBody @Valid Bucket4JConfiguration newConfig, + BindingResult bindingResult) { + if(configCacheManager == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Dynamic updating is disabled"); + + //validate that the path id matches the body + if (!newConfig.getId().equals(filterId)) { + return ResponseEntity.badRequest().body("The id in the path does not match the id in the request body."); + } + + //validate that there are no errors by the Jakarta validation + if (bindingResult.hasErrors()) { + List errors = bindingResult.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).toList(); + return ResponseEntity.badRequest().body(new ValidationErrorResponse("Configuration validation failed", errors)); + } + + //retrieve the old config and validate that it can be replaced by the new config + Bucket4JConfiguration oldConfig = configCacheManager.getValue(filterId); + ResponseEntity validationResponse = Bucket4JUtils.validateConfigurationUpdate(oldConfig, newConfig); + if (validationResponse != null) { + return validationResponse; + } + + //insert the new config into the cache, so it will trigger the cacheUpdateListeners + configCacheManager.setValue(filterId, newConfig); + + return ResponseEntity.ok().build(); + } + + /** + * note: The recommended way of updating rate limits is by sending the whole Bucket4JConfiguration (see above). + * + * This endpoint is added as an example how partial data of a configuration could be updated. + * This should only be done if you know what you are doing, since it requires additional checks + * and configuring to prevent corrupting the cache. If unsure, use the example above. + * + * @param filterId The id of the filter to update + * @param limitIndex The index number of the RateLimit (these don't have an id, so has to be index based) + * @param bandwidthId The id of the bandwidth to update + * @param bandWidth The new BandWidth configuration + * @return + */ + @PostMapping("filters/{filterId}/ratelimits/{limitIndex}/bandwidths/{bandwidthId}") + public ResponseEntity updateBandwidth( + @PathVariable String filterId, + @PathVariable int limitIndex, + @PathVariable String bandwidthId, + @RequestBody BandWidth bandWidth) { + + //validate that the path matches the body + if (!bandWidth.getId().equals(bandwidthId)) { + return ResponseEntity.badRequest().body("Bandwidth id in the path does not match the request body."); + } + + //validate that the filter, ratelimit and bandwidth all exist + Bucket4JConfiguration config = configCacheManager.getValue(filterId); + if (config == null) { + String errorMessage = "No filter with id '" + filterId + "' could be found."; + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(HtmlUtils.htmlEscape(errorMessage)); + } + RateLimit rl = config.getRateLimits().get(limitIndex); + if (rl == null) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("No ratelimit with index " + limitIndex + " could be found."); + } + Optional bw = rl.getBandwidths().stream().filter(x -> Objects.equals(x.getId(), bandwidthId)).findFirst(); + if (bw.isEmpty()) { + String errorMessage = "No bandwidth with id '" + bandwidthId + "' could be found."; + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(HtmlUtils.htmlEscape(errorMessage)); + } + + //replace the bandwidth + rl.getBandwidths().set(rl.getBandwidths().indexOf(bw.get()), bandWidth); + + //validate that the changed config is still valid + Set> violations = this.validator.validate(config); + if (!violations.isEmpty()) { + List errors = violations.stream().map(ConstraintViolation::getMessage).toList(); + return ResponseEntity.badRequest().body(new ValidationErrorResponse("Configuration validation failed", errors)); + } + + //update the version number and insert the updated config into the cache, so it will trigger the cacheUpdateListeners + config.setMinorVersion(config.getMinorVersion() + 1); + configCacheManager.setValue(filterId, config); + + return ResponseEntity.ok().build(); + } + + private record ValidationErrorResponse(String message, List errors) {} +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/security/SimpleSecurityFilter.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/security/SimpleSecurityFilter.java new file mode 100644 index 00000000..2a89f343 --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/security/SimpleSecurityFilter.java @@ -0,0 +1,33 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Objects; + +/** + * A user authorized for the url /secure when the query parameter 'username' has the value 'admin' + */ +@Component +@Order(0) +public class SimpleSecurityFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + boolean isSecurePath = request.getRequestURI().equals("/secure"); + boolean isNotAdmin = !Objects.equals("admin", request.getParameter("username")); + if(isSecurePath && isNotAdmin) { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.getWriter().write("Hello World"); + } else { + filterChain.doFilter(request, response); + } + } +} diff --git a/examples/hazelcast/pom.xml b/examples/hazelcast/pom.xml index 11dbedb7..d792e38c 100644 --- a/examples/hazelcast/pom.xml +++ b/examples/hazelcast/pom.xml @@ -79,6 +79,16 @@ lombok test + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + + + org.junit.platform + junit-platform-suite-engine + diff --git a/examples/hazelcast/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/TestController.java b/examples/hazelcast/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/TestController.java index 741994ab..6e05012d 100644 --- a/examples/hazelcast/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/TestController.java +++ b/examples/hazelcast/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/TestController.java @@ -25,7 +25,7 @@ public TestController(@Nullable CacheManager conf } @GetMapping("hello") - public ResponseEntity helloWorld() { + public ResponseEntity helloWorld() { return ResponseEntity.ok().body("Hello World"); } diff --git a/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuiteTest.java b/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuiteTest.java new file mode 100644 index 00000000..a5a1fac1 --- /dev/null +++ b/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuiteTest.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.hazelcast; + +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 +}) +public class HazelcastGeneralSuiteTest { +} diff --git a/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastTest.java b/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastTest.java deleted file mode 100644 index bfdf1e99..00000000 --- a/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastTest.java +++ /dev/null @@ -1,214 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.hazelcast; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -import java.util.Collections; -import java.util.stream.IntStream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class HazelcastTest { - - @Autowired - private MockMvc mockMvc; - - @Autowired - Bucket4JBootProperties properties; - - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter1"; - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("The id in the path does not match the id in the request body."))); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isNotFound()) - .andExpect(content().string(containsString("No filter with id 'nonexistent' could be found."))); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string("The new configuration should have a higher version than the current configuration.")); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.WEBFLUX); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterMethod of an existing filter."))); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterOrder of an existing filter."))); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the cacheName of an existing filter."))); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate name: INVALID-EXEC")); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate names: INVALID-EXEC, INVALID-SKIP")); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .andExpect(status().isOk()); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private ResultActions updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private ResultActions updateFilterCache(String filterId, String content) throws Exception { - return this.mockMvc - .perform(post("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .content(content)); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - try { - this.mockMvc - .perform(get(url)) - .andExpect(status().isOk()) - .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) - .andExpect(content().string(containsString("Hello World"))); - } catch (Exception e) { - e.printStackTrace(); - fail(e.getMessage()); - } - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - this.mockMvc - .perform(get(url)) - .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) - .andExpect(content().string(containsString("{ \"message\": \"Too many requests!\" }"))); - } -} diff --git a/examples/hazelcast/src/test/resources/application.yml b/examples/hazelcast/src/test/resources/application.yml new file mode 100644 index 00000000..3d067373 --- /dev/null +++ b/examples/hazelcast/src/test/resources/application.yml @@ -0,0 +1,7 @@ +spring: + cache: + type: jcache + jcache: + provider: com.hazelcast.cache.impl.HazelcastServerCachingProvider + main: + allow-bean-definition-overriding: true \ No newline at end of file diff --git a/examples/redis-jedis/pom.xml b/examples/redis-jedis/pom.xml index d19faa55..3bb7acaf 100644 --- a/examples/redis-jedis/pom.xml +++ b/examples/redis-jedis/pom.xml @@ -58,12 +58,26 @@ ${testcontainers-redis-junit.version} test + + org.awaitility + awaitility + test + org.springframework.boot spring-boot-starter-test test - + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + + + org.junit.platform + junit-platform-suite-engine + diff --git a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisJedisTest.java b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisJedisTest.java deleted file mode 100644 index dafd9f0c..00000000 --- a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisJedisTest.java +++ /dev/null @@ -1,248 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.redis; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.MethodOrderer; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; - -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import java.util.Collections; -import java.util.stream.IntStream; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.fail; -import static org.hamcrest.Matchers.containsString; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -@SpringBootTest -@AutoConfigureMockMvc -@Testcontainers -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class RedisJedisTest { - - @Container - static final GenericContainer redis = - new GenericContainer(DockerImageName.parse("redis:7")) - .withExposedPorts(6379); - - @DynamicPropertySource - static void redisProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.redis.host", () -> redis.getHost()); - registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); - } - - @Autowired - private MockMvc mockMvc; - - @Autowired - Bucket4JBootProperties properties; - - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter1"; - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - - @Test - @Order(1) - void worldTest() throws Exception { - String url = "/world"; - IntStream.rangeClosed(1, 10) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("The id in the path does not match the id in the request body."))); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isNotFound()) - .andExpect(content().string(containsString("No filter with id 'nonexistent' could be found."))); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string("The new configuration should have a higher version than the current configuration.")); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.WEBFLUX); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterMethod of an existing filter."))); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterOrder of an existing filter."))); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the cacheName of an existing filter."))); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate name: INVALID-EXEC")); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate names: INVALID-EXEC, INVALID-SKIP")); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .andExpect(status().isOk()); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private ResultActions updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private ResultActions updateFilterCache(String filterId, String content) throws Exception { - return this.mockMvc - .perform(post("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .content(content)); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - try { - this.mockMvc - .perform(get(url)) - .andExpect(status().isOk()) - .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) - .andExpect(content().string(containsString("Hello World"))); - } catch (Exception e) { - e.printStackTrace(); - fail(e.getMessage()); - } - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - this.mockMvc - .perform(get(url)) - .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) - .andExpect(content().string(containsString("{ \"message\": \"Too many requests!\" }"))); - } -} diff --git a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisGreadyRefillSpeedTest.java b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisGreadyRefillSpeedTest.java new file mode 100644 index 00000000..3881828a --- /dev/null +++ b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisGreadyRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(JedisConfiguraiton.class) +public class JedisGreadyRefillSpeedTest extends GreadyRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisIntervalRefillSpeedTest.java b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisIntervalRefillSpeedTest.java new file mode 100644 index 00000000..cff988e0 --- /dev/null +++ b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisIntervalRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(JedisConfiguraiton.class) +public class JedisIntervalRefillSpeedTest extends IntervalRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisServletRateLimitTest.java b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisServletRateLimitTest.java new file mode 100644 index 00000000..850598df --- /dev/null +++ b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/JedisServletRateLimitTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(JedisConfiguraiton.class) +public class JedisServletRateLimitTest extends ServletRateLimitTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-jedis/src/test/resources/application.yml b/examples/redis-jedis/src/test/resources/application.yml new file mode 100644 index 00000000..98e90925 --- /dev/null +++ b/examples/redis-jedis/src/test/resources/application.yml @@ -0,0 +1,6 @@ +spring: + data: + redis: + host: localhost + port: 6379 + diff --git a/examples/redis-lettuce/pom.xml b/examples/redis-lettuce/pom.xml index dee6d6bd..ee8bee0c 100644 --- a/examples/redis-lettuce/pom.xml +++ b/examples/redis-lettuce/pom.xml @@ -60,6 +60,12 @@ ${testcontainers-redis-junit.version} test + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + org.springframework.boot spring-boot-starter-test diff --git a/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/DebugMetricHandler.java b/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/DebugMetricHandler.java deleted file mode 100644 index 4694e13a..00000000 --- a/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/DebugMetricHandler.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter; - -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.metrics.MetricType; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.stream.Collectors; - -@Component -@Slf4j -public class DebugMetricHandler implements MetricHandler { - - @Override - public void handle(MetricType type, String name, long tokens, List tags) { - log.info(String.format("type: %s; name: %s; tags: %s; tokens: %s", - type, - name, - tags - .stream() - .map(mtr -> mtr.getKey() + ":" + mtr.getValue()) - .collect(Collectors.joining(",")), - tokens)); - - } - -} diff --git a/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index 19a20709..a15465d9 100644 --- a/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-lettuce/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -6,7 +6,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -17,12 +16,12 @@ @RestController public class TestController { - @Autowired Validator validator; private final CacheManager configCacheManager; - public TestController(@Nullable CacheManager configCacheManager){ + public TestController(Validator validator, @Nullable CacheManager configCacheManager){ + this.validator = validator; this.configCacheManager = configCacheManager; } diff --git a/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisLettuceTest.java b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisLettuceTest.java deleted file mode 100644 index 7c870303..00000000 --- a/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisLettuceTest.java +++ /dev/null @@ -1,262 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.redis; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; - -import java.util.Collections; -import java.util.stream.IntStream; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.fasterxml.jackson.databind.ObjectMapper; - -@SpringBootTest -@AutoConfigureMockMvc -@Testcontainers -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class RedisLettuceTest { - - @Container - static final GenericContainer redis = - new GenericContainer(DockerImageName.parse("redis:7")) - .withExposedPorts(6379); - - @DynamicPropertySource - static void redisProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.redis.host", () -> redis.getHost()); - registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); - } - - @Autowired - ApplicationContext context; - - @Autowired - Bucket4JBootProperties properties; - - WebTestClient rest; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter1"; - - @BeforeEach - public void setup() { - this.rest = WebTestClient - .bindToApplicationContext(this.context) - .configureClient() - .build(); - } - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void worldTest() throws Exception { - String url = "/world"; - IntStream.rangeClosed(1, 10) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - System.out.println(counter); - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The id in the path does not match the id in the request body.") - ); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .expectStatus().isNotFound() - .expectBody().jsonPath("$").value( - containsString("No filter with id 'nonexistent' could be found.") - ); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The new configuration should have a higher version than the current configuration.") - ); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.GATEWAY); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterMethod of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterOrder of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the cacheName of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors.length()").isEqualTo(1) - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate name: INVALID-EXEC"); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate names: INVALID-EXEC, INVALID-SKIP"); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .expectStatus().isOk(); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private WebTestClient.ResponseSpec updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private WebTestClient.ResponseSpec updateFilterCache(String filterId, String content) throws Exception { - return rest.post() - .uri("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(content) - .exchange(); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isOk() - .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) - .expectBody().jsonPath("error", "Too many requests!"); - } - -} diff --git a/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceGreadyRefillSpeedTest.java b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceGreadyRefillSpeedTest.java new file mode 100644 index 00000000..c51454cd --- /dev/null +++ b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceGreadyRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.LettuceConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(LettuceConfiguraiton.class) +public class LettuceGreadyRefillSpeedTest extends ReactiveGreadyRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceIntervalRefillSpeedTest.java b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceIntervalRefillSpeedTest.java new file mode 100644 index 00000000..b90dd32e --- /dev/null +++ b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceIntervalRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.LettuceConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(LettuceConfiguraiton.class) +public class LettuceIntervalRefillSpeedTest extends ReactiveIntervalRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceServletRateLimitTest.java b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceServletRateLimitTest.java new file mode 100644 index 00000000..a61abb85 --- /dev/null +++ b/examples/redis-lettuce/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/LettuceServletRateLimitTest.java @@ -0,0 +1,30 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.LettuceConfiguraiton; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(LettuceConfiguraiton.class) +@Disabled("Test ist not running on github - TODO") +public class LettuceServletRateLimitTest extends ReactiveRateLimitTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-lettuce/src/test/resources/application.yml b/examples/redis-lettuce/src/test/resources/application.yml new file mode 100644 index 00000000..cb0bf545 --- /dev/null +++ b/examples/redis-lettuce/src/test/resources/application.yml @@ -0,0 +1,6 @@ +spring: + main: + allow-bean-definition-overriding: true + data: + redis: + port: 6379 diff --git a/examples/redis-redisson/pom.xml b/examples/redis-redisson/pom.xml index 5938ded3..5133d24b 100644 --- a/examples/redis-redisson/pom.xml +++ b/examples/redis-redisson/pom.xml @@ -61,6 +61,12 @@ ${testcontainers-redis-junit.version} test + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + org.springframework.boot spring-boot-starter-test diff --git a/examples/redis-redisson/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-redisson/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index 7206ae08..dd5753de 100644 --- a/examples/redis-redisson/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-redisson/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -3,9 +3,10 @@ import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; +import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; -import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -15,12 +16,12 @@ @RestController public class TestController { - @Autowired - Validator validator; + private final Validator validator; private final CacheManager configCacheManager; - public TestController(@Nullable CacheManager configCacheManager){ + public TestController(Validator validator, @Nullable CacheManager configCacheManager){ + this.validator = validator; this.configCacheManager = configCacheManager; } diff --git a/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisRedissonTest.java b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisRedissonTest.java deleted file mode 100644 index 9f7df607..00000000 --- a/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/redis/RedisRedissonTest.java +++ /dev/null @@ -1,264 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.redis; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; - -import java.util.Collections; -import java.util.stream.IntStream; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.DynamicPropertyRegistry; -import org.springframework.test.context.DynamicPropertySource; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.reactive.server.WebTestClient; -import org.testcontainers.containers.GenericContainer; -import org.testcontainers.junit.jupiter.Container; -import org.testcontainers.junit.jupiter.Testcontainers; -import org.testcontainers.utility.DockerImageName; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.fasterxml.jackson.databind.ObjectMapper; - -@SpringBootTest -@AutoConfigureMockMvc -@Testcontainers -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true", "bucket4j.filter-config-cache-name=filterConfigCache"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class RedisRedissonTest { - - @Container - static final GenericContainer redis = - new GenericContainer(DockerImageName.parse("redis:7")) - .withExposedPorts(6379); - - @DynamicPropertySource - static void redisProperties(DynamicPropertyRegistry registry) { - registry.add("spring.data.redis.host", () -> redis.getHost()); - registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); - } - - @Autowired - ApplicationContext context; - - @Autowired - Bucket4JBootProperties properties; - - WebTestClient rest; - - private final ObjectMapper objectMapper = new ObjectMapper(); - - private final String FILTER_ID = "filter1"; - - @BeforeEach - public void setup() { - this.rest = WebTestClient - .bindToApplicationContext(this.context) - .configureClient() - .build(); - } - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void worldTest() throws Exception { - String url = "/world"; - IntStream.rangeClosed(1, 10) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - System.out.println(counter); - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The id in the path does not match the id in the request body.") - ); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .expectStatus().isNotFound() - .expectBody().jsonPath("$").value( - containsString("No filter with id 'nonexistent' could be found.") - ); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The new configuration should have a higher version than the current configuration.") - ); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.SERVLET); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterMethod of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterOrder of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the cacheName of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors.length()").isEqualTo(1) - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate name: INVALID-EXEC"); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate names: INVALID-EXEC, INVALID-SKIP"); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .expectStatus().isOk(); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private WebTestClient.ResponseSpec updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private WebTestClient.ResponseSpec updateFilterCache(String filterId, String content) throws Exception { - return rest.post() - .uri("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(content) - .exchange(); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isOk() - .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) - .expectBody().jsonPath("error", "Too many requests!"); - } - -} diff --git a/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonGreadyRefillSpeedTest.java b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonGreadyRefillSpeedTest.java new file mode 100644 index 00000000..85b3771f --- /dev/null +++ b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonGreadyRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.RedissonConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(RedissonConfiguraiton.class) +public class RedissonGreadyRefillSpeedTest extends ReactiveGreadyRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonIntervalRefillSpeedTest.java b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonIntervalRefillSpeedTest.java new file mode 100644 index 00000000..c9be3eb9 --- /dev/null +++ b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonIntervalRefillSpeedTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.RedissonConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(RedissonConfiguraiton.class) +public class RedissonIntervalRefillSpeedTest extends ReactiveIntervalRefillSpeedTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonServletRateLimitTest.java b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonServletRateLimitTest.java new file mode 100644 index 00000000..2386c97b --- /dev/null +++ b/examples/redis-redisson/src/test/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/reactive/RedissonServletRateLimitTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive; + +import com.giffing.bucket4j.spring.boot.starter.RedissonConfiguraiton; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.utility.DockerImageName; + +@Testcontainers +@Import(RedissonConfiguraiton.class) +public class RedissonServletRateLimitTest extends ReactiveRateLimitTest { + + @Container + static final GenericContainer redis = + new GenericContainer(DockerImageName.parse("redis:7")) + .withExposedPorts(6379); + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", () -> redis.getHost()); + registry.add("spring.data.redis.port", () -> redis.getFirstMappedPort()); + } + +} diff --git a/examples/redis-redisson/src/test/resources/application.yml b/examples/redis-redisson/src/test/resources/application.yml new file mode 100644 index 00000000..5cfec82c --- /dev/null +++ b/examples/redis-redisson/src/test/resources/application.yml @@ -0,0 +1,7 @@ +spring: + main: + allow-bean-definition-overriding: true + data: + redis: + host: localhost + port: 6379 diff --git a/examples/webflux-infinispan/pom.xml b/examples/webflux-infinispan/pom.xml index a4c07dae..9063b594 100644 --- a/examples/webflux-infinispan/pom.xml +++ b/examples/webflux-infinispan/pom.xml @@ -56,11 +56,22 @@ org.infinispan infinispan-core-jakarta + + org.projectlombok + lombok + provided + org.springframework.boot spring-boot-starter-test test + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + diff --git a/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/DebugMetricHandler.java b/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/DebugMetricHandler.java index d417e972..f6e4407c 100644 --- a/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/DebugMetricHandler.java +++ b/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/DebugMetricHandler.java @@ -3,6 +3,7 @@ import java.util.List; import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; @@ -10,17 +11,18 @@ import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricType; @Component +@Slf4j public class DebugMetricHandler implements MetricHandler { @Override public void handle(MetricType type, String name, long tokens, List tags) { - System.out.println(String.format("type: %s; name: %s; tags: %s", + log.info("type: {}; name: {}; tags: {}", type, name, tags .stream() .map(mtr -> mtr.getKey() + ":" + mtr.getValue()) - .collect(Collectors.joining(",")))); + .collect(Collectors.joining(","))); } diff --git a/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java b/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java index e88ea8e4..1cb7f57d 100644 --- a/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java +++ b/examples/webflux-infinispan/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java @@ -6,7 +6,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,12 +18,12 @@ @RequestMapping public class MyController { - @Autowired - Validator validator; + private final Validator validator; private final CacheManager configCacheManager; - public MyController(@Nullable CacheManager configCacheManager) { + public MyController(Validator validator, @Nullable CacheManager configCacheManager) { + this.validator = validator; this.configCacheManager = configCacheManager; } diff --git a/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java b/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java new file mode 100644 index 00000000..8a18a064 --- /dev/null +++ b/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.webflux; + +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive.WebfluxTestSuite; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + WebfluxTestSuite.class +}) +public class WebfluxGeneralSuiteTest { +} diff --git a/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxInfinispanRateLimitTest.java b/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxInfinispanRateLimitTest.java deleted file mode 100644 index f6ba4ea5..00000000 --- a/examples/webflux-infinispan/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxInfinispanRateLimitTest.java +++ /dev/null @@ -1,243 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.webflux; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; - -import java.util.Collections; -import java.util.stream.IntStream; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.web.reactive.server.WebTestClient; - -@SpringBootTest -@ActiveProfiles("webflux-infinispan") // Like this -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class WebfluxInfinispanRateLimitTest { - - @Autowired - ApplicationContext context; - - @Autowired - Bucket4JBootProperties properties; - - WebTestClient rest; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter1"; - - @BeforeEach - public void setup() { - this.rest = WebTestClient - .bindToApplicationContext(this.context) - .configureClient() - .build(); - } - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void worldTest() throws Exception { - String url = "/world"; - IntStream.rangeClosed(1, 10) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - System.out.println(counter); - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The id in the path does not match the id in the request body.") - ); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .expectStatus().isNotFound() - .expectBody().jsonPath("$").value( - containsString("No filter with id 'nonexistent' could be found.") - ); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The new configuration should have a higher version than the current configuration.") - ); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.GATEWAY); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterMethod of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterOrder of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the cacheName of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors.length()").isEqualTo(1) - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate name: INVALID-EXEC"); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate names: INVALID-EXEC, INVALID-SKIP"); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .expectStatus().isOk(); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private WebTestClient.ResponseSpec updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private WebTestClient.ResponseSpec updateFilterCache(String filterId, String content) throws Exception { - return rest.post() - .uri("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(content) - .exchange(); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isOk() - .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) - .expectBody().jsonPath("error", "Too many requests!"); - } - -} diff --git a/examples/webflux-infinispan/src/test/resources/application.yml b/examples/webflux-infinispan/src/test/resources/application.yml new file mode 100644 index 00000000..169a5129 --- /dev/null +++ b/examples/webflux-infinispan/src/test/resources/application.yml @@ -0,0 +1,6 @@ +spring: + cache: + type: infinispan +infinispan: + embedded: + config-xml: infinispan.xml \ No newline at end of file diff --git a/examples/webflux/pom.xml b/examples/webflux/pom.xml index d4321459..96569f42 100644 --- a/examples/webflux/pom.xml +++ b/examples/webflux/pom.xml @@ -60,6 +60,12 @@ spring-boot-starter-test test + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + tests + diff --git a/examples/webflux/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java b/examples/webflux/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java index 715ea920..e1cd122c 100644 --- a/examples/webflux/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java +++ b/examples/webflux/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/MyController.java @@ -6,7 +6,6 @@ import jakarta.annotation.Nullable; import jakarta.validation.ConstraintViolation; import jakarta.validation.Validator; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -19,12 +18,12 @@ @RequestMapping public class MyController { - @Autowired - Validator validator; + private final Validator validator; private final CacheManager configCacheManager; - public MyController(@Nullable CacheManager configCacheManager) { + public MyController(Validator validator, @Nullable CacheManager configCacheManager) { + this.validator = validator; this.configCacheManager = configCacheManager; } diff --git a/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java b/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java new file mode 100644 index 00000000..8a18a064 --- /dev/null +++ b/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxGeneralSuiteTest.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.webflux; + +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.reactive.WebfluxTestSuite; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + WebfluxTestSuite.class +}) +public class WebfluxGeneralSuiteTest { +} diff --git a/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxRateLimitTest.java b/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxRateLimitTest.java deleted file mode 100644 index 14b08d3c..00000000 --- a/examples/webflux/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/webflux/WebfluxRateLimitTest.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.examples.webflux; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.hamcrest.Matchers.containsString; - -import java.util.Collections; -import java.util.stream.IntStream; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.jayway.jsonpath.DocumentContext; -import com.jayway.jsonpath.JsonPath; -import org.junit.jupiter.api.*; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.context.ApplicationContext; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; -import org.springframework.test.context.TestPropertySource; -import org.springframework.test.web.reactive.server.WebTestClient; - -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - -import com.fasterxml.jackson.databind.ObjectMapper; - -@SpringBootTest -@ActiveProfiles("webflux") // Like this -@TestPropertySource(properties = {"bucket4j.filter-config-caching-enabled=true"}) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) -class WebfluxRateLimitTest { - - @Autowired - ApplicationContext context; - - @Autowired - Bucket4JBootProperties properties; - - WebTestClient rest; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final String FILTER_ID = "filter1"; - - @BeforeEach - public void setup() { - this.rest = WebTestClient - .bindToApplicationContext(this.context) - .configureClient() - .build(); - } - - @Test - @Order(1) - void helloTest() throws Exception { - String url = "/hello"; - IntStream.rangeClosed(1, 5) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void worldTest() throws Exception { - String url = "/world"; - IntStream.rangeClosed(1, 10) - .boxed() - .sorted(Collections.reverseOrder()) - .forEach(counter -> { - System.out.println(counter); - successfulWebRequest(url, counter - 1); - }); - - blockedWebRequestDueToRateLimit(url); - } - - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache("nonexistent", objectMapper.writeValueAsString(filter)) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The id in the path does not match the id in the request body.") - ); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId("nonexistent"); - updateFilterCache(filter) - .expectStatus().isNotFound() - .expectBody().jsonPath("$").value( - containsString("No filter with id 'nonexistent' could be found.") - ); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("The new configuration should have a higher version than the current configuration.") - ); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.GATEWAY); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterMethod of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the filterOrder of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName("nonexistent"); - updateFilterCache(filter) - .expectStatus().isBadRequest() - .expectBody().jsonPath("$").value( - containsString("It is not possible to modify the cacheName of an existing filter.") - ); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors.length()").isEqualTo(1) - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate name: INVALID-EXEC"); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - DocumentContext documentContext = JsonPath.parse(objectMapper.writeValueAsString(filter)); - String json = documentContext - .add("$.rateLimits[0].executePredicates", "INVALID-EXEC=TEST") - .add("$.rateLimits[0].skipPredicates", "INVALID-SKIP=TEST") - .jsonString(); - updateFilterCache(filter.getId(), json) - .expectStatus().isBadRequest() - .expectBody() - .jsonPath("$.message").isEqualTo("Configuration validation failed") - .jsonPath("$.errors[0]").isEqualTo("Invalid predicate names: INVALID-EXEC, INVALID-SKIP"); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .expectStatus().isOk(); - - Thread.sleep(100); //Short sleep to allow the cacheUpdateListeners to update the filter configuration - successfulWebRequest(url, newFilterCapacity - 1); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JsonProcessingException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertThat(config).isNotNull(); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private WebTestClient.ResponseSpec updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private WebTestClient.ResponseSpec updateFilterCache(String filterId, String content) throws Exception { - return rest.post() - .uri("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(content) - .exchange(); - } - - private void successfulWebRequest(String url, Integer remainingTries) { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isOk() - .expectHeader().valueEquals("X-Rate-Limit-Remaining", String.valueOf(remainingTries)); - } - - private void blockedWebRequestDueToRateLimit(String url) throws Exception { - rest - .get() - .uri(url) - .exchange() - .expectStatus().isEqualTo(HttpStatus.TOO_MANY_REQUESTS) - .expectBody().jsonPath("error", "Too many requests!"); - } - -} diff --git a/examples/webflux/src/test/resources/application.yml b/examples/webflux/src/test/resources/application.yml new file mode 100644 index 00000000..c7296853 --- /dev/null +++ b/examples/webflux/src/test/resources/application.yml @@ -0,0 +1,3 @@ +spring: + cache: + type: hazelcast \ No newline at end of file diff --git a/pom.xml b/pom.xml index a823a8c5..9fda8881 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ bucket4j-spring-boot-starter-context bucket4j-spring-boot-starter + examples/general-tests examples/caffeine examples/ehcache examples/hazelcast @@ -29,7 +30,7 @@ examples/redis-redisson --> examples/webflux-infinispan - + https://github.com/MarcGiffing/bucket4j-spring-boot-starter @@ -44,7 +45,7 @@ bucket4j-spring-boot-starter-parent-0.3.4 - 0.11.0 + 0.11.0-SNAPSHOT UTF-8 UTF-8 17