diff --git a/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs b/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs new file mode 100644 index 00000000..29cbcd00 --- /dev/null +++ b/RateLimiter.Tests/Factories/RateLimitDataStoreFactoryTest.cs @@ -0,0 +1,38 @@ +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Factories; + +namespace RateLimiter.Tests.Factories +{ + public class RateLimitDataStoreFactoryTest + { + [Test] + public void CreateDataStore_Returns_DataStore_When_DataStoreTypeIsKnown() + { + // Arrange + RateLimitDataStoreTypes dataStoreType = RateLimitDataStoreTypes.ConcurrentInMemory; + var dataStoreFactory = new RateLimitDataStoreFactory(); + + // Act + var dataStore = dataStoreFactory.CreateDataStore(dataStoreType); + + // Assert + Assert.NotNull(dataStore); + } + + [Test] + public void CreateDataStore_Throws_NotImplementedException_ForUnknownDataStoreType() + { + // Arrange + RateLimitDataStoreTypes unimplementedDataStoreType = (RateLimitDataStoreTypes)10000; + var dataStoreFactory = new RateLimitDataStoreFactory(); + + // Act + var datastoreDelegate = () => dataStoreFactory.CreateDataStore(unimplementedDataStoreType); + + // Assert + Assert.Throws(() => datastoreDelegate()); + } + } +} diff --git a/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs b/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs new file mode 100644 index 00000000..12f9f05b --- /dev/null +++ b/RateLimiter.Tests/Factories/RateLimitRuleFactoryTest.cs @@ -0,0 +1,54 @@ +using System; +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Factories; + +namespace RateLimiter.Tests.Factories +{ + public class RateLimitRuleFactoryTest + { + [Test] + public void CreateRule_Returns_ExpectedRule() + { + // Arrange + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + + // Act + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Assert + Assert.NotNull(rateLimitRule); + } + + [Test] + public void CreateRule_Throws_NotImplementedException() + { + // Arrange + RateLimitRuleTypes unimplementedRuleType = (RateLimitRuleTypes)10000; + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + + // Act + var rateLimitRule = () => ruleFactory.CreateRule( + unimplementedRuleType, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Assert + Assert.Throws(() => rateLimitRule()); + } + } +} diff --git a/RateLimiter.Tests/RateLimiter.Tests.csproj b/RateLimiter.Tests/RateLimiter.Tests.csproj index 5cbfc4e8..ef10b84d 100644 --- a/RateLimiter.Tests/RateLimiter.Tests.csproj +++ b/RateLimiter.Tests/RateLimiter.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..2844f116 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,4 +1,11 @@ -using NUnit.Framework; +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Moq; +using NUnit.Framework; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Stores; namespace RateLimiter.Tests; @@ -6,8 +13,78 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Test] - public void Example() + public async Task RateLimiter_AllowsRequest_When_NoRulesAreSet() { - Assert.That(true, Is.True); + // Arrange + var allowRequestsOnFailure = true; + var resourceId = "/api/resource"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var rulesetStoreMock = new Mock(); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); + + // Act + var allowed = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(allowed, Is.True); } + + [Test] + public async Task RateLimiter_AllowsRequest_When_WithinConfiguredRuleLimits() + { + // Arrange + var allowRequestsOnFailure = true; + var resourceId = "/api/resource"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var testRule = new Mock(); + var ruleList = new List() + { + testRule.Object + }; + var rulesetStoreMock = new Mock(); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); + + testRule.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(true); + rulesetStoreMock.Setup(store => store.GetRules(resourceId)).Returns(ruleList); + + // Act + var allowed = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(allowed, Is.True); + } + + [Test] + public async Task RateLimiter_DeniesRequest_When_ConfiguredRulesFail() + { + // Arrange + var allowRequestsOnFailure = true; + var requestPath = "/api/resource"; + var userId = "user1"; + var request = new RequestModel(requestPath, userId, string.Empty, string.Empty, string.Empty); + var testRule1 = new Mock(); + var testRule2 = new Mock(); + var ruleList = new List() + { + testRule1.Object, + testRule2.Object + }; + var rulesetStoreMock = new Mock(); + var loggerMock = new Mock>(); + var rateLimiter = new RateLimiter(rulesetStoreMock.Object, loggerMock.Object, allowRequestsOnFailure); + + testRule1.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(true); + testRule2.Setup(testRule => testRule.IsRequestAllowedAsync(request)).ReturnsAsync(false); + rulesetStoreMock.Setup(store => store.GetRules(requestPath)).Returns(ruleList); + + // Act + var allowed = await rateLimiter.IsRequestAllowedAsync(request); + + // Assert + Assert.That(allowed, Is.False); + } } \ No newline at end of file diff --git a/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs new file mode 100644 index 00000000..705a79a2 --- /dev/null +++ b/RateLimiter.Tests/Rules/RequestPerTimeSpanRuleTest.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Factories; +using RateLimiter.Models; + +namespace RateLimiter.Tests.Rules +{ + internal class RequestPerTimeSpanRuleTest + { + [Test] + public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsTrue_When_LimitIsNotExceeded() + { + // Arrange + var resourceId = "/api/path"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var numRequestsAllowed = 1; + var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed); + } + + [Test] + public async Task RequestPerTimeSpanRule_IsWithinLimit_ReturnsFalse_When_LimitIsExceeded() + { + // Arrange + var resourceId = "/api/path"; + int numRequestsAllowed = 1; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + } + + + [Test] + public async Task RequestPerTimeSpanRule_IsWithinLimit_MultipleRequests() + { + // Arrange + var delayInMs = 11; + var resourceId = "/api/path"; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.RequestsPerTimeSpan, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var thirdRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + var fourthRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs), "Expected third request to be allowed"); + Assert.That(() => fourthRequestAllowed(), Is.False, "Expected fourth request to be denied"); + } + } +} diff --git a/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs new file mode 100644 index 00000000..0e365adb --- /dev/null +++ b/RateLimiter.Tests/Rules/TimeSpanSinceLastRequestRuleTest.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Factories; +using RateLimiter.Models; + +namespace RateLimiter.Tests.Rules +{ + public class TimeSpanSinceLastRequestRuleTest + { + [Test] + public async Task IsRequestAllowedAsync_ReturnsTrue_When_LimitIsNotExceeded() + { + // Arrange + var resourceId = "/api/path"; + var userId = "user1"; + var request = new RequestModel(resourceId, userId, string.Empty, string.Empty, string.Empty); + var numRequestsAllowed = 1; + var interval = new TimeSpan(hours: 0, minutes: 0, seconds: 5); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed); + } + + [Test] + public async Task IsRequestAllowedAsync_ReturnsFalse_When_LimitIsExceeded() + { + // Arrange + var resourceId = "/api/path"; + int numRequestsAllowed = 1; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 1); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + } + + + [Test] + public async Task IsRequestAllowedAsync_MultipleRequests() + { + // Arrange + var delayInMs = 11; + var resourceId = "/api/path"; + var request = new RequestModel(resourceId, string.Empty, string.Empty, string.Empty, string.Empty); + int numRequestsAllowed = 1; + var interval = new TimeSpan(days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 10); + var dataStoreFactory = new RateLimitDataStoreFactory(); + var ruleFactory = new RateLimitRuleFactory(dataStoreFactory); + var rateLimitRule = ruleFactory.CreateRule( + RateLimitRuleTypes.TimeSpanSinceLastRequest, + RateLimitDataStoreTypes.ConcurrentInMemory, + DataStoreKeyTypes.RequestsPerResource, + numRequestsAllowed, + interval); + + // Act + var firstRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var secondRequestAllowed = await rateLimitRule.IsRequestAllowedAsync(request); + var thirdRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + var fourthRequestAllowed = async () => await rateLimitRule.IsRequestAllowedAsync(request); + + // Assert + Assert.IsTrue(firstRequestAllowed, "Expected first request to be allowed"); + Assert.IsFalse(secondRequestAllowed, "Expected second request to be denied"); + Assert.That(() => thirdRequestAllowed(), Is.True.After(delayInMs), "Expected third request to be allowed"); + Assert.That(() => fourthRequestAllowed(), Is.False, "Expected fourth request to be denied"); + } + } +} diff --git a/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs b/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs new file mode 100644 index 00000000..cc7b7f41 --- /dev/null +++ b/RateLimiter.Tests/Stores/DataStoreKeyGeneratorTest.cs @@ -0,0 +1,55 @@ +using NUnit.Framework; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Models; +using RateLimiter.Stores; + +namespace RateLimiter.Tests.Stores +{ + public class DataStoreKeyGeneratorTest + { + [Test] + public void GenerateKey_Returns_ExpectedKey_When_KeyTypeIsKnown() + { + // Arrange + var ipAddress = "67.88.121.44"; + var organizationId = "Simple Software Solutions Inc (SSSI)"; + var userId = "100"; + var region = "us-west"; + var requestPath = "api/profiles"; + var request = new RequestModel(requestPath, userId, organizationId, ipAddress, region); + DataStoreKeyTypes dataStoreKeyType = DataStoreKeyTypes.RequestsPerOrganizationUserPerResource; + var dataStoreKeyGenerator = new DataStoreKeyGenerator(dataStoreKeyType); + var expectedDataStoreKey = $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + + // Act + var actualDataStoreKey = dataStoreKeyGenerator.GenerateKey(request); + + // Assert + Assert.AreEqual(expectedDataStoreKey, actualDataStoreKey); + } + + [Test] + public void CreateDataStore_Throws_NotImplementedException_ForUnknownDataStoreType() + { + // Arrange + // Arrange + var ipAddress = "67.88.121.44"; + var organizationId = "Simple Software Solutions Inc (SSSI)"; + var userId = "100"; + var region = "us-west"; + var requestPath = "api/profiles"; + var request = new RequestModel(requestPath, userId, organizationId, ipAddress, region); + var expectedDataStoreKey = $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + + DataStoreKeyTypes unknownDataStoreKeyType = (DataStoreKeyTypes)10000; + var dataStoreKeyGenerator = new DataStoreKeyGenerator(unknownDataStoreKeyType); + + // Act + var dataStoreKeyGeneratorDelegate = () => dataStoreKeyGenerator.GenerateKey(request); + + // Assert + Assert.Throws(() => dataStoreKeyGeneratorDelegate()); + } + } +} diff --git a/RateLimiter/Constants/DataStoreKeyTypes.cs b/RateLimiter/Constants/DataStoreKeyTypes.cs new file mode 100644 index 00000000..7cd34b75 --- /dev/null +++ b/RateLimiter/Constants/DataStoreKeyTypes.cs @@ -0,0 +1,16 @@ +namespace RateLimiter.Constants +{ + public enum DataStoreKeyTypes + { + RequestsPerResource, + RequestsPerUser, + RequestsPerOrganization, + RequestsPerIpAddress, + RequestsPerRegion, + RequestsPerUserPerResource, + RequestsPerOrganizationUserPerResource, + RequestsPerOrganizationPerResource, + RequestsPerIpAddressPerResource, + RequestsPerRegionPerResource + } +} \ No newline at end of file diff --git a/RateLimiter/Constants/RateLimitDataStoreTypes.cs b/RateLimiter/Constants/RateLimitDataStoreTypes.cs new file mode 100644 index 00000000..e7211a11 --- /dev/null +++ b/RateLimiter/Constants/RateLimitDataStoreTypes.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Constants +{ + public enum RateLimitDataStoreTypes + { + ConcurrentInMemory + } +} diff --git a/RateLimiter/Constants/RateLimitRuleTypes.cs b/RateLimiter/Constants/RateLimitRuleTypes.cs new file mode 100644 index 00000000..35652fbb --- /dev/null +++ b/RateLimiter/Constants/RateLimitRuleTypes.cs @@ -0,0 +1,8 @@ +namespace RateLimiter.Constants +{ + public enum RateLimitRuleTypes + { + RequestsPerTimeSpan, + TimeSpanSinceLastRequest + } +} diff --git a/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs new file mode 100644 index 00000000..1828efd7 --- /dev/null +++ b/RateLimiter/Exceptions/DataStoreKeyTypeNotImplementedException.cs @@ -0,0 +1,16 @@ +using System; +using RateLimiter.Constants; + +namespace RateLimiter.Exceptions +{ + public class DataStoreKeyTypeNotImplementedException : Exception + { + private const string _message = "The requested data store key type has not been implemented"; + + public DataStoreKeyTypeNotImplementedException() : base(_message) { } + + public DataStoreKeyTypeNotImplementedException(DataStoreKeyTypes dataStoreKeyType) + : base($"{_message}: {dataStoreKeyType.ToString()}") + { } + } +} diff --git a/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs new file mode 100644 index 00000000..2161b870 --- /dev/null +++ b/RateLimiter/Exceptions/DataStoreTypeNotImplementedException.cs @@ -0,0 +1,16 @@ +using System; +using RateLimiter.Constants; + +namespace RateLimiter.Exceptions +{ + public class DataStoreTypeNotImplementedException : Exception + { + private const string _message = "The requested data store type has not been implemented"; + + public DataStoreTypeNotImplementedException() : base(_message) { } + + public DataStoreTypeNotImplementedException(RateLimitDataStoreTypes dataStoreType) + : base($"{_message}: {dataStoreType.ToString()}") + { } + } +} diff --git a/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs new file mode 100644 index 00000000..760b1a37 --- /dev/null +++ b/RateLimiter/Exceptions/RuleTypeNotImplementedException.cs @@ -0,0 +1,16 @@ +using System; +using RateLimiter.Constants; + +namespace RateLimiter.Exceptions +{ + public class RuleTypeNotImplementedException : Exception + { + private const string _message = "The requested rate limit rule type has not been implemented"; + + public RuleTypeNotImplementedException() : base(_message) { } + + public RuleTypeNotImplementedException(RateLimitRuleTypes rateLimitRuleType) + : base($"{_message}: {rateLimitRuleType.ToString()}") + { } + } +} diff --git a/RateLimiter/Factories/IRateLimitDataStoreFactory.cs b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs new file mode 100644 index 00000000..6b7f8720 --- /dev/null +++ b/RateLimiter/Factories/IRateLimitDataStoreFactory.cs @@ -0,0 +1,10 @@ +using RateLimiter.Constants; +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public interface IRateLimitDataStoreFactory + { + IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType); + } +} diff --git a/RateLimiter/Factories/IRateLimitRuleFactory.cs b/RateLimiter/Factories/IRateLimitRuleFactory.cs new file mode 100644 index 00000000..7904f0ae --- /dev/null +++ b/RateLimiter/Factories/IRateLimitRuleFactory.cs @@ -0,0 +1,16 @@ +using System; +using RateLimiter.Constants; +using RateLimiter.Rules; + +namespace RateLimiter.Factories +{ + public interface IRateLimitRuleFactory + { + IRateLimitRule CreateRule( + RateLimitRuleTypes ruleType, + RateLimitDataStoreTypes dataStoreType, + DataStoreKeyTypes dataStoreKeyType, + int numberOfRequests, + TimeSpan interval); + } +} diff --git a/RateLimiter/Factories/RateLimitDataStoreFactory.cs b/RateLimiter/Factories/RateLimitDataStoreFactory.cs new file mode 100644 index 00000000..2ab3d4e9 --- /dev/null +++ b/RateLimiter/Factories/RateLimitDataStoreFactory.cs @@ -0,0 +1,20 @@ +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public class RateLimitDataStoreFactory : IRateLimitDataStoreFactory + { + public IRateLimitDataStore CreateDataStore(RateLimitDataStoreTypes dataStoreType) + { + switch (dataStoreType) + { + case RateLimitDataStoreTypes.ConcurrentInMemory: + return new ConcurrentInMemoryRateLimitDataStore(); + default: + throw new DataStoreTypeNotImplementedException(dataStoreType); + } + } + } +} diff --git a/RateLimiter/Factories/RateLimitRuleFactory.cs b/RateLimiter/Factories/RateLimitRuleFactory.cs new file mode 100644 index 00000000..6e7c1f02 --- /dev/null +++ b/RateLimiter/Factories/RateLimitRuleFactory.cs @@ -0,0 +1,39 @@ +using System; +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Rules; +using RateLimiter.Stores; + +namespace RateLimiter.Factories +{ + public class RateLimitRuleFactory : IRateLimitRuleFactory + { + private readonly IRateLimitDataStoreFactory _rateLimitDataStoreFactory; + + public RateLimitRuleFactory(IRateLimitDataStoreFactory rateLimitDataStoreFactory) + { + _rateLimitDataStoreFactory = rateLimitDataStoreFactory; + } + + public IRateLimitRule CreateRule( + RateLimitRuleTypes ruleType, + RateLimitDataStoreTypes dataStoreType, + DataStoreKeyTypes dataStoreKeyType, + int numberOfRequests, + TimeSpan interval) + { + var dataStore = _rateLimitDataStoreFactory.CreateDataStore(dataStoreType); + var keyGenerator = new DataStoreKeyGenerator(dataStoreKeyType); + + switch (ruleType) + { + case RateLimitRuleTypes.RequestsPerTimeSpan: + return new RequestsPerTimeSpanRule(numberOfRequests, interval, dataStore, keyGenerator); + case RateLimitRuleTypes.TimeSpanSinceLastRequest: + return new TimeSpanSinceLastRequestRule(interval, dataStore, keyGenerator); + default: + throw new RuleTypeNotImplementedException(ruleType); + } + } + } +} diff --git a/RateLimiter/IRateLimiter.cs b/RateLimiter/IRateLimiter.cs new file mode 100644 index 00000000..172fb041 --- /dev/null +++ b/RateLimiter/IRateLimiter.cs @@ -0,0 +1,12 @@ +using RateLimiter.Models; +using RateLimiter.Rules; +using System.Threading.Tasks; + +namespace RateLimiter +{ + public interface IRateLimiter + { + void RegisterRule(string resourceId, IRateLimitRule rule); + Task IsRequestAllowedAsync(RequestModel request); + } +} diff --git a/RateLimiter/Models/RateLimitCounterModel.cs b/RateLimiter/Models/RateLimitCounterModel.cs new file mode 100644 index 00000000..edf7d943 --- /dev/null +++ b/RateLimiter/Models/RateLimitCounterModel.cs @@ -0,0 +1,15 @@ +namespace RateLimiter.Models +{ + public class RateLimitCounterModel + { + public RateLimitCounterModel(uint count, long requestTime) + { + RequestCount = count; + RequestTime = requestTime; + } + + public uint RequestCount { get; set; } + + public long RequestTime { get; set; } + } +} diff --git a/RateLimiter/Models/RequestModel.cs b/RateLimiter/Models/RequestModel.cs new file mode 100644 index 00000000..cdf138ec --- /dev/null +++ b/RateLimiter/Models/RequestModel.cs @@ -0,0 +1,4 @@ +namespace RateLimiter.Models +{ + public record RequestModel(string RequestPath, string UserId, string OrganizationId, string IpAddress, string Region); +} diff --git a/RateLimiter/RateLimiter.cs b/RateLimiter/RateLimiter.cs new file mode 100644 index 00000000..de471209 --- /dev/null +++ b/RateLimiter/RateLimiter.cs @@ -0,0 +1,57 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using RateLimiter.Models; +using RateLimiter.Rules; +using RateLimiter.Stores; + +namespace RateLimiter +{ + public class RateLimiter : IRateLimiter + { + private readonly IRulesetStore _rulesetStore; + private readonly ILogger _logger; + private readonly bool _allowRequestsOnFailure; + + public RateLimiter(IRulesetStore rulesetStore, ILogger logger, bool allowRequestsOnFailure) + { + _rulesetStore = rulesetStore; + _logger = logger; + _allowRequestsOnFailure = allowRequestsOnFailure; + } + + public async Task IsRequestAllowedAsync(RequestModel request) + { + try + { + var applicableRules = _rulesetStore.GetRules(request.RequestPath); + if (applicableRules == null || !applicableRules.Any()) + { + return true; + } + + foreach (var rule in applicableRules) + { + if (!await rule.IsRequestAllowedAsync(request)) + { + return false; + } + } + + return true; + } + catch (Exception e) + { + _logger.LogError(e, e.Message); + } + + return _allowRequestsOnFailure; + } + + public void RegisterRule(string resourceId, IRateLimitRule rule) + { + _rulesetStore.AddRule(resourceId, rule); + } + } +} diff --git a/RateLimiter/RateLimiter.csproj b/RateLimiter/RateLimiter.csproj index 19962f52..2fc2d6fb 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/Rules/BaseRateLimitRule.cs b/RateLimiter/Rules/BaseRateLimitRule.cs new file mode 100644 index 00000000..da5c1803 --- /dev/null +++ b/RateLimiter/Rules/BaseRateLimitRule.cs @@ -0,0 +1,37 @@ +using System.Threading; +using System.Threading.Tasks; +using RateLimiter.Models; + +namespace RateLimiter.Rules +{ + public abstract class BaseRateLimitRule : IRateLimitRule + { + private const int MINIMUM_CONCURRENT_REQUESTS = 1; + private readonly SemaphoreSlim _semaphoreSlim; + + public BaseRateLimitRule() + { + _semaphoreSlim = new SemaphoreSlim(MINIMUM_CONCURRENT_REQUESTS, MINIMUM_CONCURRENT_REQUESTS); + } + + public BaseRateLimitRule(int numberOfRequests) + { + _semaphoreSlim = new SemaphoreSlim(numberOfRequests, numberOfRequests); + } + + public async Task IsRequestAllowedAsync(RequestModel request) + { + await _semaphoreSlim.WaitAsync(); + try + { + return await ProcessRuleAsync(request); + } + finally + { + _semaphoreSlim.Release(); + } + } + + protected abstract Task ProcessRuleAsync(RequestModel request); + } +} diff --git a/RateLimiter/Rules/IRateLimitRule.cs b/RateLimiter/Rules/IRateLimitRule.cs new file mode 100644 index 00000000..0848858a --- /dev/null +++ b/RateLimiter/Rules/IRateLimitRule.cs @@ -0,0 +1,10 @@ +using RateLimiter.Models; +using System.Threading.Tasks; + +namespace RateLimiter.Rules +{ + public interface IRateLimitRule + { + Task IsRequestAllowedAsync(RequestModel request); + } +} diff --git a/RateLimiter/Rules/RequestsPerTimeSpanRule.cs b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs new file mode 100644 index 00000000..cf7bf56a --- /dev/null +++ b/RateLimiter/Rules/RequestsPerTimeSpanRule.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Models; +using RateLimiter.Stores; + +namespace RateLimiter.Rules +{ + public class RequestsPerTimeSpanRule : BaseRateLimitRule + { + private readonly IDataStoreKeyGenerator _keyGenerator; + private readonly IRateLimitDataStore _store; + private readonly TimeSpan _interval; + private readonly int _numberOfRequestsAllowed; + + public RequestsPerTimeSpanRule( + int numberOfRequests, + TimeSpan interval, + IRateLimitDataStore store, + IDataStoreKeyGenerator keyGenerator) + : base(numberOfRequests) + { + _interval = interval; + _numberOfRequestsAllowed = numberOfRequests; + _store = store; + _keyGenerator = keyGenerator; + } + + protected override Task ProcessRuleAsync(RequestModel request) + { + var rateLimitDataKey = _keyGenerator.GenerateKey(request); + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitCounterModel = _store.Get(rateLimitDataKey); + + if (limitCounterModel == null) + { + limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); + _store.Add(rateLimitDataKey, limitCounterModel); + } + + // If the current request is within the timespan, + if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) + { + limitCounterModel.RequestCount++; + } + else + { + limitCounterModel.RequestCount = 1; + limitCounterModel.RequestTime = currentRequestTime; + } + + _store.Update(rateLimitDataKey, limitCounterModel); + + if (limitCounterModel.RequestCount > _numberOfRequestsAllowed) + { + return Task.FromResult(false); + } + + return Task.FromResult(true); + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime < _interval.Ticks; + } + } +} diff --git a/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs new file mode 100644 index 00000000..ce689700 --- /dev/null +++ b/RateLimiter/Rules/TimeSpanSinceLastRequestRule.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using RateLimiter.Models; +using RateLimiter.Stores; + +namespace RateLimiter.Rules +{ + public class TimeSpanSinceLastRequestRule : BaseRateLimitRule + { + private readonly IDataStoreKeyGenerator _keyGenerator; + private readonly IRateLimitDataStore _store; + private readonly TimeSpan _interval; + + public TimeSpanSinceLastRequestRule( + TimeSpan interval, + IRateLimitDataStore store, + IDataStoreKeyGenerator keyGenerator) + { + _interval = interval; + _store = store; + _keyGenerator = keyGenerator; + } + + protected override Task ProcessRuleAsync(RequestModel request) + { + var rateLimitDataKey = _keyGenerator.GenerateKey(request); + var currentRequestTime = DateTime.UtcNow.Ticks; + var limitCounterModel = _store.Get(rateLimitDataKey); + var allowRequest = true; + + if (limitCounterModel == null) + { + limitCounterModel = new RateLimitCounterModel(0, currentRequestTime); + _store.Add(rateLimitDataKey, limitCounterModel); + } + + // If the current request is within the timespan, + if (IsWithinTimeInterval(currentRequestTime, limitCounterModel.RequestTime)) + { + limitCounterModel.RequestCount++; + if (limitCounterModel.RequestCount > 1) + { + allowRequest = false; + } + } + else + { + limitCounterModel.RequestCount = 1; + limitCounterModel.RequestTime = currentRequestTime; + } + + _store.Update(rateLimitDataKey, limitCounterModel); + + return Task.FromResult(allowRequest); + } + + private bool IsWithinTimeInterval(long currentRequestTime, long initialRequestTime) + { + return currentRequestTime - initialRequestTime < _interval.Ticks; + } + } +} diff --git a/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs new file mode 100644 index 00000000..32911360 --- /dev/null +++ b/RateLimiter/Stores/ConcurrentInMemoryRateLimitDataStore.cs @@ -0,0 +1,36 @@ +using RateLimiter.Models; +using System; +using System.Collections.Concurrent; + +namespace RateLimiter.Stores +{ + public class ConcurrentInMemoryRateLimitDataStore : IRateLimitDataStore + { + private readonly ConcurrentDictionary _store; + + public ConcurrentInMemoryRateLimitDataStore() + { + _store = new ConcurrentDictionary(); + } + + public RateLimitCounterModel? Get(string key) + { + if (_store.TryGetValue(key, out var model)) + { + return model; + } + + return null; + } + + public void Add(string key, RateLimitCounterModel value) + { + _store.GetOrAdd(key, value); + } + + public void Update(string key, RateLimitCounterModel value) + { + _store.AddOrUpdate(key, value, (key, oldValue) => value); + } + } +} diff --git a/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs new file mode 100644 index 00000000..ff547703 --- /dev/null +++ b/RateLimiter/Stores/ConcurrentInMemoryRulesetStore.cs @@ -0,0 +1,37 @@ +using RateLimiter.Rules; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace RateLimiter.Stores +{ + public class ConcurrentInMemoryRulesetStore : IRulesetStore + { + private readonly ConcurrentDictionary> _rulesetStore; + + public ConcurrentInMemoryRulesetStore() + { + _rulesetStore = new ConcurrentDictionary>(); + } + + public ConcurrentInMemoryRulesetStore(ConcurrentDictionary> rulesetStore) + { + _rulesetStore = rulesetStore; + } + + public void AddRule(string resourceId, IRateLimitRule rule) + { + var currentRules = _rulesetStore.GetOrAdd(resourceId, new List()); + currentRules.Add(rule); + } + + public void ClearRules(string resourceId) + { + _rulesetStore.TryRemove(resourceId, out _); + } + + public IList GetRules(string resourceId) + { + return _rulesetStore.GetOrAdd(resourceId, new List()); + } + } +} diff --git a/RateLimiter/Stores/DataStoreKeyGenerator.cs b/RateLimiter/Stores/DataStoreKeyGenerator.cs new file mode 100644 index 00000000..f0b72fb4 --- /dev/null +++ b/RateLimiter/Stores/DataStoreKeyGenerator.cs @@ -0,0 +1,43 @@ +using RateLimiter.Constants; +using RateLimiter.Exceptions; +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public class DataStoreKeyGenerator : IDataStoreKeyGenerator + { + private readonly DataStoreKeyTypes _keyType; + + public DataStoreKeyGenerator(DataStoreKeyTypes keyType) + { + _keyType = keyType; + } + + public string GenerateKey(RequestModel request) + { + switch (_keyType) + { + case DataStoreKeyTypes.RequestsPerResource: + return $"{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerUser: + return $"{request.UserId}"; + case DataStoreKeyTypes.RequestsPerIpAddress: + return $"{request.IpAddress}"; + case DataStoreKeyTypes.RequestsPerOrganization: + return $"{request.OrganizationId}"; + case DataStoreKeyTypes.RequestsPerUserPerResource: + return $"{request.UserId}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerOrganizationPerResource: + return $"{request.OrganizationId}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerRegionPerResource: + return $"{request.Region}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerIpAddressPerResource: + return $"{request.IpAddress}:{request.RequestPath}"; + case DataStoreKeyTypes.RequestsPerOrganizationUserPerResource: + return $"{request.UserId}:{request.OrganizationId}:{request.RequestPath}"; + default: + throw new DataStoreKeyTypeNotImplementedException(_keyType); + } + } + } +} diff --git a/RateLimiter/Stores/IDataStoreKeyGenerator.cs b/RateLimiter/Stores/IDataStoreKeyGenerator.cs new file mode 100644 index 00000000..5c1f4063 --- /dev/null +++ b/RateLimiter/Stores/IDataStoreKeyGenerator.cs @@ -0,0 +1,9 @@ +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public interface IDataStoreKeyGenerator + { + string GenerateKey(RequestModel request); + } +} diff --git a/RateLimiter/Stores/IRateLimitDataStore.cs b/RateLimiter/Stores/IRateLimitDataStore.cs new file mode 100644 index 00000000..feee7ae3 --- /dev/null +++ b/RateLimiter/Stores/IRateLimitDataStore.cs @@ -0,0 +1,11 @@ +using RateLimiter.Models; + +namespace RateLimiter.Stores +{ + public interface IRateLimitDataStore + { + RateLimitCounterModel? Get(string key); + void Add(string key, RateLimitCounterModel value); + void Update(string key, RateLimitCounterModel value); + } +} diff --git a/RateLimiter/Stores/IRulesetStore.cs b/RateLimiter/Stores/IRulesetStore.cs new file mode 100644 index 00000000..5755db31 --- /dev/null +++ b/RateLimiter/Stores/IRulesetStore.cs @@ -0,0 +1,12 @@ +using RateLimiter.Rules; +using System.Collections.Generic; + +namespace RateLimiter.Stores +{ + public interface IRulesetStore + { + IList GetRules(string resourceId); + void AddRule(string resourceId, IRateLimitRule rule); + void ClearRules(string resourceId); + } +}