diff --git a/RateLimiter.Tests/CacheHelperTests.cs b/RateLimiter.Tests/CacheHelperTests.cs new file mode 100644 index 00000000..ac9ce6de --- /dev/null +++ b/RateLimiter.Tests/CacheHelperTests.cs @@ -0,0 +1,84 @@ +using NUnit.Framework; +using RateLimiter; +using System; +using System.Collections.Generic; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class CacheHelperTests + { + private CacheHelper _cacheHelper; + + [SetUp] + public void SetUp() + { + _cacheHelper = new CacheHelper(); + } + + [Test] + public void LastRequestTime_ShouldReturnMaxValue_WhenNoRequests() + { + // Act + var result = _cacheHelper.LastRequestTime("test-key"); + + // Assert + Assert.AreEqual(TimeSpan.MaxValue, result); + } + + [Test] + public void LastRequestTime_ShouldReturnTimeSinceLastRequest_WhenRequestsExist() + { + // Arrange + var key = "test-key"; + _cacheHelper.AddRequest(key); + System.Threading.Thread.Sleep(1000); // Wait for 1 second + + // Act + var result = _cacheHelper.LastRequestTime(key); + + // Assert + Assert.IsTrue(result.TotalMilliseconds >= 1000); + } + + [Test] + public void RequestsCount_ShouldReturnZero_WhenNoRequests() + { + // Act + var result = _cacheHelper.RequestsCount("test-key", TimeSpan.FromSeconds(1)); + + // Assert + Assert.AreEqual(0, result); + } + + [Test] + public void RequestsCount_ShouldReturnCorrectCount_WhenRequestsExist() + { + // Arrange + var key = "test-key"; + _cacheHelper.AddRequest(key); + _cacheHelper.AddRequest(key); + System.Threading.Thread.Sleep(1000); // Wait for 1 second + + // Act + var result = _cacheHelper.RequestsCount(key, TimeSpan.FromSeconds(2)); + + // Assert + Assert.AreEqual(2, result); + } + + [Test] + public void AddRequest_ShouldAddRequestToCache() + { + // Arrange + var key = "test-key"; + + // Act + _cacheHelper.AddRequest(key); + var result = _cacheHelper.RequestsCount(key, TimeSpan.FromSeconds(1)); + + // Assert + Assert.AreEqual(1, result); + } + } +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..215c77da 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -8,7 +8,9 @@ + + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..bfb81b4b 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,68 @@ -using NUnit.Framework; +using Microsoft.AspNetCore.Http; +using Moq; +using NUnit.Framework; +using System.Threading.Tasks; -namespace RateLimiter.Tests; - -[TestFixture] -public class RateLimiterTest +namespace RateLimiter.Tests { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } -} \ No newline at end of file + [TestFixture] + public class RequestLimiterTests + { + private Mock _mockNext; + private Mock _mockRulesFactory; + private Mock _mockContextHelper; + private Mock _mockCacheHelper; + private RequestLimiter _requestLimiter; + private DefaultHttpContext _httpContext; + + [SetUp] + public void SetUp() + { + _mockNext = new Mock(); + _mockRulesFactory = new Mock(); + _mockContextHelper = new Mock(); + _mockContextHelper.Setup(ch => ch.GetDto(It.IsAny())).Returns(new ContextDto { Id = "test-id", Path = "", Region = "US" }); + _mockCacheHelper = new Mock(); + _requestLimiter = new RequestLimiter(_mockNext.Object, _mockRulesFactory.Object, _mockContextHelper.Object, _mockCacheHelper.Object); + _httpContext = new DefaultHttpContext(); + } + + [Test] + public async Task InvokeAsync_ShouldCallNextDelegate_WhenRequestIsAllowed() + { + // Arrange + var contextDto = new ContextDto { Id = "test-id" }; + var mockRule = new Mock(); + mockRule.Setup(r => r.CheckLimit()).Returns(true); + _mockContextHelper.Setup(ch => ch.GetDto(_httpContext)).Returns(contextDto); + _mockRulesFactory.Setup(rf => rf.GetRule(contextDto)).Returns(mockRule.Object); + + // Act + await _requestLimiter.InvokeAsync(_httpContext); + + // Assert + _mockNext.Verify(next => next(_httpContext), Times.Once); + _mockCacheHelper.Verify(ch => ch.AddRequest(contextDto.Id), Times.Once); + } + + [Test] + public async Task InvokeAsync_ShouldReturnTooManyRequests_WhenRequestIsNotAllowed() + { + // Arrange + var contextDto = new ContextDto { Id = "test-id" }; + var mockRule = new Mock(); + mockRule.Setup(r => r.CheckLimit()).Returns(false); + _mockContextHelper.Setup(ch => ch.GetDto(_httpContext)).Returns(contextDto); + _mockRulesFactory.Setup(rf => rf.GetRule(contextDto)).Returns(mockRule.Object); + + // Act + await _requestLimiter.InvokeAsync(_httpContext); + + // Assert + Assert.AreEqual(StatusCodes.Status429TooManyRequests, _httpContext.Response.StatusCode); + _mockNext.Verify(next => next(_httpContext), Times.Never); + _mockCacheHelper.Verify(ch => ch.AddRequest(It.IsAny()), Times.Never); + } + } +} + diff --git a/RateLimiter.Tests/RulesFactoryTests.cs b/RateLimiter.Tests/RulesFactoryTests.cs new file mode 100644 index 00000000..9d26d225 --- /dev/null +++ b/RateLimiter.Tests/RulesFactoryTests.cs @@ -0,0 +1,75 @@ +using NUnit.Framework; +using Moq; +using RateLimiter; +using RateLimiter.Rules; +using System.Collections.Generic; + +namespace RateLimiter.Tests +{ + [TestFixture] + public class RulesFactoryTests + { + private Mock _mockConfigLoader; + private Mock _mockCacheHelper; + private RulesFactory _rulesFactory; + + [SetUp] + public void SetUp() + { + _mockConfigLoader = new Mock(); + _mockCacheHelper = new Mock(); + _rulesFactory = new RulesFactory(_mockConfigLoader.Object, _mockCacheHelper.Object); + } + + [Test] + public void GetRule_ShouldReturnEmptyRule_WhenNoRulesAreLoaded() + { + // Arrange + var context = new ContextDto {Id = "test-id", Path = "", Region = "US" }; + _mockConfigLoader.Setup(cl => cl.LoadRules(context)).Returns(new List()); + + // Act + var result = _rulesFactory.GetRule(context); + + // Assert + Assert.IsInstanceOf(result); + } + + [Test] + public void GetRule_ShouldReturnRequestsLimitRule_WhenRequestsLimitRuleIsLoaded() + { + // Arrange + var context = new ContextDto {Id = "test-id", Path = "", Region = "US" }; + var rules = new List + { + new RuleDefinition { Name = "RequestsLimit", Variables = new Dictionary() { { "time", "2000" }, { "requests", "40" } } } + }; + _mockConfigLoader.Setup(cl => cl.LoadRules(context)).Returns(rules); + + // Act + var result = _rulesFactory.GetRule(context); + + // Assert + Assert.IsInstanceOf(result); + } + + [Test] + public void GetRule_ShouldReturnTimeLimitRule_WhenTimeLimitRuleIsLoaded() + { + // Arrange + var context = new ContextDto { Id = "test-id", Path = "", Region = "US" }; + var rules = new List + { + new RuleDefinition { Name = "TimeLimit", Variables = new Dictionary() { { "time", "2000" } } } + }; + _mockConfigLoader.Setup(cl => cl.LoadRules(context)).Returns(rules); + + // Act + var result = _rulesFactory.GetRule(context); + + // Assert + Assert.IsInstanceOf(result); + } + + } +} \ No newline at end of file diff --git a/RateLimiter/CacheHelper.cs b/RateLimiter/CacheHelper.cs new file mode 100644 index 00000000..0d41cc63 --- /dev/null +++ b/RateLimiter/CacheHelper.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + + public class CacheHelper : ICacheHelper + { + private readonly Dictionary> _cache = new(); + public int MaxTimes { get; set; } = 60000; // Default: 60 seconds + + public TimeSpan LastRequestTime(string key) + { + if (_cache.TryGetValue(key, out var timestamps) && timestamps.Count > 0) + { + return DateTime.UtcNow - timestamps[^1]; + } + return TimeSpan.MaxValue; + } + + public int RequestsCount(string key, TimeSpan time) + { + if (_cache.TryGetValue(key, out var timestamps)) + { + var threshold = DateTime.UtcNow - time; + return timestamps.RemoveAll(t => t > threshold); + } + return 0; + } + + public void AddRequest(string key) + { + if (!_cache.ContainsKey(key)) + { + _cache[key] = new List(); + } + _cache[key].Add(DateTime.UtcNow); + } + } +} diff --git a/RateLimiter/ContextDto.cs b/RateLimiter/ContextDto.cs new file mode 100644 index 00000000..e037a38f --- /dev/null +++ b/RateLimiter/ContextDto.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public class ContextDto + { + public string Path { get; set; } + public string Region { get; set; } + public string Id { get; set; } + } +} diff --git a/RateLimiter/ICacheHelper.cs b/RateLimiter/ICacheHelper.cs new file mode 100644 index 00000000..0a8e1e5b --- /dev/null +++ b/RateLimiter/ICacheHelper.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface ICacheHelper + { + int MaxTimes { get; set; } + TimeSpan LastRequestTime(string key); + int RequestsCount(string key, TimeSpan time); + void AddRequest(string key); + } +} diff --git a/RateLimiter/IConfigLoader.cs b/RateLimiter/IConfigLoader.cs new file mode 100644 index 00000000..7c1d6247 --- /dev/null +++ b/RateLimiter/IConfigLoader.cs @@ -0,0 +1,37 @@ +using Microsoft.Extensions.Configuration; +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Data; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface IConfigLoader + { + IEnumerable LoadRules(ContextDto contextDto); + } + + internal class ConfigLoader : IConfigLoader + { + public IEnumerable LoadRules(ContextDto contextDto) + { + var json = File.ReadAllText("config.json");//hardcoded path and another types of hardcode are bad in real project, but it can be another way to configure rules + var section = JsonConvert.DeserializeObject>>>(json) ?? new Dictionary>>(); + var result = section.Where(x => x.Key == contextDto.Path) + .SelectMany(x => x.Value) + .Where(x => x.Key == contextDto.Region) + .SelectMany(x => x.Value) + .Where(x => x.Variables.All(v => contextDto.Id.Contains(v.Value))) + .ToList(); + + return result; + + } + } + + +} diff --git a/RateLimiter/IContextHelper.cs b/RateLimiter/IContextHelper.cs new file mode 100644 index 00000000..f59b4b17 --- /dev/null +++ b/RateLimiter/IContextHelper.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface IContextHelper + { + ContextDto GetDto(HttpContext context); + } +} diff --git a/RateLimiter/IRequestLimiter.cs b/RateLimiter/IRequestLimiter.cs new file mode 100644 index 00000000..fac5c314 --- /dev/null +++ b/RateLimiter/IRequestLimiter.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + internal interface IRequestLimiter + { + //IHttpContextAccessor + //bool CheckLimit(AccsessToken accsessToken); + } +} diff --git a/RateLimiter/IRule.cs b/RateLimiter/IRule.cs new file mode 100644 index 00000000..b0de83d7 --- /dev/null +++ b/RateLimiter/IRule.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface IRule + { + bool CheckLimit(); + } +} diff --git a/RateLimiter/IRulesFactory.cs b/RateLimiter/IRulesFactory.cs new file mode 100644 index 00000000..5a2c31bc --- /dev/null +++ b/RateLimiter/IRulesFactory.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface IRulesFactory + { + IRule GetRule(ContextDto context); + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..eac798e7 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,10 @@ latest enable + + + + + + \ No newline at end of file diff --git a/RateLimiter/RequestLimiter.cs b/RateLimiter/RequestLimiter.cs new file mode 100644 index 00000000..b20c758b --- /dev/null +++ b/RateLimiter/RequestLimiter.cs @@ -0,0 +1,47 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public class RequestLimiter : IRequestLimiter + { + private readonly RequestDelegate _next; + private readonly IRulesFactory _rules; + private IContextHelper _contextHelper; + private ICacheHelper _cacheHelper; + + public RequestLimiter(RequestDelegate next, IRulesFactory rules, IContextHelper contextHelper, ICacheHelper cacheHelper) + { + _next = next; + _rules = rules; + _contextHelper = contextHelper; + _cacheHelper = cacheHelper; + } + + public async Task InvokeAsync(HttpContext context) + { + var contextDto = _contextHelper.GetDto(context); + var rule = _rules.GetRule(contextDto); + //var path = context.Request.Path.ToString(); + + var isAllowed = rule?.CheckLimit() ?? true; + + if (!isAllowed) + { + context.Response.StatusCode = StatusCodes.Status429TooManyRequests; + await context.Response.WriteAsync("Rate limit exceeded. Try again later."); + return; + } + + _cacheHelper.AddRequest(contextDto.Id); + + await _next(context); + } + } +} diff --git a/RateLimiter/RuleDefinition.cs b/RateLimiter/RuleDefinition.cs new file mode 100644 index 00000000..453a7993 --- /dev/null +++ b/RateLimiter/RuleDefinition.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public class RuleDefinition + { + public string Name { get; set; } + public Dictionary Variables { get; set; } + } +} diff --git a/RateLimiter/Rules/AbstractRule.cs b/RateLimiter/Rules/AbstractRule.cs new file mode 100644 index 00000000..3e1cf960 --- /dev/null +++ b/RateLimiter/Rules/AbstractRule.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public abstract class AbstractRule : IRule + { + private IRule _rule; + protected ICacheHelper _cacheHelper; + + public AbstractRule(IRule rule, ICacheHelper cacheHelper) + { + _rule = rule; + _cacheHelper = cacheHelper; + } + + protected abstract bool CheckRuleLimit(); + + public bool CheckLimit() + { + return CheckRuleLimit() & (_rule?.CheckLimit() ?? true); + } + + } + +} \ No newline at end of file diff --git a/RateLimiter/Rules/EmptyRule.cs b/RateLimiter/Rules/EmptyRule.cs new file mode 100644 index 00000000..01baeb57 --- /dev/null +++ b/RateLimiter/Rules/EmptyRule.cs @@ -0,0 +1,18 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Data; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class EmptyRule(IRule rule = null, ICacheHelper cacheHelper = null) : AbstractRule(rule, cacheHelper) + { + protected override bool CheckRuleLimit() + { + return true; + } + } +} diff --git a/RateLimiter/Rules/RequestsLimitRule.cs b/RateLimiter/Rules/RequestsLimitRule.cs new file mode 100644 index 00000000..3aee0b0e --- /dev/null +++ b/RateLimiter/Rules/RequestsLimitRule.cs @@ -0,0 +1,27 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class RequestsLimitRule : AbstractRule + { + private TimeSpan _timeLimit; + private int _requestsCount; + private string _id; + + public RequestsLimitRule(IRule rule, Dictionary Variables, ICacheHelper cacheHelper, string id) :base (rule, cacheHelper) + { + _timeLimit = TimeSpan.FromMilliseconds(int.Parse(Variables["time"])); + _requestsCount = int.Parse(Variables["requests"]); + } + + protected override bool CheckRuleLimit() + { + return _cacheHelper.RequestsCount(_id, _timeLimit)>= _requestsCount; + } + } +} diff --git a/RateLimiter/Rules/TimeLimitRule.cs b/RateLimiter/Rules/TimeLimitRule.cs new file mode 100644 index 00000000..f6b17852 --- /dev/null +++ b/RateLimiter/Rules/TimeLimitRule.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public class TimeLimitRule : AbstractRule + { + private TimeSpan _timeLimit; + private string _id; + + public TimeLimitRule(IRule rule, Dictionary Variables, ICacheHelper cacheHelper, string id) : base(rule, cacheHelper) + { + _timeLimit = TimeSpan.FromMilliseconds(int.Parse(Variables["time"])); + } + + override protected bool CheckRuleLimit() + { + return _cacheHelper.LastRequestTime(_id) < _timeLimit; + } + } +} diff --git a/RateLimiter/RulesFactory.cs b/RateLimiter/RulesFactory.cs new file mode 100644 index 00000000..eca8b1a8 --- /dev/null +++ b/RateLimiter/RulesFactory.cs @@ -0,0 +1,45 @@ +using Microsoft.AspNetCore.Http; +using RateLimiter.Rules; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public class RulesFactory : IRulesFactory + { + private IConfigLoader _configLoader; + private ICacheHelper _cacheHelper; + + public RulesFactory(IConfigLoader configLoader, ICacheHelper cacheHelper) + { + _configLoader = configLoader; + _cacheHelper = cacheHelper; + } + + public IRule GetRule(ContextDto context) + { + var rules = _configLoader.LoadRules(context); + IRule tempRule = new EmptyRule(); + + foreach (var rule in rules) + { + switch (rule.Name ?? string.Empty) + { + case "RequestsLimit": + tempRule = new RequestsLimitRule(tempRule, rule.Variables, _cacheHelper, context.Id); + break; + case "TimeLimit": + tempRule = new TimeLimitRule(tempRule, rule.Variables, _cacheHelper, context.Id); + break; + default: + tempRule = new EmptyRule(tempRule); + break; + } + } + return tempRule; + } + } +} diff --git a/RateLimiter/config.json b/RateLimiter/config.json new file mode 100644 index 00000000..3eb7b376 --- /dev/null +++ b/RateLimiter/config.json @@ -0,0 +1,54 @@ +{ + "RequestEndpoint1": { + "EU": [ + { + "Name": "RequestsLimit", + "Variables": { + "time": "1000", + "requests": "20" + } + } + ], + "US": [ + { + "Name": "RequestsLimit", + "Variables": { + "time": "5000", + "requests": "20" + } + }, + { + "Name": "TimeSinceLastCall", + "Variables": { + "time": "200" + } + } + ] + }, + "RequestEndpoint2": { + "EU": [ + { + "Name": "RequestsLimit", + "Variables": { + "time": "2000", + "requests": "40" + } + } + ], + "US": [ + { + "Name": "RequestsLimit", + "Variables": { + "time": "5000", + "requests": "20" + } + }, + { + "Name": "TimeSinceLastCall", + "Variables": { + "time": "100" + } + } + ] + } +} \ No newline at end of file