diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..484cdfdf 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..75d811fd 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,13 +1,107 @@ -using NUnit.Framework; +using System; +using Microsoft.Extensions.Caching.Memory; +using Moq; +using NUnit.Framework; +using RateLimiter.Model; namespace RateLimiter.Tests; [TestFixture] public class RateLimiterTest { - [Test] - public void Example() - { - Assert.That(true, Is.True); - } + private IRateLimiterFactory _rateLimiterFactory; + private Mock _memoryCacheMock; + + [SetUp] + public void SetUp() + { + _memoryCacheMock = new Mock(); + _rateLimiterFactory = new RateLimiterFactory(_memoryCacheMock.Object); + } + + [Test] + public void RateLimitResource2_DoNot_Allowed() + { + var clientData = new ClientModel() + { + ClientId = "123" + }; + + var cacheKey = $"rateLimiter-lastCall-resource2-{clientData.ClientId}"; + var oneMinuteAgo = DateTime.UtcNow.AddMinutes(-1); + + object outValue = oneMinuteAgo; + + _memoryCacheMock + .Setup(x => x.TryGetValue(cacheKey, out outValue)) + .Returns(true); + + _memoryCacheMock + .Setup(x => x.CreateEntry(It.IsAny())) + .Returns(Mock.Of); + + var rateLimiterResource2 = _rateLimiterFactory.CreateRateLimiter("resource2", clientData); + var isAllowedRequest = rateLimiterResource2.IsRequestAllowed(); + + Assert.That(isAllowedRequest, Is.False); + } + + [TestCase(2, true)] + [TestCase(5, false)] + public void RateLimitResource1_Allowed(int requestCount, bool result) + { + var clientData = new ClientModel() + { + ClientId = "123" + }; + + var cacheKey = $"rateLimiter-count-resource1-123"; + + object outValue = requestCount; + + _memoryCacheMock + .Setup(x => x.TryGetValue(cacheKey, out outValue)) + .Returns(true); + + _memoryCacheMock + .Setup(x => x.CreateEntry(It.IsAny())) + .Returns(Mock.Of); + + var rateLimiterResource3 = _rateLimiterFactory.CreateRateLimiter("resource1", clientData); + var isAllowedRequest = rateLimiterResource3.IsRequestAllowed(); + + Assert.AreEqual(isAllowedRequest, result); + } + + [Test] + public void RateLimitResource3_USBased_Allowed() + { + var clientData = new ClientModel() + { + ClientId = "123", + Region = "US" + }; + + _memoryCacheMock + .Setup(x => x.CreateEntry(It.IsAny())) + .Returns(Mock.Of); + + var rateLimiterResource3 = _rateLimiterFactory.CreateRateLimiter("resource3", clientData); + var isAllowedRequest = rateLimiterResource3.IsRequestAllowed(); + + Assert.That(isAllowedRequest, Is.True); + } + + [Test] + public void RateLimitResource_Exception() + { + var clientData = new ClientModel(); + + var ex = Assert.Throws(() => + { + _rateLimiterFactory.CreateRateLimiter("unknownResource", clientData); + }); + + Assert.That(ex.Message, Is.EqualTo("Cannot resolve resource mapping with url: unknownResource")); + } } \ No newline at end of file diff --git a/RateLimiter/IRateLimiterFactory.cs b/RateLimiter/IRateLimiterFactory.cs new file mode 100644 index 00000000..d80026a9 --- /dev/null +++ b/RateLimiter/IRateLimiterFactory.cs @@ -0,0 +1,10 @@ +using RateLimiter.Model; +using RateLimiter.Strategies; + +namespace RateLimiter +{ + public interface IRateLimiterFactory + { + IRateLimitService CreateRateLimiter(string resourceUrl, ClientModel clientData); + } +} \ No newline at end of file diff --git a/RateLimiter/Model/ClientModel.cs b/RateLimiter/Model/ClientModel.cs new file mode 100644 index 00000000..fc92552f --- /dev/null +++ b/RateLimiter/Model/ClientModel.cs @@ -0,0 +1,12 @@ +namespace RateLimiter.Model +{ + /// + /// Data from access token + /// + public class ClientModel + { + public string ClientId { get; set; } + public string Region { get; set; } + public string SomeOtherData { get; set; } + } +} \ No newline at end of file diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..f39b9d2d 100644 --- a/RateLimiter/RateLimiter.csproj +++ b/RateLimiter/RateLimiter.csproj @@ -4,4 +4,7 @@ latest enable + + + \ No newline at end of file diff --git a/RateLimiter/RateLimiterFactory.cs b/RateLimiter/RateLimiterFactory.cs new file mode 100644 index 00000000..1888befe --- /dev/null +++ b/RateLimiter/RateLimiterFactory.cs @@ -0,0 +1,37 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; +using RateLimiter.Strategies; + +namespace RateLimiter +{ + public class RateLimiterFactory : IRateLimiterFactory + { + private IMemoryCache _memoryCache; + + public RateLimiterFactory(IMemoryCache memoryCache) + { + _memoryCache = memoryCache; + } + + public IRateLimitService CreateRateLimiter(string resourceUrl, ClientModel clientData) + { + if (string.IsNullOrEmpty(resourceUrl)) + { + throw new ArgumentException($"{nameof(resourceUrl)} cannot be null or empty"); + } + + switch (resourceUrl) + { + case "resource1": + return new RateLimitResource1Service(_memoryCache, clientData); + case "resource2": + return new RateLimitResource2Service(_memoryCache, clientData); + case "resource3": + return new RateLimitResource3Service(_memoryCache, clientData); + default: + throw new ArgumentException($"Cannot resolve resource mapping with url: {resourceUrl}"); + } + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..952d9c5a --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,10 @@ +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; + +namespace RateLimiter.Rules +{ + public interface IRateLimitRule + { + bool IsRequestAllowed(ClientModel clientData, string resourceUrl, IMemoryCache memoryCache); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/IRateLimitRuleBuilder.cs b/RateLimiter/Rules/IRateLimitRuleBuilder.cs new file mode 100644 index 00000000..bede4eca --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRuleBuilder.cs @@ -0,0 +1,17 @@ +using System; + +namespace RateLimiter.Rules +{ + public interface IRateLimitRuleBuilder + { + IRateLimitRuleBuilder Build(); + + IRateLimitRuleBuilder WithTimeSinceLastCallRule(TimeSpan minInterval); + + IRateLimitRuleBuilder WithRequestCountRule(int maxRequests, TimeSpan timeSpan); + + IRateLimitRuleBuilder ApplyRule(bool condition, IRateLimitRule rule); + + bool IsAllowed(); + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/RateLimitRuleBuilder.cs b/RateLimiter/Rules/RateLimitRuleBuilder.cs new file mode 100644 index 00000000..5d8ea096 --- /dev/null +++ b/RateLimiter/Rules/RateLimitRuleBuilder.cs @@ -0,0 +1,69 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; + +namespace RateLimiter.Rules +{ + public class RateLimitRuleBuilder : IRateLimitRuleBuilder + { + private bool _allowRequest = true; + private readonly IMemoryCache _memoryCache; + private List _rules = new List(); + private ClientModel _clientData; + private string _resourceName; + + public RateLimitRuleBuilder(IMemoryCache memoryCache, ClientModel clientData, string resourceName) + { + _memoryCache = memoryCache; + _clientData = clientData; + _resourceName = resourceName; + } + + public IRateLimitRuleBuilder Build() + { + foreach (var rule in _rules) + { + if (!_allowRequest) + { + break; + } + + _allowRequest = rule.IsRequestAllowed(_clientData, _resourceName, _memoryCache); + } + + return this; + } + + public IRateLimitRuleBuilder WithRequestCountRule(int maxRequests, TimeSpan timeSpan) + { + var rule = new RequestCountRule(maxRequests, timeSpan); + _rules.Add(rule); + + return this; + } + + public IRateLimitRuleBuilder WithTimeSinceLastCallRule(TimeSpan minInterval) + { + var rule = new TimeSinceLastCallRule(minInterval); + _rules.Add(rule); + + return this; + } + + public IRateLimitRuleBuilder ApplyRule(bool condition, IRateLimitRule rule) + { + if (condition) + { + _rules.Add(rule); + } + + return this; + } + + public bool IsAllowed() + { + return _allowRequest; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/RequestCountRule.cs b/RateLimiter/Rules/RequestCountRule.cs new file mode 100644 index 00000000..b22731aa --- /dev/null +++ b/RateLimiter/Rules/RequestCountRule.cs @@ -0,0 +1,42 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; + +namespace RateLimiter.Rules +{ + /// + /// X requests per timespan + /// + public class RequestCountRule : IRateLimitRule + { + private readonly int _maxRequests; + private readonly TimeSpan _timeSpan; + + public RequestCountRule(int maxRequests, TimeSpan timeSpan) + { + _maxRequests = maxRequests; + _timeSpan = timeSpan; + } + + public bool IsRequestAllowed(ClientModel clientData, string resourceName, IMemoryCache memoryCache) + { + string cacheKey = $"rateLimiter-count-{resourceName}-{clientData.ClientId}"; + + if (!memoryCache.TryGetValue(cacheKey, out int requestCount)) + { + memoryCache.Set(cacheKey, 1, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _timeSpan }); + + return true; + } + + if (requestCount >= _maxRequests) + { + return false; + } + + memoryCache.Set(cacheKey, requestCount + 1, new MemoryCacheEntryOptions { AbsoluteExpirationRelativeToNow = _timeSpan }); + + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Rules/TimeSinceLastCallRule.cs b/RateLimiter/Rules/TimeSinceLastCallRule.cs new file mode 100644 index 00000000..4090503c --- /dev/null +++ b/RateLimiter/Rules/TimeSinceLastCallRule.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; + +namespace RateLimiter.Rules +{ + /// + /// A certain timespan has passed since the last call. + /// + public class TimeSinceLastCallRule : IRateLimitRule + { + private readonly TimeSpan _minInterval; + + public TimeSinceLastCallRule(TimeSpan minInterval) + { + _minInterval = minInterval; + } + + public bool IsRequestAllowed(ClientModel clientData, string resourceName, IMemoryCache memoryCache) + { + string cacheKey = $"rateLimiter-lastCall-{resourceName}-{clientData.ClientId}"; + + if (!memoryCache.TryGetValue(cacheKey, out DateTime lastRequestTime)) + { + memoryCache.Set(cacheKey, DateTime.UtcNow); + + return true; + } + + if (DateTime.UtcNow - lastRequestTime < _minInterval) + { + return false; + } + + memoryCache.Set(cacheKey, DateTime.UtcNow); + + return true; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Strategies/IRateLimitService.cs b/RateLimiter/Strategies/IRateLimitService.cs new file mode 100644 index 00000000..06822f40 --- /dev/null +++ b/RateLimiter/Strategies/IRateLimitService.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Strategies +{ + public interface IRateLimitService + { + public bool IsRequestAllowed(); + } +} \ No newline at end of file diff --git a/RateLimiter/Strategies/RateLimitResource1Service.cs b/RateLimiter/Strategies/RateLimitResource1Service.cs new file mode 100644 index 00000000..bd90cb80 --- /dev/null +++ b/RateLimiter/Strategies/RateLimitResource1Service.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; +using RateLimiter.Rules; + +namespace RateLimiter.Strategies +{ + public class RateLimitResource1Service : IRateLimitService + { + //In real project the values should be moved to configuration. + public const string Resource1Name = "resource1"; + public const int MaxRequests = 3; + public const int Period = 1; + + + private IMemoryCache _memoryCache; + private bool _isAllowed; + private ClientModel _clientData; + + public RateLimitResource1Service(IMemoryCache memoryCache, ClientModel clientData) + { + _memoryCache = memoryCache; + _clientData = clientData; + } + + public bool IsRequestAllowed() + { + _isAllowed = new RateLimitRuleBuilder(_memoryCache, _clientData, Resource1Name) + .WithRequestCountRule(MaxRequests, TimeSpan.FromMinutes(Period)) + .Build() + .IsAllowed(); + + return _isAllowed; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Strategies/RateLimitResource2Service.cs b/RateLimiter/Strategies/RateLimitResource2Service.cs new file mode 100644 index 00000000..10d7634b --- /dev/null +++ b/RateLimiter/Strategies/RateLimitResource2Service.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; +using RateLimiter.Rules; + +namespace RateLimiter.Strategies +{ + public class RateLimitResource2Service : IRateLimitService + { + //In real project the values should be moved to configuration. + public const string Resource2Name = "resource2"; + public const int MaxRequests = 5; + public const int Period = 2; + private const int Interval = 200; + + private IMemoryCache _memoryCache; + private bool _isAllowed; + private ClientModel _clientData; + + public RateLimitResource2Service(IMemoryCache memoryCache, ClientModel clientData) + { + _memoryCache = memoryCache; + _clientData = clientData; + } + public bool IsRequestAllowed() + { + _isAllowed = new RateLimitRuleBuilder(_memoryCache, _clientData, Resource2Name) + .WithTimeSinceLastCallRule(TimeSpan.FromSeconds(Interval)) + .WithRequestCountRule(MaxRequests, TimeSpan.FromMinutes(Period)) + .Build() + .IsAllowed(); + + return _isAllowed; + } + } +} \ No newline at end of file diff --git a/RateLimiter/Strategies/RateLimitResource3Service.cs b/RateLimiter/Strategies/RateLimitResource3Service.cs new file mode 100644 index 00000000..8b652482 --- /dev/null +++ b/RateLimiter/Strategies/RateLimitResource3Service.cs @@ -0,0 +1,40 @@ +using System; +using Microsoft.Extensions.Caching.Memory; +using RateLimiter.Model; +using RateLimiter.Rules; + +namespace RateLimiter.Strategies +{ + public class RateLimitResource3Service : IRateLimitService + { + //In real project the values should be moved to configuration. + public const string Resource3Name = "resource3"; + public const int MaxRequests = 5; + public const int Period = 1; + public const int Interval = 2; + + + private IMemoryCache _memoryCache; + private bool _isAllowed; + private ClientModel _clientData; + + public RateLimitResource3Service(IMemoryCache memoryCache, ClientModel clientData) + { + _memoryCache = memoryCache; + _clientData = clientData; + } + public bool IsRequestAllowed() + { + var isUSbasedRegion = string.Equals(_clientData.Region, "US", StringComparison.InvariantCultureIgnoreCase); + var isEUbasedRegion = string.Equals(_clientData.Region, "EU", StringComparison.InvariantCultureIgnoreCase); + + _isAllowed = new RateLimitRuleBuilder(_memoryCache, _clientData,Resource3Name) + .ApplyRule(isUSbasedRegion, new RequestCountRule(MaxRequests, TimeSpan.FromMinutes(Period))) + .ApplyRule(isEUbasedRegion, new TimeSinceLastCallRule(TimeSpan.FromSeconds(Interval))) + .Build() + .IsAllowed(); + + return _isAllowed; + } + } +} \ No newline at end of file