From c1c4b4ec258ab87ee110967fc9ee8f1abb6b207b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Thu, 6 Nov 2025 14:31:04 +0100 Subject: [PATCH 1/2] feature/Rate Limiting cache Key Features of the Implementation: 1. **Proper Location**: Caching is implemented at the data layer (`MappedRateLimitingProvider`) rather than the business logic layer 2. **Hourly Cache Invalidation**: Uses `YYYY-MM-DD-HH24` format in cache key for automatic hourly invalidation 3. **Configurable TTL**: Defaults to 1 hour (3600 seconds), can be customized via properties 4. **Documentation**: Properly documented in sample.props.template with clear explanation 5. **Consistent Pattern**: Follows the exact same caching pattern as other cached functions in the codebase 6. **Per-Consumer Caching**: Each consumer gets its own cache to prevent cross-consumer interference --- .../resources/props/sample.props.template | 3 ++ .../ratelimiting/MappedRateLimiting.scala | 35 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 50c77e7037..d6f3fd042e 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -66,6 +66,9 @@ starConnector_supported_types=mapped,internal #this cache is used in api level, will cache whole endpoint : v121.getTransactionsForBankAccount #api.cache.ttl.seconds.APIMethods121.getTransactions=0 +## Rate Limiting cache time-to-live in seconds, caches rate limiting configurations per consumer per hour +#ratelimiting.cache.ttl.seconds=3600 + ## MethodRouting cache time-to-live in seconds #methodRouting.cache.ttl.seconds=30 diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index 844e38f5d4..c73c07fbec 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -1,17 +1,25 @@ package code.ratelimiting import code.api.util.APIUtil +import code.api.cache.Caching import java.util.Date +import java.util.UUID.randomUUID import code.util.{MappedUUID, UUIDString} import net.liftweb.common.{Box, Full} import net.liftweb.mapper._ import net.liftweb.util.Helpers.tryo import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.tesobe.CacheKeyFromArguments import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.language.postfixOps object MappedRateLimitingProvider extends RateLimitingProviderTrait { + + // Cache TTL for rate limiting - 1 hour in milliseconds + val getRateLimitingTTL = APIUtil.getPropsValue("ratelimiting.cache.ttl.seconds", "3600").toInt * 1000 def getAll(): Future[List[RateLimiting]] = Future(RateLimiting.findAll()) def getAllByConsumerId(consumerId: String, date: Option[Date] = None): Future[List[RateLimiting]] = Future { date match { @@ -252,12 +260,29 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { RateLimiting.find(By(RateLimiting.RateLimitingId, rateLimitingId)) } + private def getActiveCallLimitsByConsumerIdAtDateCached(consumerId: String, date: Date, currentHour: String): List[RateLimiting] = { + /** + * Please note that "var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)" + * is just a temporary value field with UUID values in order to prevent any ambiguity. + * The real value will be assigned by Macro during compile time at this line of a code: + * https://github.com/OpenBankProject/scala-macros/blob/master/macros/src/main/scala/com/tesobe/CacheKeyFromArgumentsMacro.scala#L49 + */ + var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString) + CacheKeyFromArguments.buildCacheKey { + Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(getRateLimitingTTL millisecond) { + RateLimiting.findAll( + By(RateLimiting.ConsumerId, consumerId), + By_<=(RateLimiting.FromDate, date), + By_>=(RateLimiting.ToDate, date) + ) + } + } + } + def getActiveCallLimitsByConsumerIdAtDate(consumerId: String, date: Date): Future[List[RateLimiting]] = Future { - RateLimiting.findAll( - By(RateLimiting.ConsumerId, consumerId), - By_<=(RateLimiting.FromDate, date), - By_>=(RateLimiting.ToDate, date) - ) + // Create cache key based on current hour (YYYY-MM-DD-HH24 format) + val currentHour = f"${date.getYear + 1900}-${date.getMonth + 1}%02d-${date.getDate}%02d-${date.getHours}%02d" + getActiveCallLimitsByConsumerIdAtDateCached(consumerId, date, currentHour) } } From 623096e7153f35e8f0c98f845c62469e7f9e3cc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 11 Nov 2025 12:29:45 +0100 Subject: [PATCH 2/2] feature/Rate limiting guard function underCallLimits cached per hour --- .../resources/props/sample.props.template | 3 --- .../ratelimiting/MappedRateLimiting.scala | 25 +++++++++++++------ .../scala/code/api/v3_1_0/RateLimitTest.scala | 24 +++++++++--------- 3 files changed, 30 insertions(+), 22 deletions(-) diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index a80c03e3f8..200615112e 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -66,9 +66,6 @@ starConnector_supported_types=mapped,internal #this cache is used in api level, will cache whole endpoint : v121.getTransactionsForBankAccount #api.cache.ttl.seconds.APIMethods121.getTransactions=0 -## Rate Limiting cache time-to-live in seconds, caches rate limiting configurations per consumer per hour -#ratelimiting.cache.ttl.seconds=3600 - ## MethodRouting cache time-to-live in seconds #methodRouting.cache.ttl.seconds=30 diff --git a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala index c73c07fbec..0beab99b3a 100644 --- a/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala +++ b/obp-api/src/main/scala/code/ratelimiting/MappedRateLimiting.scala @@ -12,14 +12,15 @@ import net.liftweb.util.Helpers.tryo import com.openbankproject.commons.ExecutionContext.Implicits.global import com.tesobe.CacheKeyFromArguments +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + import scala.concurrent.Future import scala.concurrent.duration._ import scala.language.postfixOps object MappedRateLimitingProvider extends RateLimitingProviderTrait { - // Cache TTL for rate limiting - 1 hour in milliseconds - val getRateLimitingTTL = APIUtil.getPropsValue("ratelimiting.cache.ttl.seconds", "3600").toInt * 1000 def getAll(): Future[List[RateLimiting]] = Future(RateLimiting.findAll()) def getAllByConsumerId(consumerId: String, date: Option[Date] = None): Future[List[RateLimiting]] = Future { date match { @@ -260,16 +261,23 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { RateLimiting.find(By(RateLimiting.RateLimitingId, rateLimitingId)) } - private def getActiveCallLimitsByConsumerIdAtDateCached(consumerId: String, date: Date, currentHour: String): List[RateLimiting] = { + private def getActiveCallLimitsByConsumerIdAtDateCached(consumerId: String, currentDateWithHour: String): List[RateLimiting] = { /** * Please note that "var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString)" * is just a temporary value field with UUID values in order to prevent any ambiguity. * The real value will be assigned by Macro during compile time at this line of a code: * https://github.com/OpenBankProject/scala-macros/blob/master/macros/src/main/scala/com/tesobe/CacheKeyFromArgumentsMacro.scala#L49 */ + // Create a proper Date object from the date_with_hour string (assuming 0 mins and 0 seconds) + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH") + val localDateTime = LocalDateTime.parse(currentDateWithHour, formatter).withMinute(0).withSecond(0) + // Convert LocalDateTime to java.util.Date + val instant = localDateTime.atZone(java.time.ZoneId.systemDefault()).toInstant() + val date = Date.from(instant) + var cacheKey = (randomUUID().toString, randomUUID().toString, randomUUID().toString) CacheKeyFromArguments.buildCacheKey { - Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(getRateLimitingTTL millisecond) { + Caching.memoizeSyncWithProvider(Some(cacheKey.toString()))(3600 second) { RateLimiting.findAll( By(RateLimiting.ConsumerId, consumerId), By_<=(RateLimiting.FromDate, date), @@ -280,9 +288,12 @@ object MappedRateLimitingProvider extends RateLimitingProviderTrait { } def getActiveCallLimitsByConsumerIdAtDate(consumerId: String, date: Date): Future[List[RateLimiting]] = Future { - // Create cache key based on current hour (YYYY-MM-DD-HH24 format) - val currentHour = f"${date.getYear + 1900}-${date.getMonth + 1}%02d-${date.getDate}%02d-${date.getHours}%02d" - getActiveCallLimitsByConsumerIdAtDateCached(consumerId, date, currentHour) + def currentDateWithHour: String = { + val now = LocalDateTime.now() + val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd-HH") + now.format(formatter) + } + getActiveCallLimitsByConsumerIdAtDateCached(consumerId, currentDateWithHour) } } diff --git a/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala b/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala index 6aeca45e0e..7699e817f3 100644 --- a/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala +++ b/obp-api/src/test/scala/code/api/v3_1_0/RateLimitTest.scala @@ -196,8 +196,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitSecondJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) @@ -221,8 +221,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitMinuteJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) @@ -246,8 +246,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitHourJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) @@ -271,8 +271,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitDayJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) @@ -296,8 +296,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitWeekJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1")) @@ -321,8 +321,8 @@ class RateLimitTest extends V310ServerSetup with PropsReset { When("We make the second call after update") val response03 = makePutRequest(request310, write(callLimitMonthJson)) - Then("We should get a 429") - response03.code should equal(429) + Then("We should get a 200 since 1 hour caching") + response03.code should equal(200) // Revert to initial state Consumers.consumers.vend.updateConsumerCallLimits(id, Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"), Some("-1"))