From 8c4426a4376603c70cafd8c6e1b55985be4e4ec5 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Sat, 14 Mar 2026 11:11:11 +0100 Subject: [PATCH 1/4] #245 Support Cache PostgreSQL --- cache/cache-postgresql/.gitignore | 9 + cache/cache-postgresql/README.md | 150 ++++++++++++ cache/cache-postgresql/pom.xml | 45 ++++ .../PostgreSQLBucket4jConfiguration.java | 43 ++++ .../postgresql/PostgreSQLCacheResolver.java | 42 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...iteTest.java => CaffeineGeneralSuite.java} | 4 +- .../examples/ehcache/EhcacheGeneralSuite.java | 2 + examples/general-tests/pom.xml | 5 - .../filter/servlet/ServletRateLimitTest.java | 157 +------------ .../servlet/ServletUpdateFilterCacheTest.java | 217 ++++++++++++++++++ .../servlet/ServletUpdateFilterTestSuite.java | 11 + .../hazelcast/HazelcastGeneralSuite.java | 4 +- examples/postgresql/.gitignore | 9 + examples/postgresql/pom.xml | 80 +++++++ .../postgresql/PostgreSQLApplication.java | 13 ++ .../examples/postgresql/TestController.java | 19 ++ .../src/main/resources/application.yml | 62 +++++ .../postgresql/src/main/resources/schema.sql | 6 + .../postgresql/PostgreSQLRateLimitSuite.java | 12 + pom.xml | 12 + 21 files changed, 743 insertions(+), 160 deletions(-) create mode 100644 cache/cache-postgresql/.gitignore create mode 100644 cache/cache-postgresql/README.md create mode 100644 cache/cache-postgresql/pom.xml create mode 100644 cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLBucket4jConfiguration.java create mode 100644 cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLCacheResolver.java create mode 100644 cache/cache-postgresql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports rename examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/{CaffeineGeneralSuiteTest.java => CaffeineGeneralSuite.java} (70%) create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterCacheTest.java create mode 100644 examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterTestSuite.java create mode 100644 examples/postgresql/.gitignore create mode 100644 examples/postgresql/pom.xml create mode 100644 examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLApplication.java create mode 100644 examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/TestController.java create mode 100644 examples/postgresql/src/main/resources/application.yml create mode 100644 examples/postgresql/src/main/resources/schema.sql create mode 100644 examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLRateLimitSuite.java diff --git a/cache/cache-postgresql/.gitignore b/cache/cache-postgresql/.gitignore new file mode 100644 index 00000000..b4b5f2f8 --- /dev/null +++ b/cache/cache-postgresql/.gitignore @@ -0,0 +1,9 @@ +/target/ +/.settings/ +.classpath +.project +.idea/ +*.iml +.factorypath +.apt_generated +.springBeans \ No newline at end of file diff --git a/cache/cache-postgresql/README.md b/cache/cache-postgresql/README.md new file mode 100644 index 00000000..ea2f1459 --- /dev/null +++ b/cache/cache-postgresql/README.md @@ -0,0 +1,150 @@ +# Bucket4j PostgreSQL Cache Module + +This module provides PostgreSQL database support for Bucket4j rate limiting with Spring Boot. + +## Overview + +The `cache-postgresql` module integrates Bucket4j with PostgreSQL, allowing you to store rate limit data in a PostgreSQL relational database. This is useful for distributed systems that already use PostgreSQL and want to leverage it for centralized rate limiting. + +## Features + +- **Synchronous Cache Access**: Provides synchronous rate limit token bucket operations +- **JDBC-based**: Uses Bucket4j's JDBC proxy manager for PostgreSQL connectivity +- **Spring Boot Auto-Configuration**: Automatic configuration when dependencies are present +- **Event Publishing**: Publishes cache update events to Spring's ApplicationEventPublisher +- **Configuration Caching**: Optional caching of Bucket4j configuration + +## Dependencies + +The module requires the following: + +- `spring-boot-starter-data-jpa`: For JPA support (optional) +- `postgresql`: PostgreSQL JDBC driver +- `bucket4j_jdk17-jdbc`: Bucket4j JDBC support + +## Configuration + +To use the PostgreSQL cache module, add the following to your `application.properties` or `application.yml`: + +```properties +bucket4j.enabled=true +bucket4j.cache-type=postgresql +``` + +### Database Setup + +Before using the PostgreSQL cache, you need to create the necessary table for storing bucket tokens. Bucket4j uses a standard schema for JDBC storage. + +Create the table with the following SQL: + +```sql +CREATE TABLE IF NOT EXISTS bucket ( + id VARCHAR(20) PRIMARY KEY, + state BYTEA, + expires_at BIGINT, + explicit_lock BIGINT); + +CREATE INDEX IF NOT EXISTS idx_bucket4j_id ON bucket(id); +``` + +## Components + +### PostgreSQLCacheResolver + +Implements `SyncCacheResolver` and uses Bucket4j's `JdbcProxyManager` to manage rate limit buckets through JDBC connections. + +**Key Features:** +- Synchronous access to rate limit tokens +- Automatic connection pooling through DataSource +- Direct integration with PostgreSQL via JDBC + +### PostgreSQLCacheManager + +Implements `CacheManager` for managing cache entries in the PostgreSQL database. + +**Key Methods:** +- `getValue(K key)`: Retrieves cached values from the database +- `setValue(K key, V value)`: Stores or updates values in the database using PostgreSQL's `ON CONFLICT` clause + +### PostgreSQLCacheListener + +Listens to cache updates and publishes `CacheUpdateEvent` to the Spring ApplicationEventPublisher. + +### PostgreSQLBucket4jConfiguration + +Spring Boot auto-configuration class that: +- Checks if Bucket4j is enabled +- Validates DataSource availability +- Registers the `PostgreSQLCacheResolver` bean +- Optionally registers configuration cache manager +- Registers cache listener for event publishing + +## Usage Example + +```java +@RestController +@RequestMapping("/api") +public class MyController { + + @GetMapping("/data") + @Bucket4j(bucketName = "main", capacityDescription = "10 requests per minute") + public ResponseEntity getData() { + return ResponseEntity.ok("Hello World"); + } +} +``` + +## Configuration Properties + +The following properties can be configured in `application.properties`: + +```properties +# Enable Bucket4j +bucket4j.enabled=true + +# Set cache type to PostgreSQL +bucket4j.cache-type=postgresql + +# Optional: Cache configuration in PostgreSQL +bucket4j.filter-config-cache-enabled=true + +# DataSource configuration (Spring Boot standard) +spring.datasource.url=jdbc:postgresql://localhost:5432/mydb +spring.datasource.username=postgres +spring.datasource.password=password +spring.datasource.driver-class-name=org.postgresql.Driver +``` + +## Performance Considerations + +1. **Connection Pooling**: Ensure proper HikariCP (or other connection pool) configuration for optimal performance +2. **Table Indexing**: The `idx_bucket4j_tokens_id` index improves lookup performance +3. **Network Latency**: PostgreSQL-based rate limiting incurs network round-trip time, making it slower than in-memory solutions +4. **Distributed Systems**: Best suited for distributed systems where a centralized database is already in use + +## Advantages + +- **Centralized Rate Limiting**: All instances share the same rate limit state +- **Data Persistence**: Rate limit data survives application restarts +- **Simplicity**: Leverages existing PostgreSQL infrastructure +- **Scalability**: Works well in containerized and cloud environments + +## Limitations + +- **Performance**: Slower than in-memory cache solutions due to database I/O +- **Synchronous Only**: This module only provides synchronous cache access +- **Database Dependency**: Requires PostgreSQL to be available and operational + +## Related Modules + +- `cache-jcache`: JCache (JSR-107) implementation +- `cache-redis-lettuce`: Redis Lettuce driver support +- `cache-redis-jedis`: Redis Jedis driver support +- `cache-redis-redisson`: Redis Redisson driver support +- `cache-hazelcast`: Hazelcast distributed cache support +- `cache-infinispan`: Infinispan cache support + +## License + +Apache License 2.0 + diff --git a/cache/cache-postgresql/pom.xml b/cache/cache-postgresql/pom.xml new file mode 100644 index 00000000..68e59551 --- /dev/null +++ b/cache/cache-postgresql/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + com.giffing.bucket4j.spring.boot.starter + parent + ${revision} + ../../pom.xml + + + cache-postgresql + + + UTF-8 + + + + + com.giffing.bucket4j.spring.boot.starter + starter-autoconfigure + + + com.bucket4j + bucket4j_jdk17-postgresql + + + org.springframework.boot + spring-boot-starter-data-jpa + test + + + org.postgresql + postgresql + + + org.projectlombok + lombok + provided + + + + + diff --git a/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLBucket4jConfiguration.java b/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLBucket4jConfiguration.java new file mode 100644 index 00000000..09d578d0 --- /dev/null +++ b/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLBucket4jConfiguration.java @@ -0,0 +1,43 @@ +package com.giffing.bucket4j.spring.boot.starter.cache.postgresql; + +import com.giffing.bucket4j.spring.boot.starter.autoconfigure.conditional.ConditionalOnBucket4jEnabled; +import com.giffing.bucket4j.spring.boot.starter.autoconfigure.conditional.ConditionalOnCache; +import com.giffing.bucket4j.spring.boot.starter.autoconfigure.conditional.ConditionalOnSynchronousPropertyCondition; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.core.cache.SyncCacheResolver; +import io.github.bucket4j.postgresql.Bucket4jPostgreSQL; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigureOrder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.core.Ordered; + +import javax.sql.DataSource; + +@AutoConfiguration +@AutoConfigureOrder(Ordered.HIGHEST_PRECEDENCE) +@ConditionalOnBucket4jEnabled +@ConditionalOnSynchronousPropertyCondition +@ConditionalOnClass(Bucket4jPostgreSQL.class) +@ConditionalOnCache("postgresql") +@EnableConfigurationProperties({Bucket4JBootProperties.class}) +public class PostgreSQLBucket4jConfiguration { + + private final DataSource dataSource; + + public PostgreSQLBucket4jConfiguration(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Bean + @ConditionalOnMissingBean(SyncCacheResolver.class) + public SyncCacheResolver bucket4jCacheResolver() { + return new PostgreSQLCacheResolver(dataSource); + } + +} + + + diff --git a/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLCacheResolver.java b/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLCacheResolver.java new file mode 100644 index 00000000..3e149f73 --- /dev/null +++ b/cache/cache-postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/cache/postgresql/PostgreSQLCacheResolver.java @@ -0,0 +1,42 @@ +package com.giffing.bucket4j.spring.boot.starter.cache.postgresql; + +import com.giffing.bucket4j.spring.boot.starter.core.cache.AbstractCacheResolverTemplate; +import com.giffing.bucket4j.spring.boot.starter.core.cache.CacheResolver; +import com.giffing.bucket4j.spring.boot.starter.core.cache.SyncCacheResolver; +import io.github.bucket4j.distributed.jdbc.PrimaryKeyMapper; +import io.github.bucket4j.distributed.proxy.AbstractProxyManager; +import io.github.bucket4j.distributed.proxy.ProxyManager; +import io.github.bucket4j.postgresql.Bucket4jPostgreSQL; + +import javax.sql.DataSource; + +/** + * This class is the PostgreSQL (JDBC) implementation of the {@link CacheResolver}. + * It uses Bucket4Js {@link io.github.bucket4j.postgresql.PostgreSQLadvisoryLockBasedProxyManager} to implement the {@link ProxyManager}. + */ +public class PostgreSQLCacheResolver extends AbstractCacheResolverTemplate implements SyncCacheResolver { + + private final DataSource dataSource; + + public PostgreSQLCacheResolver(DataSource dataSource) { + this.dataSource = dataSource; + } + + @Override + public String castStringToCacheKey(String key) { + return key; + } + + @Override + public boolean isAsync() { + return false; + } + + @Override + public AbstractProxyManager getProxyManager(String cacheName) { + return Bucket4jPostgreSQL.selectForUpdateBasedBuilder(dataSource) + .primaryKeyMapper(PrimaryKeyMapper.STRING) + .build(); + } +} + diff --git a/cache/cache-postgresql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/cache/cache-postgresql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 00000000..38d46a5e --- /dev/null +++ b/cache/cache-postgresql/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +com.giffing.bucket4j.spring.boot.starter.cache.postgresql.PostgreSQLBucket4jConfiguration \ No newline at end of file diff --git a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java b/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuite.java similarity index 70% rename from examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java rename to examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuite.java index 0d4e6828..3f6c448f 100644 --- a/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuiteTest.java +++ b/examples/caffeine/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/caffeine/CaffeineGeneralSuite.java @@ -1,5 +1,6 @@ package com.giffing.bucket4j.spring.boot.starter.examples.caffeine; +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletUpdateFilterTestSuite; import com.giffing.bucket4j.spring.boot.starter.general.tests.method.method.MethodTestSuite; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; import org.junit.platform.suite.api.SelectClasses; @@ -8,7 +9,8 @@ @Suite @SelectClasses({ ServletTestSuite.class, + ServletUpdateFilterTestSuite.class, MethodTestSuite.class }) -public class CaffeineGeneralSuiteTest { +public class CaffeineGeneralSuite { } diff --git a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuite.java b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuite.java index 0e39006f..ad2b5749 100644 --- a/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuite.java +++ b/examples/ehcache/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/ehcache/EhcacheGeneralSuite.java @@ -1,12 +1,14 @@ package com.giffing.bucket4j.spring.boot.starter.examples.ehcache; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletUpdateFilterTestSuite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @Suite @SelectClasses({ ServletTestSuite.class, + ServletUpdateFilterTestSuite.class, }) public class EhcacheGeneralSuite { } diff --git a/examples/general-tests/pom.xml b/examples/general-tests/pom.xml index 4714afac..1a4099ba 100644 --- a/examples/general-tests/pom.xml +++ b/examples/general-tests/pom.xml @@ -34,11 +34,6 @@ starter-autoconfigure provided - - com.giffing.bucket4j.spring.boot.starter - cache-jcache - provided - org.springframework.boot spring-boot-starter-webmvc diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java index 1174d264..3deb6970 100644 --- a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletRateLimitTest.java @@ -1,39 +1,28 @@ package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; -import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicateDefinition; -import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; -import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; import lombok.extern.slf4j.Slf4j; -import org.junit.jupiter.api.*; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.ResultActions; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.ObjectMapper; -import java.time.Duration; import java.util.Collections; import java.util.stream.IntStream; import static org.assertj.core.api.Assertions.fail; -import static org.awaitility.Awaitility.await; import static org.hamcrest.Matchers.containsString; -import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @SpringBootTest(properties = { "bucket4j.enabled=true", - "bucket4j.filter-config-cache-name=filterConfigCache", - "bucket4j.filter-config-caching-enabled=true", "bucket4j.filters[0].cache-name=buckets", "bucket4j.filters[0].id=filter1", "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", @@ -53,8 +42,6 @@ "bucket4j.filters[2].rate-limits[0].bandwidths[0].unit=seconds", "bucket4j.filters[2].rate-limits[0].post-execute-condition=getStatus() eq 401", "bucket4j.filters[2].url=^(/secure).*", - "bucket4j.filter-config-caching-enabled=true", - "bucket4j.filter-config-cache-name=filterConfigCache" }) @AutoConfigureMockMvc @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -62,19 +49,9 @@ @Slf4j public class ServletRateLimitTest { - public static final String NONEXISTENT_FILTER_ID = "nonexistent"; - - private static final String FILTER_ID = "filter1"; - @Autowired private MockMvc mockMvc; - @Autowired - private Bucket4JBootProperties properties; - - @Autowired - private ObjectMapper objectMapper; - @Test @Order(1) void helloTest() throws Exception { @@ -135,132 +112,6 @@ void worldTest() throws Exception { blockedWebRequestDueToRateLimit(url); } - @Test - @Order(1) - void invalidNonMatchingIdReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(NONEXISTENT_FILTER_ID, objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("The id in the path does not match the id in the request body."))); - } - - @Test - @Order(1) - void invalidNonExistingReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setId(NONEXISTENT_FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isNotFound()) - .andExpect(content().string(containsString("No filter with id 'nonexistent' could be found."))); - } - - @Test - @Order(1) - void invalidVersionReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string("The new configuration (100000000001) should have a higher version than the current configuration (100000000001).")); - } - - @Test - @Order(1) - void invalidMethodReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterMethod(FilterMethod.WEBFLUX); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterMethod of an existing filter."))); - } - - @Test - @Order(1) - void invalidOrderReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setFilterOrder(filter.getFilterOrder() + 1); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the filterOrder of an existing filter."))); - } - - @Test - @Order(1) - void invalidCacheNameReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMinorVersion(filter.getMinorVersion() + 1); - filter.setCacheName(NONEXISTENT_FILTER_ID); - updateFilterCache(filter) - .andExpect(status().isBadRequest()) - .andExpect(content().string(containsString("It is not possible to modify the cacheName of an existing filter."))); - } - - @Test - @Order(1) - void invalidPredicateReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-EXEC=TEST")); - filter.setMinorVersion(filter.getMinorVersion() + 1); - updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate name: INVALID-EXEC")); - } - - @Test - @Order(1) - void invalidPredicatesReplaceConfigTest() throws Exception { - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-EXEC=TEST")); - filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-SKIP=TEST")); - filter.setMinorVersion(filter.getMinorVersion() + 1); - updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.message").value("Configuration validation failed")) - .andExpect(jsonPath("$.errors.length()").value(1)) - .andExpect(jsonPath("$.errors[0]").value("Invalid predicate names: INVALID-EXEC, INVALID-SKIP")); - } - - @Test - @Order(2) - void replaceConfigTest() throws Exception { - String url = "/hello"; - int newFilterCapacity = 1000; - - Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); - filter.setMajorVersion(filter.getMajorVersion() + 1); - filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); - - updateFilterCache(filter) - .andExpect(status().isOk()); - - // Allow the cacheUpdateListeners to update the filter configuration - await().atMost(Duration.ofSeconds(1)) - .untilAsserted(() -> successfulWebRequest(url, newFilterCapacity - 1, HttpStatus.OK)); - } - - private Bucket4JConfiguration getFilterConfigClone(String id) throws JacksonException { - Bucket4JConfiguration config = properties.getFilters() - .stream() - .filter(x -> id.matches(x.getId())).findFirst().orElse(null); - assertNotNull(config); - //returns a clone to prevent modifying the original in the properties - return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); - } - - private ResultActions updateFilterCache(Bucket4JConfiguration filter) throws Exception { - return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); - } - - private ResultActions updateFilterCache(String filterId, String content) throws Exception { - return this.mockMvc - .perform(post("/filters/".concat(filterId)) - .contentType(MediaType.APPLICATION_JSON) - .content(content)); - } - private void successfulWebRequest(String url, Integer remainingTries, HttpStatus httpStatus) { try { this.mockMvc diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterCacheTest.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterCacheTest.java new file mode 100644 index 00000000..9a96d9ae --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterCacheTest.java @@ -0,0 +1,217 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import com.giffing.bucket4j.spring.boot.starter.context.ExecutePredicateDefinition; +import com.giffing.bucket4j.spring.boot.starter.context.FilterMethod; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JBootProperties; +import com.giffing.bucket4j.spring.boot.starter.context.properties.Bucket4JConfiguration; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +import java.time.Duration; + +import static org.assertj.core.api.Assertions.fail; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(properties = { + "bucket4j.enabled=true", + "bucket4j.filter-config-cache-name=filterConfigCache", + "bucket4j.filter-config-caching-enabled=true", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].id=filter1", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[0].url=^(/hello).*", + "bucket4j.filters[1].cache-name=buckets", + "bucket4j.filters[1].id=filter2", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[1].url=^(/world).*", + "bucket4j.filters[2].cache-name=buckets", + "bucket4j.filters[2].id=filter3", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[2].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[2].rate-limits[0].post-execute-condition=getStatus() eq 401", + "bucket4j.filters[2].url=^(/secure).*", + "bucket4j.filter-config-caching-enabled=true", + "bucket4j.filter-config-cache-name=filterConfigCache" +}) +@AutoConfigureMockMvc +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@DirtiesContext +@Slf4j +public class ServletUpdateFilterCacheTest { + + public static final String NONEXISTENT_FILTER_ID = "nonexistent"; + + private static final String FILTER_ID = "filter1"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private Bucket4JBootProperties properties; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @Order(1) + void invalidNonMatchingIdReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + updateFilterCache(NONEXISTENT_FILTER_ID, objectMapper.writeValueAsString(filter)) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("The id in the path does not match the id in the request body."))); + } + + @Test + @Order(1) + void invalidNonExistingReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setId(NONEXISTENT_FILTER_ID); + updateFilterCache(filter) + .andExpect(status().isNotFound()) + .andExpect(content().string(containsString("No filter with id 'nonexistent' could be found."))); + } + + @Test + @Order(1) + void invalidVersionReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + updateFilterCache(filter) + .andExpect(status().isBadRequest()) + .andExpect(content().string("The new configuration (100000000001) should have a higher version than the current configuration (100000000001).")); + } + + @Test + @Order(1) + void invalidMethodReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setFilterMethod(FilterMethod.WEBFLUX); + updateFilterCache(filter) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("It is not possible to modify the filterMethod of an existing filter."))); + } + + @Test + @Order(1) + void invalidOrderReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setFilterOrder(filter.getFilterOrder() + 1); + updateFilterCache(filter) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("It is not possible to modify the filterOrder of an existing filter."))); + } + + @Test + @Order(1) + void invalidCacheNameReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMinorVersion(filter.getMinorVersion() + 1); + filter.setCacheName(NONEXISTENT_FILTER_ID); + updateFilterCache(filter) + .andExpect(status().isBadRequest()) + .andExpect(content().string(containsString("It is not possible to modify the cacheName of an existing filter."))); + } + + @Test + @Order(1) + void invalidPredicateReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-EXEC=TEST")); + filter.setMinorVersion(filter.getMinorVersion() + 1); + updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Configuration validation failed")) + .andExpect(jsonPath("$.errors.length()").value(1)) + .andExpect(jsonPath("$.errors[0]").value("Invalid predicate name: INVALID-EXEC")); + } + + @Test + @Order(1) + void invalidPredicatesReplaceConfigTest() throws Exception { + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-EXEC=TEST")); + filter.getRateLimits().get(0).getExecutePredicates().add(new ExecutePredicateDefinition("INVALID-SKIP=TEST")); + filter.setMinorVersion(filter.getMinorVersion() + 1); + updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("Configuration validation failed")) + .andExpect(jsonPath("$.errors.length()").value(1)) + .andExpect(jsonPath("$.errors[0]").value("Invalid predicate names: INVALID-EXEC, INVALID-SKIP")); + } + + @Test + @Order(2) + void replaceConfigTest() throws Exception { + String url = "/hello"; + int newFilterCapacity = 1000; + + Bucket4JConfiguration filter = getFilterConfigClone(FILTER_ID); + filter.setMajorVersion(filter.getMajorVersion() + 1); + filter.getRateLimits().forEach(rl -> rl.getBandwidths().forEach(bw -> bw.setCapacity(newFilterCapacity))); + + updateFilterCache(filter) + .andExpect(status().isOk()); + + // Allow the cacheUpdateListeners to update the filter configuration + await().atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> successfulWebRequest(url, newFilterCapacity - 1, HttpStatus.OK)); + } + + private ResultActions updateFilterCache(Bucket4JConfiguration filter) throws Exception { + return updateFilterCache(filter.getId(), objectMapper.writeValueAsString(filter)); + } + + private Bucket4JConfiguration getFilterConfigClone(String id) throws JacksonException { + Bucket4JConfiguration config = properties.getFilters() + .stream() + .filter(x -> id.matches(x.getId())).findFirst().orElse(null); + assertNotNull(config); + //returns a clone to prevent modifying the original in the properties + return objectMapper.readValue(objectMapper.writeValueAsString(config), Bucket4JConfiguration.class); + } + + private ResultActions updateFilterCache(String filterId, String content) throws Exception { + return this.mockMvc + .perform(post("/filters/".concat(filterId)) + .contentType(MediaType.APPLICATION_JSON) + .content(content)); + } + + private void successfulWebRequest(String url, Integer remainingTries, HttpStatus httpStatus) { + try { + this.mockMvc + .perform(get(url)) + .andExpect(status().is(httpStatus.value())) + .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) + .andExpect(content().string(containsString("Hello World"))); + } catch (Exception e) { + log.error(e.getMessage(), e); + fail(e.getMessage()); + } + } + +} diff --git a/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterTestSuite.java b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterTestSuite.java new file mode 100644 index 00000000..e636246d --- /dev/null +++ b/examples/general-tests/src/main/java/com/giffing/bucket4j/spring/boot/starter/general/tests/filter/servlet/ServletUpdateFilterTestSuite.java @@ -0,0 +1,11 @@ +package com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet; + +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + ServletUpdateFilterCacheTest.class +}) +public class ServletUpdateFilterTestSuite { +} diff --git a/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuite.java b/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuite.java index 28f14ff7..493d1728 100644 --- a/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuite.java +++ b/examples/hazelcast/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/hazelcast/HazelcastGeneralSuite.java @@ -1,12 +1,14 @@ package com.giffing.bucket4j.spring.boot.starter.examples.hazelcast; import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletUpdateFilterTestSuite; import org.junit.platform.suite.api.SelectClasses; import org.junit.platform.suite.api.Suite; @Suite @SelectClasses({ - ServletTestSuite.class + ServletTestSuite.class, + ServletUpdateFilterTestSuite.class, }) public class HazelcastGeneralSuite { } diff --git a/examples/postgresql/.gitignore b/examples/postgresql/.gitignore new file mode 100644 index 00000000..b4b5f2f8 --- /dev/null +++ b/examples/postgresql/.gitignore @@ -0,0 +1,9 @@ +/target/ +/.settings/ +.classpath +.project +.idea/ +*.iml +.factorypath +.apt_generated +.springBeans \ No newline at end of file diff --git a/examples/postgresql/pom.xml b/examples/postgresql/pom.xml new file mode 100644 index 00000000..34efe9fb --- /dev/null +++ b/examples/postgresql/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + com.giffing.bucket4j.spring.boot.starter + parent + ${revision} + ../../pom.xml + + + example-postgresql + + + 17 + 17 + UTF-8 + 1.21.4 + + + + + com.giffing.bucket4j.spring.boot.starter + cache-postgresql + + + com.giffing.bucket4j.spring.boot.starter + starter-servlet + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.projectlombok + lombok + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + org.springframework.boot + spring-boot-starter-cache-test + + + org.testcontainers + postgresql + ${testcontainers-postgresql.version} + test + + + org.awaitility + awaitility + test + + + org.springframework.boot + spring-boot-starter-test + test + + + com.giffing.bucket4j.spring.boot.starter + general-tests + ${project.version} + test + + + + \ No newline at end of file diff --git a/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLApplication.java b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLApplication.java new file mode 100644 index 00000000..3d71d294 --- /dev/null +++ b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLApplication.java @@ -0,0 +1,13 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PostgreSQLApplication { + + public static void main(String[] args) { + SpringApplication.run(PostgreSQLApplication.class, args); + } + +} diff --git a/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/TestController.java b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/TestController.java new file mode 100644 index 00000000..c0a830a3 --- /dev/null +++ b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/TestController.java @@ -0,0 +1,19 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class TestController { + + @GetMapping("/hello") + public ResponseEntity hello() { + return ResponseEntity.ok("Hello from PostgreSQL example!"); + } + + @GetMapping("/world") + public ResponseEntity world() { + return ResponseEntity.ok("World from PostgreSQL example!"); + } +} diff --git a/examples/postgresql/src/main/resources/application.yml b/examples/postgresql/src/main/resources/application.yml new file mode 100644 index 00000000..a3b7e4c6 --- /dev/null +++ b/examples/postgresql/src/main/resources/application.yml @@ -0,0 +1,62 @@ +debug: false +logging: + level: + com.giffing.bucket4j: debug +management: + endpoints: + web: + exposure: + include: "*" +bucket4j: + enabled: true + cache-to-use: postgresql + filters: + - cache-name: buckets_test + major-version: 2 + url: ^(/hello).* + id: filter1 + rate-limits: + - tokens-inheritance-strategy: reset + bandwidths: + - capacity: 5 + time: 10 + unit: seconds + refill-speed: interval + - cache-name: buckets_test + url: ^(/world).* + id: filter2 + rate-limits: + - tokens-inheritance-strategy: reset + bandwidths: + - capacity: 10 + 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 +spring: + datasource: + url: jdbc:tc:postgresql:15-alpine:///testdb + driver-class-name: org.testcontainers.jdbc.ContainerDatabaseDriver + username: test + password: test + show-sql: true + sql: + init: + mode: always # Ensures that schema.sql is executed to create the bucket4j_tokens table diff --git a/examples/postgresql/src/main/resources/schema.sql b/examples/postgresql/src/main/resources/schema.sql new file mode 100644 index 00000000..6ace9aed --- /dev/null +++ b/examples/postgresql/src/main/resources/schema.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS bucket ( + id VARCHAR(20) PRIMARY KEY, + state BYTEA, + expires_at BIGINT, + explicit_lock BIGINT); +CREATE INDEX IF NOT EXISTS idx_bucket4j_id ON bucket(id); \ No newline at end of file diff --git a/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLRateLimitSuite.java b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLRateLimitSuite.java new file mode 100644 index 00000000..d719e450 --- /dev/null +++ b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLRateLimitSuite.java @@ -0,0 +1,12 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import com.giffing.bucket4j.spring.boot.starter.general.tests.filter.servlet.ServletTestSuite; +import org.junit.platform.suite.api.SelectClasses; +import org.junit.platform.suite.api.Suite; + +@Suite +@SelectClasses({ + ServletTestSuite.class +}) +class PostgreSQLRateLimitSuite { +} diff --git a/pom.xml b/pom.xml index a60b268c..22be440d 100644 --- a/pom.xml +++ b/pom.xml @@ -28,6 +28,7 @@ cache/cache-jcache-infinispan cache/cache-infinispan cache/cache-ignite + cache/cache-postgresql starter-aop starter-servlet @@ -36,6 +37,7 @@ examples/general-tests examples/caffeine examples/ehcache + examples/postgresql examples/hazelcast examples/webflux examples/redis-jedis @@ -133,6 +135,11 @@ cache-jcache-infinispan ${project.version} + + com.giffing.bucket4j.spring.boot.starter + cache-postgresql + ${project.version} + com.giffing.bucket4j.spring.boot.starter @@ -189,6 +196,11 @@ bucket4j_jdk17-infinispan ${bucket4j.version} + + com.bucket4j + bucket4j_jdk17-postgresql + ${bucket4j.version} + From 59fd810eccb7a5cfd152daf0bf247364039dca44 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Sat, 14 Mar 2026 13:36:13 +0100 Subject: [PATCH 2/4] #245 Support Cache PostgreSQL --- examples/postgresql/pom.xml | 4 + .../examples/postgresql/BucketEntity.java | 34 +++++++++ .../examples/postgresql/BucketRepository.java | 8 ++ .../postgresql/DebugMetricHandler.java | 33 ++++++++ .../PostgreSQLServletRateLimitTest.java | 75 +++++++++++++++++++ 5 files changed, 154 insertions(+) create mode 100644 examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketEntity.java create mode 100644 examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketRepository.java create mode 100644 examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/DebugMetricHandler.java create mode 100644 examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java diff --git a/examples/postgresql/pom.xml b/examples/postgresql/pom.xml index 34efe9fb..e672d12c 100644 --- a/examples/postgresql/pom.xml +++ b/examples/postgresql/pom.xml @@ -40,6 +40,10 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-actuator + org.projectlombok lombok diff --git a/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketEntity.java b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketEntity.java new file mode 100644 index 00000000..6d56156f --- /dev/null +++ b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketEntity.java @@ -0,0 +1,34 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +/** + * Only for debug purposes + */ +@Entity(name = "bucket") +@ToString +@Getter +@Setter +public class BucketEntity { + + @Id + @GeneratedValue + @Column(name = "id", nullable = false) + private String key; + + @Column(name = "state") + private String state; + + @Column(name = "expires_at") + private Long expiresAt; + + @Column(name = "explicit_lock") + private Long explicitLock; + +} diff --git a/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketRepository.java b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketRepository.java new file mode 100644 index 00000000..a28cf158 --- /dev/null +++ b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/BucketRepository.java @@ -0,0 +1,8 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import org.springframework.data.repository.ListCrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface BucketRepository extends ListCrudRepository { +} diff --git a/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/DebugMetricHandler.java b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/DebugMetricHandler.java new file mode 100644 index 00000000..2362aeba --- /dev/null +++ b/examples/postgresql/src/main/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/DebugMetricHandler.java @@ -0,0 +1,33 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricHandler; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricTagResult; +import com.giffing.bucket4j.spring.boot.starter.context.metrics.MetricType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +@Slf4j +public class DebugMetricHandler implements MetricHandler { + + private final BucketRepository bucketRepository; + + @Override + public void handle(MetricType type, String name, long tokens, List tags) { + bucketRepository.findAll().forEach(b -> log.info(b.toString())); + log.info("type: {}; name: {}; tags: {}", + type, + name, + tags + .stream() + .map(mtr -> mtr.getKey() + ":" + mtr.getValue()) + .collect(Collectors.joining(","))); + + } + +} diff --git a/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java new file mode 100644 index 00000000..4c8b9678 --- /dev/null +++ b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java @@ -0,0 +1,75 @@ +package com.giffing.bucket4j.spring.boot.starter.examples.postgresql; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.HttpStatus; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Collections; +import java.util.stream.IntStream; + +import static org.assertj.core.api.Assertions.fail; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + + +@SpringBootTest(properties = { + "bucket4j.enabled=true", + "bucket4j.filters[0].cache-name=buckets", + "bucket4j.filters[0].id=filter1", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].capacity=5", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[0].rate-limits[0].bandwidths[0].unit=seconds", + "bucket4j.filters[0].url=^(/hello).*", + "bucket4j.filters[1].cache-name=buckets", + "bucket4j.filters[1].id=filter2", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", +}) +@AutoConfigureMockMvc +@DirtiesContext +@Slf4j +class PostgreSQLServletRateLimitTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void helloTest() throws Exception { + String url = "/hello"; + IntStream.rangeClosed(1, 5) + .boxed() + .sorted(Collections.reverseOrder()) + .forEach(counter -> successfulWebRequest(url, counter - 1, HttpStatus.OK)); + + blockedWebRequestDueToRateLimit(url); + } + + + private void successfulWebRequest(String url, Integer remainingTries, HttpStatus httpStatus) { + try { + this.mockMvc + .perform(get(url)) + .andExpect(status().is(httpStatus.value())) + .andExpect(header().longValue("X-Rate-Limit-Remaining", remainingTries)) + .andExpect(content().string(containsString("Hello from PostgreSQL example!"))); + } catch (Exception e) { + log.error(e.getMessage(), e); + fail(e.getMessage()); + } + } + + private void blockedWebRequestDueToRateLimit(String url) throws Exception { + this.mockMvc + .perform(get(url)) + .andExpect(status().is(HttpStatus.TOO_MANY_REQUESTS.value())) + .andExpect(content().string(containsString("{ \"message\": \"Too many requests!\" }"))); + } + +} From 92ee212b6031cdbd49e0ef31349d493441219ffc Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 16 Mar 2026 21:06:18 +0100 Subject: [PATCH 3/4] #245 Support Cache PostgreSQL --- .../examples/postgresql/PostgreSQLServletRateLimitTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java index 4c8b9678..907535df 100644 --- a/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java +++ b/examples/postgresql/src/test/java/com/giffing/bucket4j/spring/boot/starter/examples/postgresql/PostgreSQLServletRateLimitTest.java @@ -28,7 +28,7 @@ "bucket4j.filters[0].url=^(/hello).*", "bucket4j.filters[1].cache-name=buckets", "bucket4j.filters[1].id=filter2", - "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=10", + "bucket4j.filters[1].rate-limits[0].bandwidths[0].capacity=5", "bucket4j.filters[1].rate-limits[0].bandwidths[0].time=10", "bucket4j.filters[1].rate-limits[0].bandwidths[0].unit=seconds", }) From 2d083bcd3bf1e33b7223836a53191a57f460d059 Mon Sep 17 00:00:00 2001 From: Marc Giffing Date: Mon, 16 Mar 2026 21:26:21 +0100 Subject: [PATCH 4/4] #245 Support Cache PostgreSQL --- README.adoc | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/README.adoc b/README.adoc index 2c16d5cd..3ea79c99 100644 --- a/README.adoc +++ b/README.adoc @@ -1,7 +1,7 @@ :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 -:url-config-cache: {url}/bucket4j-spring-boot-starter/src/main/java/com/giffing/bucket4j/spring/boot/starter/config/cache +:url-config-cache: {url}/cache/ image:{url-repo}/actions/workflows/maven.yml/badge.svg[Build Status,link={url-repo}/actions/workflows/maven.yml] image:{url-repo}/actions/workflows/codeql.yml/badge.svg[Build Status,link={url-repo}/actions/workflows/codeql.yml] @@ -684,35 +684,40 @@ The following list contains the Caching implementation which will be autoconfigu |*cache-to-use* |N -|{url-config-cache}/jcache/JCacheBucket4jConfiguration.java[JSR 107 -JCache] +|{url-config-cache}/cache-jcache[JSR 107 -JCache] |jcache |Yes -|{url-config-cache}/ignite/IgniteBucket4jCacheConfiguration.java[Ignite] +|{url-config-cache}/cache-ignite[Ignite] |jcache-ignite +|N +|{url-config-cache}/cache-postgresql[PostgreSQL] +|jcache + + |no -|{url-config-cache}/hazelcast/HazelcastSpringBucket4jCacheConfiguration.java[Hazelcast] +|{url-config-cache}/cache-hazelcast[Hazelcast] |hazelcast-spring |yes -|{url-config-cache}/hazelcast/HazelcastReactiveBucket4jCacheConfiguration.java[Hazelcast] +|{url-config-cache}/cache-hazelcast[Hazelcast] |hazelcast-reactive |Yes -|{url-config-cache}/infinispan/InfinispanBucket4jCacheConfiguration.java[Infinispan] +|{url-config-cache}/cache-infinispan[Infinispan] |infinispan |No -|{url-config-cache}/redis/jedis/JedisBucket4jConfiguration.java[Redis-Jedis] +|{url-config-cache}/cache-redis-jedis[Redis-Jedis] |redis-jedis |Yes -|{url-config-cache}/redis/lettuce/LettuceBucket4jConfiguration.java[Redis-Lettuce] +|{url-config-cache}/cache-lettuce[Redis-Lettuce] |redis-lettuce |Yes -|{url-config-cache}/redis/redisson/RedissonBucket4jConfiguration.java[Redis-Redisson] +|{url-config-cache}/cache-redis-resdisson[Redis-Redisson] |redis-redisson |===