diff --git a/README.md b/README.md index 47e73daa..286e45af 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,13 @@ Some examples of request-limiting rules (you could imagine any others) * a certain timespan has passed since the last call; * For US-based tokens, we use X requests per timespan; for EU-based tokens, a certain timespan has passed since the last call. -The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; keep this fact in mind when designing the classes. +The goal is to design a class(-es) that manages each API resource's rate limits by a set of provided *configurable and extendable* rules. For example, for one resource, +you could configure the limiter to use Rule A; for another one - Rule B; for a third one - both A + B, etc. Any combination of rules should be possible; +keep this fact in mind when designing the classes. -We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, reusable class library covered by a set of tests is more than enough. +We're more interested in the design itself than in some intelligent and tricky rate-limiting algorithm. +There is no need to use a database (in-memory storage is fine) or any web framework. Do not waste time on preparing complex environment, +reusable class library covered by a set of tests is more than enough. There is a Test Project set up for you to use. However, you are welcome to create your own test project and use whatever test runner you like. diff --git a/RateLimiter.Tests/HelperRateLimiter.cs b/RateLimiter.Tests/HelperRateLimiter.cs new file mode 100644 index 00000000..2db0d2af --- /dev/null +++ b/RateLimiter.Tests/HelperRateLimiter.cs @@ -0,0 +1,101 @@ +using RateLimiter.Data; +using RateLimiter.Log; +using RateLimiter.Model.Resource.Config; +using RateLimiter.Model.Rule.Config; +using RateLimiter.Resource; +using RateLimiter.Tracking; +using System.Collections.Generic; +using System.Diagnostics; +using System.Text.Json; + +namespace RateLimiter.Tests +{ + internal class HelperRateLimiter + { + public const string EU_TOKEN = "eu-asdf-123-asdf"; + public const string TOKEN = "xxvb-66-yui"; + + public const string IP_WHITE_LIST = "68.7.53.27"; + public const string IP_BLACK_LIST = "68.7.53.99"; + + public const int DEFAULT_TIMESPAN_SECONDS = 30; + public const int DEFAULT_REQUEST_THRESHOLD = 5; + + //Assuming you wire this up in a startup class you would hook the DI but just hand rolling instances for the sake of demonstration + public static ServiceRateLimiter GetServiceRateLimiter() + { + var repo = new RepositoryRateLimiter(); + PopulateResourceConfigurations(repo); + + return new ServiceRateLimiter( + new ServiceResourceConfigLoader(repo), + repo, + new ServiceLogger(), + new ServiceRequestTracker(repo)); + } + + private static void PopulateResourceConfigurations(RepositoryRateLimiter repo) + { + var configDefault = new ResourceConfig + { + ConfigId = 1, + Resource = Model.Resource.EnumResource.GetListings, + Rules = new List + { + new RuleConfigIpBlackList + { + IpAddresses = new List { IP_BLACK_LIST } + }, + new RuleConfigTimespanThreshold + { + LastCallThreshold = new System.TimeSpan(0,0, DEFAULT_TIMESPAN_SECONDS), + MaxThreshold = DEFAULT_REQUEST_THRESHOLD + } + } + }; + + var configNewResource = new ResourceConfig + { + ConfigId = 2, + Resource = Model.Resource.EnumResource.GetListingDetail, + Rules = new List + { + new RuleConfigIpWhiteList + { + IpAddresses = new List { IP_WHITE_LIST } + } + } + }; + + var configResourceMaintenance = new ResourceConfig + { + ConfigId = 3, + Resource = Model.Resource.EnumResource.CreateListingInquiry, + Rules = new List + { + new RuleConfigEnabledResource + { + Enabled = false + } + } + }; + + repo.CreateResourceConfig(configDefault); + repo.CreateResourceConfig(configNewResource); + repo.CreateResourceConfig(configResourceMaintenance); + } + + public static void OutputResponse(object response) + { + if (response == null) + response = "response is emtpy"; + + var lineBreak = System.Environment.NewLine; + var seperator = $"{lineBreak}##############################################################################{lineBreak}"; + + Debug.WriteLine(seperator); + Debug.WriteLine(JsonSerializer.Serialize(response)); + Debug.WriteLine(seperator); + } + } +} diff --git a/RateLimiter.Tests/RateLimiterTest.cs b/RateLimiter.Tests/RateLimiterTest.cs index 172d44a7..624a6232 100644 --- a/RateLimiter.Tests/RateLimiterTest.cs +++ b/RateLimiter.Tests/RateLimiterTest.cs @@ -1,4 +1,7 @@ using NUnit.Framework; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using System.Threading; namespace RateLimiter.Tests; @@ -6,8 +9,187 @@ namespace RateLimiter.Tests; public class RateLimiterTest { [Test] - public void Example() + public void TestResourceGetListings() { - Assert.That(true, Is.True); + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var response = service.Validate(request); + + Assert.That(response.Success, Is.True); } + + [Test] + public void TestResourceGetListingsBlackList() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_BLACK_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var response = service.Validate(request); + + Assert.That(response.Success, Is.False); + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceGetListingsTimespan() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + + ResponseRateLimitValidation response = null; + + for (int i = 0; i < HelperRateLimiter.DEFAULT_REQUEST_THRESHOLD; i++) + response = service.Validate(request); + + Assert.That(response.Success, Is.True); + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceGetListingsTimespanExceeded() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + + ResponseRateLimitValidation response = null; + + for (int i = 0; i < HelperRateLimiter.DEFAULT_REQUEST_THRESHOLD + 5; i++) + response = service.Validate(request); + + Assert.That(response.Success, Is.False); + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceGetListingsTimespanEU() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.EU_TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var delay = (HelperRateLimiter.DEFAULT_TIMESPAN_SECONDS + 2) * 1000; + + ResponseRateLimitValidation response = null; + + for (int i = 0; i < HelperRateLimiter.DEFAULT_REQUEST_THRESHOLD; i++) + { + Thread.Sleep(delay); + response = service.Validate(request); + Assert.That(response.Success, Is.True); + } + + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceGetListingsTimespanExceededEU() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListings, + Token = HelperRateLimiter.EU_TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + + var response = service.Validate(request); + Assert.That(response.Success, Is.True); + + //back to back so we should brick here + response = service.Validate(request); + Assert.That(response.Success, Is.False); + + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceGetListingDetail() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.GetListingDetail, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var response = service.Validate(request); + + Assert.That(response.Success, Is.True); + + //swap to a non whitelisted ip + request.IpAddress = "123.55.123"; + + response = service.Validate(request); + + Assert.That(response.Success, Is.False); + + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceCreateListingInquiry() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.CreateListingInquiry, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var response = service.Validate(request); + + //disabled + Assert.That(response.Success, Is.False); + + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } + + [Test] + public void TestResourceCreateListingFavorite() + { + var request = new ResourceRequest + { + IpAddress = HelperRateLimiter.IP_WHITE_LIST, + Resource = EnumResource.CreateListingFavorite, + Token = HelperRateLimiter.TOKEN + }; + + var service = HelperRateLimiter.GetServiceRateLimiter(); + var response = service.Validate(request); + + //no configuration + Assert.That(response.Success, Is.False); + + HelperRateLimiter.OutputResponse(service.GetRequestLogAll()); + } } \ No newline at end of file diff --git a/RateLimiter/Data/IRepositoryRateLimiter.cs b/RateLimiter/Data/IRepositoryRateLimiter.cs new file mode 100644 index 00000000..cedb6ae1 --- /dev/null +++ b/RateLimiter/Data/IRepositoryRateLimiter.cs @@ -0,0 +1,19 @@ +using RateLimiter.Model.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Resource.Config; +using RateLimiter.Model.Response; +using System; +using System.Collections.Generic; + +namespace RateLimiter.Data +{ + public interface IRepositoryRateLimiter + { + ResourceConfig GetResourceConfig(EnumResource resource); + int GetResourceRequestCount(EnumResource resource, string token, TimeSpan lookbackTimespan); + DateTime GetLastResourceRequest(EnumResource resource, string token); + void CreateResourceConfig(ResourceConfig resourceConfig); + void LogResourceRequestAttempt(ResourceRequest request, ResponseRateLimitValidation response); + List GetRequestLogAll(); + } +} diff --git a/RateLimiter/Data/RepositoryRateLimiter.cs b/RateLimiter/Data/RepositoryRateLimiter.cs new file mode 100644 index 00000000..7d790365 --- /dev/null +++ b/RateLimiter/Data/RepositoryRateLimiter.cs @@ -0,0 +1,68 @@ +using RateLimiter.Model.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Resource.Config; +using RateLimiter.Model.Response; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace RateLimiter.Data +{ + public class RepositoryRateLimiter : IRepositoryRateLimiter + { + //Likely a combination of cache (something like redis) and persisted data but we will just store some data in memory for this poc + //Mock persisted data store + private List ResourceConfigurations = new List(); + private List RequestLogs = new List(); + //End mock persisted data + + public ResourceConfig GetResourceConfig(EnumResource resource) + { + return ResourceConfigurations.First(c => c.Resource == resource); + } + + public void CreateResourceConfig(ResourceConfig resourceConfig) + { + ResourceConfigurations.Add(resourceConfig); + } + + public void LogResourceRequestAttempt(ResourceRequest request, ResponseRateLimitValidation response) + { + RequestLogs.Add(new ResourceRequestLog + { + CreateDate = DateTime.Now, + Token = request.Token, + IpAddress = request.IpAddress, + ResourceConfigId = response.ResourceConfigId, + ResourceId = (int)request.Resource, + ResponseCode = response.ResponseCode, + ResponseMessage = response.ResponseMessage + }); + } + + public int GetResourceRequestCount(EnumResource resource, string token, TimeSpan lookbackTimespan) + { + var lookbackDate = DateTime.Now.Subtract(lookbackTimespan); + + return RequestLogs.Where + ( + r => r.ResourceId == (int)resource + && r.Token == token + && r.CreateDate >= lookbackDate + ).Count(); + } + + public DateTime GetLastResourceRequest(EnumResource resource, string token) + { + return RequestLogs + .OrderByDescending(r => r.CreateDate) + .Select(r => r.CreateDate) + .FirstOrDefault(); + } + + public List GetRequestLogAll() + { + return RequestLogs; + } + } +} diff --git a/RateLimiter/Helper/HelperResponse.cs b/RateLimiter/Helper/HelperResponse.cs new file mode 100644 index 00000000..8a03e842 --- /dev/null +++ b/RateLimiter/Helper/HelperResponse.cs @@ -0,0 +1,31 @@ +using RateLimiter.Model.Response; + +namespace RateLimiter.Helper +{ + internal class HelperResponse + { + public static T GetSuccess(string message = "Success") where T : ResponseGeneral, new() + { + return GetResponse(message, EnumResponseCode.Success); + } + + public static T GetException(string message = "We encountered an unexpected error") where T : ResponseGeneral, new() + { + return GetResponse(message, EnumResponseCode.Exception); + } + + public static T GetFailure(string message) where T : ResponseGeneral, new() + { + return GetResponse(message, EnumResponseCode.Failed); + } + + private static T GetResponse(string message, EnumResponseCode code) where T : ResponseGeneral, new() + { + return new T + { + ResponseCode = (int)code, + ResponseMessage = message + }; + } + } +} diff --git a/RateLimiter/IServiceRateLimiter.cs b/RateLimiter/IServiceRateLimiter.cs new file mode 100644 index 00000000..6f2a1fe6 --- /dev/null +++ b/RateLimiter/IServiceRateLimiter.cs @@ -0,0 +1,13 @@ +using RateLimiter.Model.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using System.Collections.Generic; + +namespace RateLimiter +{ + public interface IServiceRateLimiter + { + ResponseRateLimitValidation Validate(ResourceRequest request); + List GetRequestLogAll(); + } +} diff --git a/RateLimiter/Log/IServiceLogger.cs b/RateLimiter/Log/IServiceLogger.cs new file mode 100644 index 00000000..4ba00b79 --- /dev/null +++ b/RateLimiter/Log/IServiceLogger.cs @@ -0,0 +1,10 @@ +using System; + +namespace RateLimiter.Log +{ + public interface IServiceLogger + { + public void Log(string message); + public void Exception(Exception ex); + } +} diff --git a/RateLimiter/Log/ServiceLogger.cs b/RateLimiter/Log/ServiceLogger.cs new file mode 100644 index 00000000..04602176 --- /dev/null +++ b/RateLimiter/Log/ServiceLogger.cs @@ -0,0 +1,24 @@ +using System; + +namespace RateLimiter.Log +{ + public class ServiceLogger : IServiceLogger + { + public void Exception(Exception ex) + { + if (ex != null) + { + Log(ex.Message); + } + else + { + Log("We encountered an unexpected exception"); + } + } + + public void Log(string message) + { + System.Diagnostics.Debug.WriteLine(message); + } + } +} diff --git a/RateLimiter/Model/Data/ApiToken.cs b/RateLimiter/Model/Data/ApiToken.cs new file mode 100644 index 00000000..266a1377 --- /dev/null +++ b/RateLimiter/Model/Data/ApiToken.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Data +{ + public class ApiToken + { + public string Token { get; set; } + public bool IsUSToken { get; set; } = true; //using bool for simplicity here but would probably be a country identifier for xx handling + public bool Enabled { get; set; } = true; + } +} diff --git a/RateLimiter/Model/Data/ResourceRequestLog.cs b/RateLimiter/Model/Data/ResourceRequestLog.cs new file mode 100644 index 00000000..7c11e3a0 --- /dev/null +++ b/RateLimiter/Model/Data/ResourceRequestLog.cs @@ -0,0 +1,15 @@ +using System; + +namespace RateLimiter.Model.Data +{ + public class ResourceRequestLog + { + public int ResourceId { get; set; } + public int ResourceConfigId { get; set; } + public string Token { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public int ResponseCode { get; set; } + public string ResponseMessage { get; set; } + public DateTime CreateDate { get; set; } + } +} diff --git a/RateLimiter/Model/Resource/Config/ResourceConfig.cs b/RateLimiter/Model/Resource/Config/ResourceConfig.cs new file mode 100644 index 00000000..1c9f8245 --- /dev/null +++ b/RateLimiter/Model/Resource/Config/ResourceConfig.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace RateLimiter.Model.Resource.Config +{ + public class ResourceConfig + { + public int ConfigId { get; set; } + public EnumResource Resource { get; set; } + public List Rules { get; set; } = new List(); + } +} diff --git a/RateLimiter/Model/Resource/EnumResource.cs b/RateLimiter/Model/Resource/EnumResource.cs new file mode 100644 index 00000000..3d434b68 --- /dev/null +++ b/RateLimiter/Model/Resource/EnumResource.cs @@ -0,0 +1,11 @@ +namespace RateLimiter.Model.Resource +{ + public enum EnumResource + { + NotSet = -1, + GetListings = 1, + GetListingDetail = 2, + CreateListingInquiry = 3, + CreateListingFavorite = 4 + } +} diff --git a/RateLimiter/Model/Resource/ResourceRequest.cs b/RateLimiter/Model/Resource/ResourceRequest.cs new file mode 100644 index 00000000..70c7dd25 --- /dev/null +++ b/RateLimiter/Model/Resource/ResourceRequest.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Resource +{ + public class ResourceRequest + { + public string Token { get; set; } = string.Empty; + public string IpAddress { get; set; } = string.Empty; + public EnumResource Resource { get; set; } = EnumResource.NotSet; + } +} diff --git a/RateLimiter/Model/Response/EnumResponseCode.cs b/RateLimiter/Model/Response/EnumResponseCode.cs new file mode 100644 index 00000000..89bcc414 --- /dev/null +++ b/RateLimiter/Model/Response/EnumResponseCode.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Response +{ + public enum EnumResponseCode + { + Success = 1, + Failed = 2, + Exception = 3 + } +} diff --git a/RateLimiter/Model/Response/ResponseGeneral.cs b/RateLimiter/Model/Response/ResponseGeneral.cs new file mode 100644 index 00000000..5e563a02 --- /dev/null +++ b/RateLimiter/Model/Response/ResponseGeneral.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Response +{ + public class ResponseGeneral + { + public int ResponseCode { get; set; } = (int)EnumResponseCode.Success; + public string ResponseMessage { get; set; } = "Success"; + public bool Success => ResponseCode == (int)EnumResponseCode.Success; + } +} diff --git a/RateLimiter/Model/Response/ResponseRateLimitValidation.cs b/RateLimiter/Model/Response/ResponseRateLimitValidation.cs new file mode 100644 index 00000000..c3db3559 --- /dev/null +++ b/RateLimiter/Model/Response/ResponseRateLimitValidation.cs @@ -0,0 +1,7 @@ +namespace RateLimiter.Model.Response +{ + public class ResponseRateLimitValidation : ResponseGeneral + { + public int ResourceConfigId { get; set; } = -1; + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigBase.cs b/RateLimiter/Model/Rule/Config/RuleConfigBase.cs new file mode 100644 index 00000000..4ebea7a3 --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigBase.cs @@ -0,0 +1,12 @@ +namespace RateLimiter.Model.Rule.Config +{ + public abstract class RuleConfigBase + { + public EnumRateLimitRule Rule { get; private set; } + + public RuleConfigBase(EnumRateLimitRule rule) + { + Rule = rule; + } + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigEnabledResource.cs b/RateLimiter/Model/Rule/Config/RuleConfigEnabledResource.cs new file mode 100644 index 00000000..6a5484fe --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigEnabledResource.cs @@ -0,0 +1,11 @@ +namespace RateLimiter.Model.Rule.Config +{ + public class RuleConfigEnabledResource : RuleConfigBase + { + public bool Enabled { get; set; } = true; + + public RuleConfigEnabledResource() : base(EnumRateLimitRule.EnabledResource) + { + } + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigIpBase.cs b/RateLimiter/Model/Rule/Config/RuleConfigIpBase.cs new file mode 100644 index 00000000..8133553f --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigIpBase.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; + +namespace RateLimiter.Model.Rule.Config +{ + public abstract class RuleConfigIpBase : RuleConfigBase + { + public List IpAddresses { get; set; } = new List(); + + public RuleConfigIpBase(EnumRateLimitRule rule) : base(rule) + { + } + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigIpBlackList.cs b/RateLimiter/Model/Rule/Config/RuleConfigIpBlackList.cs new file mode 100644 index 00000000..b61bf478 --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigIpBlackList.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Rule.Config +{ + public class RuleConfigIpBlackList : RuleConfigIpBase + { + public RuleConfigIpBlackList() : base(EnumRateLimitRule.IpBlackList) + { + } + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigIpWhiteList.cs b/RateLimiter/Model/Rule/Config/RuleConfigIpWhiteList.cs new file mode 100644 index 00000000..2b35f439 --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigIpWhiteList.cs @@ -0,0 +1,9 @@ +namespace RateLimiter.Model.Rule.Config +{ + public class RuleConfigIpWhiteList : RuleConfigIpBase + { + public RuleConfigIpWhiteList() : base(EnumRateLimitRule.IpWhiteList) + { + } + } +} diff --git a/RateLimiter/Model/Rule/Config/RuleConfigTimespanThreshold.cs b/RateLimiter/Model/Rule/Config/RuleConfigTimespanThreshold.cs new file mode 100644 index 00000000..846b5339 --- /dev/null +++ b/RateLimiter/Model/Rule/Config/RuleConfigTimespanThreshold.cs @@ -0,0 +1,15 @@ +using System; + +namespace RateLimiter.Model.Rule.Config +{ + public class RuleConfigTimespanThreshold : RuleConfigBase + { + public TimeSpan LastCallThreshold { get; set; } + public int MaxThreshold { get; set; } + + public RuleConfigTimespanThreshold() + : base(EnumRateLimitRule.TimespanThreshold) + { + } + } +} diff --git a/RateLimiter/Model/Rule/EnumRateLimitRule.cs b/RateLimiter/Model/Rule/EnumRateLimitRule.cs new file mode 100644 index 00000000..bf6f4f92 --- /dev/null +++ b/RateLimiter/Model/Rule/EnumRateLimitRule.cs @@ -0,0 +1,10 @@ +namespace RateLimiter.Model.Rule +{ + public enum EnumRateLimitRule + { + TimespanThreshold = 1, + EnabledResource = 2, + IpWhiteList = 3, + IpBlackList = 4 + } +} diff --git a/RateLimiter/Resource/IServiceResourceConfigLoader.cs b/RateLimiter/Resource/IServiceResourceConfigLoader.cs new file mode 100644 index 00000000..d1fbda0c --- /dev/null +++ b/RateLimiter/Resource/IServiceResourceConfigLoader.cs @@ -0,0 +1,10 @@ +using RateLimiter.Model.Resource; +using RateLimiter.Model.Resource.Config; + +namespace RateLimiter.Resource +{ + public interface IServiceResourceConfigLoader + { + ResourceConfig GetConfig(ResourceRequest reques); + } +} diff --git a/RateLimiter/Resource/ServiceResourceConfigLoader.cs b/RateLimiter/Resource/ServiceResourceConfigLoader.cs new file mode 100644 index 00000000..d8e4d9c7 --- /dev/null +++ b/RateLimiter/Resource/ServiceResourceConfigLoader.cs @@ -0,0 +1,41 @@ +using RateLimiter.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Resource.Config; +using System; + +namespace RateLimiter.Resource +{ + public class ServiceResourceConfigLoader : IServiceResourceConfigLoader + { + private readonly RepositoryRateLimiter Repo; + + public ServiceResourceConfigLoader(RepositoryRateLimiter repo) + { + Repo = repo; + } + + public ResourceConfig GetConfig(ResourceRequest request) + { + ValidateRequest(request); + + var config = Repo.GetResourceConfig(request.Resource); + + if (config == null) + throw new Exception("Unable to load resource configuration"); + + return config; + } + + private void ValidateRequest(ResourceRequest request) + { + if (request == null) + throw new ArgumentException("A resource request is required"); + + if (request.Resource == EnumResource.NotSet) + throw new ArgumentException("Please provide the resource you are trying to validate"); + + if (string.IsNullOrWhiteSpace(request.Token)) + throw new ArgumentException("Please provide a valid client token"); + } + } +} diff --git a/RateLimiter/Rule/ResourceRuleFactory.cs b/RateLimiter/Rule/ResourceRuleFactory.cs new file mode 100644 index 00000000..d36b812f --- /dev/null +++ b/RateLimiter/Rule/ResourceRuleFactory.cs @@ -0,0 +1,33 @@ +using RateLimiter.Data; +using RateLimiter.Model.Rule; +using RateLimiter.Model.Rule.Config; +using RateLimiter.Rule.Validator; +using System; + +namespace RateLimiter.Rule +{ + internal class ResourceRuleFactory + { + public static ValidatorBase Get(IRepositoryRateLimiter repo, RuleConfigBase config) + { + var configMismatchMessage = $"Configuration Mismatch for rule type {config.Rule}"; + switch (config.Rule) + { + case EnumRateLimitRule.EnabledResource: + return (config is RuleConfigEnabledResource enabledResource) ? + new ValidatorEnabledResource(repo, enabledResource) : throw new ArgumentException(configMismatchMessage); + case EnumRateLimitRule.TimespanThreshold: + return (config is RuleConfigTimespanThreshold timespanConfig) ? + new ValidatorTimespanThreshold(repo, timespanConfig) : throw new ArgumentException(configMismatchMessage); + case EnumRateLimitRule.IpWhiteList: + return (config is RuleConfigIpWhiteList whiteListConfig) ? + new ValidatorIpWhiteList(repo, whiteListConfig) : throw new ArgumentException(configMismatchMessage); + case EnumRateLimitRule.IpBlackList: + return (config is RuleConfigIpBlackList blackListConfig) ? + new ValidatorIpBlackList(repo, blackListConfig) : throw new ArgumentException(configMismatchMessage); + default: + throw new NotImplementedException($"Implementation required for resource rule: {config.Rule}"); + } + } + } +} diff --git a/RateLimiter/Rule/Validator/ValidatorBase.cs b/RateLimiter/Rule/Validator/ValidatorBase.cs new file mode 100644 index 00000000..96742032 --- /dev/null +++ b/RateLimiter/Rule/Validator/ValidatorBase.cs @@ -0,0 +1,21 @@ +using RateLimiter.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Model.Rule; + +namespace RateLimiter.Rule.Validator +{ + public abstract class ValidatorBase + { + protected readonly EnumRateLimitRule Rule; + protected readonly IRepositoryRateLimiter Repo; + + public abstract ResponseGeneral Validate(ResourceRequest request); + + protected ValidatorBase(IRepositoryRateLimiter repo, EnumRateLimitRule rule) + { + Repo = repo; + Rule = rule; + } + } +} diff --git a/RateLimiter/Rule/Validator/ValidatorEnabledResource.cs b/RateLimiter/Rule/Validator/ValidatorEnabledResource.cs new file mode 100644 index 00000000..6ae4757f --- /dev/null +++ b/RateLimiter/Rule/Validator/ValidatorEnabledResource.cs @@ -0,0 +1,25 @@ +using RateLimiter.Data; +using RateLimiter.Helper; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Model.Rule.Config; + +namespace RateLimiter.Rule.Validator +{ + public class ValidatorEnabledResource : ValidatorBase + { + private readonly RuleConfigEnabledResource Config; + public ValidatorEnabledResource(IRepositoryRateLimiter repo, RuleConfigEnabledResource config) + : base(repo, config.Rule) + { + Config = config; + } + + public override ResponseGeneral Validate(ResourceRequest request) + { + return (Config?.Enabled ?? false) ? + HelperResponse.GetSuccess() : + HelperResponse.GetFailure("Resource is disabled."); + } + } +} diff --git a/RateLimiter/Rule/Validator/ValidatorIpBlackList.cs b/RateLimiter/Rule/Validator/ValidatorIpBlackList.cs new file mode 100644 index 00000000..9f2a224d --- /dev/null +++ b/RateLimiter/Rule/Validator/ValidatorIpBlackList.cs @@ -0,0 +1,28 @@ +using RateLimiter.Data; +using RateLimiter.Helper; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Model.Rule.Config; + +namespace RateLimiter.Rule.Validator +{ + public class ValidatorIpBlackList : ValidatorBase + { + private readonly RuleConfigIpBlackList Config; + + public ValidatorIpBlackList(IRepositoryRateLimiter repo, RuleConfigIpBlackList config) + : base(repo, config.Rule) + { + Config = config; + } + + public override ResponseGeneral Validate(ResourceRequest request) + { + var isBlackListed = (Config?.IpAddresses?.Contains(request.IpAddress) ?? false); + + return isBlackListed ? + HelperResponse.GetFailure("Resource access restricted.") : + HelperResponse.GetSuccess(); + } + } +} diff --git a/RateLimiter/Rule/Validator/ValidatorIpWhiteList.cs b/RateLimiter/Rule/Validator/ValidatorIpWhiteList.cs new file mode 100644 index 00000000..25679d9e --- /dev/null +++ b/RateLimiter/Rule/Validator/ValidatorIpWhiteList.cs @@ -0,0 +1,28 @@ +using RateLimiter.Data; +using RateLimiter.Helper; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Model.Rule.Config; + +namespace RateLimiter.Rule.Validator +{ + public class ValidatorIpWhiteList : ValidatorBase + { + private readonly RuleConfigIpWhiteList Config; + + public ValidatorIpWhiteList(IRepositoryRateLimiter repo, RuleConfigIpWhiteList config) + : base(repo, config.Rule) + { + Config = config; + } + + public override ResponseGeneral Validate(ResourceRequest request) + { + var isWhiteListed = (Config?.IpAddresses?.Contains(request.IpAddress) ?? false); + + return isWhiteListed ? + HelperResponse.GetSuccess() : + HelperResponse.GetFailure("Resource requires your IP to be whitelisted."); + } + } +} diff --git a/RateLimiter/Rule/Validator/ValidatorTimespanThreshold.cs b/RateLimiter/Rule/Validator/ValidatorTimespanThreshold.cs new file mode 100644 index 00000000..eb6d967f --- /dev/null +++ b/RateLimiter/Rule/Validator/ValidatorTimespanThreshold.cs @@ -0,0 +1,46 @@ +using RateLimiter.Data; +using RateLimiter.Helper; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Model.Rule.Config; +using System; + +namespace RateLimiter.Rule.Validator +{ + internal class ValidatorTimespanThreshold : ValidatorBase + { + private RuleConfigTimespanThreshold Config; + + public ValidatorTimespanThreshold(IRepositoryRateLimiter repo, RuleConfigTimespanThreshold config) + : base(repo, config.Rule) + { + Config = config; + } + + public override ResponseGeneral Validate(ResourceRequest request) + { + //we would have this flagged in a db table with country code or something in reality + var isEUToken = request.Token.ToLower().StartsWith("eu-"); + + return isEUToken ? ValidateTimeSinceLastCall(request) : ValidateMaxCallsThreshold(request); + } + + private ResponseGeneral ValidateMaxCallsThreshold(ResourceRequest request) + { + var requests = Repo.GetResourceRequestCount(request.Resource, request.Token, Config.LastCallThreshold); + + return requests < Config.MaxThreshold ? + HelperResponse.GetSuccess() : + HelperResponse.GetFailure("Resource max requests exceeded."); + } + + private ResponseGeneral ValidateTimeSinceLastCall(ResourceRequest request) + { + var lastRequest = Repo.GetLastResourceRequest(request.Resource, request.Token); + var lookbackDate = DateTime.Now.Subtract(Config.LastCallThreshold); + return lastRequest <= lookbackDate ? + HelperResponse.GetSuccess() : + HelperResponse.GetFailure("Resource recent request exceeded."); + } + } +} diff --git a/RateLimiter/ServiceRateLimiter.cs b/RateLimiter/ServiceRateLimiter.cs new file mode 100644 index 00000000..a0adb2a1 --- /dev/null +++ b/RateLimiter/ServiceRateLimiter.cs @@ -0,0 +1,76 @@ +using RateLimiter.Data; +using RateLimiter.Helper; +using RateLimiter.Log; +using RateLimiter.Model.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using RateLimiter.Resource; +using RateLimiter.Rule; +using RateLimiter.Tracking; +using System; +using System.Collections.Generic; + +namespace RateLimiter +{ + + public class ServiceRateLimiter : IServiceRateLimiter + { + private readonly IServiceLogger Logger; + private readonly IServiceResourceConfigLoader ResourceLoader; + private readonly IServiceRequestTracker Tracker; + private readonly IRepositoryRateLimiter Repo; + + public ServiceRateLimiter(IServiceResourceConfigLoader resourceLoader, IRepositoryRateLimiter repo, IServiceLogger logger, IServiceRequestTracker tracker) + { + ResourceLoader = resourceLoader; + Repo = repo; + Logger = logger; + Tracker = tracker; + } + + public List GetRequestLogAll() + { + return Repo.GetRequestLogAll(); + } + + public ResponseRateLimitValidation Validate(ResourceRequest request) + { + var validationResponse = HelperResponse.GetSuccess(); + + try + { + var resource = ResourceLoader.GetConfig(request); + + validationResponse.ResourceConfigId = resource.ConfigId; //for tracking purposes + + ResponseGeneral ruleResponse; + + foreach(var rule in resource.Rules) + { + var validator = ResourceRuleFactory.Get(Repo, rule); + ruleResponse = validator.Validate(request); + + if (!ruleResponse.Success) + { + validationResponse.ResponseCode = ruleResponse.ResponseCode; + validationResponse.ResponseMessage = ruleResponse.ResponseMessage; + break; + } + } + } + catch (Exception ex) + { + Logger.Exception(ex); + + //TODO: determine how much info to expose here depending how far up the chain this goes...ex you could just make static generic message + validationResponse = HelperResponse.GetException(ex.Message); + } + finally + { + Tracker.Track(request, validationResponse); + } + + return validationResponse; + } + } +} diff --git a/RateLimiter/Tracking/IServiceRequestTracker.cs b/RateLimiter/Tracking/IServiceRequestTracker.cs new file mode 100644 index 00000000..63c4b1dd --- /dev/null +++ b/RateLimiter/Tracking/IServiceRequestTracker.cs @@ -0,0 +1,10 @@ +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; + +namespace RateLimiter.Tracking +{ + public interface IServiceRequestTracker + { + void Track(ResourceRequest request, ResponseRateLimitValidation response); + } +} diff --git a/RateLimiter/Tracking/ServiceRequestTracker.cs b/RateLimiter/Tracking/ServiceRequestTracker.cs new file mode 100644 index 00000000..8d4e3498 --- /dev/null +++ b/RateLimiter/Tracking/ServiceRequestTracker.cs @@ -0,0 +1,22 @@ +using RateLimiter.Data; +using RateLimiter.Model.Resource; +using RateLimiter.Model.Response; +using System; + +namespace RateLimiter.Tracking +{ + public class ServiceRequestTracker : IServiceRequestTracker + { + private readonly IRepositoryRateLimiter Repo; + + public ServiceRequestTracker(IRepositoryRateLimiter repo) + { + Repo = repo; + } + + public void Track(ResourceRequest request, ResponseRateLimitValidation response) + { + Repo.LogResourceRequestAttempt(request, response); + } + } +}