diff --git a/README.md b/README.md index 47e73daa..89ac99c0 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,15 @@ If you have any questions or concerns, please submit them as a [GitHub issue](ht You should [fork](https://help.github.com/en/github/getting-started-with-github/fork-a-repo) the project and [create a pull request](https://help.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request-from-a-fork) named as `FirstName LastName` once you are finished. Good luck! + +___________________________ + +Here is a bried overview of the added classes: + +RateLimitingManager helps to configure the set of rules for a given resource. + +IRateLimitRule is an interface for all rules which allows flexible control over the set of rules. + +CoolingPeriodRule can be used to control if the amount of time passed since the previous request (from the same resource and client) is too small and the request should be blocked. + +FixedWindowRule helps to restrict the amount of allowed requests over the selected period of time. diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..c87c544a 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,88 @@ -using NUnit.Framework; +using System; +using System.Collections.Generic; +using NUnit.Framework; +using RateLimiter.RateLimitRules; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } +[TestFixture] + public class RateLimiterTests + { + private RateLimitingManager _rateLimitingManager; + + [SetUp] + public void Setup() + { + _rateLimitingManager = new RateLimitingManager(); + } + + [Test] + public void Test_FixedWindowRule_AllowsRequestWithinLimit() + { + var rule = new FixedWindowRule(maxRequests: 3, windowSize: TimeSpan.FromSeconds(10)); + _rateLimitingManager.ConfigureResourceRules("TestResource", new List { rule }); + + var context = new RequestContext("TestClient", "TestResource"); + + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + } + + [Test] + public void Test_FixedWindowRule_RejectsRequestExceedingLimit() + { + var rule = new FixedWindowRule(maxRequests: 3, windowSize: TimeSpan.FromSeconds(10)); + _rateLimitingManager.ConfigureResourceRules("TestResource", new List { rule }); + + var context = new RequestContext("TestClient", "TestResource"); + + _rateLimitingManager.IsRequestAllowed(context); + _rateLimitingManager.IsRequestAllowed(context); + _rateLimitingManager.IsRequestAllowed(context); + + Assert.IsFalse(_rateLimitingManager.IsRequestAllowed(context)); + } + + [Test] + public void Test_CoolingPeriodRule_AllowsRequestAfterCoolingPeriod() + { + var rule = new CoolingPeriodRule(TimeSpan.FromSeconds(5)); + _rateLimitingManager.ConfigureResourceRules("TestResource", new List { rule }); + + var context = new RequestContext("TestClient", "TestResource"); + + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + Assert.IsFalse(_rateLimitingManager.IsRequestAllowed(context)); + + System.Threading.Thread.Sleep(5000); + + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + } + + [Test] + public void Test_CombinedRules_AllowsRequestWhenBothRulesPass() + { + var fixedWindowRule = new FixedWindowRule(maxRequests: 3, windowSize: TimeSpan.FromSeconds(10)); + var coolingPeriodRule = new CoolingPeriodRule(TimeSpan.FromSeconds(5)); + _rateLimitingManager.ConfigureResourceRules("TestResource", [fixedWindowRule, coolingPeriodRule]); + + var context = new RequestContext("TestClient", "TestResource"); + + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + Assert.IsFalse(_rateLimitingManager.IsRequestAllowed(context)); + + System.Threading.Thread.Sleep(5000); + + Assert.IsTrue(_rateLimitingManager.IsRequestAllowed(context)); + + _rateLimitingManager.IsRequestAllowed(context); + _rateLimitingManager.IsRequestAllowed(context); + + Assert.IsFalse(_rateLimitingManager.IsRequestAllowed(context)); + } + } } \ No newline at end of file diff --git a/RateLimiter/IRateLimitRule.cs b/RateLimiter/IRateLimitRule.cs new file mode 100644 index 00000000..28e0c4e7 --- /dev/null +++ b/RateLimiter/IRateLimitRule.cs @@ -0,0 +1,8 @@ +using System; + +namespace RateLimiter; + +public interface IRateLimitRule +{ + bool IsRequestAllowed(RequestContext context, DateTime requestTime); +} \ No newline at end of file diff --git a/RateLimiter/RateLimitRules/CoolingPeriodRule.cs b/RateLimiter/RateLimitRules/CoolingPeriodRule.cs new file mode 100644 index 00000000..6367d4ba --- /dev/null +++ b/RateLimiter/RateLimitRules/CoolingPeriodRule.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.RateLimitRules; + +public class CoolingPeriodRule: IRateLimitRule +{ + private readonly TimeSpan _minimumInterval; + + private readonly Dictionary<(string clientId, string resource), DateTime> _lastRequest = new(); + + public CoolingPeriodRule(TimeSpan minimumInterval) + { + _minimumInterval = minimumInterval; + } + + public bool IsRequestAllowed(RequestContext context, DateTime requestTime) + { + var key = (context.ClientId, context.Resource); + if (_lastRequest.TryGetValue(key, out var lastTime)) + { + if (requestTime - lastTime < _minimumInterval) + { + return false; + } + } + + _lastRequest[key] = requestTime; + return true; + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimitRules/FixedWindowRule.cs b/RateLimiter/RateLimitRules/FixedWindowRule.cs new file mode 100644 index 00000000..99b5b967 --- /dev/null +++ b/RateLimiter/RateLimitRules/FixedWindowRule.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace RateLimiter.RateLimitRules; + +public class FixedWindowRule: IRateLimitRule +{ + private readonly int _maxRequests; + private readonly TimeSpan _windowSize; + + private readonly Dictionary<(string clientId, string resource), List> _requestsInfo = new(); + + public FixedWindowRule(int maxRequests, TimeSpan windowSize) + { + _maxRequests = maxRequests; + _windowSize = windowSize; + } + + public bool IsRequestAllowed(RequestContext context, DateTime requestTime) + { + var key = (context.ClientId, context.Resource); + + if (!_requestsInfo.ContainsKey(key)) + { + _requestsInfo[key] = new List(); + } + + var earliestAllowedTime = requestTime - _windowSize; + _requestsInfo[key].RemoveAll(dt => dt < earliestAllowedTime); + + if (_requestsInfo[key].Count >= _maxRequests) + { + return false; + } + + _requestsInfo[key].Add(requestTime); + return true; + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..156f3b69 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,20 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter; + +public class RateLimiter +{ + private readonly List _rules; + + public RateLimiter(List rules) + { + _rules = rules ?? []; + } + public bool IsRequestAllowed(RequestContext context) + { + var now = DateTime.UtcNow; + return _rules.All(rule => rule.IsRequestAllowed(context, now)); + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimitingManager.cs b/RateLimiter/RateLimitingManager.cs new file mode 100644 index 00000000..e7afe3f6 --- /dev/null +++ b/RateLimiter/RateLimitingManager.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace RateLimiter; + +public class RateLimitingManager +{ + private readonly Dictionary _resourceLimiters = new(); + + public void ConfigureResourceRules(string resource, List rules) + { + _resourceLimiters[resource] = new RateLimiter(rules); + } + + public bool IsRequestAllowed(RequestContext context) + { + return !_resourceLimiters.TryGetValue(context.Resource, out var limiter) || limiter.IsRequestAllowed(context); + } +} \ No newline at end of file diff --git a/RateLimiter/RequestContext.cs b/RateLimiter/RequestContext.cs new file mode 100644 index 00000000..718dc23f --- /dev/null +++ b/RateLimiter/RequestContext.cs @@ -0,0 +1,14 @@ +namespace RateLimiter; + +public class RequestContext +{ + public string ClientId { get; } + + public string Resource { get; } + + public RequestContext(string clientId, string resource) + { + ClientId = clientId; + Resource = resource; + } +} \ No newline at end of file