diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..c5ae134b 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,276 @@ using NUnit.Framework; +using System; +using System.Threading; +using RateLimiter.DataStore; +using System.Collections.Generic; +using RateLimiter.Ruls.Abstract; +using RateLimiter.Ruls; +using RateLimiter.User; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest -{ - [Test] - public void Example() - { - Assert.That(true, Is.True); - } +{ + [Test] + public void IsAllowedWithNoRules() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedWithAllRulesPositive() + { + Cashing.Clear(); + Dictionary restrictionsByCountry = new() + { + { "US", 2 }, + { "GE", 1 } + }; + + // Arrange + var rules = new RateLimiterRuleDecorator[] { + new IpWhiteListRule(new string[] { "192.168.18.22", "192.168.18.23" }), + new RequestMinAllowedTimeRule(TimeSpan.FromSeconds(1)), + new IpBlackListRule(new string[] { "192.168.18.48", "192.168.18.49" }), + new MaxRequestAmountInTimeSpanRule(TimeSpan.FromSeconds(1),5), + new MaxRequestAmountInTimeSpanByCountryRule(TimeSpan.FromSeconds(1), restrictionsByCountry, 2), + }; + + var userData = new UserData() { CountryCode = "US",Token="tempToken",IpAddress = "192.168.18.22" }; + + // Act + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IpWhtieListRuleNegative() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new IpWhiteListRule(new string[] { "192.168.18.22", "192.168.18.23" }) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.30" }; + + // Act + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + + // Assert + Assert.IsFalse(result); + } + [Test] + public void IpBlackListRulePositive() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new IpBlackListRule(new string[] { "192.168.18.22", "192.168.18.23" }) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.30" }; + + // Act + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IpBlackListRuleNegative() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new IpBlackListRule(new string[] { "192.168.18.22", "192.168.18.23" }) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsFalse(result); + } + [Test] + public void IsAllowedWithRequestMinAllowedTimePositive() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new RequestMinAllowedTimeRule(TimeSpan.FromSeconds(1)) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + Thread.Sleep(2000); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedRequestMinAllowedTimeNegative() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new RequestMinAllowedTimeRule(TimeSpan.FromSeconds(1)) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsFalse(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanRulePositive() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanRule(TimeSpan.FromSeconds(1),5) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanRuleWithDeleyPositive() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanRule(TimeSpan.FromSeconds(1), 2) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + Thread.Sleep(2000); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanRuleNegative() + { + Cashing.Clear(); + // Arrange + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanRule(TimeSpan.FromSeconds(1), 2) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsFalse(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanByCountryPositive() + { + Cashing.Clear(); + // Arrange + Dictionary restrictionsByCountry = new() + { + { "US", 2 }, + { "GE", 1 } + }; + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanByCountryRule( TimeSpan.FromSeconds(1), restrictionsByCountry, 2) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanByCountryNegative() + { + Cashing.Clear(); + // Arrange + Dictionary restrictionsByCountry = new() + { + { "US", 2 }, + { "GE", 1 } + }; + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanByCountryRule(TimeSpan.FromSeconds(1), restrictionsByCountry, 1) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsFalse(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanByCountryDefoultPositive() + { + Cashing.Clear(); + // Arrange + Dictionary restrictionsByCountry = new() + { + { "US", 2 }, + { "GE", 1 } + }; + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanByCountryRule(TimeSpan.FromSeconds(1), restrictionsByCountry, 2) }; + + var userData = new UserData() { CountryCode = "TR", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsTrue(result); + } + [Test] + public void IsAllowedMaxRequestAmountInTimeSpanByCountryDefoultNegative() + { + Cashing.Clear(); + // Arrange + Dictionary restrictionsByCountry = new() + { + { "US", 2 }, + { "GE", 1 } + }; + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanByCountryRule(TimeSpan.FromSeconds(1), restrictionsByCountry, 2) }; + + var userData = new UserData() { CountryCode = "TR", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + + // Assert + Assert.IsFalse(result); + } + + } \ No newline at end of file diff --git a/RateLimiter.sln b/RateLimiter.sln index 626a7bfa..77953615 100644 --- a/RateLimiter.sln +++ b/RateLimiter.sln @@ -1,11 +1,11 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 15 -VisualStudioVersion = 15.0.26730.15 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.33502.453 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimiter", "RateLimiter\RateLimiter.csproj", "{36F4BDC6-D3DA-403A-8DB7-0C79F94B938F}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "RateLimiter.Tests", "RateLimiter.Tests\RateLimiter.Tests.csproj", "{C4F9249B-010E-46BE-94B8-DD20D82F1E60}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{9B206889-9841-4B5E-B79B-D5B2610CCCFF}" ProjectSection(SolutionItems) = preProject diff --git a/RateLimiter/ConcreteRateLimiter.cs b/RateLimiter/ConcreteRateLimiter.cs new file mode 100644 index 00000000..824eae84 --- /dev/null +++ b/RateLimiter/ConcreteRateLimiter.cs @@ -0,0 +1,30 @@ +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; + +namespace RateLimiter +{ + public class ConcreteRateLimiter + { + private readonly RateLimiterRuleDecorator? _rateLimiter; + public ConcreteRateLimiter(RateLimiterRuleDecorator[] rules) + { + foreach (var rule in rules) + { + if (_rateLimiter == null) + { + _rateLimiter = rule; + } + else + { + rule.RateLimiterRule = _rateLimiter; + _rateLimiter = rule; + } + } + } + public bool IsAllowed(IUserData userData) + { + return _rateLimiter == null || _rateLimiter.IsAllowed(userData); + } + + } +} diff --git a/RateLimiter/DataStore/Cashing.cs b/RateLimiter/DataStore/Cashing.cs new file mode 100644 index 00000000..6a1ef225 --- /dev/null +++ b/RateLimiter/DataStore/Cashing.cs @@ -0,0 +1,20 @@ +using System.Collections.Concurrent; + +namespace RateLimiter.DataStore +{ + //this is only for demonstration in real world scenario will be used redis or other data store + public static class Cashing + { + private static ConcurrentDictionary Store = new(); + + public static string? Get(string key) { + Store.TryGetValue(key, out string? temp); + return temp; + } + + public static void Set(string key, string value) { + Store.AddOrUpdate(key, value, (existingKey, existingValue) => value); + } + public static void Clear() { Store.Clear(); } + } +} diff --git a/RateLimiter/Ruls/Abstract/IRateLimiterRuleDecorator.cs b/RateLimiter/Ruls/Abstract/IRateLimiterRuleDecorator.cs new file mode 100644 index 00000000..87b88187 --- /dev/null +++ b/RateLimiter/Ruls/Abstract/IRateLimiterRuleDecorator.cs @@ -0,0 +1,10 @@ +using RateLimiter.User; + +namespace RateLimiter.Ruls.Abstract +{ + public abstract class RateLimiterRuleDecorator + { + public RateLimiterRuleDecorator? RateLimiterRule { get; set; } + public abstract bool IsAllowed(IUserData userData); + } +} diff --git a/RateLimiter/Ruls/IpBlackListRule.cs b/RateLimiter/Ruls/IpBlackListRule.cs new file mode 100644 index 00000000..c9cbe4ad --- /dev/null +++ b/RateLimiter/Ruls/IpBlackListRule.cs @@ -0,0 +1,22 @@ +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; +using System; +using System.Linq; + +namespace RateLimiter.Ruls +{ + public class IpBlackListRule : RateLimiterRuleDecorator + { + private readonly string[] _restrictedIpAddresses; + + public IpBlackListRule(string[] allowedIpAddresses) + { + _restrictedIpAddresses = allowedIpAddresses; + } + + public override bool IsAllowed(IUserData userData) + { + return !_restrictedIpAddresses.Contains(userData.IpAddress) && (RateLimiterRule == null || RateLimiterRule.IsAllowed(userData)); + } + } +} diff --git a/RateLimiter/Ruls/IpWhiteListRule.cs b/RateLimiter/Ruls/IpWhiteListRule.cs new file mode 100644 index 00000000..3e0afbb3 --- /dev/null +++ b/RateLimiter/Ruls/IpWhiteListRule.cs @@ -0,0 +1,22 @@ +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; +using System; +using System.Linq; + +namespace RateLimiter.Ruls +{ + public class IpWhiteListRule : RateLimiterRuleDecorator + { + private string[] _allowedIpAddresses; + + public IpWhiteListRule(string[] allowedIpAddresses) + { + _allowedIpAddresses = allowedIpAddresses; + } + + public override bool IsAllowed(IUserData userData) + { + return _allowedIpAddresses.Contains(userData.IpAddress) && (RateLimiterRule == null || RateLimiterRule.IsAllowed(userData)); + } + } +} diff --git a/RateLimiter/Ruls/MaxRequestAmountInTimeSpanByCountryRule.cs b/RateLimiter/Ruls/MaxRequestAmountInTimeSpanByCountryRule.cs new file mode 100644 index 00000000..af064823 --- /dev/null +++ b/RateLimiter/Ruls/MaxRequestAmountInTimeSpanByCountryRule.cs @@ -0,0 +1,49 @@ +using RateLimiter.DataStore; +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Ruls +{ + public class MaxRequestAmountInTimeSpanByCountryRule : RateLimiterRuleDecorator + { + private readonly TimeSpan _timeFrame; + private readonly Dictionary _maxRequestCountByCountry; + private readonly int _maxRequestCountDefoult; + + public MaxRequestAmountInTimeSpanByCountryRule(TimeSpan timeFrame, Dictionary maxRequestCountByCountry, int maxRequestCountDefoult) + { + _timeFrame = timeFrame; + _maxRequestCountByCountry = maxRequestCountByCountry; + _maxRequestCountDefoult = maxRequestCountDefoult; + } + + public override bool IsAllowed(IUserData userData) + { + var result = true; + var key = $"{userData.Token}-MaxRAIT-by-country"; + var storedTimes = Cashing.Get(key); + if (storedTimes != null) + { + var times = storedTimes.Split("_").Select(DateTime.Parse).Where(time => DateTime.Now - time <= _timeFrame).ToList(); + var maxRequestCount = _maxRequestCountByCountry.ContainsKey(key) ? _maxRequestCountByCountry[key] : _maxRequestCountDefoult; + if (times.Count + 1 <= maxRequestCount) + { + times.Add(DateTime.Now); + Cashing.Set(key, string.Join("_", times.Select(x => x.ToString()))); + } + else + { + result = false; + } + } + else + { + Cashing.Set(key, DateTime.Now.ToString()); + } + return result && (RateLimiterRule == null || RateLimiterRule.IsAllowed(userData)); + } + } +} diff --git a/RateLimiter/Ruls/MaxRequestAmountInTimeSpanRule.cs b/RateLimiter/Ruls/MaxRequestAmountInTimeSpanRule.cs new file mode 100644 index 00000000..9aedfb23 --- /dev/null +++ b/RateLimiter/Ruls/MaxRequestAmountInTimeSpanRule.cs @@ -0,0 +1,45 @@ +using RateLimiter.DataStore; +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; +using System; +using System.Linq; + +namespace RateLimiter.Ruls +{ + public class MaxRequestAmountInTimeSpanRule : RateLimiterRuleDecorator + { + private readonly TimeSpan _timeFrame; + private readonly int _maxRequestCount; + + public MaxRequestAmountInTimeSpanRule(TimeSpan TimeFrame, int maxRequestCount) + { + _timeFrame = TimeFrame; + _maxRequestCount = maxRequestCount; + } + + public override bool IsAllowed(IUserData userData) + { + var result = true; + var key = $"{userData.Token}-MaxRAIT"; + var storedTimes = Cashing.Get(key); + if (storedTimes != null) + { + var times = storedTimes.Split("_").Select(DateTime.Parse).Where(time => DateTime.Now - time <= _timeFrame).ToList(); + if (times.Count + 1 <= _maxRequestCount) + { + times.Add(DateTime.Now); + Cashing.Set(key, string.Join("_", times.Select(x => x.ToString()))); + } + else + { + result = false; + } + } + else + { + Cashing.Set(key, DateTime.Now.ToString()); + } + return result && (RateLimiterRule == null || RateLimiterRule.IsAllowed(userData)); + } + } +} diff --git a/RateLimiter/Ruls/RequestMinAllowedTimeRule.cs b/RateLimiter/Ruls/RequestMinAllowedTimeRule.cs new file mode 100644 index 00000000..923b88a7 --- /dev/null +++ b/RateLimiter/Ruls/RequestMinAllowedTimeRule.cs @@ -0,0 +1,37 @@ +using RateLimiter.DataStore; +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; +using System; + +namespace RateLimiter.Ruls +{ + public class RequestMinAllowedTimeRule : RateLimiterRuleDecorator + { + private readonly TimeSpan _minAllowedSpan; + + public RequestMinAllowedTimeRule(TimeSpan minAllowedSpan) + { + _minAllowedSpan = minAllowedSpan; + } + + public override bool IsAllowed(IUserData userData) + { + var result = true; + var key = $"{userData.Token}-last-pass-time"; + var lastPass = Cashing.Get(key); + + if (lastPass != null) + { + var lastPassDateTime = DateTime.Parse(lastPass); + var timeElapsed = DateTime.Now - lastPassDateTime; + result = timeElapsed >= _minAllowedSpan; + } + //only update last pass time if we let rqeuest pass + if (result) + { + Cashing.Set(key, DateTime.Now.ToString()); + } + return result && (RateLimiterRule == null || RateLimiterRule.IsAllowed(userData)); + } + } +} diff --git a/RateLimiter/User/IUserData.cs b/RateLimiter/User/IUserData.cs new file mode 100644 index 00000000..5e6c40ed --- /dev/null +++ b/RateLimiter/User/IUserData.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.User +{ + public interface IUserData + { + string? IpAddress { get; set; } + string? CountryCode { get; set; } + string? Token { get; set; } + } +} diff --git a/RateLimiter/User/UserData.cs b/RateLimiter/User/UserData.cs new file mode 100644 index 00000000..789091c8 --- /dev/null +++ b/RateLimiter/User/UserData.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.User +{ + public class UserData : IUserData + { + public string? IpAddress { get; set; } + public string? CountryCode { get; set; } + public string? Token { get; set; } + } +} diff --git a/RateLimiterTest/Program.cs b/RateLimiterTest/Program.cs new file mode 100644 index 00000000..e6242b0e --- /dev/null +++ b/RateLimiterTest/Program.cs @@ -0,0 +1,22 @@ + + +using RateLimiter; +using RateLimiter.Ruls; +using RateLimiter.Ruls.Abstract; +using RateLimiter.User; + +internal class Program +{ + private static void Main(string[] args) + { + var rules = new RateLimiterRuleDecorator[] { new MaxRequestAmountInTimeSpanRule(TimeSpan.FromSeconds(1), 5) }; + + var userData = new UserData() { CountryCode = "US", Token = "tempToken", IpAddress = "192.168.18.22" }; + + // Act + new ConcreteRateLimiter(rules).IsAllowed(userData); + new ConcreteRateLimiter(rules).IsAllowed(userData); + var result = new ConcreteRateLimiter(rules).IsAllowed(userData); + Console.WriteLine("Hello, World! result = " + result); + } +} \ No newline at end of file diff --git a/RateLimiterTest/RateLimiterTest.csproj b/RateLimiterTest/RateLimiterTest.csproj new file mode 100644 index 00000000..8100007e --- /dev/null +++ b/RateLimiterTest/RateLimiterTest.csproj @@ -0,0 +1,14 @@ + + + + Exe + net7.0 + enable + enable + + + + + + +