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/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/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/Core/RateLimiterServiceUnitTests.cs b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs new file mode 100644 index 00000000..89cf04a9 --- /dev/null +++ b/RateLimiter.Tests/UnitTests/Core/RateLimiterServiceUnitTests.cs @@ -0,0 +1,200 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using Moq; +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; +using Xunit; + +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/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/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 new file mode 100644 index 00000000..82dcbbd7 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/FileConfigurationStorage.cs @@ -0,0 +1,19 @@ +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RateLimiter.ConfigurationStorageProvider.Implementations +{ + internal class FileConfigurationStorage : IConfigurationStorageProvider + { + public Task?> LoadAsync(string endpoint) + { + throw new System.NotImplementedException(); + } + + public Task SaveAsync(string key, List values) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs new file mode 100644 index 00000000..87bc5cd5 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Implementations/RedisConfigurationStorage.cs @@ -0,0 +1,20 @@ +using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace RateLimiter.ConfigurationStorageProvider.Implementations +{ + internal class RedisConfigurationStorage : IConfigurationStorageProvider + { + public Task?> LoadAsync(string endpoint) + { + throw new System.NotImplementedException(); + } + + public Task SaveAsync(string key, List values) + { + throw new System.NotImplementedException(); + } + } +} diff --git a/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs new file mode 100644 index 00000000..3ee5b21e --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IConfigurationStorageProvider.cs @@ -0,0 +1,10 @@ +using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; +using System.Collections.Generic; +using System.Threading.Tasks; + +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..c9bc0b74 --- /dev/null +++ b/RateLimiter/ConfigurationStorageProvider/Interfaces/IRateLimiterConfigRepository.cs @@ -0,0 +1,9 @@ +using RateLimiter.Rules; +using RateLimiter.Rules.Interfaces; +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..21827c4b --- /dev/null +++ b/RateLimiter/Core/ClientRequestContext.cs @@ -0,0 +1,27 @@ +using System; + +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..dac7f816 --- /dev/null +++ b/RateLimiter/Core/RateLimiterService.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using RateLimiter.ConfigurationStorageProvider; +using RateLimiter.Rules.Interfaces; +using System; +using System.Threading.Tasks; + +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/Rate-Limiter.jpg b/RateLimiter/Rate-Limiter.jpg new file mode 100644 index 00000000..a23b1af9 Binary files /dev/null and b/RateLimiter/Rate-Limiter.jpg differ 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/Readme.md b/RateLimiter/Readme.md new file mode 100644 index 00000000..5c1674f7 --- /dev/null +++ b/RateLimiter/Readme.md @@ -0,0 +1,107 @@ +# Rate Limiter + +## Overview + +The Rate Limiter Service is designed to control the rate of requests to various endpoints in an application. It supports different rate limiting rules, such as limiting the number of requests per period and ensuring a minimum time since the last call. The service is highly configurable and can be integrated into any ASP.NET Core application. + +It is composed by the following components: + +![ALT TEXT](Rate-Limiter.jpg) + +## Rate Limiter Service 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. + +**Task 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. + + +***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*** + +- **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 + +``` + +***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 diff --git a/RateLimiter/Rules/Base/RateLimiterRuleBase.cs b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs new file mode 100644 index 00000000..e81ac543 --- /dev/null +++ b/RateLimiter/Rules/Base/RateLimiterRuleBase.cs @@ -0,0 +1,20 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; +using System.Threading.Tasks; + +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..31503160 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RegionalRateLimiterRule/RegionalRateLimiterRule.cs @@ -0,0 +1,17 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Interfaces; +using System.Threading.Tasks; + +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..c455847d --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/IRequestPerPeriodRuleRepository.cs @@ -0,0 +1,11 @@ +using System; + +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..ac047c31 --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/InMemoryRequestPerPeriodRuleRepository.cs @@ -0,0 +1,19 @@ +using RateLimiter.Rules.Implementations.RequestPerPeriodRule; +using System; +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..23a4052d --- /dev/null +++ b/RateLimiter/Rules/Implementations/RequestPerPeriodRule/RequestPerPeriodRule.cs @@ -0,0 +1,41 @@ +using RateLimiter.Core; +using RateLimiter.Rules.Base; +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; + +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..425022d2 --- /dev/null +++ b/RateLimiter/Rules/Implementations/TimeOfDayRule/TimeOfDayRule.cs @@ -0,0 +1,36 @@ +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 +{ + 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..104a9192 --- /dev/null +++ b/RateLimiter/Rules/Implementations/TimeSinceLastCallRule/TimeSinceLastCallRule.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +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..568baa4d --- /dev/null +++ b/RateLimiter/Rules/Interfaces/IRateLimiterNotification.cs @@ -0,0 +1,9 @@ +using System.Threading.Tasks; + +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..57419b77 --- /dev/null +++ b/RateLimiter/Rules/Interfaces/IRateLimiterRule.cs @@ -0,0 +1,11 @@ +using RateLimiter.Core; +using System.Threading.Tasks; + +namespace RateLimiter.Rules.Interfaces +{ + public interface IRateLimiterRule + { + public Task IsRequestAllowedAsync(ClientRequestContext context); + } + +}