From 2e1d9bc5e0d591ef4b3a2e74af1a381dc4e82b00 Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 07:50:11 -0700 Subject: [PATCH 1/6] initial commit --- RateLimiter.Tests/RateLimiterTest.cs | 13 ------------- RateLimiter.Tests/UnitTests/RateLimiterTest.cs | 13 +++++++++++++ 2 files changed, 13 insertions(+), 13 deletions(-) delete mode 100644 RateLimiter.Tests/RateLimiterTest.cs create mode 100644 RateLimiter.Tests/UnitTests/RateLimiterTest.cs diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs deleted file mode 100644 index 172d44a7..00000000 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter.Tests/UnitTests/RateLimiterTest.cs b/RateLimiter.Tests/UnitTests/RateLimiterTest.cs new file mode 100644 index 00000000..bfe7f3e7 --- /dev/null +++ b/RateLimiter.Tests/UnitTests/RateLimiterTest.cs @@ -0,0 +1,13 @@ +using NUnit.Framework; + +namespace RateLimiter.Tests.UnitTests; + +[TestFixture] +public class RateLimiterTest +{ + [Test] + public void Example() + { + Assert.That(true, Is.True); + } +} \ No newline at end of file From 4173b2e45f90cafc1a8e1ace2fae5ca765c93e58 Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 07:55:05 -0700 Subject: [PATCH 2/6] adding unit test --- .../Core/RateLimiterServiceUnitTests.cs | 197 ++++++++++++++++++ .../UnitTests/RateLimiterTest.cs | 13 -- .../FileConfigurationStorage.cs | 17 ++ .../RedisConfigurationStorage.cs | 18 ++ .../IConfigurationStorageProvider.cs | 8 + .../IRateLimiterConfigRepository.cs | 8 + RateLimiter/Core/ClientRequestContext.cs | 25 +++ RateLimiter/Core/RateLimiterService.cs | 45 ++++ RateLimiter/RateLimiter.csproj | 3 + 9 files changed, 321 insertions(+), 13 deletions(-) create mode 100644 RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs delete mode 100644 RateLimiter.Tests/UnitTests/RateLimiterTest.cs create mode 100644 RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs create mode 100644 RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs create mode 100644 RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs create mode 100644 RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs create mode 100644 RateLimiter/Core/ClientRequestContext.cs create mode 100644 RateLimiter/Core/RateLimiterService.cs diff --git a/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs new file mode 100644 index 00000000..803a9967 --- /dev/null +++ b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs @@ -0,0 +1,197 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; + +public class RateLimiterServiceUnitTests //: IClassFixture +{ + private readonly Mock> _logger; + private readonly string _token = "test-token"; + + public RateLimiterServiceUnitTests() + { + _logger = new Mock>(); + } + + private DefaultHttpContext CreateHttpContext(string token, string path) + { + var context = new DefaultHttpContext(); + context.Request.Headers["Authorization"] = $"Bearer {token}"; + context.Request.Path = path; + return context; + } + + private RateLimiterService CreateRateLimiterService(string endpoint, List rules) + { + var mockConfigurationStorageProvider = new Mock>(); + mockConfigurationStorageProvider.Setup(repo => repo.LoadAsync(endpoint)).ReturnsAsync(rules); + return new RateLimiterService(mockConfigurationStorageProvider.Object, _logger.Object); + } + + [Fact] + public async Task InvokeAsync_RequestAllowed_ReturnsTrue() + { + // Arrange + var mockRuleA = new Mock(); + + mockRuleA.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + string endpoint = "/api/resourceA"; + var mockRulesConfig = new List { mockRuleA.Object }; + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_RequestNotAllowed_ReturnsFalse() + { + // Arrange + var mockRuleA = new Mock(); + mockRuleA.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(false); + + string endpoint = "/api/resourceA"; + var mockRulesConfig = new List { mockRuleA.Object }; + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task InvokeAsync_RequestAllowed_MultipleRules_ReturnsTrue() + { + // Arrange + + var mockRuleA = new Mock(); + mockRuleA.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + var mockRuleB = new Mock(); + mockRuleB.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + var mockRuleC = new Mock(); + mockRuleC.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + string endpoint = "/api/resourceA"; + + var mockRulesConfig = new List { mockRuleA.Object, mockRuleB.Object, mockRuleC.Object }; + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_RequestNotAllowed_MultipleRules_ReturnsFalse() + { + // Arrange + var mockRuleA = new Mock(); + mockRuleA.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + var mockRuleB = new Mock(); + mockRuleB.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(false); // block request + + var mockRuleC = new Mock(); + mockRuleC.Setup(r => r.IsRequestAllowedAsync(It.IsAny())).ReturnsAsync(true); + + string endpoint = "/api/resourceA"; + + var mockRulesConfig = new List { mockRuleA.Object, mockRuleB.Object, mockRuleC.Object }; + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task InvokeAsync_EndpointWithoutConfig_RequestAllowed_ReturnsTrue() + { + // Arrange + string endpoint = "/api/resourceX"; + + var mockRulesConfig = new List{}; // no rules configured for this endpoint + + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_EndpointNotFound_RequestAllowed_ReturnsTrue() + { + // Arrange + string endpoint = "/api/resourceX"; + + var rateLimiter = CreateRateLimiterService("", new List { }); // no endpoint and rules configured + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_EmptyEndpoint_ReturnsFalse() + { + // Arrange + string endpoint = string.Empty; + var mockRulesConfig = new List(); + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = CreateHttpContext(_token, endpoint); + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task InvokeAsync_NoClientTokenProvided_Returnsfalse() + { + // Arrange + string endpoint = "/api/resourceA"; + var mockRulesConfig = new List(); + + var rateLimiter = CreateRateLimiterService(endpoint, mockRulesConfig); + var context = new DefaultHttpContext(); + context.Request.Path = endpoint; + + // Act + var result = await rateLimiter.InvokeAsync(context); + + // Assert + Assert.False(result); + } +} diff --git a/RateLimiter.Tests/UnitTests/RateLimiterTest.cs b/RateLimiter.Tests/UnitTests/RateLimiterTest.cs deleted file mode 100644 index bfe7f3e7..00000000 --- a/RateLimiter.Tests/UnitTests/RateLimiterTest.cs +++ /dev/null @@ -1,13 +0,0 @@ -using NUnit.Framework; - -namespace RateLimiter.Tests.UnitTests; - -[TestFixture] -public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file diff --git a/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs b/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs new file mode 100644 index 00000000..d5d1b25d --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs @@ -0,0 +1,17 @@ +using RateLimiter.Rules.Interfaces; + +namespace RateLimiter.ConfigurationStorageProvider.Implementations +{ + internal class FileConfigurationStorage : IConfigurationStorageProvider + { + public Task?> LoadAsync(string endpoint) + { + throw new NotImplementedException(); + } + + public Task SaveAsync(string key, List values) + { + throw new NotImplementedException(); + } + } +} diff --git a/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs new file mode 100644 index 00000000..cb9bd611 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs @@ -0,0 +1,18 @@ +using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; + +namespace RateLimiter.ConfigurationStorageProvider.Implementations +{ + internal class RedisConfigurationStorage : IConfigurationStorageProvider + { + public Task?> LoadAsync(string endpoint) + { + throw new NotImplementedException(); + } + + public Task SaveAsync(string key, List values) + { + throw new NotImplementedException(); + } + } +} diff --git a/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs new file mode 100644 index 00000000..45579579 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs @@ -0,0 +1,8 @@ +using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; + +public interface IConfigurationStorageProvider where T : IRateLimiterRule +{ + Task SaveAsync(string key, List values); + Task?> LoadAsync(string endpoint); +} \ No newline at end of file diff --git a/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs b/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs new file mode 100644 index 00000000..cfde010e --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs @@ -0,0 +1,8 @@ +using RateLimiter.Rules; +using System.Collections.Generic; +using System.Threading.Tasks; + +public interface IRateLimiterConfigRepository +{ + Task?> GetRulesConfigByEndpointAsync(string endpoint); +} diff --git a/RateLimiter/Core/ClientRequestContext.cs b/RateLimiter/Core/ClientRequestContext.cs new file mode 100644 index 00000000..3451fb6e --- /dev/null +++ b/RateLimiter/Core/ClientRequestContext.cs @@ -0,0 +1,25 @@ +namespace RateLimiter.Core +{ + public class ClientRequestContext + { + public string Token { get; set; } + public string Endpoint { get; set; } + public string? Region { get; set; } // e.g., "US", "EU" + public DateTime? RequestTimeUtc { get; set; } + + public ClientRequestContext(string token, string endpoint) + { + Token = token; + Endpoint = endpoint; + } + + public ClientRequestContext(string token, string endpoint, string region, DateTime requestTimeUtc) + { + Token = token; + Endpoint = endpoint; + Region = region; + RequestTimeUtc = requestTimeUtc; + } + } + +} diff --git a/RateLimiter/Core/RateLimiterService.cs b/RateLimiter/Core/RateLimiterService.cs new file mode 100644 index 00000000..2b9b8556 --- /dev/null +++ b/RateLimiter/Core/RateLimiterService.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using RateLimiter.ConfigurationStorageProvider; +using RateLimiter.Rules.Interfaces; + +namespace RateLimiter.Core +{ + public class RateLimiterService(IConfigurationStorageProvider configurationStorageProvider, ILogger logger) + { + private readonly IConfigurationStorageProvider _configurationStorageProvider = configurationStorageProvider; + private readonly ILogger _logger = logger; + + public async Task InvokeAsync(HttpContext context) + { + var token = context.Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var endpoint = context.Request.Path.ToString(); + + if ((string.IsNullOrEmpty(token)) || (String.IsNullOrEmpty(endpoint))) + return false; + + var requestContext = new ClientRequestContext(token, endpoint); + + var configProvider = new ConfigurationProvider(_configurationStorageProvider); + + var rules = await configProvider.GetConfigAsync(endpoint); + + if (rules == null || rules.Count==0) + { + _logger.LogWarning("No rules defined for this Endpoint"); + + return true; // No rules defined for this endpoint + } + + foreach (var rule in rules) + { + if (!await rule.IsRequestAllowedAsync(requestContext)) + { + return false; + } + } + + return true; + } + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..e70963ac 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file From 2c5946621bef95e8aeab8b3f011844c821a78c2e Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 08:05:56 -0700 Subject: [PATCH 3/6] fixing references --- RateLimiter.Tests/RateLimiter.Tests.csproj | 10 ++++- .../Core/RateLimiterServiceUnitTests.cs | 3 ++ .../IConfigurationStorageProvider.cs | 2 + RateLimiter/RateLimiter.csproj | 3 -- RateLimiter/Rules/Base/RateLimiterRuleBase.cs | 19 +++++++++ .../RegionalRateLimiterRule.cs | 16 ++++++++ .../IRequestPerPeriodRuleRepository.cs | 9 +++++ .../InMemoryRequestPerPeriodRuleRepository.cs | 18 +++++++++ .../RequestPerPeriodRule.cs | 39 +++++++++++++++++++ .../TimeOfDayRule/TimeOfDayRule.cs | 34 ++++++++++++++++ .../TimeSinceLastCallRule.cs | 25 ++++++++++++ .../Interfaces/IRateLimiterNotification.cs | 7 ++++ .../Rules/Interfaces/IRateLimiterRule.cs | 10 +++++ 13 files changed, 190 insertions(+), 5 deletions(-) create mode 100644 RateLimiter/Rules/Base/RateLimiterRuleBase.cs create mode 100644 RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs create mode 100644 RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs create mode 100644 RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs create mode 100644 RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs create mode 100644 RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs create mode 100644 RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs create mode 100644 RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs create mode 100644 RateLimiter/Rules/Interfaces/IRateLimiterRule.cs diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..3f18b0e2 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,8 +8,14 @@ + + - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + \ No newline at end of file diff --git a/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs index 803a9967..89cf04a9 100644 --- a/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs +++ b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs @@ -3,6 +3,9 @@ using Moq; using RateLimiter.Core; using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; public class RateLimiterServiceUnitTests //: IClassFixture { diff --git a/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs index 45579579..3ee5b21e 100644 --- a/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs @@ -1,5 +1,7 @@ using RateLimiter.Rules; using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; public interface IConfigurationStorageProvider where T : IRateLimiterRule { diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index e70963ac..19962f52 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,7 +4,4 @@ latest enable - - - \ No newline at end of file diff --git a/RateLimiter/Rules/Base/RateLimiterRuleBase.cs b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs new file mode 100644 index 00000000..a6b33c33 --- /dev/null +++ b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs @@ -0,0 +1,19 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; + +namespace RateLimiter.Rules.Base +{ + public abstract class RateLimiterRuleBase : IRateLimiterRule + { + protected RateLimiterRuleBase() { } + public abstract string Name { get; } + public abstract string Description { get; } + public abstract string ViolationMessage { get; } + public abstract Task IsRequestAllowedAsync(ClientRequestContext context); + + protected virtual string GenerateRequestKey(ClientRequestContext context) + { + return $"{context.Token}:{context.Endpoint}"; + } + } +} diff --git a/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs b/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs new file mode 100644 index 00000000..20dd7cb8 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs @@ -0,0 +1,16 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; + +namespace RateLimiter.Rules.Implementations.RegionalRateLimiterRule +{ + public class RegionalRateLimiterRule(IRateLimiterRule usRule, IRateLimiterRule euRule) : IRateLimiterRule + { + private readonly IRateLimiterRule _usRule = usRule; + private readonly IRateLimiterRule _euRule = euRule; + + public async Task IsRequestAllowedAsync(ClientRequestContext context) + { + return await Task.FromResult(true); + } + } +} diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs new file mode 100644 index 00000000..d3246de0 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule +{ + public interface IRequestPerPeriodRuleRepository + { + (DateTime start, int count) GetOrAdd(string key, DateTime start, int counter); + void Update(string key, DateTime start, int counter); + } + +} diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs new file mode 100644 index 00000000..7f4a2fb9 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs @@ -0,0 +1,18 @@ +using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule; +public class InMemoryRequestPerPeriodRuleRepository : IRequestPerPeriodRuleRepository +{ + private readonly ConcurrentDictionary _requests = new(); + + public (DateTime start, int count) GetOrAdd(string key, DateTime start, int counter) + { + return _requests.GetOrAdd(key, (start, counter)); + } + + public void Update(string key, DateTime start, int counter) + { + _requests[key] = (start, counter); + } +} diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs new file mode 100644 index 00000000..db42c734 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs @@ -0,0 +1,39 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Base; +using System.Collections.Concurrent; + +namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule +{ + public class RequestsPerPeriodRule(int? limit, TimeSpan? period, IRequestPerPeriodRuleRepository requestPerPeriodRuleRepository) : RateLimiterRuleBase + { + private readonly int? _limit = limit; + private readonly TimeSpan? _period = period; + private readonly IRequestPerPeriodRuleRepository _requestPerPeriodRuleRepository = requestPerPeriodRuleRepository; + + public override string Description { get { return "Requests per fixed period (e.g., 100 requests per minute)"; } } + public override string Name { get { return "RequestsPerPeriodRule"; } } + public override string ViolationMessage { get { return "Request limit exceeded."; } } + + public override Task IsRequestAllowedAsync(ClientRequestContext context) + { + var key = base.GenerateRequestKey(context); + var now = DateTime.UtcNow; + var counter = 0; + + var entry = _requestPerPeriodRuleRepository.GetOrAdd(key, now, 0); + + if (now - entry.start > _period) + entry = (now, 0); + else + counter = entry.count + 1; + + if (counter >= _limit) + return Task.FromResult(false); + + _requestPerPeriodRuleRepository.Update(key, entry.start, counter); + + return Task.FromResult(true); + } + + } +} diff --git a/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs b/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs new file mode 100644 index 00000000..31089fa6 --- /dev/null +++ b/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs @@ -0,0 +1,34 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Base; +using RateLimiter.Rules.Interfaces; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Rules.Implementations.TimeOfDayRule +{ + public class TimeOfDayRule(int limit, TimeSpan start, TimeSpan final, TimeSpan period) : RateLimiterRuleBase, IRateLimiterNotification + { + private readonly int _limit = limit; + private readonly TimeSpan _start = start; + private readonly TimeSpan _end = final; + private readonly TimeSpan _period = period; + + public override string Description { get { return " Limit stricter during peak hours (e.g., 9 AM - 5 PM)."; } } + public override string Name { get { return "TimeOfDayRule"; } } + public override string ViolationMessage { get { return $"Exceeded {_limit} requests per {_period} allowed between {_start} and {_end} UTC."; } } + + public override Task IsRequestAllowedAsync(ClientRequestContext context) + { + // Logic goes here + + return Task.FromResult(true); + } + + public Task SendNotificationAsync(string recipient, string message) + { + // Logic to send notification + + return Task.FromResult(true); + } + } +} diff --git a/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs b/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs new file mode 100644 index 00000000..e308c571 --- /dev/null +++ b/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Concurrent; +using RateLimiter.Core; +using RateLimiter.Rules.Base; + +namespace RateLimiter.Rules.Implementations.TimeSinceLastCallRule +{ + public class TimeSinceLastCallRule(TimeSpan minimumInterval) : RateLimiterRuleBase + { + private readonly TimeSpan _minimumInterval = minimumInterval; + + private readonly ConcurrentDictionary _lastRequestTime = new(); + public override string Description { get { return "200 requests allowed per rolling 10-minute window."; } } + public override string Name { get { return "TimeSinceLastCallRule"; } } + public override string ViolationMessage { get { return "Exceeded requests limit."; } } + + public override Task IsRequestAllowedAsync(ClientRequestContext context) + { + // Logic goes here + + return Task.FromResult(true); + + } + } +} diff --git a/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs b/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs new file mode 100644 index 00000000..e7aa7e27 --- /dev/null +++ b/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Rules.Interfaces +{ + public interface IRateLimiterNotification + { + Task SendNotificationAsync(string recipient, string message); + } +} diff --git a/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs b/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs new file mode 100644 index 00000000..1576424b --- /dev/null +++ b/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs @@ -0,0 +1,10 @@ +using RateLimiter.Core; + +namespace RateLimiter.Rules.Interfaces +{ + public interface IRateLimiterRule + { + public Task IsRequestAllowedAsync(ClientRequestContext context); + } + +} From 8290c4dbd51a8a10b8a507b115979e32940d4efa Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 08:13:46 -0700 Subject: [PATCH 4/6] adding tests --- .../ConfigurationProvider.cs | 21 +++++++++++++++++++ .../FileConfigurationStorage.cs | 6 ++++-- .../RedisConfigurationStorage.cs | 6 ++++-- .../IRateLimiterConfigRepository.cs | 1 + RateLimiter/Core/ClientRequestContext.cs | 4 +++- RateLimiter/Core/RateLimiterService.cs | 2 ++ RateLimiter/RateLimiter.csproj | 4 ++++ RateLimiter/Rules/Base/RateLimiterRuleBase.cs | 1 + .../RegionalRateLimiterRule.cs | 1 + .../IRequestPerPeriodRuleRepository.cs | 4 +++- .../InMemoryRequestPerPeriodRuleRepository.cs | 1 + .../RequestPerPeriodRule.cs | 2 ++ .../TimeOfDayRule/TimeOfDayRule.cs | 2 ++ .../TimeSinceLastCallRule.cs | 1 + .../Interfaces/IRateLimiterNotification.cs | 4 +++- .../Rules/Interfaces/IRateLimiterRule.cs | 1 + 16 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 RateLimiter/ConfigurationStorageProvider/ConfigurationProvider.cs diff --git a/RateLimiter/ConfigurationStorageProvider/ConfigurationProvider.cs b/RateLimiter/ConfigurationStorageProvider/ConfigurationProvider.cs new file mode 100644 index 00000000..21d24850 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/ConfigurationProvider.cs @@ -0,0 +1,21 @@ +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RateLimiter.ConfigurationStorageProvider +{ + public class ConfigurationProvider(IConfigurationStorageProvider storageProvider) + { + private readonly IConfigurationStorageProvider _storageProvider = storageProvider; + + public async Task SaveConfigAsync(string key, List values) + { + return await _storageProvider.SaveAsync(key, values); + } + + public async Task?> GetConfigAsync(string key) + { + return await _storageProvider.LoadAsync(key); + } + } +} diff --git a/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs b/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs index d5d1b25d..82dcbbd7 100644 --- a/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs @@ -1,4 +1,6 @@ using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; namespace RateLimiter.ConfigurationStorageProvider.Implementations { @@ -6,12 +8,12 @@ internal class FileConfigurationStorage : IConfigurationStorageProvider?> LoadAsync(string endpoint) { - throw new NotImplementedException(); + throw new System.NotImplementedException(); } public Task SaveAsync(string key, List values) { - throw new NotImplementedException(); + throw new System.NotImplementedException(); } } } diff --git a/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs index cb9bd611..87bc5cd5 100644 --- a/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs @@ -1,5 +1,7 @@ using RateLimiter.Rules; using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; namespace RateLimiter.ConfigurationStorageProvider.Implementations { @@ -7,12 +9,12 @@ internal class RedisConfigurationStorage : IConfigurationStorageProvider?> LoadAsync(string endpoint) { - throw new NotImplementedException(); + throw new System.NotImplementedException(); } public Task SaveAsync(string key, List values) { - throw new NotImplementedException(); + throw new System.NotImplementedException(); } } } diff --git a/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs b/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs index cfde010e..c9bc0b74 100644 --- a/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs @@ -1,4 +1,5 @@ using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; using System.Collections.Generic; using System.Threading.Tasks; diff --git a/RateLimiter/Core/ClientRequestContext.cs b/RateLimiter/Core/ClientRequestContext.cs index 3451fb6e..21827c4b 100644 --- a/RateLimiter/Core/ClientRequestContext.cs +++ b/RateLimiter/Core/ClientRequestContext.cs @@ -1,4 +1,6 @@ -namespace RateLimiter.Core +using System; + +namespace RateLimiter.Core { public class ClientRequestContext { diff --git a/RateLimiter/Core/RateLimiterService.cs b/RateLimiter/Core/RateLimiterService.cs index 2b9b8556..dac7f816 100644 --- a/RateLimiter/Core/RateLimiterService.cs +++ b/RateLimiter/Core/RateLimiterService.cs @@ -2,6 +2,8 @@ using Microsoft.Extensions.Logging; using RateLimiter.ConfigurationStorageProvider; using RateLimiter.Rules.Interfaces; +using System; +using System.Threading.Tasks; namespace RateLimiter.Core { diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..b9864f6d 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,8 @@ latest enable + + + + \ No newline at end of file diff --git a/RateLimiter/Rules/Base/RateLimiterRuleBase.cs b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs index a6b33c33..e81ac543 100644 --- a/RateLimiter/Rules/Base/RateLimiterRuleBase.cs +++ b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs @@ -1,5 +1,6 @@ using RateLimiter.Core; using RateLimiter.Rules.Interfaces; +using System.Threading.Tasks; namespace RateLimiter.Rules.Base { diff --git a/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs b/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs index 20dd7cb8..31503160 100644 --- a/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs +++ b/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs @@ -1,5 +1,6 @@ using RateLimiter.Core; using RateLimiter.Rules.Interfaces; +using System.Threading.Tasks; namespace RateLimiter.Rules.Implementations.RegionalRateLimiterRule { diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs index d3246de0..c455847d 100644 --- a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs @@ -1,4 +1,6 @@ -namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule +using System; + +namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule { public interface IRequestPerPeriodRuleRepository { diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs index 7f4a2fb9..ac047c31 100644 --- a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs @@ -1,4 +1,5 @@ using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System; using System.Collections.Concurrent; namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule; diff --git a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs index db42c734..23a4052d 100644 --- a/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs @@ -1,6 +1,8 @@ using RateLimiter.Core; using RateLimiter.Rules.Base; +using System; using System.Collections.Concurrent; +using System.Threading.Tasks; namespace RateLimiter.Rules.Implementations.RequestPerPeriodRule { diff --git a/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs b/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs index 31089fa6..425022d2 100644 --- a/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs +++ b/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs @@ -1,8 +1,10 @@ using RateLimiter.Core; using RateLimiter.Rules.Base; using RateLimiter.Rules.Interfaces; +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Threading.Tasks; namespace RateLimiter.Rules.Implementations.TimeOfDayRule { diff --git a/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs b/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs index e308c571..104a9192 100644 --- a/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs +++ b/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Threading.Tasks; using RateLimiter.Core; using RateLimiter.Rules.Base; diff --git a/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs b/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs index e7aa7e27..568baa4d 100644 --- a/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs +++ b/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs @@ -1,4 +1,6 @@ -namespace RateLimiter.Rules.Interfaces +using System.Threading.Tasks; + +namespace RateLimiter.Rules.Interfaces { public interface IRateLimiterNotification { diff --git a/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs b/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs index 1576424b..57419b77 100644 --- a/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs +++ b/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs @@ -1,4 +1,5 @@ using RateLimiter.Core; +using System.Threading.Tasks; namespace RateLimiter.Rules.Interfaces { From eed369b3891f8d34725a03249c1124da277d8514 Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 08:18:32 -0700 Subject: [PATCH 5/6] adding documentation --- .../Common/InMemoryRateLimiterConfig.cs | 27 +++ .../RequestPerPeriodRuleTests.cs | 156 ++++++++++++++++++ .../RequestPerPeriodRule/RuleRepository.cs | 22 +++ .../Rules/RequestsPerPeriodRuleUnitTests.cs | 83 ++++++++++ RateLimiter/Rate-Limiter.jpg | Bin 0 -> 33751 bytes RateLimiter/Readme.md | 58 +++++++ 6 files changed, 346 insertions(+) create mode 100644 RateLimiter.Tests/IntegrationTests/Common/InMemoryRateLimiterConfig.cs create mode 100644 RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RequestPerPeriodRuleTests.cs create mode 100644 RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RuleRepository.cs create mode 100644 RateLimiter.Tests/UnitTests/Rules/RequestsPerPeriodRuleUnitTests.cs create mode 100644 RateLimiter/Rate-Limiter.jpg create mode 100644 RateLimiter/Readme.md diff --git a/RateLimiter.Tests/IntegrationTests/Common/InMemoryRateLimiterConfig.cs b/RateLimiter.Tests/IntegrationTests/Common/InMemoryRateLimiterConfig.cs new file mode 100644 index 00000000..73dee57f --- /dev/null +++ b/RateLimiter.Tests/IntegrationTests/Common/InMemoryRateLimiterConfig.cs @@ -0,0 +1,27 @@ +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; + +public class InMemoryConfigurationStorage : IConfigurationStorageProvider +{ + private readonly Dictionary> _rulesConfig; + + public InMemoryConfigurationStorage() + { + _rulesConfig = new Dictionary>(); + } + + public async Task?> LoadAsync(string endpoint) + { + _rulesConfig.TryGetValue(endpoint, out var rules); + + return await Task.FromResult(rules ?? new List()); + } + + public async Task SaveAsync(string key, List rules) + { + _rulesConfig[key] = rules; + + return await Task.FromResult(true); + } +} diff --git a/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RequestPerPeriodRuleTests.cs b/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RequestPerPeriodRuleTests.cs new file mode 100644 index 00000000..f470ef28 --- /dev/null +++ b/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RequestPerPeriodRuleTests.cs @@ -0,0 +1,156 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using RateLimiter.ConfigurationStorageProvider; +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; +using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System.Collections.Generic; +using System; +using System.Threading.Tasks; +using Xunit; + + +namespace RateLimiter.IntegrationTests.Rules.RequestPerPeriodRule; + +public class RequestPerPeriodRuleTests +{ + + private readonly int NUMBER_OF_REQUESTS_ENDPOINT_A = 2; + private readonly int PERIOD_ENDPOINT_A = 10; // seconds + private readonly int NUMBER_OF_REQUESTS_ENDPOINT_B = 2; + private readonly int PERIOD_ENDPOINT_B = 60; // seconds + private readonly Mock> _logger; + private readonly InMemoryConfigurationStorage _memoryStorage; + private readonly ConfigurationProvider _configurationProvider; + + public RequestPerPeriodRuleTests() + { + + _logger = new Mock>(); + + _memoryStorage = new InMemoryConfigurationStorage(); + _configurationProvider = new ConfigurationProvider(_memoryStorage); + + SetupConfig(); + + } + + private async void SetupConfig() + { + var ruleRepository = new InMemoryRequestPerPeriodRuleRepository(); + + // Save configuration + await _configurationProvider.SaveConfigAsync("/api/resourceA", new List { + new RequestsPerPeriodRule(NUMBER_OF_REQUESTS_ENDPOINT_A, TimeSpan.FromSeconds(PERIOD_ENDPOINT_A), ruleRepository) + }); + + await _configurationProvider.SaveConfigAsync("/api/resourceB", new List { + new RequestsPerPeriodRule(NUMBER_OF_REQUESTS_ENDPOINT_B, TimeSpan.FromSeconds(PERIOD_ENDPOINT_B), ruleRepository) + }); + } + + private DefaultHttpContext CreateHttpContext(string token, string path) + { + var context = new DefaultHttpContext(); + context.Request.Headers["Authorization"] = $"Bearer {token}"; + context.Request.Path = path; + return context; + } + + private RateLimiterService CreateRateLimiterService() + { + return new RateLimiterService(_memoryStorage, _logger.Object); + } + + [Fact] + public async Task InvokeAsync_EndPointA_RequestAllowed_ReturnsTrue() + { + // Arrange + var context = CreateHttpContext("test-token", "/api/resourceA"); + + await Task.Delay((PERIOD_ENDPOINT_A * 1000) + 1); // Simulate async operation + + var RateLimiterService = CreateRateLimiterService(); + + // Act + var result = await RateLimiterService.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_EndPointA_RequestNotAllowed_ReturnsFalse() + { + // Arrange + var context = CreateHttpContext("test-token", "/api/resourceA"); + + var RateLimiterService = CreateRateLimiterService(); + + // Simulate a request to set the last call time + await RateLimiterService.InvokeAsync(context); + + await Task.Delay(1); // Simulate async operation + + // Act + var result = await RateLimiterService.InvokeAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task InvokeAsync_EndPointB_RequestAllowed_ReturnsTrue() + { + // Arrange + var context = CreateHttpContext("test-token", "/api/resourceB"); + var RateLimiterService = CreateRateLimiterService(); + + // Simulate a request to set the last call time + for (int i = 1; i < NUMBER_OF_REQUESTS_ENDPOINT_B -1; i++) + { + await RateLimiterService.InvokeAsync(context); + } + // Act + var result = await RateLimiterService.InvokeAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task InvokeAsync_EndPointB_RequestNotAllowed_ReturnsFalse() + { + // Arrange + var context = CreateHttpContext("test-token", "/api/resourceB"); + var RateLimiterService = CreateRateLimiterService(); + + // Simulate a request to set the last call time + for (int i = 1; i < NUMBER_OF_REQUESTS_ENDPOINT_B + 10; i++) + { + await RateLimiterService.InvokeAsync(context); + } + + // Act + var result = await RateLimiterService.InvokeAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task InvokeAsync_NoRulesDefined_ReturnsTrue() + { + // Arrange + var context = CreateHttpContext("test-token", "/api/unknown"); + var RateLimiterService = CreateRateLimiterService(); + + // Act + var result = await RateLimiterService.InvokeAsync(context); + + // Assert + Assert.True(result); + } + +} diff --git a/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RuleRepository.cs b/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RuleRepository.cs new file mode 100644 index 00000000..915d8514 --- /dev/null +++ b/RateLimiter.Tests/IntegrationTests/Rules/RequestPerPeriodRule/RuleRepository.cs @@ -0,0 +1,22 @@ +using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.IntegrationTests.Rules.RequestPerPeriodRule +{ + public class InMemoryRequestPerPeriodRuleRepository : IRequestPerPeriodRuleRepository + { + private readonly ConcurrentDictionary _requests = new(); + + public (DateTime start, int count) GetOrAdd(string key, DateTime start, int counter) + { + return _requests.GetOrAdd(key, (start, counter)); + } + + public void Update(string key, DateTime start, int counter) + { + _requests[key] = (start, counter); + } + } + +} diff --git a/RateLimiter.Tests/UnitTests/Rules/RequestsPerPeriodRuleUnitTests.cs b/RateLimiter.Tests/UnitTests/Rules/RequestsPerPeriodRuleUnitTests.cs new file mode 100644 index 00000000..7e192f13 --- /dev/null +++ b/RateLimiter.Tests/UnitTests/Rules/RequestsPerPeriodRuleUnitTests.cs @@ -0,0 +1,83 @@ +using Moq; +using RateLimiter.Core; +using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System; +using System.Diagnostics.Metrics; +using System.Threading.Tasks; +using Xunit; + +public class RequestsPerPeriodRuleUnitTests +{ + [Fact] + public async Task IsRequestAllowedAsync_RequestWithinLimit_ReturnsTrue() + { + // Arrange + var limit = 5; + var period = TimeSpan.FromMinutes(1); + var mockRepository = new Mock(); + + mockRepository.Setup(repo => repo.GetOrAdd(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((DateTime.UtcNow, 0)); // Whenever GetOrAdd is called with any string, DateTime, and int, it returns (DateTime.UtcNow, 0) + + var rule = new RequestsPerPeriodRule(limit, period, mockRepository.Object); + + var context = new ClientRequestContext("client1", "/api/resource"); + + // Act + var result = await rule.IsRequestAllowedAsync(context); + + // Assert + Assert.True(result); + } + + [Fact] + public async Task IsRequestAllowedAsync_RequestExceedsLimit_ReturnsFalse() + { + // Arrange + var limit = 5; + var period = TimeSpan.FromMinutes(1); + var mockRepository = new Mock(); + mockRepository.Setup(repo => repo.GetOrAdd(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((DateTime.UtcNow, limit)); // Whenever GetOrAdd is called with any string, DateTime, and int, it returns (DateTime.UtcNow, limit) + + var rule = new RequestsPerPeriodRule(limit, period, mockRepository.Object); + var context = new ClientRequestContext("client1", "/api/resource"); + + // Act + for (int i = 0; i < limit; i++) + { + await rule.IsRequestAllowedAsync(context); + } + var result = await rule.IsRequestAllowedAsync(context); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task IsRequestAllowedAsync_RequestAfterPeriod_ReturnsTrue() + { + // Arrange + var limit = 5; + var period = TimeSpan.FromSeconds(1); + var mockRepository = new Mock(); + + mockRepository.SetupSequence(repo => repo.GetOrAdd(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((DateTime.UtcNow, limit)) //First call: Simulates the user has already hit the limit. + .Returns((DateTime.UtcNow.Add(period), 0)); //Second call: Simulates that a new time window has started, and the counter is reset. + + var rule = new RequestsPerPeriodRule(limit, period, mockRepository.Object); + var context = new ClientRequestContext("client1", "/api/resource"); + + // Act + for (int i = 0; i < limit; i++) + { + await rule.IsRequestAllowedAsync(context); + } + await Task.Delay(period); + var result = await rule.IsRequestAllowedAsync(context); + + // Assert + Assert.True(result); + } +} diff --git a/RateLimiter/Rate-Limiter.jpg b/RateLimiter/Rate-Limiter.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a23b1af98aada512bc467317489d5b83a06d01d0 GIT binary patch literal 33751 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

