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 183ab4a2..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 @@ -12,17 +12,17 @@ * * @param the type of the root object which us used for the SpEl expression. */ +@Getter @RequiredArgsConstructor public class ExpressionParams { - @Getter 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/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 9dbff6dd..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 @@ -1,21 +1,37 @@ 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 java.util.Optional; -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) { + Optional.ofNullable(metricTags).ifPresent(tags -> tags.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..d4ebf4ea 100644 --- a/bucket4j-spring-boot-starter/pom.xml +++ b/bucket4j-spring-boot-starter/pom.xml @@ -45,6 +45,7 @@ spring-cloud-starter-gateway provided + 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 0b68e6fd..521e9623 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; @@ -33,19 +34,21 @@ public class RateLimitAspect { private final RateLimitService rateLimitService; - private final List methodProperties; + private final Bucket4JBootProperties bucket4JBootProperties; private final SyncCacheResolver syncCacheResolver; + 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) @@ -53,7 +56,7 @@ public void init() { KeyFilter keyFilter = rateLimitService.getKeyFilter(sr.getRootObject().getName(), rl); return keyFilter.key(sr); }) - .metrics(new Metrics()) + .metrics(new Metrics(bucket4JBootProperties.getDefaultMethodMetricTags())) .proxyWrapper(proxyManagerWrapper) .build(); var rateLimitConfigResult = rateLimitService.configureRateLimit(rateLimitConfig); @@ -62,14 +65,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() { } @@ -80,21 +84,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()); } } @@ -116,7 +120,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(); @@ -126,7 +130,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); @@ -151,7 +154,7 @@ private static RateLimitConsumedResult performRateLimit(RateLimitService.RateLim } } } - if(allConsumed) { + if (allConsumed) { log.debug("rate-limit-remaining;limit:{}", remainingLimit); } return new RateLimitConsumedResult(allConsumed, remainingLimit); @@ -173,14 +176,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]); } @@ -188,6 +191,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..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 @@ -46,80 +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 { - - 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()); - } - } - } + 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/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/ServletRequestFilter.java b/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/filter/servlet/ServletRequestFilter.java index 9e3aca93..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 @@ -1,17 +1,16 @@ 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 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 +21,7 @@ /** * Servlet {@link Filter} class to configure Bucket4j on each request. */ +@Setter @Slf4j public class ServletRequestFilter extends OncePerRequestFilter implements Ordered { @@ -31,10 +31,6 @@ public ServletRequestFilter(FilterConfiguration filterConfig) { - this.filterConfig = filterConfig; - } - @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { return !request.getRequestURI().matches(filterConfig.getUrl()); @@ -71,7 +67,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 +88,6 @@ private void handleHttpResponseOnRateLimiting(HttpServletResponse httpResponse, } - @Override public int getOrder() { return filterConfig.getOrder(); diff --git a/examples/redis-jedis/README.adoc b/examples/redis-jedis/README.adoc new file mode 100644 index 00000000..85afc908 --- /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 +|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/pom.xml b/examples/redis-jedis/pom.xml index 0ee5a6b2..38b54d89 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 f1f67d06..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 @@ -1,5 +1,8 @@ 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; import jakarta.validation.Valid; @@ -9,68 +12,75 @@ 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; - - public TestController(@Nullable CacheManager configCacheManager){ - this.configCacheManager = configCacheManager; - } - - @GetMapping("hello") - public ResponseEntity hello() { - return ResponseEntity.ok("Hello World"); - } - - @GetMapping("world") - public ResponseEntity world() { - return ResponseEntity.ok("Hello World"); - } - - - /** - * Example of how a filter configuration can be updated during runtime - * @param filterId id of the filter to update - * @param newConfig the new filter configuration - * @param bindingResult the result of the Jakarta validation - * @return - */ - @PostMapping("filters/{filterId}") - public ResponseEntity updateConfig( - @PathVariable String filterId, - @RequestBody @Valid Bucket4JConfiguration newConfig, - BindingResult bindingResult) { - if(configCacheManager == null) return ResponseEntity.status(HttpStatus.FORBIDDEN).body("Dynamic updating is disabled"); - - //validate that the path id matches the body - if (!newConfig.getId().equals(filterId)) { - return ResponseEntity.badRequest().body("The id in the path does not match the id in the request body."); - } - - //validate that there are no errors by the Jakarta validation - if (bindingResult.hasErrors()) { - List errors = bindingResult.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).toList(); - return ResponseEntity.badRequest().body(new ValidationErrorResponse("Configuration validation failed", errors)); - } - - //retrieve the old config and validate that it can be replaced by the new config - Bucket4JConfiguration oldConfig = configCacheManager.getValue(filterId); - ResponseEntity validationResponse = Bucket4JUtils.validateConfigurationUpdate(oldConfig, newConfig); - if (validationResponse != null) { - return validationResponse; - } - - //insert the new config into the cache, so it will trigger the cacheUpdateListeners - configCacheManager.setValue(filterId, newConfig); - - return ResponseEntity.ok().build(); - } - - private record ValidationErrorResponse(String message, List errors) {} + 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") + public String greeting() { + return testService.greetings(); + } + + + /** + * 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 + */ + @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/TestService.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestService.java new file mode 100644 index 00000000..bf613cba --- /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(); + +} 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..7fb7c528 --- /dev/null +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/service/TestServiceImpl.java @@ -0,0 +1,38 @@ +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/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java new file mode 100644 index 00000000..7f6a6d67 --- /dev/null +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/IpHandlerInterceptor.java @@ -0,0 +1,42 @@ +package com.giffing.bucket4j.spring.boot.starter.servlet; + +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 { + + public static final String IP = "ip"; + + /** + * 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(IP, RequestUtils.getIpFromRequest(request), RequestAttributes.SCOPE_REQUEST); + + return true; + } +} diff --git a/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java new file mode 100644 index 00000000..b1c59c70 --- /dev/null +++ b/examples/redis-jedis/src/main/java/com/giffing/bucket4j/spring/boot/starter/servlet/RequestUtils.java @@ -0,0 +1,20 @@ +package com.giffing.bucket4j.spring.boot.starter.servlet; + +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/src/main/resources/application.yml b/examples/redis-jedis/src/main/resources/application.yml index 8785e2cc..28af2ebf 100644 --- a/examples/redis-jedis/src/main/resources/application.yml +++ b/examples/redis-jedis/src/main/resources/application.yml @@ -37,7 +37,33 @@ bucket4j: time: 10 unit: seconds refill-speed: interval - + methods: + - name: method_test + cache-name: greetings + rate-limit: + bandwidths: + - capacity: 5 + time: 30 + unit: seconds + refill-speed: interval + default-metric-tags: + - key: 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 + - PARKED_COUNTER + - INTERRUPTED_COUNTER + - DELAYED_COUNTER spring: main: allow-bean-definition-overriding: true 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