diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..29b50065 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,77 @@ -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 +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using System.Collections.Concurrent; +namespace RateLimiter.Tests; + +[TestFixture] +public class RateLimiterTests +{ + private RateLimiter _rateLimiter; + private Dictionary? _factors; + private ConcurrentQueue _log; + + [SetUp] + public void Setup() + { + _rateLimiter = new RateLimiter(); + _factors = new() { { "k", "v" } }; + _log = new(); + } + + [Test] + public void IsRequestAllowed_ShouldReturnTrue_WhenAllRulesAllow() + { + // Arrange + var rule = new MockRateLimitingRule(_log) { IsAllowed = true }; + _rateLimiter.AddGlobalRule(rule); + + // Act + var result = _rateLimiter.IsRequestAllowed("resource1", "client1", null); + + // Assert + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_ShouldReturnFalse_WhenAnyRuleDisallows() + { + // Arrange + var rule = new MockRateLimitingRule(_log) { IsAllowed = false }; + _rateLimiter.AddGlobalRule(rule); + + // Act + var result = _rateLimiter.IsRequestAllowed("resource1", "client1", null); + + // Assert + Assert.IsFalse(result); + } + + [Test] + public void GetRequestLog_ShouldReturnLogEntries() + { + // Arrange + var rule = new MockRateLimitingRule(_log) { IsAllowed = true }; + _rateLimiter.AddGlobalRule(rule); + _rateLimiter.IsRequestAllowed("resource1", "client1", null); + + // Act + var log = _rateLimiter.GetRequestLog(); + + // Assert + Assert.AreEqual(1, log.Count()); + } + } + + public class MockRateLimitingRule(IEnumerable log) : BaseRule(log) + { + public bool IsAllowed { get; set; } + + public override bool IsRequestAllowed(string clientId, Dictionary? factors) + { + return IsAllowed; + } + } + + + diff --git a/RateLimiter.Tests/TimespanSinceLastCallRuleTests.cs b/RateLimiter.Tests/TimespanSinceLastCallRuleTests.cs new file mode 100644 index 00000000..568dfa62 --- /dev/null +++ b/RateLimiter.Tests/TimespanSinceLastCallRuleTests.cs @@ -0,0 +1,68 @@ +using NUnit.Framework; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class TimespanSinceLastCallRuleTests + { + private TimespanSinceLastCallRule _rule; + private Dictionary _factors; + private ConcurrentQueue _log; + + [SetUp] + public void Setup() + { + _log = new(); + _rule = new(TimeSpan.FromSeconds(0.1), _log); + _factors = new() { {"k", "v" } }; + } + + [Test] + public void IsRequestAllowed_FirstRequest_ReturnsTrue() + { + var result = _rule.IsRequestAllowed("client1", _factors); + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_RequestWithinTimespan_ReturnsFalse() + { + var isAllowed = _rule.IsRequestAllowed("client1", _factors); // First request + System.Threading.Thread.Sleep(10); // Wait for 0.01 seconds + + var entry = new RequestLogEntry + { + ClientId = "client1", + Resource = "resource", + Timestamp = DateTime.UtcNow, + IsAllowed = isAllowed, + Factors = new(_factors) + }; + + _log.Enqueue(entry); + + + + var result = _rule.IsRequestAllowed("client1", _factors); + Assert.IsFalse(result); + } + + [Test] + public void IsRequestAllowed_RequestAfterTimespan_ReturnsTrue() + { + + + + + _rule.IsRequestAllowed("client1", _factors); // First request + System.Threading.Thread.Sleep(110); // Wait for 0.11 seconds + + var result = _rule.IsRequestAllowed("client1", _factors); + Assert.IsTrue(result); + } + } +} \ No newline at end of file diff --git a/RateLimiter.Tests/XRequestsPerTimespanRuleTests.cs b/RateLimiter.Tests/XRequestsPerTimespanRuleTests.cs new file mode 100644 index 00000000..a02ab0b5 --- /dev/null +++ b/RateLimiter.Tests/XRequestsPerTimespanRuleTests.cs @@ -0,0 +1,101 @@ +using NUnit.Framework; + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class XRequestsPerTimespanRuleTests + { + //private TimespanSinceLastCallRule _rule; + private Dictionary _factors; + private ConcurrentQueue _log; + + [SetUp] + public void Setup() + { + //_rule = new(TimeSpan.FromSeconds(0.1)); + _factors = new() { { "k", "v" } }; + _log = new(); + //_rule.CommonLog = _log; + } + + + [Test] + public void IsRequestAllowed_FirstRequest_ReturnsTrue() + { + var rule = new XRequestsPerTimespanRule(5, TimeSpan.FromSeconds(0.1), _log); + var result = rule.IsRequestAllowed("client1", null); + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_WithinLimit_ReturnsTrue() + { + var rule = new XRequestsPerTimespanRule(5, TimeSpan.FromSeconds(0.1), _log); + for (int i = 0; i < 4; i++) + { + var isAllowed = rule.IsRequestAllowed("client1", _factors); + var entry = new RequestLogEntry + { + ClientId = "client1", + Resource = "resource", + Timestamp = DateTime.UtcNow, + IsAllowed = isAllowed, + Factors = new(_factors) + }; + + _log.Enqueue(entry); + } + var result = rule.IsRequestAllowed("client1", null); + Assert.IsTrue(result); + } + + [Test] + public void IsRequestAllowed_ExceedsLimit_ReturnsFalse() + { + var rule = new XRequestsPerTimespanRule(5, TimeSpan.FromSeconds(0.1), _log); + for (int i = 0; i < 5; i++) + { + var isAllowed = rule.IsRequestAllowed("client1", _factors); + var entry = new RequestLogEntry + { + ClientId = "client1", + Resource = "resource", + Timestamp = DateTime.UtcNow, + IsAllowed = isAllowed, + Factors = new(_factors) + }; + + _log.Enqueue(entry); + } + var result = rule.IsRequestAllowed("client1", null); + Assert.IsFalse(result); + } + + [Test] + public void IsRequestAllowed_AfterTimespan_ReturnsTrue() + { + var rule = new XRequestsPerTimespanRule(5, TimeSpan.FromSeconds(0.1), _log); + for (int i = 0; i < 5; i++) + { + var isAllowed = rule.IsRequestAllowed("client1", _factors); + var entry = new RequestLogEntry + { + ClientId = "client1", + Resource = "resource", + Timestamp = DateTime.UtcNow, + IsAllowed = isAllowed, + Factors = new(_factors) + }; + + _log.Enqueue(entry); + } + System.Threading.Thread.Sleep(TimeSpan.FromSeconds(0.1)); + var result = rule.IsRequestAllowed("client1", null); + Assert.IsTrue(result); + } + } +} \ No newline at end of file diff --git a/RateLimiter.sln.DotSettings.user b/RateLimiter.sln.DotSettings.user new file mode 100644 index 00000000..d3459605 --- /dev/null +++ b/RateLimiter.sln.DotSettings.user @@ -0,0 +1,4 @@ + + <SessionState ContinuousTestingMode="0" IsActive="True" Name="All tests from &lt;RateLimiter.Tests&gt;" xmlns="urn:schemas-jetbrains-com:jetbrains-ut-session"> + <Project Location="C:\Source\TulacoRateLimiter\RateLimiter.Tests" Presentation="&lt;RateLimiter.Tests&gt;" /> +</SessionState> \ No newline at end of file diff --git a/RateLimiter/BaseRule.cs b/RateLimiter/BaseRule.cs new file mode 100644 index 00000000..c346eaad --- /dev/null +++ b/RateLimiter/BaseRule.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + + +/// +/// Interface for rate limiting rules +/// +public interface IRateLimitingRule +{ + bool IsRequestAllowed(string clientId, Dictionary? factors = null); + Dictionary? Factors { get; set; } + IEnumerable CommonLog { get; set; } +} + + +/// +/// Base class for rate limiting rules +/// +public abstract class BaseRule(IEnumerable log) : IRateLimitingRule +{ + /// + /// Factors that can be used to determine if the rule is applicable + /// + public Dictionary? Factors { get; set; } + + + /// + /// Common log of requests + /// + public IEnumerable CommonLog { get; set; } = log; + + /// + /// Check if the request is allowed + /// + /// + /// + /// + public virtual bool IsRequestAllowed(string clientId, Dictionary? factors) + { + // If factors are not set or are not used, the rule is not applicable + return Factors != null + && factors?.ContainsAllElements(Factors) != true; + } +} + + +/// +/// Extension methods for dictionaries +/// +public static class DictionaryComparer +{ + public static bool ContainsAllElements( + this Dictionary mainDict, + Dictionary subDict) where TKey : notnull + { + return subDict.All(kv => mainDict.ContainsKey(kv.Key) && EqualityComparer.Default.Equals(mainDict[kv.Key], kv.Value)); + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..ea8115f0 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + + +/// +/// Class to limit the number of requests +/// +public class RateLimiter +{ + private readonly Dictionary> _resourceRules = new(); + private readonly List _globalRules = []; + private readonly ConcurrentQueue _requestLog = []; //todo: use cache + + /// + /// Time to keep the log entries + /// + public TimeSpan LogRetentionTime { get; set; } = TimeSpan.FromDays(1); //todo: make it configurable + + + /// + /// Add a rule for a specific resource + /// + /// + /// + public void AddRule(string resource, IRateLimitingRule rule) + { + if (!_resourceRules.ContainsKey(resource)) + _resourceRules[resource] = []; + + _resourceRules[resource].Add(rule); + } + + + /// + /// Add a global rule + /// + /// + public void AddGlobalRule(IRateLimitingRule rule) + { + _globalRules.Add(rule); + } + + + /// + /// Remove old entries from the log + /// + private void RemoveOldEntries() + { + var now = DateTime.UtcNow; + while (_requestLog.TryPeek(out var first)) + { + if (now - first.Timestamp <= LogRetentionTime) + break; + _requestLog.TryDequeue(out _); + } + } + + + /// + /// Check if a request is allowed + /// + /// + /// + /// + /// + public bool IsRequestAllowed(string resource, string clientId, Dictionary? factors) + { + var rulesToCheck = new List(_globalRules); + + if (_resourceRules.TryGetValue(resource, out var resourceRule)) + rulesToCheck.AddRange(resourceRule); + + RemoveOldEntries(); + + var isAllowed = rulesToCheck.All(rule => rule.IsRequestAllowed(clientId, factors)); + + var entry = new RequestLogEntry + { + ClientId = clientId, + Resource = resource, + Timestamp = DateTime.UtcNow, + IsAllowed = isAllowed, + }; + if (factors != null) + entry.Factors = new Dictionary(factors); + _requestLog.Enqueue(entry); + + return isAllowed; + } + + + /// + /// Get the request log + /// + /// + public IEnumerable GetRequestLog() => _requestLog.ToArray(); + + +} + + + + diff --git a/RateLimiter/RequestLogEntry.cs b/RateLimiter/RequestLogEntry.cs new file mode 100644 index 00000000..1955b8ed --- /dev/null +++ b/RateLimiter/RequestLogEntry.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter +{ + /// + /// Class to limit the number of requests per timespan + /// + public class RequestLogEntry + { + public string ClientId { get; set; } + public string Resource { get; set; } + public DateTime Timestamp { get; set; } + public bool IsAllowed { get; set; } + public Dictionary? Factors { get; set; } + } +} diff --git a/RateLimiter/TimespanSinceLastCallRule.cs b/RateLimiter/TimespanSinceLastCallRule.cs new file mode 100644 index 00000000..2fd72eaa --- /dev/null +++ b/RateLimiter/TimespanSinceLastCallRule.cs @@ -0,0 +1,40 @@ +using System.Collections.Generic; +using System; +using System.Linq; + +namespace RateLimiter; + +/// +/// Rule to limit the certain timespan has passed since the last call +/// +public class TimespanSinceLastCallRule(TimeSpan requiredTimespan, IEnumerable log) : BaseRule(log) +{ + public override bool IsRequestAllowed(string clientId, Dictionary? factors) + { + if (base.IsRequestAllowed(clientId, factors)) + return true; + + var now = DateTime.UtcNow; + var lastDeniedRequest = CommonLog.LastOrDefault(entry => + entry.ClientId == clientId + && entry.IsAllowed == false + && (Factors == null + || entry.Factors?.ContainsAllElements(Factors) == true)); + + // If the timespan from the last denied request has not passed yet, allow the request + if (lastDeniedRequest != null && now - lastDeniedRequest.Timestamp <= requiredTimespan) + return true; + + var lastRequest = CommonLog.LastOrDefault(entry => + entry.ClientId == clientId + && (Factors == null + || entry.Factors?.ContainsAllElements(Factors) == true)); + + return lastRequest == null || now - lastRequest.Timestamp >= requiredTimespan; + } + +} + + + + diff --git a/RateLimiter/XRequestsPerTimespanRule.cs b/RateLimiter/XRequestsPerTimespanRule.cs new file mode 100644 index 00000000..7976b99e --- /dev/null +++ b/RateLimiter/XRequestsPerTimespanRule.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + +/// +/// This rule limits the number of requests a client can make within a specified timespan. +/// It checks the number of requests made by a client within the given timespan and denies further requests if the limit is exceeded. +/// +public class XRequestsPerTimespanRule(int maxRequests, TimeSpan timespan, IEnumerable log) : BaseRule(log) +{ + public override bool IsRequestAllowed(string clientId, Dictionary? factors) + { + if (base.IsRequestAllowed(clientId, factors)) + return true; + + var now = DateTime.UtcNow; + var lastDeniedRequest = CommonLog.LastOrDefault(entry => + entry.ClientId == clientId + && entry.IsAllowed == false + && (Factors == null + || entry.Factors?.ContainsAllElements(Factors) == true)); + + //first time of the log entry that should pass the timespan + var lastTime = now - timespan; + //chose the latest denied time as the start time for the count if it passes the timespan + if (lastDeniedRequest != null && lastDeniedRequest.Timestamp > lastTime) + lastTime = lastDeniedRequest.Timestamp; + + var requestsCount = CommonLog?.Count(entry => entry.ClientId == clientId && entry.Timestamp > lastTime); + + return requestsCount < maxRequests; + } + + +} + +