From 3837b8c1c64f53a494d0bef38043af4afc46cb9e Mon Sep 17 00:00:00 2001 From: Christoph Guse <117303690+mf-guse@users.noreply.github.com> Date: Thu, 17 Oct 2024 15:20:08 +0200 Subject: [PATCH 1/5] fixed redis-jedis example, added annotation rate limit to redis-jedis example, added Metrics to annotation rate limit --- .../starter/context/ExpressionParams.java | 8 ++- .../starter/context/properties/Metrics.java | 38 ++++++++++--- bucket4j-spring-boot-starter/pom.xml | 4 ++ .../config/aspect/Bucket4jAopConfig.java | 10 ++-- .../config/aspect/RateLimitAspect.java | 44 ++++++++------- ...ucket4JAutoConfigurationServletFilter.java | 17 +++++- .../actuator/Bucket4jMetricHandler.java | 12 +--- .../filter/servlet/IpHandlerInterceptor.java | 42 ++++++++++++++ .../filter/servlet/ServletRequestFilter.java | 14 ++--- .../boot/starter/utils/RequestUtils.java | 20 +++++++ examples/redis-jedis/README.adoc | 55 +++++++++++++++++++ .../spring/boot/starter/TestController.java | 9 ++- .../boot/starter/service/TestService.java | 7 +++ .../boot/starter/service/TestServiceImpl.java | 24 ++++++++ .../src/main/resources/application.yml | 20 ++++++- 15 files changed, 267 insertions(+), 57 deletions(-) create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java create mode 100644 bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java create mode 100644 examples/redis-jedis/README.adoc create mode 100644 examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java create mode 100644 examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java index 183ab4a2..e07c7447 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java @@ -12,17 +12,19 @@ * * @param the type of the root object which us used for the SpEl expression. */ +@Getter @RequiredArgsConstructor public class ExpressionParams { - @Getter + public static final String IP = "ip"; + private final R rootObject; - @Getter private final Map params = new HashMap<>(); - public void addParam(String name, Object value) { + public ExpressionParams addParam(String name, Object value) { params.put(name, value); + return this; } public ExpressionParams addParams(Map params) { diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java index 9dbff6dd..12ff987f 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java @@ -1,21 +1,41 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + import java.io.Serializable; import java.util.ArrayList; import java.util.Arrays; import java.util.List; -import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricType; - -import lombok.Data; - +@NoArgsConstructor +@AllArgsConstructor +@Builder @Data public class Metrics implements Serializable { - private boolean enabled = true; - - private List types = Arrays.asList(MetricType.values()); - - private List tags = new ArrayList<>(); + private boolean enabled = true; + + private List types = Arrays.asList(MetricType.values()); + + private List tags = new ArrayList<>(); + + public Metrics(List metricTags) { + + metricTags.forEach(tag -> { + this.tags.add(tag); + + tag.getTypes().forEach(type -> { + if(!types.contains(type)) { + types.add(type); + } + }); + }); + + } + } diff --git a/bucket4j-spring-boot-starter/pom.xml b/bucket4j-spring-boot-starter/pom.xml index 9fd21a76..5d2900b5 100644 --- a/bucket4j-spring-boot-starter/pom.xml +++ b/bucket4j-spring-boot-starter/pom.xml @@ -45,6 +45,10 @@ spring-cloud-starter-gateway provided + + org.springframework.boot + spring-boot-starter-aop + org.springframework.boot spring-boot-starter-cache diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/Bucket4jAopConfig.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/Bucket4jAopConfig.java index 3c0ac084..892061fd 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/Bucket4jAopConfig.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/Bucket4jAopConfig.java @@ -6,6 +6,7 @@ import com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator.SpringBootActuatorConfig; import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import org.aspectj.lang.annotation.Aspect; @@ -18,6 +19,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Import; +import java.util.List; + /** * Enables the support for the {@link RateLimiting} annotation to rate limit on method level. */ @@ -25,14 +28,13 @@ @ConditionalOnBucket4jEnabled @ConditionalOnClass(Aspect.class) @EnableConfigurationProperties({Bucket4JBootProperties.class}) -@AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) +@AutoConfigureAfter(value = {CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class}) @ConditionalOnBean(value = SyncCacheResolver.class) @Import(value = {ServiceConfiguration.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class}) public class Bucket4jAopConfig { @Bean - public RateLimitAspect rateLimitAspect(RateLimitService rateLimitService, Bucket4JBootProperties bucket4JBootProperties, SyncCacheResolver syncCacheResolver) { - return new RateLimitAspect(rateLimitService, bucket4JBootProperties.getMethods(), syncCacheResolver); + public RateLimitAspect rateLimitAspect(RateLimitService rateLimitService, Bucket4JBootProperties bucket4JBootProperties, SyncCacheResolver syncCacheResolver, List metricHandlers) { + return new RateLimitAspect(rateLimitService, bucket4JBootProperties, syncCacheResolver, metricHandlers); } - } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java index 319a9572..25a07446 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java @@ -2,7 +2,8 @@ import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; import com.giffing.bucket4j.spring.boot.starter.context.*; -import com.giffing.bucket4j.spring.boot.starter.context.properties.MethodProperties; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; import com.giffing.bucket4j.spring.boot.starter.context.properties.Metrics; import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; @@ -16,6 +17,8 @@ import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; import java.lang.reflect.Method; import java.util.Arrays; @@ -35,19 +38,21 @@ public class RateLimitAspect { private final RateLimitService rateLimitService; - private final List methodProperties; + private final Bucket4JBootProperties bucket4JBootProperties; private final SyncCacheResolver syncCacheResolver; - private Map> rateLimitConfigResults = new HashMap<>(); + private final List metricHandlers; + + private final Map> rateLimitConfigResults = new HashMap<>(); @PostConstruct public void init() { - for(var methodProperty : methodProperties) { + for (var methodProperty : bucket4JBootProperties.getMethods()) { var proxyManagerWrapper = syncCacheResolver.resolve(methodProperty.getCacheName()); var rateLimitConfig = RateLimitService.RateLimitConfig.builder() .rateLimits(List.of(methodProperty.getRateLimit())) - .metricHandlers(List.of()) + .metricHandlers(metricHandlers) .executePredicates(Map.of()) .cacheName(methodProperty.getCacheName()) .configVersion(0) @@ -55,7 +60,7 @@ public void init() { KeyFilter keyFilter = rateLimitService.getKeyFilter(sr.getRootObject().getName(), rl); return keyFilter.key(sr); }) - .metrics(new Metrics()) + .metrics(new Metrics(bucket4JBootProperties.getDefaultMetricTags())) .proxyWrapper(proxyManagerWrapper) .build(); var rateLimitConfigResult = rateLimitService.configureRateLimit(rateLimitConfig); @@ -64,14 +69,15 @@ public void init() { } @Pointcut("execution(public * *(..))") - public void publicMethod() {} + public void publicMethod() { + } @Pointcut("@annotation(com.giffing.bucket4j.spring.boot.starter.context.RateLimiting)") private void methodsAnnotatedWithRateLimitAnnotation() { } @Pointcut("@within(com.giffing.bucket4j.spring.boot.starter.context.RateLimiting) && publicMethod()") - private void classAnnotatedWithRateLimitAnnotation(){ + private void classAnnotatedWithRateLimitAnnotation() { } @@ -82,21 +88,21 @@ public Object processMethodsAnnotatedWithRateLimitAnnotation(ProceedingJoinPoint var ignoreRateLimitAnnotation = RateLimitAopUtils.getAnnotationFromMethodOrClass(method, IgnoreRateLimiting.class); // if the class or method is annotated with IgnoreRateLimiting we will skip rate limiting - if(ignoreRateLimitAnnotation != null){ + if (ignoreRateLimitAnnotation != null) { return joinPoint.proceed(); } var rateLimitAnnotation = RateLimitAopUtils.getAnnotationFromMethodOrClass(method, RateLimiting.class); Method fallbackMethod = null; - if(rateLimitAnnotation.fallbackMethodName() != null) { + if (rateLimitAnnotation.fallbackMethodName() != null) { var fallbackMethods = Arrays.stream(method.getDeclaringClass().getMethods()) .filter(p -> p.getName().equals(rateLimitAnnotation.fallbackMethodName())) .toList(); - if(fallbackMethods.size() > 1) { + if (fallbackMethods.size() > 1) { throw new IllegalStateException("Found " + fallbackMethods.size() + " fallbackMethods for " + rateLimitAnnotation.fallbackMethodName()); } - if(!fallbackMethods.isEmpty()) { + if (!fallbackMethods.isEmpty()) { fallbackMethod = joinPoint.getTarget().getClass().getMethod(rateLimitAnnotation.fallbackMethodName(), ((MethodSignature) joinPoint.getSignature()).getParameterTypes()); } } @@ -118,7 +124,7 @@ public Object processMethodsAnnotatedWithRateLimitAnnotation(ProceedingJoinPoint // no rate limit - execute the surrounding method methodResult = joinPoint.proceed(); performPostRateLimit(rateLimitConfigResult, method, methodResult); - } else if (fallbackMethod != null){ + } else if (fallbackMethod != null) { return fallbackMethod.invoke(joinPoint.getTarget(), joinPoint.getArgs()); } else { throw new RateLimitException(); @@ -128,7 +134,6 @@ public Object processMethodsAnnotatedWithRateLimitAnnotation(ProceedingJoinPoint } - private static void performPostRateLimit(RateLimitService.RateLimitConfigresult rateLimitConfigResult, Method method, Object methodResult) { for (var rlc : rateLimitConfigResult.getPostRateLimitChecks()) { var result = rlc.rateLimit(method, methodResult); @@ -142,7 +147,8 @@ private static RateLimitConsumedResult performRateLimit(RateLimitService.RateLim boolean allConsumed = true; Long remainingLimit = null; for (RateLimitCheck rl : rateLimitConfigResult.getRateLimitChecks()) { - var wrapper = rl.rateLimit(new ExpressionParams<>(method).addParams(params), annotationRateLimit); + + var wrapper = rl.rateLimit(new ExpressionParams<>(method).addParams(params).addParam(ExpressionParams.IP, RequestContextHolder.currentRequestAttributes().getAttribute(ExpressionParams.IP, RequestAttributes.SCOPE_REQUEST)), annotationRateLimit); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { @@ -153,7 +159,7 @@ private static RateLimitConsumedResult performRateLimit(RateLimitService.RateLim } } } - if(allConsumed) { + if (allConsumed) { log.debug("rate-limit-remaining;limit:{}", remainingLimit); } return new RateLimitConsumedResult(allConsumed, remainingLimit); @@ -175,14 +181,14 @@ private static RateLimit buildMainRateLimitConfiguration(RateLimiting rateLimitA } private void assertValidCacheName(RateLimiting rateLimitAnnotation) { - if(!rateLimitConfigResults.containsKey(rateLimitAnnotation.name())) { + if (!rateLimitConfigResults.containsKey(rateLimitAnnotation.name())) { throw new IllegalStateException("Could not find cache " + rateLimitAnnotation.name()); } } private static Map collectExpressionParameter(Object[] args, String[] parameterNames) { Map params = new HashMap<>(); - for (int i = 0; i< args.length; i++) { + for (int i = 0; i < args.length; i++) { log.debug("expresion-params;name:{};arg:{}", parameterNames[i], args[i]); params.put(parameterNames[i], args[i]); } @@ -190,6 +196,4 @@ private static Map collectExpressionParameter(Object[] args, Str } - - } 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 351dc3c6..aa2837c7 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 @@ -15,6 +15,7 @@ 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.IpHandlerInterceptor; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRequestFilter; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import jakarta.servlet.Filter; @@ -35,6 +36,8 @@ import org.springframework.context.annotation.Import; import org.springframework.context.support.GenericApplicationContext; import org.springframework.util.StringUtils; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -54,7 +57,7 @@ @Import(value = { ServiceConfiguration.class, ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class }) @Slf4j public class Bucket4JAutoConfigurationServletFilter extends Bucket4JBaseConfiguration - implements WebServerFactoryCustomizer { + implements WebServerFactoryCustomizer, WebMvcConfigurer { private final Bucket4JBootProperties properties; @@ -122,4 +125,16 @@ public void onCacheUpdateEvent(CacheUpdateEvent e } } } + + /** + * Add Spring MVC lifecycle interceptors for pre- and post-processing of + * controller method invocations and resource handler requests. + * Interceptors can be registered to apply to all requests or be limited + * to a subset of URL patterns. + * + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new IpHandlerInterceptor()); + } } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jMetricHandler.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jMetricHandler.java index f309b1ad..df58d2f2 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jMetricHandler.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/metrics/actuator/Bucket4jMetricHandler.java @@ -1,19 +1,13 @@ package com.giffing.bucket4j.spring.boot.starter.config.metrics.actuator; -import java.util.ArrayList; -import java.util.List; - -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Component; - 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 io.micrometer.core.instrument.Metrics; -@Component -@Primary +import java.util.ArrayList; +import java.util.List; + public class Bucket4jMetricHandler implements MetricHandler { public static final String METRIC_COUNTER_PREFIX = "bucket4j_summary_"; diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java new file mode 100644 index 00000000..3bd2eeb1 --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java @@ -0,0 +1,42 @@ +package com.giffing.bucket4j.spring.boot.starter.filter.servlet; + +import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; +import com.giffing.bucket4j.spring.boot.starter.utils.RequestUtils; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.servlet.AsyncHandlerInterceptor; +import org.springframework.web.servlet.HandlerInterceptor; + +public class IpHandlerInterceptor implements HandlerInterceptor { + + /** + * Interception point before the execution of a handler. Called after + * HandlerMapping determined an appropriate handler object, but before + * HandlerAdapter invokes the handler. + *

DispatcherServlet processes a handler in an execution chain, consisting + * of any number of interceptors, with the handler itself at the end. + * With this method, each interceptor can decide to abort the execution chain, + * typically sending an HTTP error or writing a custom response. + *

Note: special considerations apply for asynchronous + * request processing. For more details see + * {@link AsyncHandlerInterceptor}. + *

The default implementation returns {@code true}. + * + * @param request current HTTP request + * @param response current HTTP response + * @param handler chosen handler to execute, for type and/or instance evaluation + * @return {@code true} if the execution chain should proceed with the + * next interceptor or the handler itself. Else, DispatcherServlet assumes + * that this interceptor has already dealt with the response itself. + * @throws Exception in case of errors + */ + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + + RequestContextHolder.currentRequestAttributes().setAttribute(ExpressionParams.IP, RequestUtils.getIpFromRequest(request), RequestAttributes.SCOPE_REQUEST); + + return true; + } +} 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 9e3aca93..eecdb4df 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,17 +1,17 @@ package com.giffing.bucket4j.spring.boot.starter.filter.servlet; import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; -import com.giffing.bucket4j.spring.boot.starter.context.RateLimitCheck; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitConditionMatchingStrategy; import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; -import com.giffing.bucket4j.spring.boot.starter.context.properties.RateLimit; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; +import com.giffing.bucket4j.spring.boot.starter.utils.RequestUtils; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.Setter; import lombok.extern.slf4j.Slf4j; import org.springframework.core.Ordered; import org.springframework.web.filter.OncePerRequestFilter; @@ -22,6 +22,7 @@ /** * Servlet {@link Filter} class to configure Bucket4j on each request. */ +@Setter @Slf4j public class ServletRequestFilter extends OncePerRequestFilter implements Ordered { @@ -31,10 +32,6 @@ public ServletRequestFilter(FilterConfiguration filterConfig) { - this.filterConfig = filterConfig; - } - @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return !request.getRequestURI().matches(filterConfig.getUrl()); @@ -46,7 +43,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse boolean allConsumed = true; Long remainingLimit = null; for (var rl : filterConfig.getRateLimitChecks()) { - var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); + var wrapper = rl.rateLimit(new ExpressionParams<>(request).addParam(ExpressionParams.IP, RequestUtils.getIpFromRequest(request)), null); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { @@ -71,7 +68,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse filterConfig.getPostRateLimitChecks() .forEach(rlc -> { var result = rlc.rateLimit(request, response); - if(result != null) { + if (result != null) { log.debug("post-rate-limit;remaining-tokens:{}", result.getRateLimitResult().getRemainingTokens()); } }); @@ -92,7 +89,6 @@ private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, } - @Override public int getOrder() { return filterConfig.getOrder(); diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java new file mode 100644 index 00000000..83e59593 --- /dev/null +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java @@ -0,0 +1,20 @@ +package com.giffing.bucket4j.spring.boot.starter.utils; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.util.StringUtils; + +public class RequestUtils { + + public static String getIpFromRequest(HttpServletRequest request) { + var ip = request.getHeader("x-forwarded-for"); + if (!StringUtils.hasText(ip)) { + ip = request.getHeader("X-Forwarded-For"); + } + if (!StringUtils.hasText(ip)) { + ip = request.getRemoteAddr(); + } + + return ip; + } + +} diff --git a/examples/redis-jedis/README.adoc b/examples/redis-jedis/README.adoc new file mode 100644 index 00000000..7a16097e --- /dev/null +++ b/examples/redis-jedis/README.adoc @@ -0,0 +1,55 @@ += Bucket4j redis-jedis example + +== Introduction + +This example can be locally executed to examine the jedis-redis implementation. + +This example contains rate limit settings as ServletFilter and as Annotation. + +To run the example locally you need: + +- JDK 17 +- docker + +== Start Redis / KeyDB + +Start a local KeyDB (compatible with Redis) in a terminal / shell with available docker. + +[source,bash] +---- +docker run -d -p 6379:6379 eqalpha/keydb +---- + +== Start RedisJedisApplication + +Just start RedisJedisApplication in your application. + +== URLs + +|=== +|Method|URL|Testcase + +|GET +|http://localhost:8080/hello +|RateLimit done by ServletFilter for filter1 + +|GET +|http://localhost:8080/world +|RateLimit done by ServletFilter for filter2 + +|GET +|http://localhost:8080/greeting/{name} +|RateLimit done by Annotation having fallback method + +|GET +|http://localhost:8080/actuator/metrics/bucket4j_summary_consumed +|metric for consumed and not blocked requests + +|GET +|http://localhost:8080/actuator/metrics/bucket4j_summary_rejected +|metric for rejected (blocked or fallback) requests + + +|=== + + diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index f1f67d06..a5c674d2 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -1,5 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter; +import com.giffing.bucket4j.spring.boot.starter.service.TestService; import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; import jakarta.annotation.Nullable; import jakarta.validation.Valid; @@ -19,8 +20,11 @@ public class TestController { private final CacheManager configCacheManager; - public TestController(@Nullable CacheManager configCacheManager){ + private final TestService testService; + + public TestController(@Nullable CacheManager configCacheManager, TestService testService) { this.configCacheManager = configCacheManager; + this.testService = testService; } @GetMapping("hello") @@ -33,6 +37,9 @@ public ResponseEntity world() { return ResponseEntity.ok("Hello World"); } + @GetMapping("greeting/{name}") + public String greeting(@PathVariable final String name) {return testService.greetings(name);} + /** * Example of how a filter configuration can be updated during runtime diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java new file mode 100644 index 00000000..ce58cb8a --- /dev/null +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java @@ -0,0 +1,7 @@ +package com.giffing.bucket4j.spring.boot.starter.service; + +public interface TestService { + + String greetings(String name); + +} diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java new file mode 100644 index 00000000..8d77c94a --- /dev/null +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java @@ -0,0 +1,24 @@ +package com.giffing.bucket4j.spring.boot.starter.service; + +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import org.springframework.stereotype.Component; + +@Component +public class TestServiceImpl implements TestService { + + @RateLimiting( + name = "method_test", + cacheKey = "#ip", + ratePerMethod = true, + fallbackMethodName = "greetingsFallback" + ) + @Override + public String greetings(String name) { + return "Hello " + name; + } + + @SuppressWarnings("unused") + public String greetingsFallback(String name) { + return "You are not welcome " + name; + } +} diff --git a/examples/redis-jedis/src/main/resources/application.yml b/examples/redis-jedis/src/main/resources/application.yml index 8785e2cc..ff72a906 100644 --- a/examples/redis-jedis/src/main/resources/application.yml +++ b/examples/redis-jedis/src/main/resources/application.yml @@ -2,6 +2,7 @@ debug: false logging: level: com.giffing.bucket4j: debug + redis.clients.jedis: trace management: endpoints: web: @@ -37,7 +38,24 @@ bucket4j: time: 10 unit: seconds refill-speed: interval - + methods: + - name: method_test + cache-name: buckets_test + rate-limit: + bandwidths: + - capacity: 5 + time: 30 + unit: seconds + refill-speed: interval + default-metric-tags: + - key: IP + expression: "#ip" + types: + - REJECTED_COUNTER + - CONSUMED_COUNTER + - PARKED_COUNTER + - INTERRUPTED_COUNTER + - DELAYED_COUNTER spring: main: allow-bean-definition-overriding: true From 5d952d9fadc3732d4c57758065bb485310ad968d Mon Sep 17 00:00:00 2001 From: Christoph Guse <117303690+mf-guse@users.noreply.github.com> Date: Fri, 18 Oct 2024 13:07:08 +0200 Subject: [PATCH 2/5] removed IP specific stuff from the context project, removed spring-boot-starter-aop dependency from main project, added defaultMethodMetricTags to Bucket4JBootProperties, adjusted jedis-redis example, improved documentation --- README.adoc | 201 +++++++++++++----- .../starter/context/ExpressionParams.java | 2 - .../properties/Bucket4JBootProperties.java | 17 +- .../starter/context/properties/Metrics.java | 12 +- bucket4j-spring-boot-starter/pom.xml | 5 +- .../config/aspect/RateLimitAspect.java | 7 +- ...ucket4JAutoConfigurationServletFilter.java | 161 +++++++------- .../filter/servlet/ServletRequestFilter.java | 3 +- examples/redis-jedis/pom.xml | 6 + ...iguraiton.java => JedisConfiguration.java} | 18 +- .../spring/boot/starter/TestController.java | 134 ++++++------ .../boot/starter/service/TestServiceImpl.java | 14 +- .../servlet/IpHandlerInterceptor.java | 8 +- .../boot/starter/servlet}/RequestUtils.java | 2 +- .../src/main/resources/application.yml | 12 +- .../servlet/JedisGreadyRefillSpeedTest.java | 4 +- .../servlet/JedisIntervalRefillSpeedTest.java | 4 +- .../servlet/JedisServletRateLimitTest.java | 4 +- 18 files changed, 366 insertions(+), 248 deletions(-) rename examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/{JedisConfiguraiton.java => JedisConfiguration.java} (58%) rename {bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter => examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter}/servlet/IpHandlerInterceptor.java (85%) rename {bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils => examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet}/RequestUtils.java (89%) diff --git a/README.adoc b/README.adoc index 987d71ae..1bdbb232 100644 --- a/README.adoc +++ b/README.adoc @@ -1,5 +1,3 @@ - - :url-repo: https://github.com/MarcGiffing/bucket4j-spring-boot-starter :url: https://github.com/MarcGiffing/bucket4j-spring-boot-starter/tree/master :url-examples: {url}/examples @@ -38,11 +36,11 @@ Project version overview: ** <> ** <> - [[introduction]] == Spring Boot Starter for Bucket4j -This project is a Spring Boot Starter for Bucket4j, allowing you to set access limits on your API effortlessly. Its key advantage lies in the configuration via properties or yaml files, eliminating the need for manual code authoring. +This project is a Spring Boot Starter for Bucket4j, allowing you to set access limits on your API effortlessly. +Its key advantage lies in the configuration via properties or yaml files, eliminating the need for manual code authoring. Here are some example use cases: @@ -61,11 +59,12 @@ The project offers several features, some utilizing Spring's Expression Language You have two options for rate limit configuration: adding a filter for incoming web requests or applying fine-grained control at the method level. - [[introduction_filter]] === Use Filter for rate limiting -Filters are customizable components designed to intercept incoming web requests, capable of rejecting requests to halt further processing. You can incorporate multiple filters for various URLs or opt to bypass rate limits entirely for authenticated users. When the limit is exceeded, the web request is aborted, and the client receives an HTTP Status 429 Too Many Requests error. +Filters are customizable components designed to intercept incoming web requests, capable of rejecting requests to halt further processing. +You can incorporate multiple filters for various URLs or opt to bypass rate limits entirely for authenticated users. +When the limit is exceeded, the web request is aborted, and the client receives an HTTP Status 429 Too Many Requests error. This projects supports the following filters: @@ -86,8 +85,14 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=intervall [[introduction_method]] === Use Annotations on methods for rate limiting -Utilizing the '@RateLimiting' annotation, AOP intercepts your method. This grants you comprehensive access to method parameters, empowering you to define the rate limit key or conditionally skip rate limiting with ease. +Utilizing the '@RateLimiting' annotation, AOP intercepts your method. +This grants you comprehensive access to method parameters, empowering you to define the rate limit key or conditionally skip rate limiting with ease. +==== Method Configuration + +Bucket configuration is done in application.properties or application.yaml. + +.application.properties [source,properties] ---- bucket4j.methods[0].name=not_an_admin # the name of the configuration for annotation reference @@ -96,8 +101,46 @@ bucket4j.methods[0].rate-limits[0].bandwidths[0].capacity=5 # refills 5 tokens e bucket4j.methods[0].rate-limits[0].bandwidths[0].time=10 bucket4j.methods[0].rate-limits[0].bandwidths[0].unit=seconds bucket4j.methods[0].rate-limits[0].bandwidths[0].refill-speed=intervall +bucket4j.default-method-metric-tags[0].key=IP +bucket4j.default-method-metric-tags[0].expression="@testServiceImpl.getRemoteAddr()" # reference to a bean method to fill the metric key +bucket4j.default-method-metric-tags[0].types[0]=REJECTED_COUNTER +bucket4j.default-method-metric-tags[0].types[1]=CONSUMED_COUNTER +bucket4j.default-method-metric-tags[0].types[2]=PARKED_COUNTER +bucket4j.default-method-metric-tags[0].types[3]=INTERRUPTED_COUNTER +bucket4j.default-method-metric-tags[0].types[4]=DELAYED_COUNTER +---- + +.application.yaml +[source,yaml] +---- +bucket4j: + methods: + - name: not_an_admin # the name of the configuration for annotation reference + cache-name: buckets # the name of the cache + rate-limit: + bandwidths: + - capacity: 5 # refills 5 tokens every 10 seconds (intervall) + time: 30 + unit: seconds + refill-speed: interval + default-method-metric-tags: + - key: IP + expression: "@testServiceImpl.getRemoteAddr()" # reference to a bean method to fill the metric key + types: + - REJECTED_COUNTER + - CONSUMED_COUNTER + - PARKED_COUNTER + - INTERRUPTED_COUNTER + - DELAYED_COUNTER ---- +The in this example configuration referenced testServiceImpl is not part of bucket4j-spring-boot-starter. +If you would like to have the IP as metric tag you need to implement you own mechanism for that. + +Working example for method annotation and IPs in metrics: {url-examples}/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/example/jedis-redis[jedis-redis Example project] + +==== Method annotation + [source,java] ---- @RateLimiting( @@ -125,8 +168,8 @@ bucket4j.methods[0].rate-limits[0].bandwidths[0].refill-speed=intervall } ---- -The '@RateLimiting' annotation on class level executes the rate limit on all public methods of the class. With '@IgnoreRateLimiting' you can ignore the rate limit at all on class level or for specific method on method level. - +The '@RateLimiting' annotation on class level executes the rate limit on all public methods of the class. +With '@IgnoreRateLimiting' you can ignore the rate limit at all on class level or for specific method on method level. [source,java] ---- @@ -147,6 +190,22 @@ public class TestService { } ---- +==== Method dependencies + +As the @RateLimiting mechanism uses AOP you need to ensure your spring-boot provides the necessary dependencies. + +Just add + +[source,xml] +---- + + org.springframework.boot + spring-boot-starter-aop + +---- + +to your project. + You can find some Configuration examples in the test project: {url-examples}/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/method[Examples] [[project_configuration]] @@ -155,7 +214,7 @@ You can find some Configuration examples in the test project: {url-examples}/gen [[bucket4j_complete_properties]] === General Bucket4j properties -[source, properties] +[source,properties] ---- bucket4j.enabled=true # enable/disable bucket4j support bucket4j.cache-to-use= # If you use multiple caching implementation in your project and you want to choose a specific one you can set the cache here (jcache, hazelcast, ignite, redis) @@ -168,7 +227,7 @@ bucket4j.default-metric-tags[0].types=REJECTED_COUNTER ==== Filter Bucket4j properties -[source, properties] +[source,properties] ---- bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. @@ -216,10 +275,10 @@ bucket4j.filters[0].metrics.tags[2].expression=@securityService.username() != nu ==== Refill Speed The refill speed defines the period of the regeneration of consumed tokens. -This starter supports two types of token regeneration. The refill speed can be set with the following -property: +This starter supports two types of token regeneration. +The refill speed can be set with the following property: -[source, properties] +[source,properties] ---- bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=greedy # [greedy,interval] ---- @@ -234,14 +293,17 @@ You can read more about the refill speed in the https://bucket4j.com/8.1.1/toc.h If multiple rate limits are defined the strategy defines how many of them should be executed. -[source, properties] +[source,properties] ---- bucket4j.filters[0].strategy=first # [first, all] ---- ===== first -The *first* is the default strategy. This the default strategy which only executes one rate limit configuration. If a rate limit configuration is skipped due to the provided condition. It does not count as an executed rate limit. +The *first* is the default strategy. +This the default strategy which only executes one rate limit configuration. +If a rate limit configuration is skipped due to the provided condition. +It does not count as an executed rate limit. ===== all @@ -250,21 +312,23 @@ The *all* strategy executes all rate limit independently. [[skip_execution_predicates]] ==== Skip and Execution Predicates (experimental) -Skip and Execution Predicates can be used to conditionally skip or execute the rate limiting. Each predicate has a unique name and a self-contained configuration. +Skip and Execution Predicates can be used to conditionally skip or execute the rate limiting. +Each predicate has a unique name and a self-contained configuration. The following section describes the build in Execution Predicates and how to use them. ===== Path Predicates The Path Predicate takes a list of path parameters where any of the paths must match. -See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java[PathPattern] for the available configuration options. Segments are not evaluated further. +See https://github.com/spring-projects/spring-framework/blob/main/spring-web/src/main/java/org/springframework/web/util/pattern/PathPattern.java[PathPattern] for the available configuration options. +Segments are not evaluated further. -[source, properties] +[source,properties] ---- bucket4j.filters[0].rate-limits[0].skip-predicates[0]=PATH=/hello,/world,/admin bucket4j.filters[0].rate-limits[0].execute-predicates[0]=PATH=/hello,/world,/admin ---- -Matches the paths '/hello', '/world' or '/admin'. +Matches the paths '/hello', '/world' or '/admin'. ===== Method Predicate @@ -274,6 +338,7 @@ The Method Predicate takes a list of method parameters where any of the methods bucket4j.filters[0].rate-limits[0].skip-predicates[0]=METHOD=GET,POST bucket4j.filters[0].rate-limits[0].execute-predicates[0]=METHOD=GET,POST ---- + Matches if the HTTP method is 'GET' or 'POST'. ===== Query Predicate @@ -284,6 +349,7 @@ The Query Predicate takes a single parameter to check for the existence of the q bucket4j.filters[0].rate-limits[0].skip-predicates[0]=QUERY=PARAM_1 bucket4j.filters[0].rate-limits[0].execute-predicates[0]=QUERY=PARAM_1 ---- + Matches if the query parameter 'PARAM_1' exists. ===== Header Predicate @@ -296,13 +362,14 @@ The Header Predicate takes to parameters. ---- bucket4j.filters[0].rate-limits[0].execute-predicates[0]=Content-Type,.*PDF.* ---- + Matches if the query parameter 'PARAM_1' exists. ===== Custom Predicate You can also define you own Execution Predicate: -[source, java] +[source,java] ---- @Component @Slf4j @@ -347,16 +414,19 @@ The configured URL which is used for filtering is added to the cache-key to prov You can read more about it https://github.com/MarcGiffing/bucket4j-spring-boot-starter/issues/19[here]. *Limiting based on IP-Address*: + [source] ---- getRemoteAddress() ---- *Limiting based on Username - If not logged in use IP-Address*: + [source] ---- @securityService.username()?: getRemoteAddr() ---- + [source,java] ---- /** @@ -379,9 +449,14 @@ public class SecurityService { [[post-execute-condition]] === Post Execution (Consume) Condition -If you define a post execution condition the available tokens are not consumed on a rate limit configuration execution. It will only estimate the remaining available tokens. Only if there are no tokens left the rate limit is applied by. If the request was proceeded by the application we can check the return value check if the token should be consumed. +If you define a post execution condition the available tokens are not consumed on a rate limit configuration execution. +It will only estimate the remaining available tokens. +Only if there are no tokens left the rate limit is applied by. +If the request was proceeded by the application we can check the return value check if the token should be consumed. -Example: You want to limit the rate only for unauthorized users. You can't consume the available token for the incoming request because you don't know if the user will be authenticated afterward. With the post execute condition you can check the HTTP response status code and only consume the token if it has the status Code 401 UNAUTHORIZED. +Example: You want to limit the rate only for unauthorized users. +You can't consume the available token for the incoming request because you don't know if the user will be authenticated afterward. +With the post execute condition you can check the HTTP response status code and only consume the token if it has the status Code 401 UNAUTHORIZED. image::src/main/doc/plantuml/post_execution_condition.png[] @@ -390,6 +465,7 @@ image::src/main/doc/plantuml/post_execution_condition.png[] [[dynamic_config_updates]] === Dynamically updating rate limits (experimental) + Sometimes it might be useful to modify filter configurations during runtime. In order to support this behaviour a cache-based configuration update system has been added. The following section describes what configurations are required to enable this feature. @@ -397,17 +473,23 @@ The following section describes what configurations are required to enable this ==== Properties ===== base properties + In order to dynamically update rate limits, it is required to enable caching for filter configurations. -[source, properties] + +[source,properties] ---- bucket4j.filter-config-caching-enabled=true #Enable/disable caching of filter configurations. bucket4j.filter-config-cache-name=filterConfigCache #The name of the cache where the configurations are stored. Defaults to 'filterConfigCache'. ---- ===== Filter properties + - When filter caching is enabled, it is mandatory to configure a unique id for every filter. -- Configurations are implicitly replaced based on a combination of the major and minor version. If changes are made to the configuration without increasing either of the version numbers, it is most likely that the changes will not be applied. Instead the cached configuration will be used. -[source, properties] +- Configurations are implicitly replaced based on a combination of the major and minor version. +If changes are made to the configuration without increasing either of the version numbers, it is most likely that the changes will not be applied. +Instead the cached configuration will be used. + +[source,properties] ---- bucket4j.filters[0].id=filter1 #The id of the filter. This should always be a unique string. bucket4j.filters[0].major-version=1 #[min = 1, max = 92 million] Major version number. @@ -415,16 +497,20 @@ bucket4j.filters[0].minor-version=1 #[min = 1, max = 99 billion] Minor version n ---- ===== RateLimit properties -For each ratelimit a tokens inheritance strategy can be configured. This strategy will determine how to handle existing rate limits when replacing a configuration. If no strategy is configured it will default to 'RESET'. + +For each ratelimit a tokens inheritance strategy can be configured. +This strategy will determine how to handle existing rate limits when replacing a configuration. +If no strategy is configured it will default to 'RESET'. Further explanation of the strategies can be found at https://bucket4j.com/8.1.1/toc.html#tokensinheritancestrategy-explanation[Bucket4J TokensInheritanceStrategy explanation] -[source, properties] +[source,properties] ---- bucket4j.filters[0].rate-limits[0].tokens-inheritance-strategy=RESET #[RESET, AS_IS, ADDITIVE, PROPORTIONALLY] ---- ===== Bandwidth properties + This property is only mandatory when *BOTH* of the following statements apply to your configuration. - The rate-limit uses a different TokensInheritanceStrategy than 'RESET' @@ -432,33 +518,41 @@ This property is only mandatory when *BOTH* of the following statements apply to This is required so Bucket4J knows how to map the current bandwidth tokens to the updated bandwidths. It is possible to configure id's when 'RESET' strategy is applied, but the id's should still be unique within the rate-limit then. -[source, properties] + +[source,properties] ---- bucket4j.filters[0].rate-limits[0].bandwidths[0].id=bandwidthId #The id of the bandwidth; Optional when the rate-limit only contains 1 bandwidth or when using tokensInheritanceStrategy.RESET. ---- ==== Example project + An example on how to dynamically update a filter can be found at: {url-examples}/caffeine[Caffeine example project]. Some important considerations: - This is an experimental feature and might be subject to changes. -- Configurations will be read from the cache during startup (when using a persistent cache). This means that putting corrupted configurations into the cache during runtime can cause the application to crash during startup. -- Most configuration errors can be prevented by using the Jakarta validator to validate updated configurations. In the example this is done by adding @Valid to the request body method parameter, but it is also possible to @Autowire the Validator and use it directly to validate the configuration. -- Some Filter properties are not intended to be modified during runtime. To simplify validating a configuration update the Bucket4JUtils.validateConfigurationUpdate method has been added. This method executes the following validations and will return a ResponseEntity: +- Configurations will be read from the cache during startup (when using a persistent cache). +This means that putting corrupted configurations into the cache during runtime can cause the application to crash during startup. +- Most configuration errors can be prevented by using the Jakarta validator to validate updated configurations. +In the example this is done by adding @Valid to the request body method parameter, but it is also possible to @Autowire the Validator and use it directly to validate the configuration. +- Some Filter properties are not intended to be modified during runtime. +To simplify validating a configuration update the Bucket4JUtils.validateConfigurationUpdate method has been added. +This method executes the following validations and will return a ResponseEntity: ** old configuration != null -> NOT_FOUND ** new configuration has a higher version than the old configuration -> BAD_REQUEST ** filterMethod not changed -> BAD_REQUEST ** filterOrder not changed -> BAD_REQUEST ** cacheName not changed -> BAD_REQUEST -- The configCacheManager currently does *not* contain validation in the setValue method. The configuration should be validated before calling the this method. - +- The configCacheManager currently does *not* contain validation in the setValue method. +The configuration should be validated before calling the this method. [[monitoring]] === Monitoring - Spring Boot Actuator -Spring Boot ships with a great support for collecting metrics. This project automatically provides metric information about the consumed and rejected buckets. You can extend these information with configurable https://micrometer.io/docs/concepts#_tag_naming[custom tags] like the username or the IP-Address which can then be evaluated in a monitoring system like prometheus/grafana. +Spring Boot ships with a great support for collecting metrics. +This project automatically provides metric information about the consumed and rejected buckets. +You can extend these information with configurable https://micrometer.io/docs/concepts#_tag_naming[custom tags] like the username or the IP-Address which can then be evaluated in a monitoring system like prometheus/grafana. [source,yml] ---- @@ -497,7 +591,8 @@ This section is meant to help you migrate your application to new version of thi ==== Spring Boot Starter Bucket4j 0.12 -* Removed deprecated 'bucket4j.filters[x].rate-limits[x].expression' property. Use 'bucket4j.filters[x].rate-limits[x].cache-key' instead. +* Removed deprecated 'bucket4j.filters[x].rate-limits[x].expression' property. +Use 'bucket4j.filters[x].rate-limits[x].cache-key' instead. * three new metric counter are added per default (PARKED, INTERRUPTED and DELAYED) ==== Spring Boot Starter Bucket4j 0.9 @@ -511,25 +606,23 @@ This section is meant to help you migrate your application to new version of thi ===== Compatibility to Java 8 -The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release -of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. -The project is switching to the dedicated artifacts which supports Java 8. You can read more about -it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. +The version 0.8 tries to be compatible with Java 8 as long as Bucket4j is supporting Java 8. With the release of Bucket4j 8.0.0 Bucket4j decided to migrate to Java 11 but provides dedicated artifacts for Java 8. +The project is switching to the dedicated artifacts which supports Java 8. You can read more about it https://github.com/bucket4j/bucket4j#java-compatibility-matrix[here]. ===== Rename property expression to cache-key The property *..rate-limits[0].expression* is renamed to *..rate-limits[0].cache-key*. An Exception is thrown on startup if the *expression* property is configured. -To ensure that the property is not filled falsely the property is marked with *@Null*. This change requires -a Bean Validation implementation. +To ensure that the property is not filled falsely the property is marked with *@Null*. +This change requires a Bean Validation implementation. ===== JSR 380 - Bean Validation implementation required To ensure that the Bucket4j property configuration is correct an Validation API implementation is required. You can add the Spring Boot Starter Validation which will automatically configures one. -[source, xml] +[source,xml] ---- org.springframework.boot @@ -539,12 +632,12 @@ You can add the Spring Boot Starter Validation which will automatically configur ===== Explicit Configuration of the Refill Speed - API Break -The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. You can choose between -a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. +The refill speed of the Buckets can now configured explicitly with the Enum RefillSpeed. +You can choose between a greedy or interval refill see the https://bucket4j.com/8.1.1/toc.html#refill[official documentation]. Before 0.8 the refill speed was configured implicitly by setting the fixed-refill-interval property explicit. -[source, properties] +[source,properties] ---- bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval=0 bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minutes @@ -552,7 +645,7 @@ bucket4j.filters[0].rate-limits[0].bandwidths[0].fixed-refill-interval-unit=minu These properties are removed and replaced by the following configuration: -[source, properties] +[source,properties] ---- bucket4j.filters[0].rate-limits[0].bandwidths[0].refill-speed=interval ---- @@ -604,13 +697,11 @@ The following list contains the Caching implementation which will be autoconfigu |=== -Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver -or the AsynchCacheResolver by yourself. +Instead of determine the Caching Provider by the Bucket4j Spring Boot Starter project you can implement the SynchCacheResolver or the AsynchCacheResolver by yourself. -You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting -it to an invalid value to disable all auto configurations. +You can enable the cache auto configuration explicitly by using the *cache-to-use* property name or setting it to an invalid value to disable all auto configurations. -[source, properties] +[source,properties] ---- bucket4j.cache-to-use=jcache # ---- @@ -647,8 +738,10 @@ bucket4j: unit: seconds ---- -Conditional filtering depending of anonymous or logged in user. Because the *bucket4j.filters[0].strategy* is *first* -you don't have to check in the second rate-limit that the user is logged in. Only the first one is executed. +Conditional filtering depending of anonymous or logged in user. +Because the *bucket4j.filters[0].strategy* is *first* +you don't have to check in the second rate-limit that the user is logged in. +Only the first one is executed. [source,yml] ---- diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java index e07c7447..370d2ebd 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/ExpressionParams.java @@ -16,8 +16,6 @@ @RequiredArgsConstructor public class ExpressionParams { - public static final String IP = "ip"; - private final R rootObject; private final Map params = new HashMap<>(); diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java index b63eb379..001a4d40 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Bucket4JBootProperties.java @@ -1,18 +1,16 @@ package com.giffing.bucket4j.spring.boot.starter.context.properties; -import java.util.ArrayList; -import java.util.List; - import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; - +import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; -import lombok.Data; +import java.util.ArrayList; +import java.util.List; /** * Holds all the relevant starter properties which can be configured with @@ -73,6 +71,15 @@ public boolean isValidFilterIds() { @Valid private List defaultMetricTags = new ArrayList<>(); + /** + * A list of default metric tags which should be applied to all methods. + * Additional configuration is necessary as the evaluation context for resolving + * tag expression is different from filters. + */ + @Valid + private List defaultMethodMetricTags = new ArrayList<>(); + + public static String getPropertyPrefix() { return PROPERTY_PREFIX; } diff --git a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java index 12ff987f..678d8d64 100644 --- a/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java +++ b/bucket4j-spring-boot-starter-context/src/main/java/com/giffing/bucket4j/spring/boot/starter/context/properties/Metrics.java @@ -10,6 +10,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; @NoArgsConstructor @AllArgsConstructor @@ -24,18 +25,13 @@ public class Metrics implements Serializable { private List tags = new ArrayList<>(); public Metrics(List metricTags) { - - metricTags.forEach(tag -> { + Optional.ofNullable(metricTags).ifPresent(tags -> tags.forEach(tag -> { this.tags.add(tag); - tag.getTypes().forEach(type -> { - if(!types.contains(type)) { + if (!types.contains(type)) { types.add(type); } }); - }); - + })); } - - } diff --git a/bucket4j-spring-boot-starter/pom.xml b/bucket4j-spring-boot-starter/pom.xml index 5d2900b5..d4ebf4ea 100644 --- a/bucket4j-spring-boot-starter/pom.xml +++ b/bucket4j-spring-boot-starter/pom.xml @@ -45,10 +45,7 @@ spring-cloud-starter-gateway provided - - org.springframework.boot - spring-boot-starter-aop - + org.springframework.boot spring-boot-starter-cache diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java index 25a07446..d420d821 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java +++ b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/aspect/RateLimitAspect.java @@ -17,8 +17,6 @@ import org.aspectj.lang.annotation.Pointcut; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; import java.lang.reflect.Method; import java.util.Arrays; @@ -60,7 +58,7 @@ public void init() { KeyFilter keyFilter = rateLimitService.getKeyFilter(sr.getRootObject().getName(), rl); return keyFilter.key(sr); }) - .metrics(new Metrics(bucket4JBootProperties.getDefaultMetricTags())) + .metrics(new Metrics(bucket4JBootProperties.getDefaultMethodMetricTags())) .proxyWrapper(proxyManagerWrapper) .build(); var rateLimitConfigResult = rateLimitService.configureRateLimit(rateLimitConfig); @@ -147,8 +145,7 @@ private static RateLimitConsumedResult performRateLimit(RateLimitService.RateLim boolean allConsumed = true; Long remainingLimit = null; for (RateLimitCheck rl : rateLimitConfigResult.getRateLimitChecks()) { - - var wrapper = rl.rateLimit(new ExpressionParams<>(method).addParams(params).addParam(ExpressionParams.IP, RequestContextHolder.currentRequestAttributes().getAttribute(ExpressionParams.IP, RequestAttributes.SCOPE_REQUEST)), annotationRateLimit); + var wrapper = rl.rateLimit(new ExpressionParams<>(method).addParams(params), annotationRateLimit); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { 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 aa2837c7..a6c73c4d 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 @@ -15,7 +15,6 @@ 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.IpHandlerInterceptor; import com.giffing.bucket4j.spring.boot.starter.filter.servlet.ServletRequestFilter; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; import jakarta.servlet.Filter; @@ -36,8 +35,6 @@ import org.springframework.context.annotation.Import; import org.springframework.context.support.GenericApplicationContext; import org.springframework.util.StringUtils; -import org.springframework.web.servlet.config.annotation.InterceptorRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -49,92 +46,82 @@ */ @Configuration @ConditionalOnBucket4jEnabled -@ConditionalOnClass({ Filter.class }) -@EnableConfigurationProperties({ Bucket4JBootProperties.class }) +@ConditionalOnClass({Filter.class}) +@EnableConfigurationProperties({Bucket4JBootProperties.class}) @AutoConfigureBefore(ServletWebServerFactoryAutoConfiguration.class) -@AutoConfigureAfter(value = { CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class }) +@AutoConfigureAfter(value = {CacheAutoConfiguration.class, Bucket4jCacheConfiguration.class}) @ConditionalOnBean(value = SyncCacheResolver.class) -@Import(value = { ServiceConfiguration.class, ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class }) +@Import(value = {ServiceConfiguration.class, ServletRequestExecutePredicateConfiguration.class, Bucket4JAutoConfigurationServletFilterBeans.class, Bucket4jCacheConfiguration.class, SpringBootActuatorConfig.class}) @Slf4j public class Bucket4JAutoConfigurationServletFilter extends Bucket4JBaseConfiguration - implements WebServerFactoryCustomizer, WebMvcConfigurer { - - private final Bucket4JBootProperties properties; - - private final GenericApplicationContext context; - - private final SyncCacheResolver cacheResolver; - - private final RateLimitService rateLimitService; - - private final Bucket4jConfigurationHolder servletConfigurationHolder; - - public Bucket4JAutoConfigurationServletFilter( - Bucket4JBootProperties properties, - GenericApplicationContext context, - SyncCacheResolver cacheResolver, - List metricHandlers, - List> executePredicates, - Bucket4jConfigurationHolder servletConfigurationHolder, - RateLimitService rateLimitService, - @Autowired(required = false) CacheManager configCacheManager) { - super(rateLimitService, configCacheManager, metricHandlers, executePredicates - .stream() - .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); - this.properties = properties; - this.context = context; - this.cacheResolver = cacheResolver; - this.rateLimitService = rateLimitService; - this.servletConfigurationHolder = servletConfigurationHolder; - } - - @Override - public void customize(ConfigurableServletWebServerFactory factory) { - var filterCount = new AtomicInteger(0); - properties - .getFilters() - .stream() - .filter(filter -> StringUtils.hasText(filter.getUrl()) && filter.getFilterMethod().equals(FilterMethod.SERVLET)) - .map(filter -> properties.isFilterConfigCachingEnabled() ? getOrUpdateConfigurationFromCache(filter) : filter) - .forEach(filter -> { - rateLimitService.addDefaultMetricTags(properties, filter); - filterCount.incrementAndGet(); - var filterConfig = buildFilterConfig(filter, cacheResolver.resolve(filter.getCacheName())); - - servletConfigurationHolder.addFilterConfiguration(filter); - - //Use either the filter id as bean name or the prefix + counter if no id is configured - var beanName = filter.getId() != null ? filter.getId() : ("bucket4JServletRequestFilter" + filterCount); - context.registerBean(beanName, Filter.class, () -> new ServletRequestFilter(filterConfig)); - - log.info("create-servlet-filter;{};{};{}", filterCount, filter.getCacheName(), filter.getUrl()); - }); - } - - @Override - public void onCacheUpdateEvent(CacheUpdateEvent event) { - //only handle servlet filter updates - Bucket4JConfiguration newConfig = event.getNewValue(); - if(newConfig.getFilterMethod().equals(FilterMethod.SERVLET)) { - try { - var filter = context.getBean(event.getKey(), ServletRequestFilter.class); - var newFilterConfig = buildFilterConfig(newConfig, cacheResolver.resolve(newConfig.getCacheName())); - filter.setFilterConfig(newFilterConfig); - } catch (Exception exception) { - log.warn("Failed to update Servlet Filter configuration. {}", exception.getMessage()); - } - } - } - - /** - * Add Spring MVC lifecycle interceptors for pre- and post-processing of - * controller method invocations and resource handler requests. - * Interceptors can be registered to apply to all requests or be limited - * to a subset of URL patterns. - * - */ - @Override - public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(new IpHandlerInterceptor()); - } + implements WebServerFactoryCustomizer { + + private final Bucket4JBootProperties properties; + + private final GenericApplicationContext context; + + private final SyncCacheResolver cacheResolver; + + private final RateLimitService rateLimitService; + + private final Bucket4jConfigurationHolder servletConfigurationHolder; + + public Bucket4JAutoConfigurationServletFilter( + Bucket4JBootProperties properties, + GenericApplicationContext context, + SyncCacheResolver cacheResolver, + List metricHandlers, + List> executePredicates, + Bucket4jConfigurationHolder servletConfigurationHolder, + RateLimitService rateLimitService, + @Autowired(required = false) CacheManager configCacheManager) { + super(rateLimitService, configCacheManager, metricHandlers, executePredicates + .stream() + .collect(Collectors.toMap(ExecutePredicate::name, Function.identity()))); + this.properties = properties; + this.context = context; + this.cacheResolver = cacheResolver; + this.rateLimitService = rateLimitService; + this.servletConfigurationHolder = servletConfigurationHolder; + } + + @Override + public void customize(ConfigurableServletWebServerFactory factory) { + var filterCount = new AtomicInteger(0); + properties + .getFilters() + .stream() + .filter(filter -> StringUtils.hasText(filter.getUrl()) && filter.getFilterMethod().equals(FilterMethod.SERVLET)) + .map(filter -> properties.isFilterConfigCachingEnabled() ? getOrUpdateConfigurationFromCache(filter) : filter) + .forEach(filter -> { + rateLimitService.addDefaultMetricTags(properties, filter); + filterCount.incrementAndGet(); + var filterConfig = buildFilterConfig(filter, cacheResolver.resolve(filter.getCacheName())); + + servletConfigurationHolder.addFilterConfiguration(filter); + + //Use either the filter id as bean name or the prefix + counter if no id is configured + var beanName = filter.getId() != null ? filter.getId() : ("bucket4JServletRequestFilter" + filterCount); + context.registerBean(beanName, Filter.class, () -> new ServletRequestFilter(filterConfig)); + + log.info("create-servlet-filter;{};{};{}", filterCount, filter.getCacheName(), filter.getUrl()); + }); + } + + @Override + public void onCacheUpdateEvent(CacheUpdateEvent event) { + //only handle servlet filter updates + Bucket4JConfiguration newConfig = event.getNewValue(); + if (newConfig.getFilterMethod().equals(FilterMethod.SERVLET)) { + try { + var filter = context.getBean(event.getKey(), ServletRequestFilter.class); + var newFilterConfig = buildFilterConfig(newConfig, cacheResolver.resolve(newConfig.getCacheName())); + filter.setFilterConfig(newFilterConfig); + } catch (Exception exception) { + log.warn("Failed to update Servlet Filter configuration. {}", exception.getMessage()); + } + } + } + + } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java index eecdb4df..3dff4037 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 @@ -5,7 +5,6 @@ import com.giffing.bucket4j.spring.boot.starter.context.RateLimitResult; import com.giffing.bucket4j.spring.boot.starter.context.properties.FilterConfiguration; import com.giffing.bucket4j.spring.boot.starter.service.RateLimitService; -import com.giffing.bucket4j.spring.boot.starter.utils.RequestUtils; import jakarta.servlet.Filter; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -43,7 +42,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse boolean allConsumed = true; Long remainingLimit = null; for (var rl : filterConfig.getRateLimitChecks()) { - var wrapper = rl.rateLimit(new ExpressionParams<>(request).addParam(ExpressionParams.IP, RequestUtils.getIpFromRequest(request)), null); + var wrapper = rl.rateLimit(new ExpressionParams<>(request), null); if (wrapper != null && wrapper.getRateLimitResult() != null) { var rateLimitResult = wrapper.getRateLimitResult(); if (rateLimitResult.isConsumed()) { diff --git a/examples/redis-jedis/pom.xml b/examples/redis-jedis/pom.xml index 16a9c9a7..cb53273c 100644 --- a/examples/redis-jedis/pom.xml +++ b/examples/redis-jedis/pom.xml @@ -47,6 +47,12 @@ jedis ${jedis.version} + + + org.springframework.boot + spring-boot-starter-aop + + org.projectlombok lombok diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguraiton.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguration.java similarity index 58% rename from examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguraiton.java rename to examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguration.java index 5e140658..43f5da52 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguraiton.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/JedisConfiguration.java @@ -1,20 +1,23 @@ package com.giffing.bucket4j.spring.boot.starter; +import com.giffing.bucket4j.spring.boot.starter.servlet.IpHandlerInterceptor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import redis.clients.jedis.JedisPool; import redis.clients.jedis.JedisPoolConfig; import java.time.Duration; @Configuration -public class JedisConfiguraiton { +public class JedisConfiguration implements WebMvcConfigurer { @Bean public JedisPool jedisPool(@Value("${spring.data.redis.port}") String port) { final JedisPoolConfig poolConfig = buildPoolConfig(); - return new JedisPool(poolConfig, "localhost", Integer.valueOf(port)); + return new JedisPool(poolConfig, "localhost", Integer.parseInt(port)); } private JedisPoolConfig buildPoolConfig() { @@ -31,4 +34,15 @@ private JedisPoolConfig buildPoolConfig() { poolConfig.setBlockWhenExhausted(true); return poolConfig; } + + /** + * Add Spring MVC lifecycle interceptors for pre- and post-processing of + * controller method invocations and resource handler requests. + * Interceptors can be registered to apply to all requests or be limited + * to a subset of URL patterns. + */ + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(new IpHandlerInterceptor()); + } } diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index a5c674d2..9ac84161 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -1,5 +1,7 @@ package com.giffing.bucket4j.spring.boot.starter; +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.service.TestService; import com.giffing.bucket4j.spring.boot.starter.utils.Bucket4JUtils; import jakarta.annotation.Nullable; @@ -10,74 +12,76 @@ import org.springframework.validation.BindingResult; import org.springframework.web.bind.annotation.*; -import com.giffing.bucket4j.spring.boot.starter.config.cache.CacheManager; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; - import java.util.List; @RestController public class TestController { - private final CacheManager configCacheManager; - - private final TestService testService; - - public TestController(@Nullable CacheManager configCacheManager, TestService testService) { - this.configCacheManager = configCacheManager; - this.testService = testService; - } - - @GetMapping("hello") - public ResponseEntity hello() { - return ResponseEntity.ok("Hello World"); - } - - @GetMapping("world") - public ResponseEntity world() { - return ResponseEntity.ok("Hello World"); - } - - @GetMapping("greeting/{name}") - public String greeting(@PathVariable final String name) {return testService.greetings(name);} - - - /** - * 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) {} + private final CacheManager configCacheManager; + + private final TestService testService; + + public TestController(@Nullable CacheManager configCacheManager, TestService testService) { + this.configCacheManager = configCacheManager; + this.testService = testService; + } + + @GetMapping("hello") + public ResponseEntity hello() { + return ResponseEntity.ok("Hello World"); + } + + @GetMapping("world") + public ResponseEntity world() { + return ResponseEntity.ok("Hello World"); + } + + @GetMapping("greeting/{name}") + public String greeting(@PathVariable final String name) { + return testService.greetings(name); + } + + + /** + * 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/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java index 8d77c94a..28511fd3 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java @@ -1,14 +1,17 @@ package com.giffing.bucket4j.spring.boot.starter.service; import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.servlet.IpHandlerInterceptor; import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; @Component public class TestServiceImpl implements TestService { @RateLimiting( name = "method_test", - cacheKey = "#ip", + cacheKey = "@testServiceImpl.getRemoteAddr()", ratePerMethod = true, fallbackMethodName = "greetingsFallback" ) @@ -21,4 +24,13 @@ public String greetings(String name) { public String greetingsFallback(String name) { return "You are not welcome " + name; } + + @SuppressWarnings("unused") + public String getRemoteAddr() { + try { + return (String) RequestContextHolder.currentRequestAttributes().getAttribute(IpHandlerInterceptor.IP, RequestAttributes.SCOPE_REQUEST); + } catch (IllegalStateException e) { + return "0.0.0.0"; + } + } } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java similarity index 85% rename from bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java rename to examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java index 3bd2eeb1..7f6a6d67 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/IpHandlerInterceptor.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java @@ -1,7 +1,5 @@ -package com.giffing.bucket4j.spring.boot.starter.filter.servlet; +package com.giffing.bucket4j.spring.boot.starter.servlet; -import com.giffing.bucket4j.spring.boot.starter.context.ExpressionParams; -import com.giffing.bucket4j.spring.boot.starter.utils.RequestUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import org.springframework.web.context.request.RequestAttributes; @@ -11,6 +9,8 @@ public class IpHandlerInterceptor implements HandlerInterceptor { + public static final String IP = "ip"; + /** * Interception point before the execution of a handler. Called after * HandlerMapping determined an appropriate handler object, but before @@ -35,7 +35,7 @@ public class IpHandlerInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { - RequestContextHolder.currentRequestAttributes().setAttribute(ExpressionParams.IP, RequestUtils.getIpFromRequest(request), RequestAttributes.SCOPE_REQUEST); + RequestContextHolder.currentRequestAttributes().setAttribute(IP, RequestUtils.getIpFromRequest(request), RequestAttributes.SCOPE_REQUEST); return true; } diff --git a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java similarity index 89% rename from bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java rename to examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java index 83e59593..b1c59c70 100644 --- a/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/utils/RequestUtils.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java @@ -1,4 +1,4 @@ -package com.giffing.bucket4j.spring.boot.starter.utils; +package com.giffing.bucket4j.spring.boot.starter.servlet; import jakarta.servlet.http.HttpServletRequest; import org.springframework.util.StringUtils; diff --git a/examples/redis-jedis/src/main/resources/application.yml b/examples/redis-jedis/src/main/resources/application.yml index ff72a906..f1d5fb2f 100644 --- a/examples/redis-jedis/src/main/resources/application.yml +++ b/examples/redis-jedis/src/main/resources/application.yml @@ -2,7 +2,6 @@ debug: false logging: level: com.giffing.bucket4j: debug - redis.clients.jedis: trace management: endpoints: web: @@ -49,7 +48,16 @@ bucket4j: refill-speed: interval default-metric-tags: - key: IP - expression: "#ip" + expression: "getRemoteAddr()" + types: + - REJECTED_COUNTER + - CONSUMED_COUNTER + - PARKED_COUNTER + - INTERRUPTED_COUNTER + - DELAYED_COUNTER + default-method-metric-tags: + - key: IP + expression: "@testServiceImpl.getRemoteAddr()" types: - REJECTED_COUNTER - CONSUMED_COUNTER 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 index 3881828a..e8ad675e 100644 --- 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 @@ -1,6 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; -import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguration; import org.springframework.context.annotation.Import; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -10,7 +10,7 @@ import org.testcontainers.utility.DockerImageName; @Testcontainers -@Import(JedisConfiguraiton.class) +@Import(JedisConfiguration.class) public class JedisGreadyRefillSpeedTest extends GreadyRefillSpeedTest { @Container 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 index cff988e0..dba6386d 100644 --- 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 @@ -1,6 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; -import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguration; import org.springframework.context.annotation.Import; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -10,7 +10,7 @@ import org.testcontainers.utility.DockerImageName; @Testcontainers -@Import(JedisConfiguraiton.class) +@Import(JedisConfiguration.class) public class JedisIntervalRefillSpeedTest extends IntervalRefillSpeedTest { @Container 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 index 850598df..8ba1823e 100644 --- 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 @@ -1,6 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; -import com.giffing.bucket4j.spring.boot.starter.JedisConfiguraiton; +import com.giffing.bucket4j.spring.boot.starter.JedisConfiguration; import org.springframework.context.annotation.Import; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; @@ -10,7 +10,7 @@ import org.testcontainers.utility.DockerImageName; @Testcontainers -@Import(JedisConfiguraiton.class) +@Import(JedisConfiguration.class) public class JedisServletRateLimitTest extends ServletRateLimitTest { @Container From 1b662baed92b04d3ea8977d0cf072bf3c9a0855a Mon Sep 17 00:00:00 2001 From: Christoph Guse <117303690+mf-guse@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:11:48 +0200 Subject: [PATCH 3/5] fixed XSS vulnerability, adjusted documentation --- examples/redis-jedis/README.adoc | 2 +- .../bucket4j/spring/boot/starter/TestController.java | 7 +++---- .../spring/boot/starter/service/TestService.java | 2 +- .../spring/boot/starter/service/TestServiceImpl.java | 10 ++++++---- .../redis-jedis/src/main/resources/application.yml | 2 +- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/examples/redis-jedis/README.adoc b/examples/redis-jedis/README.adoc index 7a16097e..85afc908 100644 --- a/examples/redis-jedis/README.adoc +++ b/examples/redis-jedis/README.adoc @@ -38,7 +38,7 @@ Just start RedisJedisApplication in your application. |RateLimit done by ServletFilter for filter2 |GET -|http://localhost:8080/greeting/{name} +|http://localhost:8080/greeting |RateLimit done by Annotation having fallback method |GET diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index 9ac84161..159d9a9b 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -36,9 +36,9 @@ public ResponseEntity world() { return ResponseEntity.ok("Hello World"); } - @GetMapping("greeting/{name}") - public String greeting(@PathVariable final String name) { - return testService.greetings(name); + @GetMapping("greeting/") + public String greeting() { + return testService.greetings(); } @@ -48,7 +48,6 @@ public String greeting(@PathVariable final String name) { * @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( diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java index ce58cb8a..bf613cba 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java @@ -2,6 +2,6 @@ public interface TestService { - String greetings(String name); + String greetings(); } diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java index 28511fd3..7fb7c528 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java @@ -9,6 +9,8 @@ @Component public class TestServiceImpl implements TestService { + private static final String name = "Horst"; + @RateLimiting( name = "method_test", cacheKey = "@testServiceImpl.getRemoteAddr()", @@ -16,13 +18,13 @@ public class TestServiceImpl implements TestService { fallbackMethodName = "greetingsFallback" ) @Override - public String greetings(String name) { - return "Hello " + name; + public String greetings() { + return String.format("Hello %s!", name); } @SuppressWarnings("unused") - public String greetingsFallback(String name) { - return "You are not welcome " + name; + public String greetingsFallback() { + return String.format("You are not welcome %s!", name); } @SuppressWarnings("unused") diff --git a/examples/redis-jedis/src/main/resources/application.yml b/examples/redis-jedis/src/main/resources/application.yml index f1d5fb2f..28af2ebf 100644 --- a/examples/redis-jedis/src/main/resources/application.yml +++ b/examples/redis-jedis/src/main/resources/application.yml @@ -39,7 +39,7 @@ bucket4j: refill-speed: interval methods: - name: method_test - cache-name: buckets_test + cache-name: greetings rate-limit: bandwidths: - capacity: 5 From c554d2ffb459d9a578f6d8bf87394e37a3450c7c Mon Sep 17 00:00:00 2001 From: Christoph Guse <117303690+mf-guse@users.noreply.github.com> Date: Mon, 21 Oct 2024 11:20:37 +0200 Subject: [PATCH 4/5] fixed XSS vulnerability, adjusted documentation --- .../giffing/bucket4j/spring/boot/starter/TestController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java index 159d9a9b..912a322a 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/TestController.java @@ -36,7 +36,7 @@ public ResponseEntity world() { return ResponseEntity.ok("Hello World"); } - @GetMapping("greeting/") + @GetMapping("greeting") public String greeting() { return testService.greetings(); } From 6465ae81086b034b392556093841c966f063e613 Mon Sep 17 00:00:00 2001 From: Christoph Guse <117303690+mf-guse@users.noreply.github.com> Date: Thu, 31 Oct 2024 15:52:53 +0100 Subject: [PATCH 5/5] added a very first version of bucket4j-spring-boot-starter-test supporting creating unit-tests for @RateLimited annotated methods --- bucket4j-spring-boot-starter-test/.gitignore | 9 + .../.mvn/wrapper/maven-wrapper.jar | Bin 0 -> 47610 bytes .../.mvn/wrapper/maven-wrapper.properties | 1 + bucket4j-spring-boot-starter-test/README.adoc | 54 +++++ bucket4j-spring-boot-starter-test/mvnw | 225 ++++++++++++++++++ bucket4j-spring-boot-starter-test/mvnw.cmd | 143 +++++++++++ bucket4j-spring-boot-starter-test/pom.xml | 65 +++++ .../test/aop/Bucket4JAnnotationTest.java | 35 +++ ...cket4JAnnotationTestAutoconfiguration.java | 37 +++ examples/redis-jedis/pom.xml | 5 + .../boot/starter/service/TestService.java | 34 ++- .../boot/starter/service/TestServiceImpl.java | 38 --- .../boot/starter/service/TestServiceTest.java | 27 +++ .../application-ratelimit.properties | 15 ++ pom.xml | 2 + 15 files changed, 650 insertions(+), 40 deletions(-) create mode 100644 bucket4j-spring-boot-starter-test/.gitignore create mode 100644 bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.jar create mode 100644 bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.properties create mode 100644 bucket4j-spring-boot-starter-test/README.adoc create mode 100644 bucket4j-spring-boot-starter-test/mvnw create mode 100644 bucket4j-spring-boot-starter-test/mvnw.cmd create mode 100644 bucket4j-spring-boot-starter-test/pom.xml create mode 100644 bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTest.java create mode 100644 bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTestAutoconfiguration.java delete mode 100644 examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java create mode 100644 examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceTest.java create mode 100644 examples/redis-jedis/src/test/resources/application-ratelimit.properties diff --git a/bucket4j-spring-boot-starter-test/.gitignore b/bucket4j-spring-boot-starter-test/.gitignore new file mode 100644 index 00000000..b4b5f2f8 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/.gitignore @@ -0,0 +1,9 @@ +/target/ +/.settings/ +.classpath +.project +.idea/ +*.iml +.factorypath +.apt_generated +.springBeans \ No newline at end of file diff --git a/bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.jar b/bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9cc84ea9b4d95453115d0c26488d6a78694e0bc6 GIT binary patch literal 47610 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 literal 0 HcmV?d00001 diff --git a/bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.properties b/bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 00000000..c3150437 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1 @@ +distributionUrl=https://repo1.maven.org/maven2/org/apache/maven/apache-maven/3.5.0/apache-maven-3.5.0-bin.zip diff --git a/bucket4j-spring-boot-starter-test/README.adoc b/bucket4j-spring-boot-starter-test/README.adoc new file mode 100644 index 00000000..5f1d004a --- /dev/null +++ b/bucket4j-spring-boot-starter-test/README.adoc @@ -0,0 +1,54 @@ += bucket4j-spring-boot-starter-test + +:toc: + +== Introduction + +Rate limiting should be tested by unit-tests. To ease the test setup bucket4j-spring-boot-starter-test was introduced. + +Just add the dependency to your project. + +.Maven dependency +[source,xml] +---- + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-test + ${project.version} + +---- + +== @Bucket4JAnnotationTest + +Use this annotation to test if the rate limiting is working as expected for your @RateLimited annotated methods. + +.@Bucket4JAnnotationTest example +[source,java] +---- +@Bucket4JAnnotationTest (1) +@TestPropertySource("classpath:application-ratelimit.properties") (2) +@ContextConfiguration(classes = TestService.class) (3) +class TestServiceTest { + + @Autowired + private TestService testService; + + @Test + void shouldUseFallbackMethod() { + + assertEquals("Hello Horst!", testService.greetings()); + assertEquals("You are not welcome Horst!", testService.greetings()); + + } + +} +---- +(1) Add the annotation to your unit test. By adding it a slim spring context (not a spring-boot context!!) is started. +A Caffeine in memory cache is started, bucket4j and the RateLimitAspect is initialized. + +(2) Define the location of the application.properties file which contains the bucket4j configuration. + +(3) Include your class with the @RateLimited annotated method. + +After doing these three steps you are ready to run you unit-test. You can find a working example in the redis-jedis example project. + diff --git a/bucket4j-spring-boot-starter-test/mvnw b/bucket4j-spring-boot-starter-test/mvnw new file mode 100644 index 00000000..5bf251c0 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/mvnw @@ -0,0 +1,225 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +echo $MAVEN_PROJECTBASEDIR +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/bucket4j-spring-boot-starter-test/mvnw.cmd b/bucket4j-spring-boot-starter-test/mvnw.cmd new file mode 100644 index 00000000..019bd74d --- /dev/null +++ b/bucket4j-spring-boot-starter-test/mvnw.cmd @@ -0,0 +1,143 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/bucket4j-spring-boot-starter-test/pom.xml b/bucket4j-spring-boot-starter-test/pom.xml new file mode 100644 index 00000000..465e0d54 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-parent + ${revision} + .. + + bucket4j-spring-boot-starter-test + jar + bucket4j-spring-boot-starter + Spring Boot Starter Test für Bucket4J + + UTF-8 + UTF-8 + 17 + + + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-context + + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + provided + + + org.projectlombok + lombok + provided + + + + org.springframework.boot + spring-boot-starter-test + + + + org.springframework + spring-context-support + + + + javax.cache + cache-api + + + com.github.ben-manes.caffeine + caffeine + + + com.github.ben-manes.caffeine + jcache + + + + diff --git a/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTest.java b/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTest.java new file mode 100644 index 00000000..a0214be9 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTest.java @@ -0,0 +1,35 @@ +package com.giffing.bucket4j.spring.boot.starter.test.aop; + +import com.giffing.bucket4j.spring.boot.starter.Bucket4jStartupCheckConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.aspect.Bucket4jAopConfig; +import com.giffing.bucket4j.spring.boot.starter.config.aspect.RateLimitAspect; +import com.giffing.bucket4j.spring.boot.starter.config.cache.jcache.JCacheBucket4jConfiguration; +import com.giffing.bucket4j.spring.boot.starter.config.service.ServiceConfiguration; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.EnableAspectJAutoProxy; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@ExtendWith(SpringExtension.class) +@EnableCaching +@EnableAspectJAutoProxy +@Import({ + CacheAutoConfiguration.class, + Bucket4jAopConfig.class, + Bucket4jStartupCheckConfiguration.class, + ServiceConfiguration.class, + JCacheBucket4jConfiguration.class, + Bucket4JAnnotationTestAutoconfiguration.class, + RateLimitAspect.class}) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +public @interface Bucket4JAnnotationTest { +} diff --git a/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTestAutoconfiguration.java b/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTestAutoconfiguration.java new file mode 100644 index 00000000..bcbb0dd4 --- /dev/null +++ b/bucket4j-spring-boot-starter-test/src/main/java/com/giffing/bucket4j/spring/boot/starter/test/aop/Bucket4JAnnotationTestAutoconfiguration.java @@ -0,0 +1,37 @@ +package com.giffing.bucket4j.spring.boot.starter.test.aop; + + +import com.giffing.bucket4j.spring.boot.starter.config.cache.SyncCacheResolver; +import com.giffing.bucket4j.spring.boot.starter.config.condition.ConditionalOnBucket4jEnabled; +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; + +import java.util.concurrent.TimeUnit; + +@ConditionalOnBucket4jEnabled +@ConditionalOnBean(value = SyncCacheResolver.class) +@TestConfiguration +public class Bucket4JAnnotationTestAutoconfiguration { + + @Bean + public CacheManager cacheManager(Caffeine caffeine) { + CaffeineCacheManager cacheManager = new + CaffeineCacheManager(); + cacheManager.setCaffeine(caffeine); + + return cacheManager; + } + + @Bean + Caffeine caffeineSpec() { + return Caffeine.newBuilder() + .initialCapacity(10) + .maximumSize(100) + .expireAfterAccess(2, TimeUnit.MINUTES) + .executor(Runnable::run); + } +} diff --git a/examples/redis-jedis/pom.xml b/examples/redis-jedis/pom.xml index cb53273c..18aa7606 100644 --- a/examples/redis-jedis/pom.xml +++ b/examples/redis-jedis/pom.xml @@ -80,6 +80,11 @@ ${project.version} test + + com.giffing.bucket4j.spring.boot.starter + bucket4j-spring-boot-starter-test + ${project.version} + org.junit.platform junit-platform-suite-engine diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java index bf613cba..ae696cc4 100644 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java @@ -1,7 +1,37 @@ package com.giffing.bucket4j.spring.boot.starter.service; -public interface TestService { +import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; +import com.giffing.bucket4j.spring.boot.starter.servlet.IpHandlerInterceptor; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestAttributes; +import org.springframework.web.context.request.RequestContextHolder; - String greetings(); +@Component +public class TestService { + private static final String name = "Horst"; + + @RateLimiting( + name = "method_test", + cacheKey = "@testService.getRemoteAddr()", + ratePerMethod = true, + fallbackMethodName = "greetingsFallback" + ) + public String greetings() { + return String.format("Hello %s!", name); + } + + @SuppressWarnings("unused") + public String greetingsFallback() { + return String.format("You are not welcome %s!", name); + } + + @SuppressWarnings("unused") + public String getRemoteAddr() { + try { + return (String) RequestContextHolder.currentRequestAttributes().getAttribute(IpHandlerInterceptor.IP, RequestAttributes.SCOPE_REQUEST); + } catch (IllegalStateException e) { + return "0.0.0.0"; + } + } } diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java deleted file mode 100644 index 7fb7c528..00000000 --- a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.giffing.bucket4j.spring.boot.starter.service; - -import com.giffing.bucket4j.spring.boot.starter.context.RateLimiting; -import com.giffing.bucket4j.spring.boot.starter.servlet.IpHandlerInterceptor; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestAttributes; -import org.springframework.web.context.request.RequestContextHolder; - -@Component -public class TestServiceImpl implements TestService { - - private static final String name = "Horst"; - - @RateLimiting( - name = "method_test", - cacheKey = "@testServiceImpl.getRemoteAddr()", - ratePerMethod = true, - fallbackMethodName = "greetingsFallback" - ) - @Override - public String greetings() { - return String.format("Hello %s!", name); - } - - @SuppressWarnings("unused") - public String greetingsFallback() { - return String.format("You are not welcome %s!", name); - } - - @SuppressWarnings("unused") - public String getRemoteAddr() { - try { - return (String) RequestContextHolder.currentRequestAttributes().getAttribute(IpHandlerInterceptor.IP, RequestAttributes.SCOPE_REQUEST); - } catch (IllegalStateException e) { - return "0.0.0.0"; - } - } -} diff --git a/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceTest.java b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceTest.java new file mode 100644 index 00000000..c4b6ffaa --- /dev/null +++ b/examples/redis-jedis/src/test/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceTest.java @@ -0,0 +1,27 @@ +package com.giffing.bucket4j.spring.boot.starter.service; + +import com.giffing.bucket4j.spring.boot.starter.test.aop.Bucket4JAnnotationTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@Bucket4JAnnotationTest +@TestPropertySource("classpath:application-ratelimit.properties") +@ContextConfiguration(classes = TestService.class) +class TestServiceTest { + + @Autowired + private TestService testService; + + @Test + void shouldUseFallbackMethod() { + + assertEquals("Hello Horst!", testService.greetings()); + assertEquals("You are not welcome Horst!", testService.greetings()); + + } + +} \ No newline at end of file diff --git a/examples/redis-jedis/src/test/resources/application-ratelimit.properties b/examples/redis-jedis/src/test/resources/application-ratelimit.properties new file mode 100644 index 00000000..f1bca3de --- /dev/null +++ b/examples/redis-jedis/src/test/resources/application-ratelimit.properties @@ -0,0 +1,15 @@ +spring.cache.cache-names=method_test_cache +spring.cache.caffeine.spec=maximumSize=1000000,expireAfterAccess=3600s +spring.cache.jcache.provider=com.github.benmanes.caffeine.jcache.spi.CaffeineCachingProvider + +# every cache is configured to be accessible only once per 10 minutes +bucket4j.enabled=true +bucket4j.filter-config-caching-enabled=false +bucket4j.cache-to-use=jcache +bucket4j.methods[0].name=method_test +bucket4j.methods[0].cache-name=method_test_cache +bucket4j.methods[0].rate-limit.bandwidths[0].initial-capacity=1 +bucket4j.methods[0].rate-limit.bandwidths[0].capacity=1 +bucket4j.methods[0].rate-limit.bandwidths[0].time=10 +bucket4j.methods[0].rate-limit.bandwidths[0].unit=minutes +bucket4j.methods[0].rate-limit.bandwidths[0].refill-speed=interval \ No newline at end of file diff --git a/pom.xml b/pom.xml index a2e3ad7b..4f1d5b14 100644 --- a/pom.xml +++ b/pom.xml @@ -18,6 +18,7 @@ bucket4j-spring-boot-starter-context bucket4j-spring-boot-starter + bucket4j-spring-boot-starter-test examples/general-tests examples/caffeine examples/ehcache @@ -155,6 +156,7 @@ bucket4j-spring-boot-starter-context bucket4j-spring-boot-starter + bucket4j-spring-boot-starter-test