oriR%e>zUJNI@_z0?=#XIQg93N4*Itz* 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 InvokeAsync(HttpContext context)**: Evaluates the request based on the configured rules and returns a RateLimiterResult indicating whether the request is allowed. + + +### Rules + +The Rules folder contains the base definition for the rate limiting rules and also some sample implementations. + +***Rule Base Class and Contract*** + +- **RateLimiterRuleBase**: base class that provides the foundation for all rules. +- **IRateLimterRule**: An interface that defines the base contract for rate limiting rules. +- **IRateLimiterNotification**: Additional behavior that may or may not be implemented by the rules. + +***Rules Sample Implementations*** + +Folder containing the concrete implementation of the rules. In a real scenario, the rules could be separate components, implemented in isolated Class Libraries that could be loaded via Reflection. In this example, only the **RequestsPerPeriodRule** rule has an implementation. + +### Rate Limiter Configuration + +The **ConfigurationStorageProvider** folder contains classes and interfaces that handle the storage and retrieval of rate limiter endpoints and rules configurations. This allows the rate limiter service to dynamically load and update its configuration from various storage providers, such as Redis and file storage. + +*Configuration Provider Definition* + +- **IConfigurationStorageProvider**: The IConfigurationStorageProvider interface defines the contract for a storage provider that can save and load rate limiter configurations. + +For example: + +``` +/api/endpont1 --> Rule A +/api/endpont2 --> Rule B +/api/endpont3 --> Rule A and Rule B + +``` + +- **ConfigurationProvider**: The ConfigurationProvider class is a high-level abstraction that uses an IConfigurationStorageProvider to save and load rate limiter configurations. + +*Configuration Providers Implementation Samples* + +- **RedisConfigurationStorage**: The RedisConfigurationStorage class is an implementation of the IConfigurationStorageProvider interface that uses Redis as the storage backend. +- **FileConfigurationStorage**: The FileConfigurationStorage class is an implementation of the IConfigurationStorageProvider interface that uses file storage as the backend. From 7704f8ad053ec35b73879106001c5ce16f04ae67 Mon Sep 17 00:00:00 2001 From: Felipe Cembranelli Date: Sun, 23 Mar 2025 16:17:05 -0700 Subject: [PATCH 6/6] improving documentation --- RateLimiter/Readme.md | 55 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/RateLimiter/Readme.md b/RateLimiter/Readme.md index ae101185..5c1674f7 100644 --- a/RateLimiter/Readme.md +++ b/RateLimiter/Readme.md @@ -14,7 +14,6 @@ It is composed by the following components: ### Rate Limiter Core The **RateLimiterService** class is the core component that handles rate limiting logic. It uses the pre-configured rules provided by the "Rules" component to determine whether a request should be allowed or denied. This component is the orchestrator of the Rate Limiter solution. -Methods **Task InvokeAsync(HttpContext context)**: Evaluates the request based on the configured rules and returns a RateLimiterResult indicating whether the request is allowed. @@ -29,15 +28,21 @@ The Rules folder contains the base definition for the rate limiting rules and al - **IRateLimterRule**: An interface that defines the base contract for rate limiting rules. - **IRateLimiterNotification**: Additional behavior that may or may not be implemented by the rules. + +***How to create a new rule?*** + +To create a new rule, you must extend the base class **RateLimiterRuleBase** and implement the abstract method **IsRequestAllowedAsync**. If you want to add other behaviors, you can also implement additional interfaces, such as **IRateLimiterNotification**. + ***Rules Sample Implementations*** Folder containing the concrete implementation of the rules. In a real scenario, the rules could be separate components, implemented in isolated Class Libraries that could be loaded via Reflection. In this example, only the **RequestsPerPeriodRule** rule has an implementation. + ### Rate Limiter Configuration The **ConfigurationStorageProvider** folder contains classes and interfaces that handle the storage and retrieval of rate limiter endpoints and rules configurations. This allows the rate limiter service to dynamically load and update its configuration from various storage providers, such as Redis and file storage. -*Configuration Provider Definition* +***Configuration Provider Definition*** - **IConfigurationStorageProvider**: The IConfigurationStorageProvider interface defines the contract for a storage provider that can save and load rate limiter configurations. @@ -50,9 +55,53 @@ For example: ``` -- **ConfigurationProvider**: The ConfigurationProvider class is a high-level abstraction that uses an IConfigurationStorageProvider to save and load rate limiter configurations. +***How to add a new Rule for an Enpoint?*** + +- **ConfigurationProvider**: The ConfigurationProvider class is a high-level abstraction that uses an IConfigurationStorageProvider to save and load rule configurations for the endpoints. + +In the example below, using the **ConfigurationProvider** we are configuring rules A, B and C for "endpoint4": + +``` +await _configurationProvider.SaveConfigAsync("/api/endpoint4", new List { + new RuleA(1000, 60, ruleCounterRepository), + new RuleB(100, 60, ruleCounterRepository), + new RuleC(2000, 30, ruleCounterRepository) +}); + +``` *Configuration Providers Implementation Samples* - **RedisConfigurationStorage**: The RedisConfigurationStorage class is an implementation of the IConfigurationStorageProvider interface that uses Redis as the storage backend. - **FileConfigurationStorage**: The FileConfigurationStorage class is an implementation of the IConfigurationStorageProvider interface that uses file storage as the backend. + +## Current RateLimiterService Design Limitation + +The current implementation of the RateLimiterService class does not allow for the incorporation of new logic around rule processing. +For example, currently a request must pass all rules configured for a given endpoint in order to reach the API server ("all must pass rule"). + +If we wanted to implement a different rule, such as "any can pass rule" or "priority based rule", we could incorporate the following improvement into the class design: + +``` +public class AllRulesMustPassEvaluator : IRuleEvaluator +{ + public async Task EvaluateAsync(IEnumerable rules, ClientRequestContext context) + { + foreach (var rule in rules) + { + if (!await rule.IsRequestAllowedAsync(context)) + return false; + } + + return true; + } +} +``` + +Then we could inject the **RuleEvaluator** into **RateLimiterService** and replace the current "foreach" with a call like the one below: + +``` +... +return await _ruleEvaluator.EvaluateAsync(rules, requestContext); +... +``` \ No newline at end of file